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
6 changes: 3 additions & 3 deletions .sandcastle/eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { evaluate, pickEligible } from "./eligibility";
import type { IssueSnapshot, OpenPRClosing } from "./gh";
import type { IssueSnapshot, OpenPRClosing } from "./github";

const cfg = { label: "Sandcastle" };

Expand Down Expand Up @@ -68,14 +68,14 @@ describe("pickEligible", () => {
expect(result.excluded).toEqual([]);
});

test("returns eligible issues ordered by issue number ascending", () => {
test("preserves input snapshot order so callers control selection ordering", () => {
const snapshots = [
snapshot({ number: 5, title: "fifth" }),
snapshot({ number: 1, title: "first" }),
snapshot({ number: 3, title: "third" }),
];
const result = pickEligible(snapshots, [], cfg);
expect(result.eligible.map((i) => i.number)).toEqual([1, 3, 5]);
expect(result.eligible.map((i) => i.number)).toEqual([5, 1, 3]);
});

test("excludes issues missing the tracker label", () => {
Expand Down
7 changes: 3 additions & 4 deletions .sandcastle/eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ export type EligibilityResult = {

// Iteration-start partition: applies `evaluate` to every snapshot, mints
// branch names for survivors, validates them through issue.ts's Issue
// schema. Eligible issues are returned ordered by issue number ascending
// so callers have a deterministic "oldest first" pick without a separate
// sort.
// schema. Eligible issues are returned in the same order as the input
// snapshots — callers control ordering by passing snapshots in the order
// they want them processed (e.g. project board drag-order).
export function pickEligible(
snapshots: IssueSnapshot[],
openPRs: OpenPRClosing[],
Expand All @@ -68,6 +68,5 @@ export function pickEligible(
);
}

eligible.sort((a, b) => a.number - b.number);
return { eligible, excluded };
}
63 changes: 63 additions & 0 deletions .sandcastle/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
fetchIssueLiveState,
fetchOpenLabelledIssues,
fetchOpenPRsClosingIssues,
fetchProjectReadyIssues,
type RunGh,
} from "./github";

Expand Down Expand Up @@ -77,6 +78,68 @@ describe("fetchOpenPRsClosingIssues", () => {
});
});

describe("fetchProjectReadyIssues", () => {
const buildItem = (overrides: {
status?: string;
labels?: string[];
repo?: string;
type?: string;
number?: number;
title?: string;
}) => ({
status: overrides.status ?? "Ready",
labels: overrides.labels ?? ["Sandcastle"],
content: {
type: overrides.type ?? "Issue",
number: overrides.number ?? 1,
title: overrides.title ?? "Title",
repository: overrides.repo ?? "applification/contexture",
},
});

test("filters by status, repo, label, and type=Issue and preserves board order", async () => {
const raw = JSON.stringify({
items: [
buildItem({ number: 237, title: "Top of Ready" }),
buildItem({ number: 99, title: "Wrong column", status: "Backlog" }),
buildItem({ number: 50, title: "Wrong repo", repo: "applification/other" }),
buildItem({ number: 60, title: "Missing label", labels: ["enhancement"] }),
buildItem({ number: 70, title: "Draft item", type: "DraftIssue" }),
buildItem({ number: 233, title: "Second of Ready" }),
],
});
const result = await fetchProjectReadyIssues(
"applification",
1,
"applification/contexture",
"Sandcastle",
fakeRunGh(raw),
);
expect(result.map((i) => i.number)).toEqual([237, 233]);
expect(result[0]).toEqual({
number: 237,
title: "Top of Ready",
state: "open",
labels: ["Sandcastle"],
});
});

test("passes owner/number/limit through to gh args", async () => {
const { runGh, calls } = capturingRunGh(JSON.stringify({ items: [] }));
await fetchProjectReadyIssues("applification", 1, "applification/contexture", "Sandcastle", runGh);
expect(calls).toEqual([
["project", "item-list", "1", "--owner", "applification", "--format", "json", "--limit", "200"],
]);
});

test("rejects malformed gh output", async () => {
const raw = JSON.stringify({ items: [{ status: "Ready", labels: [], content: { type: "Issue" } }] });
await expect(
fetchProjectReadyIssues("applification", 1, "applification/contexture", "Sandcastle", fakeRunGh(raw)),
).rejects.toThrow();
});
});

describe("fetchIssueLiveState", () => {
test("normalises uppercase CLOSED to lowercase", async () => {
const raw = JSON.stringify({
Expand Down
67 changes: 67 additions & 0 deletions .sandcastle/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ const IssueSnapshotSchema = z.object({
});
const IssueListSchema = z.array(IssueSnapshotSchema);

// Project items expose the underlying issue under `content` and lift the
// kanban Status column to a top-level `status` string. `repository` is the
// owner/name slug — we filter on it because the project can hold items from
// multiple repos, and Sandcastle only ever wants this repo's issues. `number`
// and `repository` are absent for `DraftIssue` items (project-only cards
// not yet promoted to a real issue), so the schema tolerates that and we
// filter draft items out by `content.type === "Issue"`.
const ProjectItemSchema = z.object({
status: z.string().optional(),
labels: z.array(z.string()).optional().default([]),
content: z.object({
type: z.string(),
number: z.number().int().positive().optional(),
title: z.string(),
repository: z.string().optional(),
}),
});
const ProjectItemListSchema = z.object({ items: z.array(ProjectItemSchema) });

const PRBodyEntry = z.object({
number: z.number().int().positive(),
body: z.string().nullable(),
Expand Down Expand Up @@ -78,6 +97,54 @@ export async function fetchOpenLabelledIssues(
return IssueListSchema.parse(raw);
}

// Fetch issues sitting in the project's `Ready` column for the given repo,
// preserving the board's drag-order. The orchestrator uses this in place of
// `fetchOpenLabelledIssues` so the user's kanban order drives selection. We
// synthesise `state: "open"` because Ready items are by definition open —
// closing an issue moves it out of Ready automatically.
export async function fetchProjectReadyIssues(
owner: string,
projectNumber: number,
repo: string,
label: string,
runGh: RunGh = defaultRunGh,
): Promise<IssueSnapshot[]> {
const raw = await ghJson(runGh, [
"project",
"item-list",
String(projectNumber),
"--owner",
owner,
"--format",
"json",
"--limit",
"200",
]);
const { items } = ProjectItemListSchema.parse(raw);
const out: IssueSnapshot[] = [];
for (const item of items) {
// Filter to Ready issues for this repo with the tracker label. DraftIssue
// items lack `content.number` and `content.repository`, so the type guard
// also narrows the optionals to defined values for the push below.
if (
item.status !== "Ready" ||
item.content.type !== "Issue" ||
item.content.repository !== repo ||
item.content.number === undefined ||
!item.labels.includes(label)
) {
continue;
}
out.push({
number: item.content.number,
title: item.content.title,
state: "open",
labels: item.labels,
});
}
return out;
}

// Fetch every open PR and extract the issue numbers each one closes via its
// body. We don't filter PRs by tracker label — a PR opened against any
// Sandcastle issue still claims that issue.
Expand Down
Loading
Loading