fix(mirror): drive solved_by_pr from ClosedEvent.closer#123
Merged
Conversation
Solver attribution previously rode the cross-reference reconcile path: on every PR metadata fetch, the mirror wrote issue.solved_by_pr from the PR's closingIssueNumbers array. That's a record of "PR text declared it would close #N," not "GitHub determined PR-N caused the issue's current closure," so reopen-then-reclose cycles, late body edits, and stray "Closes #N" mentions could attribute to the wrong PR. Switches the source of truth to ClosedEvent.closer anchored to the issue's current closedAt: - Add fetchIssueClosingPr / selectClosingPrFromTimeline on the GitHub fetcher. Returns the closer PR number when the close event matches the current closedAt and the closer is a merged same-repo PR; null for manual closes, non-PR closers, or NOT_PLANNED closures. - Add an ISSUE_CLOSURE BullMQ job. Webhook handler enqueues on the closed action; the processor writes solved_by_pr from the fetcher. - Extend backfillIssues to query the closure timeline alongside the label timeline so each issue's solver is resolved in the same pass. - Drop reconcileSolvedIssueLinks. PR metadata still refreshes the PR-side closing_issue_numbers column; it no longer writes to issues.solved_by_pr. The downstream effect: gittensor's issue discovery (reads MirrorIssue.solved_by_pr) and the issue-bounty solver lookup (entrius/gittensor#1305, also moved to ClosedEvent.closer) now share one primitive and stay 1:1. Schema unchanged. After deploy, re-running BACKFILL_REPO with a long window reconciles existing solved_by_pr values.
entrius
approved these changes
May 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Switches
issues.solved_by_prfrom the cross-reference reconcile path to GitHub'sClosedEvent.closer, anchored to the issue's currentclosedAt. Same primitive that entrius/gittensor#1305 moved the issue-bounty solver lookup to, so the bounty path and the issue-discovery path (which readsMirrorIssue.solved_by_pr) stay 1:1.Why
The old reconcile wrote
solved_by_prfrom each PR'sclosingIssueNumberson every PR-metadata fetch — that's "PR text declared it would close #N," not "GitHub determined PR-N caused the close." Reopen-then-reclose cycles and strayCloses #Nmentions in later PRs could attribute the issue to the wrong PR.ClosedEvent.closeris GitHub's own record of what caused the current close.After this lands, both bounty payout attribution and issue-discovery scoring read the same column, and that column reflects the actual closer rather than a text-declared candidate.
What changed
github-fetcher.service.ts): newfetchIssueClosingPrruns a GraphQL query forCLOSED_EVENTtimeline items, walks newest-to-oldest looking for the event whosecreatedAt == issue.closedAt, and returns the closer PR number when the closer is a merged same-repoPullRequest.nullfor manual closes, non-PR closers,NOT_PLANNEDclosures, orclosedAtmissing.backfillIssues): the same GraphQL query gains an aliasedclosureTimelineblock (CLOSED_EVENT,last: 20). The backfill loop computessolved_by_prper issue in the same pass — no extra round trip.constants.ts+fetch.processor.ts): newISSUE_CLOSUREjob runsfetchIssueClosingPrand writes the result. Open issues short-circuit tosolved_by_pr = null.issue.handler.ts): onaction == "closed", enqueue anISSUE_CLOSUREjob.closure-<repo>-<issue>jobId dedupes. Reopen still nullssolved_by_prvia the existing OPEN-state branch.reconcileSolvedIssueLinks. PR metadata still refreshesclosing_issue_numberson the PR row (used bypr_linked_issuesto enumerate which issues each PR is linked to), but no longer writes toissues.solved_by_pr.Schema
No DDL changes — same
solved_by_prcolumn, semantics tightened from "any merged PR claimed to close" to "the PR GitHub recorded as causing the current close."Deploy / runbook
BACKFILL_REPOjob per tracked repo with a long window (e.g.days: 365) so existingsolved_by_prvalues get reconciled againstClosedEvent.closer. Going forward, the webhook handles new closes and the regular backfill cadence handles reconciliation.Validation
npm run buildcleannpm run lintcleannpm run format:checkcleanRelated
ClosedEvent.closer. This PR aligns the mirror so issue discovery picks up the same primitive.