@@ -291,9 +291,22 @@ export async function fileUpstreamDriftIssues(env: Env): Promise<Record<string,
291291 let created = 0 ;
292292 let updated = 0 ;
293293 let skipped = 0 ;
294+ let unchanged = 0 ;
294295 for ( const report of reports ) {
295296 const existing = ( await validateRecordedGitHubIssue ( repo , token , report ) ) ?? ( await findGitHubIssueForFingerprint ( repo , token , report . fingerprint ) ) ;
296297 if ( existing ) {
298+ // Keep the recorded issue reference correct even when the content below turns out unchanged -- this is a
299+ // local D1 write (no GitHub API cost), and it is what lets validateRecordedGitHubIssue's fast path replace
300+ // the slower findGitHubIssueForFingerprint list-search on every later cycle once a report is discovered.
301+ await updateUpstreamDriftReportIssue ( env , report . fingerprint , existing ) ;
302+ // #4503: skip the PATCH entirely when it would be a no-op -- githubDriftIssuePayload has no always-changing
303+ // field (no timestamp), so an unresolved report with unchanged content produces a byte-identical payload
304+ // across cycles; PATCHing it anyway wastes a GitHub call and spuriously bumps the issue's "updated" time,
305+ // notifying assignees/watchers with no real change.
306+ if ( driftIssueUnchanged ( existing , report , assignees ) ) {
307+ unchanged += 1 ;
308+ continue ;
309+ }
297310 const issue = await updateGitHubDriftIssue ( repo , token , existing . number , report , assignees ) ;
298311 if ( ! issue ) {
299312 skipped += 1 ;
@@ -314,9 +327,9 @@ export async function fileUpstreamDriftIssues(env: Env): Promise<Record<string,
314327 await recordAuditEvent ( env , {
315328 eventType : "upstream.drift_issues_filed" ,
316329 outcome : "completed" ,
317- metadata : { created, updated, skipped, repo } ,
330+ metadata : { created, updated, skipped, unchanged , repo } ,
318331 } ) ;
319- return { status : "completed" , created, updated, skipped } ;
332+ return { status : "completed" , created, updated, skipped, unchanged } ;
320333}
321334
322335export async function buildUpstreamDriftReport ( current : UpstreamRulesetSnapshotRecord , previous : UpstreamRulesetSnapshotRecord | null ) : Promise < UpstreamDriftReportRecord | null > {
@@ -1013,17 +1026,40 @@ function publicDriftReport(report: UpstreamDriftReportRecord): Record<string, Js
10131026 } ;
10141027}
10151028
1016- async function findGitHubIssueForFingerprint ( repo : string , token : string , fingerprint : string ) : Promise < { number : number ; url : string } | null > {
1029+ /** The subset of a resolved existing GitHub drift issue needed to detect whether a fresh PATCH would be a no-op
1030+ * (#4503) -- body/labels/assignees are exactly the three fields updateGitHubDriftIssue's payload writes. */
1031+ type ExistingDriftIssue = { number : number ; url : string ; body : string | null ; labels : string [ ] ; assignees : string [ ] } ;
1032+
1033+ function githubIssueLabelNames ( labels : Array < string | { name ?: string } > | undefined ) : string [ ] {
1034+ return ( labels ?? [ ] ) . map ( ( label ) => ( typeof label === "string" ? label : ( label . name ?? "" ) ) ) . filter ( ( name ) => name . length > 0 ) ;
1035+ }
1036+
1037+ async function findGitHubIssueForFingerprint ( repo : string , token : string , fingerprint : string ) : Promise < ExistingDriftIssue | null > {
10171038 const [ owner , name ] = repo . split ( "/" ) ;
10181039 if ( ! owner || ! name ) return null ;
10191040 try {
10201041 for ( let page = 1 ; ; page += 1 ) {
10211042 const url = `https://api.github.com/repos/${ owner } /${ name } /issues?state=open&labels=signals&per_page=100&page=${ page } ` ;
10221043 const response = await timeoutFetch ( url , { headers : githubHeaders ( token , "application/vnd.github+json" ) } ) ;
10231044 if ( ! response . ok ) return null ;
1024- const issues = ( await response . json ( ) ) as Array < { number ?: number ; html_url ?: string ; body ?: string | null } > ;
1045+ const issues = ( await response . json ( ) ) as Array < {
1046+ number ?: number ;
1047+ html_url ?: string ;
1048+ body ?: string | null ;
1049+ labels ?: Array < string | { name ?: string } > ;
1050+ assignees ?: Array < { login ?: string } > ;
1051+ } > ;
10251052 const match = issues . find ( ( issue ) => issue . body ?. includes ( `gittensory-upstream-drift:${ fingerprint } ` ) ) ;
1026- if ( match ?. number && match . html_url ) return { number : match . number , url : match . html_url } ;
1053+ if ( match ?. number && match . html_url )
1054+ return {
1055+ number : match . number ,
1056+ url : match . html_url ,
1057+ /* v8 ignore next -- unreachable: `match` only exists when `issue.body?.includes(...)` was truthy above,
1058+ * which already requires match.body to be a defined, non-empty string. */
1059+ body : match . body ?? null ,
1060+ labels : githubIssueLabelNames ( match . labels ) ,
1061+ assignees : ( match . assignees ?? [ ] ) . map ( ( assignee ) => assignee . login ?? "" ) . filter ( ( login ) => login . length > 0 ) ,
1062+ } ;
10271063 if ( ! response . headers . get ( "link" ) ?. includes ( 'rel="next"' ) ) return null ;
10281064 }
10291065 } catch {
@@ -1057,7 +1093,7 @@ async function updateGitHubDriftIssue(repo: string, token: string, issueNumber:
10571093 return payload . number && payload . html_url ? { number : payload . number , url : payload . html_url } : null ;
10581094}
10591095
1060- async function validateRecordedGitHubIssue ( repo : string , token : string , report : UpstreamDriftReportRecord ) : Promise < { number : number ; url : string } | null > {
1096+ async function validateRecordedGitHubIssue ( repo : string , token : string , report : UpstreamDriftReportRecord ) : Promise < ExistingDriftIssue | null > {
10611097 if ( ! Number . isInteger ( report . issueNumber ) || ! report . issueNumber || report . issueNumber <= 0 || ! report . issueUrl ) return null ;
10621098 const parsedUrl = parseGitHubIssueUrl ( report . issueUrl ) ;
10631099 const [ owner , name ] = repo . split ( "/" ) ;
@@ -1066,14 +1102,29 @@ async function validateRecordedGitHubIssue(repo: string, token: string, report:
10661102 try {
10671103 const response = await timeoutFetch ( `https://api.github.com/repos/${ owner } /${ name } /issues/${ report . issueNumber } ` , { headers : githubHeaders ( token , "application/vnd.github+json" ) } ) ;
10681104 if ( ! response . ok ) return null ;
1069- const issue = ( await response . json ( ) ) as { number ?: number ; html_url ?: string ; state ?: string ; body ?: string | null ; labels ?: Array < string | { name ?: string } > } ;
1105+ const issue = ( await response . json ( ) ) as {
1106+ number ?: number ;
1107+ html_url ?: string ;
1108+ state ?: string ;
1109+ body ?: string | null ;
1110+ labels ?: Array < string | { name ?: string } > ;
1111+ assignees ?: Array < { login ?: string } > ;
1112+ } ;
10701113 if ( issue . number !== report . issueNumber || ! issue . html_url || issue . state !== "open" ) return null ;
10711114 if ( ! issue . body ?. includes ( `gittensory-upstream-drift:${ report . fingerprint } ` ) ) return null ;
10721115 if ( ! issue . labels ?. some ( ( label ) => ( typeof label === "string" ? label : label . name ) ?. toLowerCase ( ) === "signals" ) ) return null ;
10731116 const issueUrl = parseGitHubIssueUrl ( issue . html_url ) ;
10741117 if ( ! issueUrl || issueUrl . number !== report . issueNumber ) return null ;
10751118 if ( issueUrl . owner . toLowerCase ( ) !== owner . toLowerCase ( ) || issueUrl . name . toLowerCase ( ) !== name . toLowerCase ( ) ) return null ;
1076- return { number : report . issueNumber , url : issue . html_url } ;
1119+ return {
1120+ number : report . issueNumber ,
1121+ url : issue . html_url ,
1122+ /* v8 ignore next -- unreachable: `issue.body?.includes(...)` above already required issue.body to be a
1123+ * defined, non-empty string, or this function would have returned null before reaching here. */
1124+ body : issue . body ?? null ,
1125+ labels : githubIssueLabelNames ( issue . labels ) ,
1126+ assignees : ( issue . assignees ?? [ ] ) . map ( ( assignee ) => assignee . login ?? "" ) . filter ( ( login ) => login . length > 0 ) ,
1127+ } ;
10771128 } catch {
10781129 return null ;
10791130 }
@@ -1112,15 +1163,36 @@ export function resolveDriftAssignees(env: Env): string[] {
11121163 . filter ( ( login ) => login . length > 0 ) ;
11131164}
11141165
1166+ function githubDriftIssueLabels ( report : UpstreamDriftReportRecord ) : string [ ] {
1167+ return [ "signals" , "scoring" , "data" , report . severity === "high" || report . severity === "blocking" ? "high-impact" : "backend" ] ;
1168+ }
1169+
11151170function githubDriftIssuePayload ( report : UpstreamDriftReportRecord , assignees : string [ ] ) : Record < string , JsonValue > {
11161171 return {
11171172 title : githubDriftIssueTitle ( report ) ,
11181173 body : githubDriftIssueBody ( report ) ,
1119- labels : [ "signals" , "scoring" , "data" , report . severity === "high" || report . severity === "blocking" ? "high-impact" : "backend" ] ,
1174+ labels : githubDriftIssueLabels ( report ) ,
11201175 assignees,
11211176 } ;
11221177}
11231178
1179+ /** Would a fresh PATCH of `existing` with `report`/`assignees` change anything on GitHub? (#4503) Compares
1180+ * against the issue's LIVE state (already fetched by validateRecordedGitHubIssue / findGitHubIssueForFingerprint)
1181+ * rather than any locally-stored copy — ground truth, no extra fetch or DB column needed. Body is an exact
1182+ * string match: githubDriftIssueBody has no always-changing field (no timestamp), so an unresolved report with
1183+ * unchanged content produces a byte-identical body every cycle. Labels/assignees are compared as
1184+ * case-insensitive SETS, not ordered arrays — GitHub does not guarantee either the order or the casing it
1185+ * echoes back matches what we last sent. */
1186+ function driftIssueUnchanged ( existing : ExistingDriftIssue , report : UpstreamDriftReportRecord , assignees : string [ ] ) : boolean {
1187+ if ( existing . body !== githubDriftIssueBody ( report ) ) return false ;
1188+ const sameSet = ( a : string [ ] , b : string [ ] ) : boolean => {
1189+ const normalizedA = new Set ( a . map ( ( value ) => value . toLowerCase ( ) ) ) ;
1190+ const normalizedB = new Set ( b . map ( ( value ) => value . toLowerCase ( ) ) ) ;
1191+ return normalizedA . size === normalizedB . size && [ ...normalizedA ] . every ( ( value ) => normalizedB . has ( value ) ) ;
1192+ } ;
1193+ return sameSet ( existing . labels , githubDriftIssueLabels ( report ) ) && sameSet ( existing . assignees , assignees ) ;
1194+ }
1195+
11241196function githubDriftIssueBody ( report : UpstreamDriftReportRecord ) : string {
11251197 return [
11261198 `<!-- gittensory-upstream-drift:${ report . fingerprint } -->` ,
0 commit comments