From be123161856fa0d6f54e7009cf7dd2b92d17e044 Mon Sep 17 00:00:00 2001 From: volcano303 <75143900+volcano303@users.noreply.github.com> Date: Wed, 20 May 2026 05:57:01 -1000 Subject: [PATCH 01/13] fix(webhook): sync repo default branch from webhook payloads (#40) --- .../das/src/webhook/handlers/issue.handler.ts | 10 +++++++-- .../webhook/handlers/pull-request.handler.ts | 10 +++++++-- packages/das/src/webhook/webhook.service.ts | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/das/src/webhook/handlers/issue.handler.ts b/packages/das/src/webhook/handlers/issue.handler.ts index ac54368..fb91029 100644 --- a/packages/das/src/webhook/handlers/issue.handler.ts +++ b/packages/das/src/webhook/handlers/issue.handler.ts @@ -54,8 +54,14 @@ export class IssueHandler { await this.issueRepo.upsert(data, ["repoFullName", "issueNumber"]); - await this.repoRepo.update(repoFullName, { + const repoUpdate: Partial = { lastEventAt: new Date().toISOString(), - }); + }; + const defaultBranch: string | null = + payload.repository?.default_branch ?? null; + if (defaultBranch) { + repoUpdate.defaultBranch = defaultBranch; + } + await this.repoRepo.update(repoFullName, repoUpdate); } } diff --git a/packages/das/src/webhook/handlers/pull-request.handler.ts b/packages/das/src/webhook/handlers/pull-request.handler.ts index 7213834..bd70e83 100644 --- a/packages/das/src/webhook/handlers/pull-request.handler.ts +++ b/packages/das/src/webhook/handlers/pull-request.handler.ts @@ -53,9 +53,15 @@ export class PullRequestHandler { await this.prRepo.upsert(data, ["repoFullName", "prNumber"]); - await this.repoRepo.update(repoFullName, { + const repoUpdate: Partial = { lastEventAt: new Date().toISOString(), - }); + }; + const defaultBranch: string | null = + payload.repository?.default_branch ?? null; + if (defaultBranch) { + repoUpdate.defaultBranch = defaultBranch; + } + await this.repoRepo.update(repoFullName, repoUpdate); // Enqueue metadata fetch (closing issues + body + lastEditedAt) on relevant actions. // Also run on `edited` so post-merge body edits are captured. diff --git a/packages/das/src/webhook/webhook.service.ts b/packages/das/src/webhook/webhook.service.ts index ab4d707..aafa29d 100644 --- a/packages/das/src/webhook/webhook.service.ts +++ b/packages/das/src/webhook/webhook.service.ts @@ -91,6 +91,9 @@ export class WebhookService { } switch (event) { + case "repository": + await this.handleRepositoryEvent(payload); + break; case "pull_request": await this.pullRequestHandler.handle(payload); if (payload.action === "labeled" || payload.action === "unlabeled") { @@ -119,4 +122,22 @@ export class WebhookService { this.logger.debug(`Unhandled event type: ${event}`); } } + + private async handleRepositoryEvent( + payload: Record, + ): Promise { + const repoFullName: string | undefined = payload.repository?.full_name; + if (!repoFullName) return; + + const repoUpdate: Partial = { + lastEventAt: new Date().toISOString(), + }; + const defaultBranch: string | null = + payload.repository?.default_branch ?? null; + if (defaultBranch) { + repoUpdate.defaultBranch = defaultBranch; + } + + await this.repoRepo.update(repoFullName, repoUpdate); + } } From a9a0a18ed8f6e86f9713b44b317c69e4e12672f8 Mon Sep 17 00:00:00 2001 From: volcano303 <75143900+volcano303@users.noreply.github.com> Date: Wed, 20 May 2026 09:47:42 -1000 Subject: [PATCH 02/13] fix(webhook): fail GraphQL responses with errors (#78) --- .../das/src/webhook/github-fetcher.service.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/das/src/webhook/github-fetcher.service.ts b/packages/das/src/webhook/github-fetcher.service.ts index c50e067..8a0526e 100644 --- a/packages/das/src/webhook/github-fetcher.service.ts +++ b/packages/das/src/webhook/github-fetcher.service.ts @@ -150,6 +150,14 @@ export class GitHubFetcherService implements OnModuleInit { return 60_000; } + private assertNoGraphQLErrors(body: any, context: string): void { + if (!body?.errors) return; + + throw new Error( + `${context} GraphQL errors: ${JSON.stringify(body.errors)}`, + ); + } + // --- Authentication --- private createAppJwt(): string { @@ -319,7 +327,13 @@ export class GitHubFetcherService implements OnModuleInit { } const body: any = await res.json(); - const pr = body.data?.repository?.pullRequest ?? {}; + this.assertNoGraphQLErrors(body, "PR metadata fetch"); + + const pr = body.data?.repository?.pullRequest; + if (!pr) { + throw new Error(`GraphQL PR metadata fetch returned no PR data`); + } + const nodes = pr.closingIssuesReferences?.nodes ?? []; return { @@ -606,11 +620,7 @@ export class GitHubFetcherService implements OnModuleInit { } const body: any = await res.json(); - if (body.errors) { - throw new Error( - `GraphQL content fetch errors: ${JSON.stringify(body.errors)}`, - ); - } + this.assertNoGraphQLErrors(body, "Content fetch"); const repoData = body.data?.repository ?? {}; @@ -786,9 +796,13 @@ export class GitHubFetcherService implements OnModuleInit { } const body: any = await res.json(); + this.assertNoGraphQLErrors(body, "Backfill PR fetch"); + const repoData: any = body.data?.repository; const page: any = repoData?.pullRequests; - if (!page) break; + if (!page) { + throw new Error(`Backfill PR GraphQL returned no pullRequests page`); + } // defaultBranchRef is the same across every page — write once. if (!defaultBranchWritten) { @@ -969,8 +983,12 @@ export class GitHubFetcherService implements OnModuleInit { } const body: any = await res.json(); + this.assertNoGraphQLErrors(body, "Backfill issue fetch"); + const page: any = body.data?.repository?.issues; - if (!page) break; + if (!page) { + throw new Error(`Backfill issue GraphQL returned no issues page`); + } let shouldStop = false; for (const issue of page.nodes) { From 966879ef3dbeaae80495359d40d9fc54823eb21b Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 20 May 2026 16:04:13 -0700 Subject: [PATCH 03/13] fix(backfill): create issue rows before PR metadata jobs (#95) Move issue backfill ahead of PR metadata and file job enqueueing so metadata jobs can resolve solved_by_pr links during backfill. Fixes #28 --- packages/das/src/queue/fetch.processor.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/das/src/queue/fetch.processor.ts b/packages/das/src/queue/fetch.processor.ts index 27860d2..d341167 100644 --- a/packages/das/src/queue/fetch.processor.ts +++ b/packages/das/src/queue/fetch.processor.ts @@ -139,6 +139,10 @@ export class FetchProcessor extends WorkerHost { ); this.logger.log(`Backfilled ${prs.length} PRs from ${repoFullName}`); + // Fetch and upsert issues before PR metadata jobs can link solved_by_pr. + await this.fetcher.backfillIssues(repoFullName, sinceDate); + this.logger.log(`Backfilled issues from ${repoFullName}`); + // Enqueue follow-up jobs (metadata + files for every PR). for (const { prNumber, headSha, baseSha } of prs) { await this.fetchQueue.add( @@ -160,10 +164,6 @@ export class FetchProcessor extends WorkerHost { baseSha ?? null, ); } - - // Fetch and upsert issues - await this.fetcher.backfillIssues(repoFullName, sinceDate); - this.logger.log(`Backfilled issues from ${repoFullName}`); } private async handleStalePrFilesJob( From 022450b67d82bc9c15def9d242a6e7bc9e257b0b Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 20 May 2026 16:17:50 -0700 Subject: [PATCH 04/13] fix(mirror): reconcile stale solved issue links (#96) Clear solved_by_pr links that still point to a refreshed PR when its closing references change or it is no longer merged, then reapply current merged links. Fixes #59 --- packages/das/src/queue/fetch.processor.ts | 71 +++++++++++++++++++---- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/das/src/queue/fetch.processor.ts b/packages/das/src/queue/fetch.processor.ts index d341167..8da02c8 100644 --- a/packages/das/src/queue/fetch.processor.ts +++ b/packages/das/src/queue/fetch.processor.ts @@ -1,7 +1,7 @@ import { Processor, WorkerHost, InjectQueue } from "@nestjs/bullmq"; import { Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { IsNull, Repository } from "typeorm"; +import { In, IsNull, Repository } from "typeorm"; import { Job, Queue } from "bullmq"; import { Issue, PullRequest } from "../entities"; import { GitHubFetcherService } from "../webhook/github-fetcher.service"; @@ -79,28 +79,33 @@ export class FetchProcessor extends WorkerHost { ): Promise { this.logger.log(`Fetching PR metadata for ${repoFullName}#${prNumber}`); + const previousPr = await this.prRepo.findOneBy({ repoFullName, prNumber }); + const previousClosingIssueNumbers = this.uniqueIssueNumbers( + previousPr?.closingIssueNumbers ?? [], + ); + const { closingIssueNumbers, body, lastEditedAt } = await this.fetcher.fetchPrMetadata(repoFullName, prNumber); + const currentClosingIssueNumbers = + this.uniqueIssueNumbers(closingIssueNumbers); await this.prRepo.update( { repoFullName, prNumber }, { - closingIssueNumbers, + closingIssueNumbers: currentClosingIssueNumbers, body, lastEditedAt, }, ); - // If this PR is merged, mark each linked issue as solved_by_pr const pr = await this.prRepo.findOneBy({ repoFullName, prNumber }); - if (pr?.state === "MERGED" && closingIssueNumbers.length > 0) { - for (const issueNumber of closingIssueNumbers) { - await this.issueRepo.update( - { repoFullName, issueNumber }, - { solvedByPr: prNumber }, - ); - } - } + await this.reconcileSolvedIssueLinks( + repoFullName, + prNumber, + previousClosingIssueNumbers, + currentClosingIssueNumbers, + pr?.state === "MERGED", + ); } private async handlePrFiles(data: PrFilesJobData): Promise { @@ -222,4 +227,48 @@ export class FetchProcessor extends WorkerHost { baseSha: generation.baseSha ?? IsNull(), }; } + + private async reconcileSolvedIssueLinks( + repoFullName: string, + prNumber: number, + previousIssueNumbers: number[], + currentIssueNumbers: number[], + isMerged: boolean, + ): Promise { + const currentIssueNumberSet = new Set(currentIssueNumbers); + const staleIssueNumbers = previousIssueNumbers.filter( + (issueNumber) => !currentIssueNumberSet.has(issueNumber), + ); + + const clearIssueNumbers = isMerged + ? staleIssueNumbers + : this.uniqueIssueNumbers([ + ...previousIssueNumbers, + ...currentIssueNumbers, + ]); + + if (clearIssueNumbers.length > 0) { + await this.issueRepo.update( + { + repoFullName, + issueNumber: In(clearIssueNumbers), + solvedByPr: prNumber, + }, + { solvedByPr: null }, + ); + } + + if (!isMerged || currentIssueNumbers.length === 0) { + return; + } + + await this.issueRepo.update( + { repoFullName, issueNumber: In(currentIssueNumbers) }, + { solvedByPr: prNumber }, + ); + } + + private uniqueIssueNumbers(issueNumbers: number[]): number[] { + return [...new Set(issueNumbers)]; + } } From b301567c966a87d868e5013d28175c2f1373f1f6 Mon Sep 17 00:00:00 2001 From: Chan <101856681+enjoyandlove@users.noreply.github.com> Date: Wed, 20 May 2026 19:23:08 -0400 Subject: [PATCH 05/13] fix(installation): replace find-then-insert with atomic upsert (#110) (#111) --- .../webhook/handlers/installation.handler.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/das/src/webhook/handlers/installation.handler.ts b/packages/das/src/webhook/handlers/installation.handler.ts index 2638652..7eaf630 100644 --- a/packages/das/src/webhook/handlers/installation.handler.ts +++ b/packages/das/src/webhook/handlers/installation.handler.ts @@ -38,22 +38,19 @@ export class InstallationHandler { payload.repositories ?? payload.repositories_added ?? []; for (const repo of repos) { - // Check existence first so we only set added_at on insert, not on every - // re-fire of installation.created / installation_repositories.added. - const existing = await this.repoRepo.findOneBy({ - repoFullName: repo.full_name, - }); - if (existing) { - await this.repoRepo.update(repo.full_name, { - installationId: String(installationId), - }); - } else { - await this.repoRepo.insert({ + // Atomic upsert: insert with addedAt on first encounter; on conflict only + // update installationId so addedAt is never overwritten on re-fires. + await this.repoRepo + .createQueryBuilder() + .insert() + .into(Repo) + .values({ repoFullName: repo.full_name, installationId: String(installationId), addedAt: new Date().toISOString(), - }); - } + }) + .orUpdate(["installationId"], ["repoFullName"]) + .execute(); this.logger.log(`Tracking repo: ${repo.full_name}`); } From 437fddfa23d4f6709b0c426fc79200d3ac04c9c7 Mon Sep 17 00:00:00 2001 From: Ander <61125407+anderdc@users.noreply.github.com> Date: Wed, 20 May 2026 18:35:16 -0500 Subject: [PATCH 06/13] fix(webhook): don't retain failed PR metadata jobs (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `fetch-pr-metadata` job uses a stable custom jobId per PR (`meta--`). BullMQ ignores `add()` when a job with that id already exists in any state, including the failed-retention set. With `removeOnFail: 50`, a metadata job that exhausts its 3 retries during a transient GitHub outage sat in the failed set and blocked every later `edited`/`closed`/`reopened`/`synchronize` webhook for the same PR until the global 50-slot cap evicted it — leaving `body`, `last_edited_at`, `closing_issue_numbers`, and downstream `issues.solved_by_pr` stale. Drop retention for these enqueues to `true` so failed jobs evict immediately and the next webhook gets a fresh fetch. Failure detail remains in service logs. Fixes #75. Co-authored-by: anderdc --- packages/das/src/queue/fetch.processor.ts | 4 +++- packages/das/src/webhook/handlers/pull-request.handler.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/das/src/queue/fetch.processor.ts b/packages/das/src/queue/fetch.processor.ts index 8da02c8..4809baf 100644 --- a/packages/das/src/queue/fetch.processor.ts +++ b/packages/das/src/queue/fetch.processor.ts @@ -156,7 +156,9 @@ export class FetchProcessor extends WorkerHost { { jobId: `meta-${repoFullName}-${prNumber}`, removeOnComplete: true, - removeOnFail: 50, + // Match the webhook handler — failed metadata jobs must not squat + // on the stable per-PR jobId (#75). + removeOnFail: true, attempts: 3, backoff: { type: "exponential", delay: 5000 }, }, diff --git a/packages/das/src/webhook/handlers/pull-request.handler.ts b/packages/das/src/webhook/handlers/pull-request.handler.ts index bd70e83..53dbee8 100644 --- a/packages/das/src/webhook/handlers/pull-request.handler.ts +++ b/packages/das/src/webhook/handlers/pull-request.handler.ts @@ -79,9 +79,11 @@ export class PullRequestHandler { { repoFullName, prNumber }, { jobId, - // Replace any pending job for the same PR (e.g. rapid pushes) + // Pending/active jobs for the same PR still dedupe by jobId. + // Don't retain failed jobs — they'd block future enqueues for this + // PR until the failed-set cap evicts them (#75). removeOnComplete: true, - removeOnFail: 50, + removeOnFail: true, attempts: 3, backoff: { type: "exponential", delay: 5000 }, }, From 12029ee78533872f850425fb4774aefe4e3e9029 Mon Sep 17 00:00:00 2001 From: Ander <61125407+anderdc@users.noreply.github.com> Date: Fri, 22 May 2026 17:10:39 -0500 Subject: [PATCH 07/13] docs: add open-item rate limit rule to CONTRIBUTING (#122) Co-authored-by: anderdc --- CONTRIBUTING.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48cf627..f10e6a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,14 @@ The repository runs an automated maintainer agent that may close PRs in the foll - Unresolved merge conflicts for 12+ hours with no resolution push - Requested changes from a maintainer for 12+ hours with no follow-up commits +## Automatic Closures + +The maintainer bot enforces these rules without manual review. Contributions that violate them are closed automatically. + +### Open item limits + +Each contributor may have at most **2 open PRs** and **2 open issues** in this repository at any time. Submitting a 3rd of either type while at the cap closes the new one on submission. The limits apply independently — you can have 2 open PRs and 2 open issues at the same time. + ## PR Labels Apply appropriate labels to help categorize and track your contribution: From 0f6a9757cbd703eae90f7a94d6ec18f0c15f5423 Mon Sep 17 00:00:00 2001 From: Ander <61125407+anderdc@users.noreply.github.com> Date: Fri, 22 May 2026 20:07:28 -0500 Subject: [PATCH 08/13] fix(mirror): drive solved_by_pr from ClosedEvent.closer (#123) 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. Co-authored-by: anderdc --- packages/das/src/queue/constants.ts | 1 + packages/das/src/queue/fetch.processor.ts | 105 ++++++------ .../das/src/webhook/github-fetcher.service.ts | 150 +++++++++++++++++- .../das/src/webhook/handlers/issue.handler.ts | 22 +++ 4 files changed, 220 insertions(+), 58 deletions(-) diff --git a/packages/das/src/queue/constants.ts b/packages/das/src/queue/constants.ts index fd77f4e..04046ea 100644 --- a/packages/das/src/queue/constants.ts +++ b/packages/das/src/queue/constants.ts @@ -4,6 +4,7 @@ export const FETCH_JOBS = { PR_METADATA: "fetch-pr-metadata", PR_FILES: "fetch-pr-files", BACKFILL_REPO: "backfill-repo", + ISSUE_CLOSURE: "fetch-issue-closure", } as const; export const DEFAULT_BACKFILL_DAYS = 40; diff --git a/packages/das/src/queue/fetch.processor.ts b/packages/das/src/queue/fetch.processor.ts index 4809baf..08e1e6d 100644 --- a/packages/das/src/queue/fetch.processor.ts +++ b/packages/das/src/queue/fetch.processor.ts @@ -1,7 +1,7 @@ import { Processor, WorkerHost, InjectQueue } from "@nestjs/bullmq"; import { Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { In, IsNull, Repository } from "typeorm"; +import { IsNull, Repository } from "typeorm"; import { Job, Queue } from "bullmq"; import { Issue, PullRequest } from "../entities"; import { GitHubFetcherService } from "../webhook/github-fetcher.service"; @@ -29,12 +29,21 @@ export interface BackfillRepoJobData { days?: number; } +export interface IssueClosureJobData { + repoFullName: string; + issueNumber: number; +} + interface PrFilesGeneration { headSha: string | null; baseSha: string | null; } -type JobData = PrMetadataJobData | PrFilesJobData | BackfillRepoJobData; +type JobData = + | PrMetadataJobData + | PrFilesJobData + | BackfillRepoJobData + | IssueClosureJobData; @Processor(FETCH_QUEUE, { concurrency: 5 }) export class FetchProcessor extends WorkerHost { @@ -68,22 +77,52 @@ export class FetchProcessor extends WorkerHost { await this.handleBackfill(repoFullName, days ?? DEFAULT_BACKFILL_DAYS); break; } + case FETCH_JOBS.ISSUE_CLOSURE: { + const { repoFullName, issueNumber } = job.data as IssueClosureJobData; + await this.handleIssueClosure(repoFullName, issueNumber); + break; + } default: this.logger.warn(`Unknown job name: ${job.name}`); } } + private async handleIssueClosure( + repoFullName: string, + issueNumber: number, + ): Promise { + this.logger.log(`Resolving closer for ${repoFullName}#${issueNumber}`); + + const issue = await this.issueRepo.findOneBy({ + repoFullName, + issueNumber, + }); + if (!issue) return; + + // Reopens already null out solvedByPr in the webhook handler; never + // re-fetch for an open issue. + if (issue.state !== "CLOSED") { + await this.issueRepo.update( + { repoFullName, issueNumber }, + { solvedByPr: null }, + ); + return; + } + + const solvedByPr = await this.fetcher.fetchIssueClosingPr( + repoFullName, + issueNumber, + ); + + await this.issueRepo.update({ repoFullName, issueNumber }, { solvedByPr }); + } + private async handlePrMetadata( repoFullName: string, prNumber: number, ): Promise { this.logger.log(`Fetching PR metadata for ${repoFullName}#${prNumber}`); - const previousPr = await this.prRepo.findOneBy({ repoFullName, prNumber }); - const previousClosingIssueNumbers = this.uniqueIssueNumbers( - previousPr?.closingIssueNumbers ?? [], - ); - const { closingIssueNumbers, body, lastEditedAt } = await this.fetcher.fetchPrMetadata(repoFullName, prNumber); const currentClosingIssueNumbers = @@ -98,14 +137,10 @@ export class FetchProcessor extends WorkerHost { }, ); - const pr = await this.prRepo.findOneBy({ repoFullName, prNumber }); - await this.reconcileSolvedIssueLinks( - repoFullName, - prNumber, - previousClosingIssueNumbers, - currentClosingIssueNumbers, - pr?.state === "MERGED", - ); + // Issue solver attribution is closure-driven (ISSUE_CLOSURE jobs read + // ClosedEvent.closer). PR metadata only refreshes the PR-side text view + // of which issues this PR claims to close — it never writes + // issues.solved_by_pr. } private async handlePrFiles(data: PrFilesJobData): Promise { @@ -230,46 +265,6 @@ export class FetchProcessor extends WorkerHost { }; } - private async reconcileSolvedIssueLinks( - repoFullName: string, - prNumber: number, - previousIssueNumbers: number[], - currentIssueNumbers: number[], - isMerged: boolean, - ): Promise { - const currentIssueNumberSet = new Set(currentIssueNumbers); - const staleIssueNumbers = previousIssueNumbers.filter( - (issueNumber) => !currentIssueNumberSet.has(issueNumber), - ); - - const clearIssueNumbers = isMerged - ? staleIssueNumbers - : this.uniqueIssueNumbers([ - ...previousIssueNumbers, - ...currentIssueNumbers, - ]); - - if (clearIssueNumbers.length > 0) { - await this.issueRepo.update( - { - repoFullName, - issueNumber: In(clearIssueNumbers), - solvedByPr: prNumber, - }, - { solvedByPr: null }, - ); - } - - if (!isMerged || currentIssueNumbers.length === 0) { - return; - } - - await this.issueRepo.update( - { repoFullName, issueNumber: In(currentIssueNumbers) }, - { solvedByPr: prNumber }, - ); - } - private uniqueIssueNumbers(issueNumbers: number[]): number[] { return [...new Set(issueNumbers)]; } diff --git a/packages/das/src/webhook/github-fetcher.service.ts b/packages/das/src/webhook/github-fetcher.service.ts index 8a0526e..ba8a666 100644 --- a/packages/das/src/webhook/github-fetcher.service.ts +++ b/packages/das/src/webhook/github-fetcher.service.ts @@ -360,6 +360,126 @@ export class GitHubFetcherService implements OnModuleInit { .filter((number): number is number => typeof number === "number"); } + // --- GraphQL: issue closure (which PR caused the current close) --- + + /** + * 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. Returns the PR number when the closer is a merged same-repo PR; + * `null` for manual closes, non-PR closers (commits, projects), or + * `NOT_PLANNED` closures. + * + * Source of truth for `issues.solved_by_pr`. Issue discovery and the + * issue-bounty solver lookup both read from this field, so they stay 1:1. + */ + async fetchIssueClosingPr( + repoFullName: string, + issueNumber: number, + ): Promise { + const [owner, repo] = repoFullName.split("/"); + const token = await this.getTokenForRepo(repoFullName); + + const query = ` + query($owner: String!, $repo: String!, $issue: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issue) { + closedAt + timelineItems(itemTypes: [CLOSED_EVENT], last: 20) { + nodes { + ... on ClosedEvent { + createdAt + stateReason + closer { + __typename + ... on PullRequest { + number + merged + state + baseRepository { nameWithOwner } + } + } + } + } + } + } + } + } + `; + + const res = await this.githubFetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query, + variables: { owner, repo, issue: issueNumber }, + }), + }); + + if (!res.ok) { + throw new Error( + `GraphQL issue closure fetch failed: ${res.status} ${await res.text()}`, + ); + } + + const body: any = await res.json(); + this.assertNoGraphQLErrors(body, "Issue closure fetch"); + + const issue = body.data?.repository?.issue; + if (!issue) return null; + + return this.selectClosingPrFromTimeline(repoFullName, issue); + } + + private selectClosingPrFromTimeline( + repoFullName: string, + issue: { + closedAt: string | null; + timelineItems?: { nodes?: any[] }; + }, + ): number | null { + const closedAt = issue.closedAt; + if (!closedAt) 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. + for (let i = nodes.length - 1; i >= 0; i--) { + const ev = nodes[i]; + if (!ev) continue; + const stateReason = ev.stateReason; + if ( + stateReason != null && + String(stateReason).toUpperCase() !== "COMPLETED" + ) { + continue; + } + if (ev.createdAt !== closedAt) continue; + const closer = ev.closer; + if (!closer || closer.__typename !== "PullRequest") return null; + if ( + (closer.baseRepository?.nameWithOwner ?? "").toLowerCase() !== + expectedRepo + ) { + return null; + } + const merged = + closer.merged === true || + String(closer.state ?? "").toUpperCase() === "MERGED"; + if (!merged) return null; + return typeof closer.number === "number" ? closer.number : null; + } + return null; + } + // --- PR files + contents (REST for list, batched GraphQL for contents) --- /** @@ -952,6 +1072,26 @@ export class GitHubFetcherService implements OnModuleInit { } } } + closureTimeline: timelineItems( + itemTypes: [CLOSED_EVENT] + last: 20 + ) { + nodes { + ... on ClosedEvent { + createdAt + stateReason + closer { + __typename + ... on PullRequest { + number + merged + state + baseRepository { nameWithOwner } + } + } + } + } + } } } } @@ -1024,9 +1164,13 @@ export class GitHubFetcherService implements OnModuleInit { issueData.isTransferred = true; } - if (issue.state === "OPEN") { - issueData.solvedByPr = null; - } + issueData.solvedByPr = + issue.state === "CLOSED" + ? this.selectClosingPrFromTimeline(repoFullName, { + closedAt: issue.closedAt ?? null, + timelineItems: { nodes: issue.closureTimeline?.nodes ?? [] }, + }) + : null; await this.issueRepo.upsert(issueData, ["repoFullName", "issueNumber"]); diff --git a/packages/das/src/webhook/handlers/issue.handler.ts b/packages/das/src/webhook/handlers/issue.handler.ts index fb91029..a25ba8b 100644 --- a/packages/das/src/webhook/handlers/issue.handler.ts +++ b/packages/das/src/webhook/handlers/issue.handler.ts @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { InjectQueue } from "@nestjs/bullmq"; import { Repository } from "typeorm"; +import { Queue } from "bullmq"; import { Issue, Repo } from "../../entities"; +import { FETCH_QUEUE, FETCH_JOBS } from "../../queue/constants"; @Injectable() export class IssueHandler { @@ -11,6 +14,8 @@ export class IssueHandler { private readonly issueRepo: Repository, @InjectRepository(Repo) private readonly repoRepo: Repository, + @InjectQueue(FETCH_QUEUE) + private readonly fetchQueue: Queue, ) {} async handle(payload: Record): Promise { @@ -54,6 +59,23 @@ export class IssueHandler { await this.issueRepo.upsert(data, ["repoFullName", "issueNumber"]); + // Resolve solver attribution from the issue's ClosedEvent timeline. + // The same primitive feeds issue discovery and the issue-bounty solver + // lookup so the two paths stay 1:1. + if (payload.action === "closed") { + await this.fetchQueue.add( + FETCH_JOBS.ISSUE_CLOSURE, + { repoFullName, issueNumber: issue.number }, + { + jobId: `closure-${repoFullName}-${issue.number}`, + removeOnComplete: true, + removeOnFail: true, + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + }, + ); + } + const repoUpdate: Partial = { lastEventAt: new Date().toISOString(), }; From 838a3364a81de3b405e66340f30bb492a0ff10f9 Mon Sep 17 00:00:00 2001 From: Jonathan Chang <55106972+jonathanchang31@users.noreply.github.com> Date: Fri, 29 May 2026 03:32:13 +0700 Subject: [PATCH 09/13] fix: allowing unknown solvers through mirror solved-issue pipeline (#87) * fix: allowing unknown solvers through mirror solved-issue pipeline * fix: code change after review --- packages/das/src/api/miners/miners.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/das/src/api/miners/miners.service.ts b/packages/das/src/api/miners/miners.service.ts index 113a502..2a5a1a2 100644 --- a/packages/das/src/api/miners/miners.service.ts +++ b/packages/das/src/api/miners/miners.service.ts @@ -156,6 +156,7 @@ const ISSUE_SELECT_COLUMNS = ` AND sp.pr_number = i.solved_by_pr -- Skip null-author solving PRs (no one to credit) AND sp.author_github_id IS NOT NULL + AND BTRIM(sp.author_github_id) <> '' -- Skip corrupted MERGED-without-merged_at shape AND NOT (sp.state = 'MERGED' AND sp.merged_at IS NULL) ) AS solving_pr`; From e261e2f56d836f1f73aece0aefbbaab7829e9fcb Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 29 May 2026 11:00:46 -0700 Subject: [PATCH 10/13] fix(queue): evict failed PR_FILES jobs (#128) --- packages/das/src/queue/fetch.processor.ts | 2 +- packages/das/src/webhook/handlers/pull-request.handler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/das/src/queue/fetch.processor.ts b/packages/das/src/queue/fetch.processor.ts index 08e1e6d..9600559 100644 --- a/packages/das/src/queue/fetch.processor.ts +++ b/packages/das/src/queue/fetch.processor.ts @@ -245,7 +245,7 @@ export class FetchProcessor extends WorkerHost { expectedBaseSha, ), removeOnComplete: true, - removeOnFail: 50, + removeOnFail: true, attempts: 3, backoff: { type: "exponential", delay: 5000 }, }, diff --git a/packages/das/src/webhook/handlers/pull-request.handler.ts b/packages/das/src/webhook/handlers/pull-request.handler.ts index 53dbee8..ea3f9cd 100644 --- a/packages/das/src/webhook/handlers/pull-request.handler.ts +++ b/packages/das/src/webhook/handlers/pull-request.handler.ts @@ -118,7 +118,7 @@ export class PullRequestHandler { { jobId, removeOnComplete: true, - removeOnFail: 50, + removeOnFail: true, attempts: 3, backoff: { type: "exponential", delay: 5000 }, }, From 23def8c1b117da0b7248f349999d3c028613171c Mon Sep 17 00:00:00 2001 From: Jeff <158072326+jeffrey701@users.noreply.github.com> Date: Fri, 29 May 2026 12:03:54 -0700 Subject: [PATCH 11/13] fix(webhook): refresh PR files on pull_request.edited base retarget (#62) (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(webhook): refresh PR files on pull_request.edited base retarget (#62) The handler enqueues PR_FILES on opened / synchronize / merged-closed but not on pull_request.edited with a base-ref change. The PR row updates its baseSha, but the stored pr_files and scoringDataStored stay pinned to the old base — leaving scoring inputs stale. Detect a base retarget via payload.changes.base, clear scoringDataStored, and enqueue PR_FILES with the new base/head SHAs through the existing prFilesJobId path. Closes #62 * chore: prettier --write on pull-request.handler.ts (#62) The lint CI run failed Prettier formatting on f830e3d. Prettier wanted the isBaseRetarget assignment on a single line. Single-line fit, no behaviour change. --------- Co-authored-by: jeffrey701 --- .../src/webhook/handlers/pull-request.handler.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/das/src/webhook/handlers/pull-request.handler.ts b/packages/das/src/webhook/handlers/pull-request.handler.ts index ea3f9cd..e99a531 100644 --- a/packages/das/src/webhook/handlers/pull-request.handler.ts +++ b/packages/das/src/webhook/handlers/pull-request.handler.ts @@ -90,14 +90,19 @@ export class PullRequestHandler { ); } - // Enqueue diff fetch on open, push, or merge + // Enqueue diff fetch on open, push, merge, or base-branch retarget. + // GitHub sends `pull_request.edited` with `changes.base` when the base ref + // changes; stored pr_files were resolved against the old base and need a + // fresh fetch even when head_sha is unchanged. const diffActions = ["opened", "synchronize", "closed"]; + const isBaseRetarget = action === "edited" && payload.changes?.base != null; const shouldFetchDiff = - diffActions.includes(action) && (action !== "closed" || pr.merged); + (diffActions.includes(action) && (action !== "closed" || pr.merged)) || + isBaseRetarget; if (shouldFetchDiff) { - // Reset scoring flag on new pushes - if (action === "synchronize") { + // Reset scoring flag on new pushes or base retargets + if (action === "synchronize" || isBaseRetarget) { await this.prRepo.update( { repoFullName, prNumber }, { scoringDataStored: false }, From a13ad156e7c705b3a63ab7c06a73a6dcd313adfc Mon Sep 17 00:00:00 2001 From: Jonathan Chang <55106972+jonathanchang31@users.noreply.github.com> Date: Sat, 30 May 2026 04:08:39 +0700 Subject: [PATCH 12/13] fix: merged into Non-scoring Branch (#131) --- packages/das/src/api/miners/miners.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/das/src/api/miners/miners.service.ts b/packages/das/src/api/miners/miners.service.ts index 2a5a1a2..2469d98 100644 --- a/packages/das/src/api/miners/miners.service.ts +++ b/packages/das/src/api/miners/miners.service.ts @@ -131,6 +131,10 @@ const ISSUE_SELECT_COLUMNS = ` 'head_sha', sp.head_sha, 'base_sha', sp.base_sha, 'merge_base_sha', sp.merge_base_sha, + 'base_ref', sp.base_ref, + 'head_ref', sp.head_ref, + 'head_repo_full_name', LOWER(sp.head_repo_full_name), + 'default_branch', sr.default_branch, 'labels', COALESCE(( SELECT json_agg(json_build_object( 'name', plt.label_name, @@ -152,6 +156,8 @@ const ISSUE_SELECT_COLUMNS = ` LEFT JOIN pr_review_summary rs ON rs.repo_full_name = sp.repo_full_name AND rs.pr_number = sp.pr_number + LEFT JOIN repos sr + ON sr.repo_full_name = sp.repo_full_name WHERE sp.repo_full_name = i.repo_full_name AND sp.pr_number = i.solved_by_pr -- Skip null-author solving PRs (no one to credit) From 7bd025dd2cfb8d4e3aefb869c7e6a168c9fbaa34 Mon Sep 17 00:00:00 2001 From: Leona Lee <63717587+leonaIee@users.noreply.github.com> Date: Sat, 30 May 2026 04:09:56 +0700 Subject: [PATCH 13/13] fix(health): exclude uninstalled repos from /api/v1/health (#133) --- packages/das/src/api/health.controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/das/src/api/health.controller.ts b/packages/das/src/api/health.controller.ts index d2c61e7..e38545e 100644 --- a/packages/das/src/api/health.controller.ts +++ b/packages/das/src/api/health.controller.ts @@ -3,7 +3,7 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { InjectQueue } from "@nestjs/bullmq"; import { InjectRepository } from "@nestjs/typeorm"; import { Queue } from "bullmq"; -import { DataSource, Repository } from "typeorm"; +import { DataSource, Not, IsNull, Repository } from "typeorm"; import { NoCache } from "../cache"; import { Repo } from "../entities"; import { FETCH_QUEUE } from "../queue/constants"; @@ -106,7 +106,10 @@ export class HealthController { } private async listRepoHealth(): Promise { + // Soft-cleared rows (installationId=null after uninstall/remove) are kept + // for historical scoring evidence but are no longer tracked. const repos = await this.repoRepo.find({ + where: { installationId: Not(IsNull()) }, select: ["repoFullName", "lastEventAt"], });