Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions packages/das/src/webhook/github-fetcher.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,10 @@ export class GitHubFetcherService implements OnModuleInit {
* Resolve the PR responsible for an issue's current closed state.
*
* Reads `ClosedEvent.closer` from the issue timeline and anchors to the
* issue's current `closedAt`, so reopen-then-reclose cycles attribute to
* the latest closer, not whichever PR first declared `Closes #N` in its
* body. When no PR closer is recorded — e.g. the issue was closed manually
* issue's most-recent close (GitHub freezes `closedAt` at the *first* close,
* so the latest `ClosedEvent` is used as the effective close), so reopen-
* then-reclose cycles attribute to the latest closer, not whichever PR first
* declared `Closes #N` in its body. When no PR closer is recorded — e.g. the issue was closed manually
* rather than auto-closed by the merge — falls back to the issue's
* closing-PR references (a merged same-repo PR). Returns `null` for non-PR
* closures (commits, projects), `NOT_PLANNED` closures, or when neither
Expand Down Expand Up @@ -471,16 +472,16 @@ export class GitHubFetcherService implements OnModuleInit {
closedAt: string | null;
timelineItems?: { nodes?: any[] };
},
effectiveClosedAt: string | null,
): number | null {
const closedAt = issue.closedAt;
if (!closedAt) return null;
if (!effectiveClosedAt) return null;

const expectedRepo = repoFullName.toLowerCase();
const nodes = issue.timelineItems?.nodes ?? [];

// Walk newest to oldest, find the close event matching the issue's
// current closedAt. NOT_PLANNED closures (and anything else non-COMPLETED)
// don't attribute a solver.
// current (most-recent) close. NOT_PLANNED closures (and anything else
// non-COMPLETED) don't attribute a solver.
for (let i = nodes.length - 1; i >= 0; i--) {
const ev = nodes[i];
if (!ev) continue;
Expand All @@ -491,7 +492,7 @@ export class GitHubFetcherService implements OnModuleInit {
) {
continue;
}
if (ev.createdAt !== closedAt) continue;
if (ev.createdAt !== effectiveClosedAt) continue;
const closer = ev.closer;
if (!closer || closer.__typename !== "PullRequest") return null;
if (
Expand All @@ -509,6 +510,25 @@ export class GitHubFetcherService implements OnModuleInit {
return null;
}

/**
* GitHub freezes `issue.closedAt` at the first time the issue entered the
* closed state and does not advance it on a re-close, so it is unreliable as
* the "current close" anchor for a reopened issue. The current close is the
* most-recent CLOSED_EVENT in the timeline; use its `createdAt`. Falls back to
* `issue.closedAt` only when the timeline carries no CLOSED_EVENT.
*/
private effectiveClosedAt(issue: {
closedAt: string | null;
timelineItems?: { nodes?: any[] };
}): string | null {
const nodes = issue.timelineItems?.nodes ?? [];
for (let i = nodes.length - 1; i >= 0; i--) {
const createdAt = nodes[i]?.createdAt;
if (createdAt) return createdAt;
}
return issue.closedAt;
}

/**
* Resolve an issue's solving PR: prefer the authoritative
* `ClosedEvent.closer`, then fall back to the issue's closing-PR references.
Expand All @@ -524,9 +544,18 @@ export class GitHubFetcherService implements OnModuleInit {
closedByPullRequestsReferences?: { nodes?: any[] };
},
): number | null {
const viaCloser = this.selectClosingPrFromTimeline(repoFullName, issue);
const effectiveClosedAt = this.effectiveClosedAt(issue);
const viaCloser = this.selectClosingPrFromTimeline(
repoFullName,
issue,
effectiveClosedAt,
);
if (viaCloser != null) return viaCloser;
return this.selectClosingPrFromClosingRefs(repoFullName, issue);
return this.selectClosingPrFromClosingRefs(
repoFullName,
issue,
effectiveClosedAt,
);
}

/**
Expand All @@ -544,6 +573,7 @@ export class GitHubFetcherService implements OnModuleInit {
stateReason?: string | null;
closedByPullRequestsReferences?: { nodes?: any[] };
},
effectiveClosedAt: string | null,
): number | null {
// Only COMPLETED closures attribute a solver — parity with the closer path.
if (
Expand All @@ -553,7 +583,7 @@ export class GitHubFetcherService implements OnModuleInit {
return null;
}

const closedAt = issue.closedAt ? Date.parse(issue.closedAt) : null;
const closedAt = effectiveClosedAt ? Date.parse(effectiveClosedAt) : null;
const expectedRepo = repoFullName.toLowerCase();

const candidates = (issue.closedByPullRequestsReferences?.nodes ?? [])
Expand Down
Loading