Skip to content

Commit 7b94b71

Browse files
snomiaoclaude
andcommitted
feat: add gh-pr-release-tagger task to label released PRs
Adds a new GitHub task that monitors core/1.* and cloud/1.* branches in ComfyUI_frontend and automatically adds 'released:core' or 'released:cloud' labels to PRs included in each release. - Lists release branches matching the configured patterns - Fetches releases and groups by branch, filtering by processSince - Compares consecutive releases to find included commits - Labels associated PRs with the appropriate branch prefix label - Ensures labels exist in repo before applying (creates if missing) - Tracks processed releases in MongoDB GithubPRReleaseTaggerTask collection - Registers in run-gh-tasks.ts for 5-minute scheduled execution Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 0ae58c1 commit 7b94b71

File tree

3 files changed

+585
-0
lines changed

3 files changed

+585
-0
lines changed
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { describe, it, expect } from "bun:test";
2+
import type { GithubPRReleaseTaggerTask } from "./index";
3+
4+
describe("GithubPRReleaseTaggerTask", () => {
5+
describe("configuration", () => {
6+
const config = {
7+
repo: "https://github.com/Comfy-Org/ComfyUI_frontend",
8+
reReleaseBranchPatterns: /^(core|cloud)\/1\.\d+$/,
9+
getLabelForBranch: (branch: string) => `released:${branch.split("/")[0]}`,
10+
maxReleasesToCheck: 5,
11+
processSince: new Date("2026-01-01T00:00:00Z").toISOString(),
12+
};
13+
14+
it("should have valid repo URL", () => {
15+
expect(config.repo).toContain("github.com");
16+
expect(config.repo).toContain("ComfyUI_frontend");
17+
});
18+
19+
it("should match core/* branch patterns", () => {
20+
expect(config.reReleaseBranchPatterns.test("core/1.0")).toBe(true);
21+
expect(config.reReleaseBranchPatterns.test("core/1.36")).toBe(true);
22+
expect(config.reReleaseBranchPatterns.test("core/1.100")).toBe(true);
23+
});
24+
25+
it("should match cloud/* branch patterns", () => {
26+
expect(config.reReleaseBranchPatterns.test("cloud/1.0")).toBe(true);
27+
expect(config.reReleaseBranchPatterns.test("cloud/1.36")).toBe(true);
28+
expect(config.reReleaseBranchPatterns.test("cloud/1.100")).toBe(true);
29+
});
30+
31+
it("should not match non-release branches", () => {
32+
expect(config.reReleaseBranchPatterns.test("main")).toBe(false);
33+
expect(config.reReleaseBranchPatterns.test("develop")).toBe(false);
34+
expect(config.reReleaseBranchPatterns.test("feature/dark-mode")).toBe(false);
35+
expect(config.reReleaseBranchPatterns.test("core/1.x")).toBe(false); // non-numeric minor
36+
expect(config.reReleaseBranchPatterns.test("staging/1.0")).toBe(false);
37+
});
38+
39+
it("should generate correct labels for branches", () => {
40+
expect(config.getLabelForBranch("core/1.36")).toBe("released:core");
41+
expect(config.getLabelForBranch("cloud/1.36")).toBe("released:cloud");
42+
expect(config.getLabelForBranch("core/1.100")).toBe("released:core");
43+
expect(config.getLabelForBranch("cloud/1.0")).toBe("released:cloud");
44+
});
45+
46+
it("should have positive maxReleasesToCheck", () => {
47+
expect(config.maxReleasesToCheck).toBeGreaterThan(0);
48+
});
49+
50+
it("should have a valid processSince date", () => {
51+
const date = new Date(config.processSince);
52+
expect(date.getFullYear()).toBeGreaterThanOrEqual(2026);
53+
});
54+
});
55+
56+
describe("task state structure", () => {
57+
it("should accept valid GithubPRReleaseTaggerTask shape", () => {
58+
const task: GithubPRReleaseTaggerTask = {
59+
releaseUrl: "https://github.com/Comfy-Org/ComfyUI_frontend/releases/tag/v1.0.0",
60+
releaseTag: "v1.0.0",
61+
branch: "core/1.36",
62+
labeledPRs: [
63+
{
64+
prNumber: 100,
65+
prUrl: "https://github.com/Comfy-Org/ComfyUI_frontend/pull/100",
66+
prTitle: "feat: add dark mode",
67+
labeledAt: new Date("2026-01-15T00:00:00Z"),
68+
},
69+
],
70+
taskStatus: "completed",
71+
checkedAt: new Date("2026-01-15T00:00:00Z"),
72+
};
73+
74+
expect(task.releaseUrl).toContain("releases/tag");
75+
expect(task.branch).toMatch(/^(core|cloud)\/1\.\d+$/);
76+
expect(task.taskStatus).toBe("completed");
77+
expect(task.labeledPRs).toHaveLength(1);
78+
expect(task.labeledPRs[0].prNumber).toBe(100);
79+
});
80+
81+
it("should allow all taskStatus values", () => {
82+
const statuses: GithubPRReleaseTaggerTask["taskStatus"][] = [
83+
"checking",
84+
"completed",
85+
"failed",
86+
];
87+
expect(statuses).toContain("checking");
88+
expect(statuses).toContain("completed");
89+
expect(statuses).toContain("failed");
90+
});
91+
92+
it("should handle empty labeledPRs array", () => {
93+
const task: GithubPRReleaseTaggerTask = {
94+
releaseUrl: "https://github.com/Comfy-Org/ComfyUI_frontend/releases/tag/v1.0.0",
95+
releaseTag: "v1.0.0",
96+
branch: "core/1.36",
97+
labeledPRs: [],
98+
taskStatus: "checking",
99+
checkedAt: new Date(),
100+
};
101+
102+
expect(task.labeledPRs).toEqual([]);
103+
});
104+
});
105+
106+
describe("branch grouping logic", () => {
107+
it("should group releases by branch correctly", () => {
108+
const releases = [
109+
{ tag_name: "core-v1.0.0", target_commitish: "core/1.36", created_at: "2026-01-01T00:00:00Z" },
110+
{ tag_name: "core-v1.0.1", target_commitish: "core/1.36", created_at: "2026-01-02T00:00:00Z" },
111+
{ tag_name: "cloud-v1.0.0", target_commitish: "cloud/1.36", created_at: "2026-01-01T00:00:00Z" },
112+
];
113+
114+
const releasesByBranch = new Map<string, typeof releases>();
115+
for (const release of releases) {
116+
const branch = release.target_commitish;
117+
if (!releasesByBranch.has(branch)) {
118+
releasesByBranch.set(branch, []);
119+
}
120+
releasesByBranch.get(branch)!.push(release);
121+
}
122+
123+
expect(releasesByBranch.size).toBe(2);
124+
expect(releasesByBranch.get("core/1.36")).toHaveLength(2);
125+
expect(releasesByBranch.get("cloud/1.36")).toHaveLength(1);
126+
});
127+
128+
it("should sort releases ascending by created_at", () => {
129+
const releases = [
130+
{ tag_name: "v1.0.2", target_commitish: "core/1.36", created_at: "2026-01-03T00:00:00Z" },
131+
{ tag_name: "v1.0.0", target_commitish: "core/1.36", created_at: "2026-01-01T00:00:00Z" },
132+
{ tag_name: "v1.0.1", target_commitish: "core/1.36", created_at: "2026-01-02T00:00:00Z" },
133+
];
134+
135+
const sorted = [...releases].sort(
136+
(a, b) => +new Date(a.created_at) - +new Date(b.created_at),
137+
);
138+
139+
expect(sorted[0].tag_name).toBe("v1.0.0");
140+
expect(sorted[1].tag_name).toBe("v1.0.1");
141+
expect(sorted[2].tag_name).toBe("v1.0.2");
142+
});
143+
144+
it("should limit releases per branch to maxReleasesToCheck", () => {
145+
const maxReleasesToCheck = 5;
146+
const releases = Array.from({ length: 10 }, (_, i) => ({
147+
tag_name: `v1.0.${i}`,
148+
target_commitish: "core/1.36",
149+
created_at: new Date(2026, 0, i + 1).toISOString(),
150+
}));
151+
152+
const limited = releases.slice(-maxReleasesToCheck);
153+
expect(limited).toHaveLength(5);
154+
expect(limited[0].tag_name).toBe("v1.0.5");
155+
expect(limited[4].tag_name).toBe("v1.0.9");
156+
});
157+
});
158+
159+
describe("label operations", () => {
160+
it("should not re-label already labeled PRs", () => {
161+
const labelName = "released:core";
162+
const alreadyLabeledPrNumbers = new Set([100, 101]);
163+
164+
const prNumbers = [100, 101, 102, 103];
165+
const toLabel = prNumbers.filter((n) => !alreadyLabeledPrNumbers.has(n));
166+
167+
expect(toLabel).toEqual([102, 103]);
168+
});
169+
170+
it("should skip PRs that already have the label", () => {
171+
const labelName = "released:core";
172+
const pr = {
173+
number: 100,
174+
labels: [{ name: "released:core" }, { name: "bug" }],
175+
};
176+
177+
const existingLabels = pr.labels.map((l) =>
178+
typeof l === "string" ? l : l.name || "",
179+
);
180+
const alreadyHasLabel = existingLabels.includes(labelName);
181+
182+
expect(alreadyHasLabel).toBe(true);
183+
});
184+
185+
it("should detect missing labels that need to be added", () => {
186+
const labelName = "released:cloud";
187+
const pr = {
188+
number: 200,
189+
labels: [{ name: "bug" }, { name: "enhancement" }],
190+
};
191+
192+
const existingLabels = pr.labels.map((l) =>
193+
typeof l === "string" ? l : l.name || "",
194+
);
195+
const alreadyHasLabel = existingLabels.includes(labelName);
196+
197+
expect(alreadyHasLabel).toBe(false);
198+
});
199+
});
200+
201+
describe("processSince filtering", () => {
202+
const processSince = new Date("2026-01-01T00:00:00Z").toISOString();
203+
204+
it("should include releases after processSince", () => {
205+
const releaseDate = "2026-02-01T00:00:00Z";
206+
const isIncluded = +new Date(releaseDate) >= +new Date(processSince);
207+
expect(isIncluded).toBe(true);
208+
});
209+
210+
it("should exclude releases before processSince", () => {
211+
const releaseDate = "2025-12-31T00:00:00Z";
212+
const isIncluded = +new Date(releaseDate) >= +new Date(processSince);
213+
expect(isIncluded).toBe(false);
214+
});
215+
216+
it("should include releases exactly at processSince", () => {
217+
const releaseDate = "2026-01-01T00:00:00Z";
218+
const isIncluded = +new Date(releaseDate) >= +new Date(processSince);
219+
expect(isIncluded).toBe(true);
220+
});
221+
});
222+
223+
describe("error handling", () => {
224+
it("should handle failed task status", () => {
225+
const task: GithubPRReleaseTaggerTask = {
226+
releaseUrl: "https://github.com/Comfy-Org/ComfyUI_frontend/releases/tag/v1.0.0",
227+
releaseTag: "v1.0.0",
228+
branch: "core/1.36",
229+
labeledPRs: [],
230+
taskStatus: "failed",
231+
checkedAt: new Date(),
232+
};
233+
234+
expect(task.taskStatus).toBe("failed");
235+
});
236+
237+
it("should preserve already labeled PRs when task fails", () => {
238+
const existingLabeledPRs = [
239+
{
240+
prNumber: 100,
241+
prUrl: "https://github.com/Comfy-Org/ComfyUI_frontend/pull/100",
242+
prTitle: "feat: test feature",
243+
labeledAt: new Date(),
244+
},
245+
];
246+
247+
// Simulating preservation of labeled PRs from existing task
248+
const savedLabeledPRs = [...existingLabeledPRs];
249+
expect(savedLabeledPRs).toHaveLength(1);
250+
expect(savedLabeledPRs[0].prNumber).toBe(100);
251+
});
252+
});
253+
254+
describe("database indexes", () => {
255+
it("should use releaseUrl as unique identifier", () => {
256+
const uniqueField = "releaseUrl";
257+
expect(uniqueField).toBe("releaseUrl");
258+
});
259+
260+
it("should include all expected indexes", () => {
261+
const indexes = ["releaseUrl", "releaseTag", "branch", "checkedAt"];
262+
expect(indexes).toContain("releaseUrl");
263+
expect(indexes).toContain("releaseTag");
264+
expect(indexes).toContain("branch");
265+
expect(indexes).toContain("checkedAt");
266+
});
267+
});
268+
});

0 commit comments

Comments
 (0)