Skip to content

Commit 7ce8094

Browse files
authored
Merge pull request #48 from Comfy-Org/sno-bugcop-dont-set-answered
refactor(gh-bugcop): remove ANSWERED_LABEL management and improve label handling
2 parents d289591 + 8e22ef0 commit 7ce8094

File tree

4 files changed

+308
-164
lines changed

4 files changed

+308
-164
lines changed

app/tasks/gh-bugcop/GithubBugcopTaskStatus.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import prettyMilliseconds from "pretty-ms";
55
import { useEffect, useState } from "react";
66
import sflow from "sflow";
77
import useAsyncEffect from "use-async-effect";
8-
import { ASKING_LABEL, GithubBugcopTask } from "./gh-bugcop";
8+
import { BUGCOP_ANSWERED, BUGCOP_ASKING_FOR_INFO, BUGCOP_RESPONSE_RECEIVED, GithubBugcopTask } from "./gh-bugcop";
99

1010
if (import.meta.main) render(<GithubBugcopTaskStatus />);
1111

@@ -16,8 +16,9 @@ export default function GithubBugcopTaskStatus({}) {
1616

1717
// Color mappers
1818
const getStatusColor = (labels?: string[]) => {
19-
if (labels?.includes(ASKING_LABEL)) return "yellow";
20-
// if (labels?.includes(ANSWERED_LABEL)) return "green";
19+
if (labels?.includes(BUGCOP_ASKING_FOR_INFO)) return "yellow";
20+
if (labels?.includes(BUGCOP_ANSWERED)) return "blue";
21+
if (labels?.includes(BUGCOP_RESPONSE_RECEIVED)) return "green";
2122
return "red";
2223
};
2324

app/tasks/gh-bugcop/gh-bugcop.tsx

Lines changed: 117 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#!/usr/bin/env bun --watch
12
/**
23
* Github Bugcop Bot
34
* 1. bot matches issues for label "bug-cop:ask-for-info"
@@ -10,13 +11,16 @@ import { TaskMetaCollection } from "@/src/db/TaskMeta";
1011
import { gh, type GH } from "@/src/gh";
1112
import { parseIssueUrl } from "@/src/parseIssueUrl";
1213
import { parseUrlRepoOwner } from "@/src/parseOwnerRepo";
14+
import KeyvSqlite from "@keyv/sqlite";
1315
import DIE from "@snomiao/die";
1416
import chalk from "chalk";
1517
import { compareBy } from "comparing";
1618
import fastDiff from "fast-diff";
19+
import { mkdir } from "fs/promises";
1720
import hotMemo from "hot-memo";
1821
import isCI from "is-ci";
19-
import { difference, union } from "rambda";
22+
import Keyv from "keyv";
23+
import { union } from "rambda";
2024
import sflow, { pageFlow } from "sflow";
2125
import z from "zod";
2226
import { createTimeLogger } from "../gh-design/createTimeLogger";
@@ -28,12 +32,23 @@ export const REPOLIST = [
2832
"https://github.com/Comfy-Org/ComfyUI_frontend",
2933
"https://github.com/Comfy-Org/desktop",
3034
];
31-
export const ASKING_LABEL = "bug-cop:ask-for-info";
32-
// export const ANSWERED_LABEL = "bug-cop:answered"; // 2025-08-09 “answered” is never managed by bot
33-
export const RESPONSE_RECEIVED_LABEL = "bug-cop:response-received";
35+
await mkdir("./.cache", { recursive: true });
36+
const kv = new Keyv({ store: new KeyvSqlite("sqlite://.cache/bugcop-cache.sqlite") });
37+
function createKeyvCachedFn<FN extends (...args: any[]) => Promise<unknown>>(key: string, fn: FN): FN {
38+
return (async (...args) => {
39+
const mixedKey = key + "(" + JSON.stringify(args) + ")";
40+
if (await kv.has(mixedKey)) return await kv.get(mixedKey);
41+
const ret = await fn(...args);
42+
await kv.set(mixedKey, ret);
43+
return ret;
44+
}) as FN;
45+
}
46+
export const BUGCOP_ASKING_FOR_INFO = "bug-cop:ask-for-info" as const; // asking user for more info
47+
export const BUGCOP_ANSWERED = "bug-cop:answered" as const; // an issue is answered by ComfyOrg Team member
48+
export const BUGCOP_RESPONSE_RECEIVED = "bug-cop:response-received" as const; // user has responded ask-for-info or answered label
3449
export const GithubBugcopTaskDefaultMeta = {
3550
repoUrls: REPOLIST,
36-
matchLabel: [ASKING_LABEL],
51+
matchLabel: [BUGCOP_ASKING_FOR_INFO],
3752
};
3853

3954
export type GithubBugcopTask = {
@@ -79,70 +94,74 @@ if (import.meta.main) {
7994

8095
export default async function runGithubBugcopTask() {
8196
tlog("Running Github Bugcop Task...");
97+
const matchingLabels = [BUGCOP_ASKING_FOR_INFO, BUGCOP_ANSWERED];
8298
const openningIssues = await sflow(REPOLIST)
8399
// list issues for each repo
84-
.map((repoUrl) => {
85-
tlog(`Fetching issues for ${repoUrl}...`);
86-
return pageFlow(1, async (page) => {
87-
const { data: issues } = await hotMemo(gh.issues.listForRepo, [
88-
{
89-
...parseUrlRepoOwner(repoUrl),
90-
state: "open" as const,
91-
page,
92-
per_page: 100,
93-
labels: ASKING_LABEL,
94-
},
95-
]);
96-
tlog(`Found ${issues.length} matched issues in ${repoUrl}`);
97-
return { data: issues, next: issues.length >= 100 ? page + 1 : undefined };
98-
}).flat();
99-
})
100+
.flatMap((repoUrl) =>
101+
matchingLabels.map((label) =>
102+
pageFlow(1, async (page) => {
103+
const { data: issues } = await hotMemo(gh.issues.listForRepo, [
104+
{
105+
...parseUrlRepoOwner(repoUrl),
106+
state: "open" as const,
107+
page,
108+
per_page: 100,
109+
labels: label,
110+
},
111+
]);
112+
tlog(`Found ${issues.length} ${label} issues in ${repoUrl}`);
113+
return { data: issues, next: issues.length >= 100 ? page + 1 : undefined };
114+
}).flat(),
115+
),
116+
)
100117
.confluenceByParallel() // unpack pageFlow, order does not matter, so we can run in parallel
101118
.forEach(processIssue)
102119
.toArray();
103120

104-
tlog(`Processed ${openningIssues.length} open issues with label "${ASKING_LABEL}"`);
121+
tlog(`Processed ${openningIssues.length} open issues`);
105122

106123
// once openning issues are processed,
107124
// now we should process the issues in db that's not openning anymore
108-
await sflow(
125+
const existingTasks = await sflow(
109126
GithubBugcopTask.find({
110127
url: { $nin: openningIssues.map((e) => e.html_url) },
111128
}),
112129
)
113130
.map((task) => task.url)
114-
.log()
115131
.map(async (issueUrl) => await hotMemo(gh.issues.get, [{ ...parseIssueUrl(issueUrl) }]).then((e) => e.data))
116-
// .log()
117132
.forEach(processIssue)
118-
.run();
133+
.toArray();
119134

120-
tlog(chalk.green("Github Bugcop Task completed successfully!"));
135+
tlog(chalk.green("Processed " + existingTasks.length + " existing tasks that are not openning/labeled anymore"));
136+
137+
tlog(chalk.green("All Github Bugcop Task completed successfully!"));
121138
}
122139

123140
async function processIssue(issue: GH["issue"]) {
124-
const url = issue.html_url; // ?? ("issue.html_url is required") ;
125-
const issueId = parseIssueUrl(issue.html_url); // = {owner, repo, issue_number}
141+
const url = issue.html_url;
142+
const issueId = parseIssueUrl(issue.html_url);
126143
let task = await GithubBugcopTask.findOne({ url });
127144
const saveTask = async (data: Partial<GithubBugcopTask>) =>
128145
(task =
129-
(await GithubBugcopTask.findOneAndUpdate({ url }, { $set: data }, { returnDocument: "after", upsert: true })) ||
130-
DIE("never"));
146+
(await GithubBugcopTask.findOneAndUpdate(
147+
{ url },
148+
{ $set: { updatedAt: new Date(), ...data } },
149+
{ returnDocument: "after", upsert: true },
150+
)) || DIE("never"));
151+
152+
const issueLabels = issue.labels.map((l) => (typeof l === "string" ? l : (l.name ?? ""))).filter(Boolean);
131153
task = await saveTask({
132154
taskStatus: "processing",
133155
user: issue.user?.login,
134-
labels: issue.labels.map((l) => (typeof l === "string" ? l : (l.name ?? ""))).filter(Boolean),
156+
labels: issueLabels,
135157
updatedAt: new Date(issue.updated_at),
136158
});
137159

138160
if (issue.state === "closed") {
139-
await saveTask({
140-
status: "closed",
141-
statusReason: "issue closed",
142-
updatedAt: new Date(issue.updated_at),
143-
lastChecked: new Date(),
144-
});
145-
return;
161+
if (task.status !== "closed") {
162+
tlog(chalk.bgRedBright("Issue is closed: " + issue.html_url));
163+
}
164+
return await saveTask({ status: "closed", lastChecked: new Date() });
146165
}
147166

148167
// check if the issue body is updated since last successful scan
@@ -153,111 +172,78 @@ async function processIssue(issue: GH["issue"]) {
153172
issue.body !== task.body &&
154173
fastDiff(task.body ?? "", issue.body ?? "").filter(([op, val]) => op === fastDiff.INSERT).length > 0; // check if the issue body has added new content after the label added time
155174

156-
tlog(chalk.bgBlackBright("Issue: " + issue.html_url));
175+
tlog(chalk.bgBlackBright("Processing Issue: " + issue.html_url));
176+
tlog(chalk.bgBlue("Labels: " + JSON.stringify(issueLabels)));
157177

158-
const timeline = await pageFlow(1, async (page) => {
159-
const { data: events } = await hotMemo(gh.issues.listEventsForTimeline, [
160-
{
161-
...issueId,
162-
page,
163-
per_page: 100,
164-
},
165-
]);
166-
return { data: events, next: events.length >= 100 ? page + 1 : undefined };
167-
})
168-
// flat
169-
.filter((e) => e.length)
170-
.by((s) => s.pipeThrough(flats()))
171-
.toArray();
178+
const timeline = await fetchAllIssueTimeline(issueId);
172179

173180
// list all label events
174181
const labelEvents = await sflow([...timeline])
175-
.forEach((_e) => {
176-
if (_e.event === "labeled") {
177-
const e = _e as GH["labeled-issue-event"];
178-
// tlog(`#${issue.number} ${new Date(e.created_at).toISOString()} @${e.actor.login} + label:${e.label.name}`);
179-
return e;
180-
}
181-
if (_e.event === "unlabeled") {
182-
const e = _e as GH["unlabeled-issue-event"];
183-
// tlog(`#${issue.number} ${new Date(e.created_at).toISOString()} @${e.actor.login} - label:${e.label.name}`);
184-
return e;
185-
}
186-
if (_e.event === "commented") {
187-
const e = _e as GH["timeline-comment-event"];
188-
// tlog(`#${issue.number} ${new Date(e.created_at).toISOString()} @${e.actor?.login} ${e.body?.slice(0, 20)}`);
189-
return e;
190-
}
191-
192-
tlog(`#${issue.number} ${new Date((_e as any).created_at ?? new Date()).toISOString()} ? ${_e.event}`);
193-
// ignore other events
182+
.map((_e) => {
183+
return _e.event === "labeled" || _e.event === "unlabeled" || _e.event === "commented"
184+
? (_e as GH["labeled-issue-event"] | GH["unlabeled-issue-event"] | GH["timeline-comment-event"])
185+
: null;
194186
})
187+
.filter((e): e is NonNullable<typeof e> => e !== null)
195188
.toArray();
196-
// tlog("Found " + labelEvents.length + " timeline events");
189+
tlog("Found " + labelEvents.length + " unlabeled/labeled/commented events");
197190
await saveTask({ timeline: labelEvents as any });
198191

199-
const latestLabeledEvent =
200-
labelEvents
201-
.filter((e) => e.event === "labeled")
192+
function lastLabeled(labelName: string) {
193+
return labelEvents
194+
.filter((e) => e?.event === "labeled")
202195
.map((e) => e as GH["labeled-issue-event"])
203-
.filter((e) => e.label?.name === ASKING_LABEL)
196+
.filter((e) => e.label?.name === labelName)
204197
.sort(compareBy((e) => e.created_at))
205-
.reverse()[0] ||
206-
DIE("No labeled event found, this should not happen since we are filtering issues by this label");
207-
// last added time of this label
208-
const labelLastAddedTime = new Date(latestLabeledEvent?.created_at);
209-
tlog('Last added time of label "' + ASKING_LABEL + '" is ' + labelLastAddedTime.toISOString());
198+
.reverse()[0];
199+
}
200+
201+
const latestLabeledEvent = lastLabeled(BUGCOP_ASKING_FOR_INFO) || lastLabeled(BUGCOP_ANSWERED);
202+
if (!latestLabeledEvent) {
203+
lastLabeled(BUGCOP_RESPONSE_RECEIVED) ||
204+
DIE(
205+
new Error(
206+
`No labeled event found, this should not happen since we are filtering issues by those label, ${JSON.stringify(task.labels)}`,
207+
),
208+
);
209+
return task;
210+
}
210211

211-
// checkif it's answered
212+
// check if it's answered since lastLabel
212213
const hasNewComment = await (async function () {
213-
// 1. list issue comments that is updated/created later than this label last added
214+
const labelLastAddedTime = new Date(latestLabeledEvent?.created_at);
214215
const newComments = await pageFlow(1, async (page) => {
215-
const { data: comments } = await hotMemo(gh.issues.listComments.bind(gh.issues), [
216-
{ ...issueId, page, per_page: 100 },
217-
]);
216+
const { data: comments } = await hotMemo(gh.issues.listComments, [{ ...issueId, page, per_page: 100 }]);
218217
return { data: comments, next: comments.length >= 100 ? page + 1 : undefined };
219218
})
220-
.filter((page) => page.length)
221219
.flat()
222220
.filter((e) => e.user) // filter out comments without user
223221
.filter((e) => !e.user?.login.match(/\[bot\]$|-bot/)) // no bots
222+
.filter((e) => +new Date(e.updated_at) > +new Date(labelLastAddedTime)) // only comments that is updated later than the label added time
224223
.filter((e) => !["COLLABORATOR", "CONTRIBUTOR", "MEMBER", "OWNER"].includes(e.author_association)) // not by collaborators, usually askForInfo for more info
225224
.filter((e) => e.user?.login !== latestLabeledEvent.actor.login) // ignore the user who added the label
226-
.filter((e) => +new Date(e.updated_at) > +new Date(labelLastAddedTime)) // only comments that is updated later than the label added time
227225
.toArray();
228-
newComments.length && tlog("Found " + newComments.length + " comments after last added time for " + issue.html_url);
229-
// tlog("Found " + JSON.stringify(comments));
226+
newComments.length &&
227+
tlog(chalk.bgGreen("Found " + newComments.length + " comments after last added time for " + issue.html_url));
230228
return !!newComments.length;
231229
})();
232230

233-
// TODO: maybe search in notion db about this issue, if it's answered in notion, then mark as answered
234-
// tlog('issue body not updated after last added time, checking comments...');
235-
236-
const responseReceived = hasNewComment || isBodyAddedContent; // check if user responsed info by new comment or body update
237-
const status: "responseReceived" | "askForInfo" = responseReceived ? "responseReceived" : "askForInfo";
238-
const workinglabels = [ASKING_LABEL, RESPONSE_RECEIVED_LABEL];
239-
const labelSet = {
240-
responseReceived: [RESPONSE_RECEIVED_LABEL],
241-
askForInfo: [ASKING_LABEL],
242-
closed: [], // clear bug-cop labels
243-
}[status];
244-
245-
const currentLabels = issue.labels
246-
.filter((l) => l != null)
247-
.map((l) => (typeof l === "string" ? l : (l.name ?? "")))
248-
.filter(Boolean);
249-
const addLabels = difference(labelSet, currentLabels);
250-
const removeLabels = difference(
251-
currentLabels.filter((label) => workinglabels.includes(label)),
252-
labelSet,
253-
);
231+
const isResponseReceived = hasNewComment || isBodyAddedContent; // check if user responsed info by new comment or body updated since last scanned
232+
if (!isResponseReceived) {
233+
return await saveTask({
234+
taskStatus: "ok",
235+
lastChecked: new Date(),
236+
});
237+
}
238+
const addLabels = [BUGCOP_RESPONSE_RECEIVED];
239+
const removeLabels = [latestLabeledEvent.label.name];
254240

255-
tlog(`Issue ${issue.html_url}`);
256-
tlog(
257-
`>> status: ${status}, labels: ${chalk.bgBlue([...addLabels.map((e) => "+ " + e), ...removeLabels.map((e) => "- " + e)].join(", "))}`,
258-
);
241+
if (isResponseReceived) {
242+
console.log(chalk.bgBlue("Adding:"), addLabels);
243+
console.log(chalk.bgBlue("Removing:"), removeLabels);
244+
}
259245

260-
if (isDryRun) return;
246+
if (isDryRun) return task;
261247

262248
await sflow(addLabels)
263249
.forEach((label) => tlog(`Adding label ${label} to ${issue.html_url}`))
@@ -268,24 +254,23 @@ async function processIssue(issue: GH["issue"]) {
268254
.map((label) => gh.issues.removeLabel({ ...issueId, name: label }))
269255
.run();
270256

271-
// update task status
272-
await saveTask({
273-
status,
257+
return await saveTask({
258+
// status,
274259
statusReason: isBodyAddedContent ? "body updated" : hasNewComment ? "new comment" : "unknown",
275260
taskStatus: "ok",
276-
taskAction: [...addLabels.map((e) => "+ " + e), ...removeLabels.map((e) => "- " + e)].join(", "),
277261
lastChecked: new Date(),
278262
labels: union(task.labels || [], addLabels).filter((e) => !removeLabels.includes(e)),
279263
});
280264
}
281-
function flats<T>(): TransformStream<T[], T> {
282-
return new TransformStream<T[], T>({
283-
transform: (e, controller) => {
284-
e.forEach((event) => controller.enqueue(event));
285-
},
286-
flush: (controller) => {
287-
// No finalization needed
288-
// Stream will be closed automatically after flush
289-
},
290-
});
265+
266+
async function fetchAllIssueTimeline(issueId: { owner: string; repo: string; issue_number: number }) {
267+
return await pageFlow(1, async (page, size = 100) => {
268+
const { data: events } = await createKeyvCachedFn("gh.issues.listEventsForTimeline", (...args) =>
269+
gh.issues.listEventsForTimeline(...args),
270+
)({ ...issueId, page, per_page: size });
271+
console.log("Fetched " + JSON.stringify({ ...issueId, page, per_page: size, events: events.length }) + " events");
272+
return { data: events, next: events.length >= size ? page + 1 : undefined };
273+
})
274+
.flat()
275+
.toArray();
291276
}

0 commit comments

Comments
 (0)