1- import { fetchLinkedIssueFacts , type LinkedIssueFactsFetch } from "../github/backfill" ;
1+ import { fetchLinkedIssueFacts , type LinkedIssueFactsFetch , type LinkedIssueFactsResult } from "../github/backfill" ;
22import { createInstallationToken , getRepositoryCollaboratorPermission } from "../github/app" ;
33import { githubRateLimitAdmissionKeyForToken } from "../github/client" ;
44import { parseGitHubLoginList } from "../auth/security" ;
@@ -40,6 +40,19 @@ async function isRepoMaintainerLogin(env: Env, installationId: number, repoFullN
4040 return permission != null && new Set ( [ "admin" , "maintain" , "write" ] ) . has ( permission ) ;
4141}
4242
43+ /** True when the linked issue's authority for propagation can be trusted (#4528): it's still OPEN, or it
44+ * was closed no earlier than THIS PR's own merge. Merging a PR whose body says "Closes #N" auto-closes
45+ * issue #N as an immediate side effect of that same merge -- so `closedAt >= prMergedAt` is exactly the
46+ * signature of "this merge is what closed it," the single most authoritative moment for propagation to
47+ * fire, not a weaker one. An issue closed BEFORE this PR ever merged (`closedAt < prMergedAt`) is the
48+ * gaming case the OPEN-only check originally existed to block -- a PR opportunistically referencing some
49+ * unrelated, already-resolved issue to borrow its label -- and stays blocked, unchanged. `prMergedAt`
50+ * absent (PR not yet merged) never trusts a closed issue, also unchanged. */
51+ function isLinkedIssueTrustworthy ( facts : LinkedIssueFactsResult , prMergedAt : string | null ) : boolean {
52+ if ( facts . state === "open" ) return true ;
53+ return prMergedAt !== null && facts . closedAt !== null && facts . closedAt >= prMergedAt ;
54+ }
55+
4356/** Per-issue label resolution for {@link fetchLinkedIssueLabelsForPropagation}: a direct PR-author-is-
4457 * issue-author-or-assignee match unlocks EVERY label the issue carries (today's original behavior,
4558 * unchanged). Failing that, a mapping explicitly opted into `trustMaintainerAuthoredIssue` OR
@@ -61,8 +74,9 @@ async function resolveIssueLabelsForPropagation(
6174 result : LinkedIssueFactsFetch ,
6275 prAuthorLogin : string | undefined ,
6376 relaxableLabels : ReadonlySet < string > ,
77+ prMergedAt : string | null ,
6478) : Promise < string [ ] > {
65- if ( result . status !== "found" || result . facts . state !== "open" || ! prAuthorLogin ) return [ ] ;
79+ if ( result . status !== "found" || ! isLinkedIssueTrustworthy ( result . facts , prMergedAt ) || ! prAuthorLogin ) return [ ] ;
6680 const allLabels = result . facts . labels ;
6781 const issueAuthorLogin = result . facts . authorLogin ?. toLowerCase ( ) ;
6882 const assignees = result . facts . assignees . map ( ( login ) => login . toLowerCase ( ) ) ;
@@ -89,9 +103,9 @@ async function resolveIssueLabelsForPropagation(
89103}
90104
91105/** FETCH every linked issue's labels (fail-open) and flatten into one label list for
92- * `resolvePrTypeLabel` (`src/settings/pr-type-label.ts`) to match against. Only verified OPEN issues
93- * can contribute labels; closing-keyword text in a PR body is author-controlled and is not authority by
94- * itself. Mirrors
106+ * `resolvePrTypeLabel` (`src/settings/pr-type-label.ts`) to match against. Only an OPEN issue, or one
107+ * closed no earlier than THIS PR's own merge (#4528, { @link isLinkedIssueTrustworthy}), can contribute
108+ * labels; closing-keyword text in a PR body is author-controlled and is not authority by itself. Mirrors
95109 * `resolveLinkedIssueHardRule`'s own fetch idiom (`src/review/linked-issue-hard-rules.ts`): a per-issue
96110 * fetch failure contributes no labels rather than throwing, so if EVERY linked issue fails, the result is
97111 * `[]` — which can never match a mapping, meaning a sensitive label like `gittensor:priority` never applies
@@ -108,14 +122,18 @@ async function resolveIssueLabelsForPropagation(
108122 * `mappings` (optional, #priority-linked-issue-gate-ownership) is the propagation config's own mapping
109123 * list, used ONLY to know which `issueLabel`s are allowed to unlock via `resolveIssueLabelsForPropagation`'s
110124 * relaxed maintainer-authored-issue path (either trust flag) -- omitting it (or a mapping never setting
111- * either flag) reproduces today's strict author-or-assignee-only behavior exactly. */
125+ * either flag) reproduces today's strict author-or-assignee-only behavior exactly.
126+ *
127+ * `prMergedAt` (#4528) is this PR's own `merged_at`, or `null` while unmerged -- the caller's `pr.mergedAt`
128+ * straight from the DB row, no extra fetch. */
112129export async function fetchLinkedIssueLabelsForPropagation ( args : {
113130 env : Env ;
114131 repoFullName : string ;
115132 linkedIssues : number [ ] ;
116133 installationId : number ;
117134 prAuthorLogin : string | null | undefined ;
118135 mappings ?: readonly LinkedIssueLabelPropagationMapping [ ] | undefined ;
136+ prMergedAt ?: string | null | undefined ;
119137} ) : Promise < string [ ] > {
120138 if ( args . linkedIssues . length === 0 ) return [ ] ;
121139 const linkedIssues = args . linkedIssues . slice ( 0 , MAX_LINKED_ISSUES_TO_FETCH ) ;
@@ -129,6 +147,7 @@ export async function fetchLinkedIssueLabelsForPropagation(args: {
129147 args . installationId ,
130148 ) ;
131149 const prAuthorLogin = args . prAuthorLogin ?. toLowerCase ( ) ;
150+ const prMergedAt = args . prMergedAt ?? null ;
132151 const relaxableLabels = new Set (
133152 ( args . mappings ?? [ ] )
134153 . filter ( ( mapping ) => mapping . trustMaintainerAuthoredIssue === true || mapping . trustMaintainerAuthoredIssueForReward === true )
@@ -152,6 +171,7 @@ export async function fetchLinkedIssueLabelsForPropagation(args: {
152171 result ,
153172 prAuthorLogin ,
154173 relaxableLabels ,
174+ prMergedAt ,
155175 ) ,
156176 ) ,
157177 ) ;
0 commit comments