Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions apps/api/src/storage/task-board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type {
TaskBoardItemThreadRef,
} from "./types";
import { generatePrefixedId } from "@decocms/shared/utils/generate-id";
import { DEFAULT_TASK_TYPE } from "@decocms/shared/task-board";
import { DEFAULT_TASK_TYPE, DELIVERY_LANES } from "@decocms/shared/task-board";
import {
type ReviewCycleActivity,
REVIEWER_KINDS,
Expand Down Expand Up @@ -212,6 +212,14 @@ function newestIso(
return new Date(Math.max(a, b)).toISOString();
}

/** Lanes a merged PR can leave a card on, and therefore where the merged-tag
* sweep has to look. `archived` is deliberately absent: a card that far along
* is history, and tagging it moves nothing. */
const TAGGABLE_MERGED_STATUSES: TaskBoardItemStatus[] = [
...DELIVERY_LANES,
"done",
];

export class TaskBoardStorage {
constructor(private db: Kysely<Database>) {}

Expand Down Expand Up @@ -752,7 +760,11 @@ export class TaskBoardStorage {
const rows = await this.db
.selectFrom("task_board_items as i")
.select(["i.id", "i.organization_id as organizationId"])
.where("i.status", "=", "done")
// Not just Done: with the delivery lanes on a merge lands the card on
// `merged`, and gating on Done alone would mean no card is ever tagged
// until a human drags it the rest of the way — days after the tag was
// worth seeing, which is the whole reason this sweep is not the archive's.
.where("i.status", "in", TAGGABLE_MERGED_STATUSES)
.where("i.dismissed_at", "is", null)
.where((eb) =>
eb.exists(
Expand Down Expand Up @@ -1980,7 +1992,9 @@ export class TaskBoardStorage {
}

/** A null actor is a machine path; a `reason` marks a move that meant something
* other than "not done" (Rerun-from-Done stamps `reason: "rerun"`). */
* other than "not done" (Rerun-from-Done stamps `reason: "rerun"`).
* `merged` counts as leaving Done — with the delivery lanes on, that is
* where a merged PR lands. */
async hasHumanRejectedDone(
taskBoardItemId: string,
organizationId: string,
Expand All @@ -1997,7 +2011,7 @@ export class TaskBoardStorage {
.where("a.task_board_item_id", "=", taskBoardItemId)
.where("a.action", "=", "status_changed")
.where(byMember, "is not", null)
.where(laneLeft, "=", "done")
.where(laneLeft, "in", ["done", "merged"])
.where(moveReason, "is", null)
.limit(1)
.executeTakeFirst();
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,9 @@ export type TaskBoardItemStatus =
| "todo"
| "in_progress"
| "in_review"
| "approved"
| "merged"
| "post_deploy_validation"
| "done"
| "archived";

Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/tools/task-board/archive-merged.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ describe("auto-archive sweep", () => {
]);
});

// The delivery lanes sit BEFORE Done, so a card parked in one is still in flight.
it("never sweeps a card resting in a delivery lane", async () => {
const settled = new Date(Date.now() - 3 * DAY_MS);
const lanes = ["approved", "merged", "post_deploy_validation"] as const;
const parked = await Promise.all(
lanes.map((lane) => seed(lane, settled, true)),
);

const candidates = await taskBoard.listItemsAwaitingArchive(
new Date(Date.now() - DAY_MS),
200,
);
const ids = candidates.map((c) => c.id);
for (const id of parked) expect(ids).not.toContain(id);

// And the write path refuses too, even when handed the id directly.
const swept = await archiveMergedForOrg(ctx, ORG, parked, async () => ({
state: "closed" as const,
merged: true,
}));
expect(swept.archived).toBe(0);
for (const [i, id] of parked.entries()) {
expect((await taskBoard.getById(id, ORG))?.status).toBe(lanes[i]);
}
});

it("archives a merged card, logs it, and won't archive it twice", async () => {
const id = await seed("done", new Date(Date.now() - 3 * DAY_MS), true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ describe("hasHumanRejectedDone", () => {
expect(await taskBoard.hasHumanRejectedDone(id, ORG)).toBe(true);
});

// Merged is where a merged PR lands, so leaving it is the same veto as Done.
it("is true once a member moves the card out of Merged", async () => {
const id = await seed();
await taskBoard.recordActivity({
taskBoardItemId: id,
action: "status_changed",
actorId: USER,
data: { from: "merged", to: "in_review" },
});
expect(await taskBoard.hasHumanRejectedDone(id, ORG)).toBe(true);
});

// Past Merged, moving forward agrees with the ship rather than rejecting it.
it("is false for a member's move out of a later delivery lane", async () => {
const id = await seed();
await taskBoard.recordActivity({
taskBoardItemId: id,
action: "status_changed",
actorId: USER,
data: { from: "post_deploy_validation", to: "done" },
});
expect(await taskBoard.hasHumanRejectedDone(id, ORG)).toBe(false);
});

it("ignores a machine move out of Done", async () => {
const id = await seed();
await taskBoard.recordActivity({
Expand Down
109 changes: 109 additions & 0 deletions apps/api/src/tools/task-board/lanes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* The lane order and the two questions that ride on it.
*
* These are the assertions that make "flag off means nothing changed" and
* "automation never drags a card backward" checkable facts rather than a
* property five call sites happen to agree on.
*/

import { describe, expect, it } from "bun:test";
import { DELIVERY_LANES, shippedLane } from "@decocms/shared/task-board";
import type { TaskBoardItemStatus } from "@/storage/types";
import {
DELIVERY_LANE_STATUSES,
LANE_RANK,
movesForward,
SHIP_ELIGIBLE_LANES,
} from "./lanes";

const BOARD_ORDER: TaskBoardItemStatus[] = [
"triage",
"todo",
"in_progress",
"in_review",
"approved",
"merged",
"post_deploy_validation",
"done",
"archived",
];

describe("LANE_RANK", () => {
it("is strictly increasing in board order", () => {
const ranks = BOARD_ORDER.map((s) => LANE_RANK[s]);
expect(ranks).toEqual([...ranks].sort((a, b) => a - b));
expect(new Set(ranks).size).toBe(ranks.length);
});

it("puts every delivery lane between In Review and Done", () => {
for (const lane of DELIVERY_LANE_STATUSES) {
expect(LANE_RANK[lane]).toBeGreaterThan(LANE_RANK.in_review);
expect(LANE_RANK[lane]).toBeLessThan(LANE_RANK.done);
}
});

it("covers every lane the board knows", () => {
expect(Object.keys(LANE_RANK).sort()).toEqual([...BOARD_ORDER].sort());
});

// The shared union must stay assignable to this side's lane type.
it("agrees with the shared delivery-lane names", () => {
expect(DELIVERY_LANE_STATUSES).toEqual(DELIVERY_LANES);
});
});

describe("movesForward", () => {
it("advances along the board", () => {
expect(movesForward("in_review", "merged")).toBe(true);
expect(movesForward("in_review", "done")).toBe(true);
expect(movesForward("merged", "post_deploy_validation")).toBe(true);
});

// `prs-get`'s old enumerated guard dragged a card resting past Merged back.
it("refuses to drag a card back to an earlier lane", () => {
expect(movesForward("post_deploy_validation", "merged")).toBe(false);
expect(movesForward("done", "merged")).toBe(false);
expect(movesForward("archived", "merged")).toBe(false);
expect(movesForward("in_review", "in_progress")).toBe(false);
});

it("is not satisfied by staying put", () => {
for (const lane of BOARD_ORDER) {
expect(movesForward(lane, lane)).toBe(false);
}
});
});

describe("shippedLane", () => {
// Every falsy shape of the flags bag must resolve to `done` — that IS the guarantee.
it("ships to Done by default", () => {
expect(shippedLane(undefined)).toBe("done");
expect(shippedLane(null)).toBe("done");
expect(shippedLane({})).toBe("done");
expect(shippedLane({ delivery_lanes_enabled: false })).toBe("done");
expect(shippedLane({ auto_merge: true })).toBe("done");
});

it("ships to Merged once the org runs the delivery lanes", () => {
expect(shippedLane({ delivery_lanes_enabled: true })).toBe("merged");
});

it("always names a lane a merged PR may actually move to", () => {
for (const flags of [{}, { delivery_lanes_enabled: true }]) {
expect(movesForward("in_review", shippedLane(flags))).toBe(true);
}
});
});

describe("SHIP_ELIGIBLE_LANES", () => {
it("is exactly In Review and Approved", () => {
expect([...SHIP_ELIGIBLE_LANES].sort()).toEqual(["approved", "in_review"]);
});

// Reachable from every reviewed-but-unshipped lane, and from none that shipped.
it("excludes the lanes a shipped card rests in", () => {
for (const lane of ["merged", "post_deploy_validation", "done"] as const) {
expect(SHIP_ELIGIBLE_LANES.has(lane)).toBe(false);
}
});
});
67 changes: 67 additions & 0 deletions apps/api/src/tools/task-board/lanes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Board lane order, and the questions that depend on it.
*
* `LANE_RANK` is a total order over the lanes, used as a forward-only guard:
* automation moves a card ALONG the board, never back. It is an exhaustive
* `Record` over `TaskBoardItemStatus` on purpose — adding a lane is then a
* compile error here rather than a silent rank collision.
*
* Lives in its own module (rather than in `run-reactions`, where the table used
* to be) because the guard is read from `prs-get` and `promote-to-production`
* too, and those importing `run-reactions` for a constant would be a cycle.
*/

import { DELIVERY_LANES } from "@decocms/shared/task-board";
import type { TaskBoardItemStatus } from "@/storage/types";

export const LANE_RANK: Record<TaskBoardItemStatus, number> = {
triage: 0,
todo: 1,
in_progress: 2,
in_review: 3,
approved: 4,
merged: 5,
post_deploy_validation: 6,
done: 7,
archived: 8,
};

/** The delivery lanes, as board statuses — the assertion that the shared
* literal union stays a subset of this side's lane vocabulary. */
export const DELIVERY_LANE_STATUSES: TaskBoardItemStatus[] = DELIVERY_LANES;

/**
* True when moving `from` → `to` advances the card.
*
* Every automatic move must go through this rather than enumerating the lanes
* it refuses to leave (`status !== "done" && status !== "archived"`): an
* enumeration is correct only for the lanes that existed when it was written,
* so adding one silently turns it into a path that drags cards BACKWARD.
*/
export function movesForward(
from: TaskBoardItemStatus,
to: TaskBoardItemStatus,
): boolean {
return LANE_RANK[to] > LANE_RANK[from];
}

/**
* Lanes the "Ship to production" button may act from: In Review (auto-merge
* off, reviewers approved) and Approved (a human parked it there deliberately).
* Without Approved, moving a card into it would lock the ship button out — the
* lane would be a dead end.
*/
export const SHIP_ELIGIBLE_LANES: ReadonlySet<TaskBoardItemStatus> = new Set([
"in_review",
"approved",
]);

/**
* True where a merged pull request can have left the card, which is what the
* merged-tag sweep gates on. Mirrors `TAGGABLE_MERGED_STATUSES` in storage: the
* candidate query and the re-read inside the org's context have to agree, or
* the sweep picks cards it then refuses.
*/
export function isTaggableMergedStatus(status: TaskBoardItemStatus): boolean {
return status === "done" || DELIVERY_LANE_STATUSES.includes(status);
}
6 changes: 4 additions & 2 deletions apps/api/src/tools/task-board/merge-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
allReviewersApproved,
approvedButUnverified,
enabledReviewerKinds,
shippedLane,
} from "@decocms/shared/task-board";
import { recordTaskActivity } from "./activity";
import { reactToApprovedPrConflict } from "./conflict-reaction";
Expand Down Expand Up @@ -448,17 +449,18 @@ export async function retryAutoMergeIfApproved(
return false;
}

const shipped = shippedLane(settings?.flags);
const done = await ctx.storage.taskBoard.update(
item.id,
orgId,
{ status: "done" },
{ status: shipped },
item.updatedBy,
);
await recordTaskActivity(ctx, {
taskBoardItemId: item.id,
action: "status_changed",
actorId: null,
data: { from: item.status, to: "done" },
data: { from: item.status, to: shipped },
});
emitTaskBoardUpdated(orgId, done);
return true;
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/tools/task-board/promote-to-production.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,22 @@ describe("isReadyToShip", () => {
),
).toBe(true);
});

// Refusing to ship from Approved would make the lane a dead end.
it("allows shipping from Approved", () => {
expect(isReadyToShip("approved", [], [])).toBe(true);
expect(
isReadyToShip(
"approved",
[approved("qa"), approved("code_review")],
["qa", "code_review"],
),
).toBe(true);
});

it("rejects an already-shipped task", () => {
expect(isReadyToShip("merged", [], [])).toBe(false);
expect(isReadyToShip("post_deploy_validation", [], [])).toBe(false);
expect(isReadyToShip("done", [], [])).toBe(false);
});
});
Loading
Loading