1
+ #!/usr/bin/env bun --watch
1
2
/**
2
3
* Github Bugcop Bot
3
4
* 1. bot matches issues for label "bug-cop:ask-for-info"
@@ -10,13 +11,16 @@ import { TaskMetaCollection } from "@/src/db/TaskMeta";
10
11
import { gh , type GH } from "@/src/gh" ;
11
12
import { parseIssueUrl } from "@/src/parseIssueUrl" ;
12
13
import { parseUrlRepoOwner } from "@/src/parseOwnerRepo" ;
14
+ import KeyvSqlite from "@keyv/sqlite" ;
13
15
import DIE from "@snomiao/die" ;
14
16
import chalk from "chalk" ;
15
17
import { compareBy } from "comparing" ;
16
18
import fastDiff from "fast-diff" ;
19
+ import { mkdir } from "fs/promises" ;
17
20
import hotMemo from "hot-memo" ;
18
21
import isCI from "is-ci" ;
19
- import { difference , union } from "rambda" ;
22
+ import Keyv from "keyv" ;
23
+ import { union } from "rambda" ;
20
24
import sflow , { pageFlow } from "sflow" ;
21
25
import z from "zod" ;
22
26
import { createTimeLogger } from "../gh-design/createTimeLogger" ;
@@ -28,12 +32,23 @@ export const REPOLIST = [
28
32
"https://github.com/Comfy-Org/ComfyUI_frontend" ,
29
33
"https://github.com/Comfy-Org/desktop" ,
30
34
] ;
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
34
49
export const GithubBugcopTaskDefaultMeta = {
35
50
repoUrls : REPOLIST ,
36
- matchLabel : [ ASKING_LABEL ] ,
51
+ matchLabel : [ BUGCOP_ASKING_FOR_INFO ] ,
37
52
} ;
38
53
39
54
export type GithubBugcopTask = {
@@ -79,70 +94,74 @@ if (import.meta.main) {
79
94
80
95
export default async function runGithubBugcopTask ( ) {
81
96
tlog ( "Running Github Bugcop Task..." ) ;
97
+ const matchingLabels = [ BUGCOP_ASKING_FOR_INFO , BUGCOP_ANSWERED ] ;
82
98
const openningIssues = await sflow ( REPOLIST )
83
99
// 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
+ )
100
117
. confluenceByParallel ( ) // unpack pageFlow, order does not matter, so we can run in parallel
101
118
. forEach ( processIssue )
102
119
. toArray ( ) ;
103
120
104
- tlog ( `Processed ${ openningIssues . length } open issues with label " ${ ASKING_LABEL } " ` ) ;
121
+ tlog ( `Processed ${ openningIssues . length } open issues` ) ;
105
122
106
123
// once openning issues are processed,
107
124
// now we should process the issues in db that's not openning anymore
108
- await sflow (
125
+ const existingTasks = await sflow (
109
126
GithubBugcopTask . find ( {
110
127
url : { $nin : openningIssues . map ( ( e ) => e . html_url ) } ,
111
128
} ) ,
112
129
)
113
130
. map ( ( task ) => task . url )
114
- . log ( )
115
131
. map ( async ( issueUrl ) => await hotMemo ( gh . issues . get , [ { ...parseIssueUrl ( issueUrl ) } ] ) . then ( ( e ) => e . data ) )
116
- // .log()
117
132
. forEach ( processIssue )
118
- . run ( ) ;
133
+ . toArray ( ) ;
119
134
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!" ) ) ;
121
138
}
122
139
123
140
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 ) ;
126
143
let task = await GithubBugcopTask . findOne ( { url } ) ;
127
144
const saveTask = async ( data : Partial < GithubBugcopTask > ) =>
128
145
( 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 ) ;
131
153
task = await saveTask ( {
132
154
taskStatus : "processing" ,
133
155
user : issue . user ?. login ,
134
- labels : issue . labels . map ( ( l ) => ( typeof l === "string" ? l : ( l . name ?? "" ) ) ) . filter ( Boolean ) ,
156
+ labels : issueLabels ,
135
157
updatedAt : new Date ( issue . updated_at ) ,
136
158
} ) ;
137
159
138
160
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 ( ) } ) ;
146
165
}
147
166
148
167
// check if the issue body is updated since last successful scan
@@ -153,111 +172,78 @@ async function processIssue(issue: GH["issue"]) {
153
172
issue . body !== task . body &&
154
173
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
155
174
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 ) ) ) ;
157
177
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 ) ;
172
179
173
180
// list all label events
174
181
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 ;
194
186
} )
187
+ . filter ( ( e ) : e is NonNullable < typeof e > => e !== null )
195
188
. toArray ( ) ;
196
- // tlog("Found " + labelEvents.length + " timeline events");
189
+ tlog ( "Found " + labelEvents . length + " unlabeled/labeled/commented events" ) ;
197
190
await saveTask ( { timeline : labelEvents as any } ) ;
198
191
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" )
202
195
. map ( ( e ) => e as GH [ "labeled-issue-event" ] )
203
- . filter ( ( e ) => e . label ?. name === ASKING_LABEL )
196
+ . filter ( ( e ) => e . label ?. name === labelName )
204
197
. 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
+ }
210
211
211
- // checkif it's answered
212
+ // check if it's answered since lastLabel
212
213
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 ) ;
214
215
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 } ] ) ;
218
217
return { data : comments , next : comments . length >= 100 ? page + 1 : undefined } ;
219
218
} )
220
- . filter ( ( page ) => page . length )
221
219
. flat ( )
222
220
. filter ( ( e ) => e . user ) // filter out comments without user
223
221
. filter ( ( e ) => ! e . user ?. login . match ( / \[ b o t \] $ | - b o t / ) ) // no bots
222
+ . filter ( ( e ) => + new Date ( e . updated_at ) > + new Date ( labelLastAddedTime ) ) // only comments that is updated later than the label added time
224
223
. filter ( ( e ) => ! [ "COLLABORATOR" , "CONTRIBUTOR" , "MEMBER" , "OWNER" ] . includes ( e . author_association ) ) // not by collaborators, usually askForInfo for more info
225
224
. 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
227
225
. 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 ) ) ;
230
228
return ! ! newComments . length ;
231
229
} ) ( ) ;
232
230
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 ] ;
254
240
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
+ }
259
245
260
- if ( isDryRun ) return ;
246
+ if ( isDryRun ) return task ;
261
247
262
248
await sflow ( addLabels )
263
249
. forEach ( ( label ) => tlog ( `Adding label ${ label } to ${ issue . html_url } ` ) )
@@ -268,24 +254,23 @@ async function processIssue(issue: GH["issue"]) {
268
254
. map ( ( label ) => gh . issues . removeLabel ( { ...issueId , name : label } ) )
269
255
. run ( ) ;
270
256
271
- // update task status
272
- await saveTask ( {
273
- status,
257
+ return await saveTask ( {
258
+ // status,
274
259
statusReason : isBodyAddedContent ? "body updated" : hasNewComment ? "new comment" : "unknown" ,
275
260
taskStatus : "ok" ,
276
- taskAction : [ ...addLabels . map ( ( e ) => "+ " + e ) , ...removeLabels . map ( ( e ) => "- " + e ) ] . join ( ", " ) ,
277
261
lastChecked : new Date ( ) ,
278
262
labels : union ( task . labels || [ ] , addLabels ) . filter ( ( e ) => ! removeLabels . includes ( e ) ) ,
279
263
} ) ;
280
264
}
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 ( ) ;
291
276
}
0 commit comments