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
31 changes: 31 additions & 0 deletions src/features/planner/session/publishSteps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,24 @@ describe("createIssues (generated from features)", () => {
expect(status["issue:o/app:login"].status).toBe("error");
expect(calls.some(c => c.method === "post" && c.path === "repos/o/app/issues")).toBe(false);
});

it("tags each created issue with its owning stream's label AT CREATION (#2397)", async () => {
// A feature IS a stream (stream defaults to the slug), so the issue must be created already
// carrying `stream:login` — no fragile post-hoc "label by predicted number" pass that 404s.
const { api, calls } = makeApi({
rest: () => [], post: () => ({ number: 7, node_id: "N7", html_url: "u" }), gql: () => ({}),
});
const { upd } = makeUpd();
await createIssues(api, upd, {
repos: ["o/app"], featuresContent: features, projectId: undefined, streams: [], viewerLogin: "",
}, noop);
// The stream label is ensured up front…
expect(calls.some(c => c.method === "post" && c.path === "repos/o/app/labels"
&& (c.body as { name?: string }).name === "stream:login")).toBe(true);
// …and the created issue already carries it.
const issuePost = calls.find(c => c.method === "post" && c.path === "repos/o/app/issues");
expect((issuePost?.body as { labels?: string[] }).labels).toContain("stream:login");
});
});

// ── applyStreamLabels ──────────────────────────────────────────────────────────
Expand All @@ -218,6 +236,19 @@ describe("applyStreamLabels", () => {
await applyStreamLabels(api, upd, { streams: [stream("s2", "o/app", [])] });
expect(status["stream:s2"].status).toBe("exists");
});

it("SKIPS a plan-ref number that 404s instead of aborting or erroring the stream (#2397)", async () => {
// #4 was never posted as a real issue → labeling it 404s. The stream must still complete: #3
// labeled, #4 skipped — the 404 is never surfaced as a publish error (the bug this fixes).
const { api } = makeApi({
post: (p: string) => { if (/\/issues\/4\/labels$/.test(p)) throw new Error("404 Not Found"); return {}; },
});
const { upd, status } = makeUpd();
await applyStreamLabels(api, upd, { streams: [stream("s1", "o/app", ["#3", "#4"])] });
expect(status["stream:s1"].status).toBe("created"); // NOT "error"
expect(status["stream:s1"].detail).toContain("1 issue labeled");
expect(status["stream:s1"].detail).toContain("1 skipped");
});
});

// ── seedPublishStatus ──────────────────────────────────────────────────────────
Expand Down
32 changes: 24 additions & 8 deletions src/features/planner/session/publishSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ export async function createIssues(
for (const name of [...new Set(mine.flatMap(iss => iss.labels))]) {
await api.post(`repos/${fullName}/labels`, { name, color: "0e8a16" }).catch(() => {});
}
// Ensure each owning stream's `stream:<id>` label up front so issues are created ALREADY tagged
// (#2397). Labeling at creation — where the real GitHub number is in hand — avoids the fragile
// post-hoc "label by the plan's predicted number" pass, whose numbers 404 when an issue failed to
// post or the numbering drifted.
for (const label of new Set(mine.map(iss => iss.stream).filter(Boolean).map(s => `stream:${s}`))) {
await api.post(`repos/${fullName}/labels`, { name: label, color: "5319e7" }).catch(() => {});
}
// ref → created GitHub node id, so feature parents + their sub-issues can be linked.
const nodeByRef: Record<string, string> = {};
for (const iss of mine) {
Expand All @@ -281,7 +288,10 @@ export async function createIssues(
upd(id, { status: "running" });
try {
const body: Record<string, unknown> = { title: iss.title, body: renderIssueBody(iss) };
body.labels = withProvenanceLabel(iss.labels); // provenance stamp (#738)
// Tag the owning stream at creation (#2397) so ownership is correct without a number-matching
// pass. Provenance stamp (#738) rides along.
const labels = iss.stream ? [...iss.labels, `stream:${iss.stream}`] : iss.labels;
body.labels = withProvenanceLabel(labels);
const issue = await api.post<{ number: number; node_id: string; html_url: string }>(`repos/${fullName}/issues`, body);
if (issue.node_id) nodeByRef[iss.ref] = issue.node_id;
if (projectId && issue.node_id) {
Expand Down Expand Up @@ -311,9 +321,11 @@ export async function createIssues(
}
}

// ── 4. Stream labels — tag each fleet stream's owned issues with `stream:<id>` so ownership is
// visible on GitHub and the board. Ensure the label, then apply it to each owned issue
// resolvable by number. Idempotent. ──
// ── 4. Stream labels — a best-effort RECONCILIATION pass (#2397). Issues created in this publish are
// already tagged `stream:<id>` at creation (createIssues); this pass re-applies the label to any
// PRE-EXISTING issues the stream owns, resolvable by number. Resilient: a plan-ref number that
// never became a real issue (a post that failed, or numbering that drifted) 404s — that issue is
// SKIPPED, never aborting the stream or surfacing the 404 as a publish error. Idempotent. ──
export async function applyStreamLabels(
api: GhApi,
upd: Upd,
Expand All @@ -330,13 +342,17 @@ export async function applyStreamLabels(
.map(ref => parseInt(ref.replace(/[^0-9]/g, ""), 10))
.filter(n => Number.isFinite(n) && n > 0);
let applied = 0;
let skipped = 0;
for (const n of nums) {
await api.post(`repos/${st.repo}/issues/${n}/labels`, { labels: [label] });
applied++;
// Per-issue resilience: a 404 (the number isn't a real issue) skips just that one.
const ok = await api.post(`repos/${st.repo}/issues/${n}/labels`, { labels: [label] })
.then(() => true).catch(() => false);
if (ok) applied++; else skipped++;
}
const skipNote = skipped > 0 ? ` · ${skipped} skipped (no matching issue)` : "";
upd(id, applied > 0
? { status: "created", detail: `${applied} issue${applied === 1 ? "" : "s"} labeled` }
: { status: "exists", detail: "label ready · no numbered issues" });
? { status: "created", detail: `${applied} issue${applied === 1 ? "" : "s"} labeled${skipNote}` }
: { status: "exists", detail: `label ready${nums.length ? skipNote : " · no numbered issues"}` });
} catch (e) {
upd(id, { status: "error", detail: String(e) });
}
Expand Down
Loading