diff --git a/packages/das/src/api/miners/miners.service.ts b/packages/das/src/api/miners/miners.service.ts index cb9b5df..075a210 100644 --- a/packages/das/src/api/miners/miners.service.ts +++ b/packages/das/src/api/miners/miners.service.ts @@ -16,7 +16,7 @@ const PR_SELECT_COLUMNS = ` p.state, p.author_github_id, COALESCE(p.author_login, '') AS author_login, - p.author_association, + COALESCE(m_author.association, p.author_association) AS author_association, p.created_at, p.closed_at, p.merged_at, @@ -96,7 +96,7 @@ const ISSUE_SELECT_COLUMNS = ` i.state_reason, i.author_github_id, i.author_login, - i.author_association, + COALESCE(m_author.association, i.author_association) AS author_association, i.created_at, i.closed_at, i.updated_at, @@ -210,6 +210,9 @@ export class MinersService { AND rs.pr_number = p.pr_number LEFT JOIN repos r ON r.repo_full_name = p.repo_full_name + LEFT JOIN maintainers m_author + ON m_author.github_id = p.author_github_id + AND m_author.repo_full_name = LOWER(p.repo_full_name) WHERE p.author_github_id = $1 AND ( (p.state = 'OPEN' AND p.created_at >= $2) @@ -302,6 +305,9 @@ export class MinersService { AND rs.pr_number = p.pr_number LEFT JOIN repos r ON r.repo_full_name = p.repo_full_name + LEFT JOIN maintainers m_author + ON m_author.github_id = p.author_github_id + AND m_author.repo_full_name = LOWER(p.repo_full_name) WHERE p.author_github_id = $1 AND ( (p.state = 'OPEN' AND p.created_at >= w.since) @@ -376,6 +382,9 @@ export class MinersService { ` SELECT${ISSUE_SELECT_COLUMNS} FROM issues i + LEFT JOIN maintainers m_author + ON m_author.github_id = i.author_github_id + AND m_author.repo_full_name = LOWER(i.repo_full_name) WHERE i.author_github_id = $1 AND ( (i.state = 'OPEN' AND ($2::timestamptz IS NULL OR i.created_at >= $2)) @@ -462,6 +471,9 @@ export class MinersService { FROM issues i JOIN windows w ON w.repo_full_name = LOWER(i.repo_full_name) + LEFT JOIN maintainers m_author + ON m_author.github_id = i.author_github_id + AND m_author.repo_full_name = LOWER(i.repo_full_name) WHERE i.author_github_id = $1 AND ( (i.state = 'OPEN' AND i.created_at >= w.since) diff --git a/packages/das/src/api/repos/repos.controller.ts b/packages/das/src/api/repos/repos.controller.ts index 09314a7..98c001c 100644 --- a/packages/das/src/api/repos/repos.controller.ts +++ b/packages/das/src/api/repos/repos.controller.ts @@ -11,10 +11,10 @@ export class ReposController { @ApiOperation({ summary: "Maintainer-role contributors for a repo", description: - "Returns users whose latest known GitHub association for the repo " + - "is OWNER, MEMBER, or COLLABORATOR, synthesized from PR/issue/" + - "review/comment activity (contributor_repo_roles view). An unknown " + - "repo returns an empty maintainers list, not a 404.", + "Returns users whose live GitHub association for the repo is OWNER, " + + "MEMBER, or COLLABORATOR, from the maintainers table (direct " + + "collaborators + org members, refreshed hourly). An unknown repo " + + "returns an empty maintainers list, not a 404.", }) @ApiParam({ name: "owner", description: "Repository owner (org or user)" }) @ApiParam({ name: "repo", description: "Repository name" }) diff --git a/packages/das/src/api/repos/repos.service.ts b/packages/das/src/api/repos/repos.service.ts index 18e3b9c..a16e496 100644 --- a/packages/das/src/api/repos/repos.service.ts +++ b/packages/das/src/api/repos/repos.service.ts @@ -16,18 +16,18 @@ export class ReposService { }> { const repoFullName = `${owner}/${repo}`; - // The association literals must stay in sync with gittensor - // constants.py MAINTAINER_ASSOCIATIONS. + // Reads the live maintainers table (direct collaborators + org members), + // populated by MaintainerPopulateService. Every row is already a maintainer + // (OWNER/MEMBER/COLLABORATOR), so no association filter is needed. const rows = await this.dataSource.query( ` SELECT - cr.author_github_id AS github_id, - cr.author_login AS login, - cr.author_association AS association - FROM contributor_repo_roles cr - WHERE LOWER(cr.repo_full_name) = LOWER($1) - AND cr.author_association IN ('OWNER', 'MEMBER', 'COLLABORATOR') - ORDER BY cr.author_github_id + m.github_id AS github_id, + m.login AS login, + m.association AS association + FROM maintainers m + WHERE m.repo_full_name = LOWER($1) + ORDER BY m.github_id `, [repoFullName], ); diff --git a/packages/das/src/maintainer/maintainer-populate.service.ts b/packages/das/src/maintainer/maintainer-populate.service.ts new file mode 100644 index 0000000..97ff96a --- /dev/null +++ b/packages/das/src/maintainer/maintainer-populate.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { InjectRepository } from "@nestjs/typeorm"; +import { DataSource, Repository } from "typeorm"; +import { Repo } from "../entities"; +import { + GitHubFetcherService, + MaintainerRole, +} from "../webhook/github-fetcher.service"; + +interface MaintainerEntry { + login: string | null; + association: string; +} + +@Injectable() +export class MaintainerPopulateService implements OnModuleInit { + private readonly logger = new Logger(MaintainerPopulateService.name); + + constructor( + private readonly fetcher: GitHubFetcherService, + private readonly dataSource: DataSource, + @InjectRepository(Repo) + private readonly repoRepo: Repository, + ) {} + + // Populate once on boot so a fresh deploy fills the maintainers table within + // seconds — serve-time author/actor resolution and the label/review views all + // read it. Fire-and-forget; the hourly @Cron keeps it fresh thereafter. + onModuleInit(): void { + void this.populate(); + } + + // author/reviewer association is snapshotted at ingest and never refreshed, so + // a contributor who becomes (or stops being) a maintainer keeps a stale role + // on every historical row. Rather than rewrite those stored snapshots, we keep + // a live maintainers table (direct collaborators + org members) that the serve + // path resolves against, for registered + installed repos only. + @Cron(CronExpression.EVERY_HOUR) + async populate(): Promise { + const repos: { repo_full_name: string }[] = await this.repoRepo.query( + `SELECT repo_full_name FROM repos + WHERE registered = true AND installation_id IS NOT NULL`, + ); + this.logger.log(`Populating maintainers for ${repos.length} repos`); + + for (const { repo_full_name } of repos) { + try { + await this.populateRepo(repo_full_name); + } catch (err) { + // Fail closed per repo: a fetch/DB error skips this repo (never wipe a + // repo's maintainers on partial data) and the next sweep retries it. + this.logger.error( + `Maintainer populate failed for ${repo_full_name}: ${String(err)}`, + ); + } + } + } + + private async populateRepo(repoFullName: string): Promise { + // Fetch BOTH sets before any write — a partial fetch must never read as a + // wipe. Hard failures throw and propagate to the per-repo catch above. + const collaborators = + await this.fetcher.fetchRepoCollaborators(repoFullName); + const members = await this.fetcher.fetchOrgMembers(repoFullName); + const current = this.buildRoleMap(repoFullName, collaborators, members); + + if (current.size === 0) { + // A real repo always has at least its owner; an empty set means an + // unexpected (but non-throwing) API response. Skip rather than wipe the + // repo's maintainers. + this.logger.warn( + `${repoFullName}: empty maintainer set from GitHub, skipping`, + ); + return; + } + + const repoKey = repoFullName.toLowerCase(); + const ids = [...current.keys()]; + + // Atomic per-repo refresh: upsert the live set, then drop anyone no longer + // in it. Wrapped in a transaction so the table is never half-empty for this + // repo mid-refresh (the serve path reads it concurrently). + await this.dataSource.transaction(async (tx) => { + for (const [githubId, entry] of current) { + await tx.query( + `INSERT INTO maintainers (repo_full_name, github_id, login, association, refreshed_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (repo_full_name, github_id) + DO UPDATE SET login = EXCLUDED.login, + association = EXCLUDED.association, + refreshed_at = NOW()`, + [repoKey, githubId, entry.login, entry.association], + ); + } + await tx.query( + `DELETE FROM maintainers + WHERE repo_full_name = $1 AND github_id <> ALL($2)`, + [repoKey, ids], + ); + }); + + this.logger.log(`${repoFullName}: ${current.size} maintainers refreshed`); + } + + // Precedence COLLABORATOR < MEMBER < OWNER: org members override direct + // collaborators, and the repo owner (user-owned repos) outranks both. + private buildRoleMap( + repoFullName: string, + collaborators: MaintainerRole[], + members: MaintainerRole[], + ): Map { + const ownerLogin = repoFullName.split("/")[0].toLowerCase(); + const roles = new Map(); + for (const c of collaborators) { + if (c.githubId) + roles.set(c.githubId, { + login: c.login ?? null, + association: "COLLABORATOR", + }); + } + for (const m of members) { + if (m.githubId) + roles.set(m.githubId, { + login: m.login ?? null, + association: "MEMBER", + }); + } + for (const u of [...collaborators, ...members]) { + if (u.githubId && u.login?.toLowerCase() === ownerLogin) { + roles.set(u.githubId, { login: u.login ?? null, association: "OWNER" }); + } + } + return roles; + } +} diff --git a/packages/das/src/maintainer/maintainer-role-reconcile.service.ts b/packages/das/src/maintainer/maintainer-role-reconcile.service.ts deleted file mode 100644 index 372b611..0000000 --- a/packages/das/src/maintainer/maintainer-role-reconcile.service.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { Cron, CronExpression } from "@nestjs/schedule"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { Repo } from "../entities"; -import { - GitHubFetcherService, - MaintainerRole, -} from "../webhook/github-fetcher.service"; - -// The four tables that carry a per-row GitHub author/reviewer association — -// the same set the contributor_repo_roles view reads. Normalizing all four -// keeps /maintainers, the maintainer_cut carve-out, and the issue-bonus tier -// agreeing on one source of truth: GitHub's *current* roles. -const ASSOCIATION_TABLES = [ - { - table: "pull_requests", - idCol: "author_github_id", - assocCol: "author_association", - }, - { - table: "issues", - idCol: "author_github_id", - assocCol: "author_association", - }, - { - table: "comments", - idCol: "author_github_id", - assocCol: "author_association", - }, - { - table: "reviews", - idCol: "reviewer_github_id", - assocCol: "reviewer_association", - }, -] as const; - -@Injectable() -export class MaintainerRoleReconcileService { - private readonly logger = new Logger(MaintainerRoleReconcileService.name); - - constructor( - private readonly fetcher: GitHubFetcherService, - @InjectRepository(Repo) - private readonly repoRepo: Repository, - ) {} - - // author_association is snapshotted at ingest and never refreshed by the - // webhook path, so a contributor who becomes (or stops being) a maintainer - // after filing keeps a stale role on every historical row — e.g. a private - // collaborator's issues stay CONTRIBUTOR forever, costing solvers the - // maintainer issue-bonus tier. This sweep pulls the live collaborator/member - // set from GitHub and rewrites the stored association columns to match, for - // registered + installed repos only. - @Cron(CronExpression.EVERY_HOUR) - async reconcile(): Promise { - const repos: { repo_full_name: string }[] = await this.repoRepo.query( - `SELECT repo_full_name FROM repos - WHERE registered = true AND installation_id IS NOT NULL`, - ); - this.logger.log(`Reconciling maintainer roles for ${repos.length} repos`); - - for (const { repo_full_name } of repos) { - try { - await this.reconcileRepo(repo_full_name); - } catch (err) { - // Fail closed per repo: a fetch/DB error skips this repo (never demote - // on partial data) and the next sweep retries it. - this.logger.error( - `Maintainer reconcile failed for ${repo_full_name}: ${String(err)}`, - ); - } - } - } - - private async reconcileRepo(repoFullName: string): Promise { - // Fetch BOTH sets before any write — a partial fetch must never read as a - // demotion. Hard failures throw and propagate to the per-repo catch above. - const collaborators = - await this.fetcher.fetchRepoCollaborators(repoFullName); - const members = await this.fetcher.fetchOrgMembers(repoFullName); - const current = this.buildRoleMap(repoFullName, collaborators, members); - - if (current.size === 0) { - // A real repo always has at least its owner; an empty set means an - // unexpected (but non-throwing) API response. Skip rather than demote - // every contributor on the repo. - this.logger.warn( - `${repoFullName}: empty maintainer set from GitHub, skipping`, - ); - return; - } - - const ids = [...current.keys()]; - let promoted = 0; - let demoted = 0; - - // Table/column names come from the fixed ASSOCIATION_TABLES list, never - // user input — safe to interpolate. - for (const { table, idCol, assocCol } of ASSOCIATION_TABLES) { - // Promote: align each current maintainer's rows to their live role. - for (const [githubId, association] of current) { - const res: unknown[] = await this.repoRepo.query( - `UPDATE ${table} SET ${assocCol} = $1 - WHERE repo_full_name = $2 AND ${idCol} = $3 - AND ${assocCol} IS DISTINCT FROM $1 - RETURNING 1`, - [association, repoFullName, githubId], - ); - promoted += this.affectedRows(res); - } - // Demote: anyone still flagged a maintainer who is no longer in the set. - const res: unknown[] = await this.repoRepo.query( - `UPDATE ${table} SET ${assocCol} = 'CONTRIBUTOR' - WHERE repo_full_name = $1 - AND ${assocCol} IN ('OWNER', 'MEMBER', 'COLLABORATOR') - AND ${idCol} <> ALL($2) - RETURNING 1`, - [repoFullName, ids], - ); - demoted += this.affectedRows(res); - } - - if (promoted || demoted) { - this.logger.log( - `${repoFullName}: ${current.size} maintainers — ` + - `promoted ${promoted} rows, demoted ${demoted} rows`, - ); - } - } - - // Precedence COLLABORATOR < MEMBER < OWNER: org members override direct - // collaborators, and the repo owner (user-owned repos) outranks both. - private buildRoleMap( - repoFullName: string, - collaborators: MaintainerRole[], - members: MaintainerRole[], - ): Map { - const ownerLogin = repoFullName.split("/")[0].toLowerCase(); - const roles = new Map(); - for (const c of collaborators) { - if (c.githubId) roles.set(c.githubId, "COLLABORATOR"); - } - for (const m of members) { - if (m.githubId) roles.set(m.githubId, "MEMBER"); - } - for (const u of [...collaborators, ...members]) { - if (u.githubId && u.login?.toLowerCase() === ownerLogin) { - roles.set(u.githubId, "OWNER"); - } - } - return roles; - } - - // `RETURNING 1` makes the affected count the returned row count, which is - // stable across TypeORM/pg versions (unlike the raw driver result shape). - private affectedRows(res: unknown): number { - return Array.isArray(res) ? res.length : 0; - } -} diff --git a/packages/das/src/maintainer/maintainer.module.ts b/packages/das/src/maintainer/maintainer.module.ts index 7836918..91f788d 100644 --- a/packages/das/src/maintainer/maintainer.module.ts +++ b/packages/das/src/maintainer/maintainer.module.ts @@ -10,13 +10,14 @@ import { Review, } from "../entities"; import { GitHubFetcherService } from "../webhook/github-fetcher.service"; -import { MaintainerRoleReconcileService } from "./maintainer-role-reconcile.service"; +import { MaintainerPopulateService } from "./maintainer-populate.service"; @Module({ - // GitHubFetcherService injects these repositories; the reconcile service - // itself only needs Repo (its UPDATEs run as raw SQL via repoRepo.query). - // This provides a self-contained GitHubFetcherService instance — its own - // installation-token cache, independent of the QueueModule copy. + // GitHubFetcherService injects these repositories; the populate service itself + // only needs Repo (it reads the registered-repo list and writes maintainers as + // raw SQL via DataSource). This provides a self-contained GitHubFetcherService + // instance — its own installation-token cache, independent of the QueueModule + // copy. imports: [ TypeOrmModule.forFeature([ Repo, @@ -28,6 +29,6 @@ import { MaintainerRoleReconcileService } from "./maintainer-role-reconcile.serv PrFileContent, ]), ], - providers: [GitHubFetcherService, MaintainerRoleReconcileService], + providers: [GitHubFetcherService, MaintainerPopulateService], }) export class MaintainerModule {} diff --git a/packages/das/src/webhook/github-fetcher.service.ts b/packages/das/src/webhook/github-fetcher.service.ts index 9f194d1..474915b 100644 --- a/packages/das/src/webhook/github-fetcher.service.ts +++ b/packages/das/src/webhook/github-fetcher.service.ts @@ -1404,9 +1404,9 @@ export class GitHubFetcherService implements OnModuleInit { * Insert LABELED_EVENT / UNLABELED_EVENT timeline nodes into label_events. * Idempotent: relies on the uq_label_events_natural_key UNIQUE index so * re-running backfill (or BullMQ retries) collapses to a no-op for events - * already written. Actor role is resolved at read time via - * contributor_repo_roles using stored PR/issue, review, and comment - * association evidence; GraphQL's actor type doesn't expose authorAssociation. + * already written. Actor role is resolved at read time against the live + * maintainers table (see pr_labels_by_actor view); GraphQL's actor type + * doesn't expose authorAssociation. */ private async saveLabelTimelineEvents( repoFullName: string, diff --git a/packages/das/src/webhook/handlers/label.handler.ts b/packages/das/src/webhook/handlers/label.handler.ts index ab813bb..547364e 100644 --- a/packages/das/src/webhook/handlers/label.handler.ts +++ b/packages/das/src/webhook/handlers/label.handler.ts @@ -34,9 +34,8 @@ export class LabelHandler { source === "pr" ? payload.pull_request.number : payload.issue.number; // Append to label_events log. Actor's repo role is resolved at read time - // via contributor_repo_roles (see pr_labels_by_actor view) using stored - // PR/issue, review, and comment association evidence — neither the webhook - // sender nor GraphQL LabeledEvent.actor expose author_association. + // against the live maintainers table (see pr_labels_by_actor view) — neither + // the webhook sender nor GraphQL LabeledEvent.actor expose author_association. // orIgnore() makes the insert idempotent under the uq_label_events_natural_key // constraint; same-delivery retries are already gated upstream by // webhook_deliveries, this is defense-in-depth. diff --git a/packages/db/02_pull_requests.sql b/packages/db/02_pull_requests.sql index 080a41f..22f7980 100644 --- a/packages/db/02_pull_requests.sql +++ b/packages/db/02_pull_requests.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS pull_requests ( pr_number INTEGER NOT NULL, author_github_id VARCHAR(255), author_login VARCHAR(255), - author_association VARCHAR(20), + author_association VARCHAR(20), -- ingest snapshot; live role resolved at serve time via the maintainers table title TEXT, body TEXT, state VARCHAR(10) NOT NULL, diff --git a/packages/db/03_issues.sql b/packages/db/03_issues.sql index e3e9453..227f0e6 100644 --- a/packages/db/03_issues.sql +++ b/packages/db/03_issues.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS issues ( issue_number INTEGER NOT NULL, author_github_id VARCHAR(255), author_login VARCHAR(255), - author_association VARCHAR(20), + author_association VARCHAR(20), -- ingest snapshot; live role resolved at serve time via the maintainers table title TEXT, state VARCHAR(10) NOT NULL, state_reason VARCHAR(20), diff --git a/packages/db/04_reviews.sql b/packages/db/04_reviews.sql index 93761c0..aa1c9a9 100644 --- a/packages/db/04_reviews.sql +++ b/packages/db/04_reviews.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS reviews ( pr_number INTEGER NOT NULL, reviewer_github_id VARCHAR(255), reviewer_login VARCHAR(255), - reviewer_association VARCHAR(20), + reviewer_association VARCHAR(20), -- ingest snapshot; live role resolved at serve time via the maintainers table review_state VARCHAR(30) NOT NULL, submitted_at TIMESTAMPTZ NOT NULL, diff --git a/packages/db/05_comments.sql b/packages/db/05_comments.sql index b20252b..3ac11d7 100644 --- a/packages/db/05_comments.sql +++ b/packages/db/05_comments.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS comments ( comment_context VARCHAR(10) NOT NULL DEFAULT 'issue', author_github_id VARCHAR(255), author_login VARCHAR(255), - author_association VARCHAR(20), + author_association VARCHAR(20), -- ingest snapshot; live role resolved at serve time via the maintainers table body TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ, diff --git a/packages/db/11_maintainers.sql b/packages/db/11_maintainers.sql new file mode 100644 index 0000000..cb81ef6 --- /dev/null +++ b/packages/db/11_maintainers.sql @@ -0,0 +1,16 @@ +-- Maintainers resolved live from GitHub (direct collaborators + org members). +-- Populated per registered+installed repo by MaintainerPopulateService and read +-- at serve time to resolve author/actor association WITHOUT mutating the stored +-- per-row ingest snapshots. repo_full_name is stored lowercased so every read +-- joins as `m.repo_full_name = LOWER(.repo_full_name)` and still uses the +-- primary-key index (LOWER applied to the probe side only). + +CREATE TABLE IF NOT EXISTS maintainers ( + repo_full_name VARCHAR(255) NOT NULL, + github_id VARCHAR(255) NOT NULL, + login VARCHAR(255), + association VARCHAR(20) NOT NULL, -- OWNER | MEMBER | COLLABORATOR + refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (repo_full_name, github_id) +); diff --git a/packages/db/21_view_pr_review_summary.sql b/packages/db/21_view_pr_review_summary.sql index bcad964..3cb8ab8 100644 --- a/packages/db/21_view_pr_review_summary.sql +++ b/packages/db/21_view_pr_review_summary.sql @@ -1,15 +1,21 @@ -- Aggregates review counts per PR by review type. -- Maintainer-only CHANGES_REQUESTED count is the scoring-relevant field. +-- The reviewer's maintainer status is resolved at read time against the live +-- maintainers table, falling back to the stored ingest snapshot otherwise. CREATE OR REPLACE VIEW pr_review_summary AS SELECT - repo_full_name, - pr_number, - COUNT(*) FILTER (WHERE review_state = 'CHANGES_REQUESTED' - AND reviewer_association IN ('OWNER', 'MEMBER', 'COLLABORATOR')) + r.repo_full_name, + r.pr_number, + COUNT(*) FILTER (WHERE r.review_state = 'CHANGES_REQUESTED' + AND COALESCE(m.association, r.reviewer_association) + IN ('OWNER', 'MEMBER', 'COLLABORATOR')) AS maintainer_changes_requested_count, - COUNT(*) FILTER (WHERE review_state = 'CHANGES_REQUESTED') AS changes_requested_count, - COUNT(*) FILTER (WHERE review_state = 'APPROVED') AS approved_count, - COUNT(*) FILTER (WHERE review_state = 'COMMENTED') AS commented_count -FROM reviews -GROUP BY repo_full_name, pr_number; + COUNT(*) FILTER (WHERE r.review_state = 'CHANGES_REQUESTED') AS changes_requested_count, + COUNT(*) FILTER (WHERE r.review_state = 'APPROVED') AS approved_count, + COUNT(*) FILTER (WHERE r.review_state = 'COMMENTED') AS commented_count +FROM reviews r +LEFT JOIN maintainers m + ON m.github_id = r.reviewer_github_id + AND m.repo_full_name = LOWER(r.repo_full_name) +GROUP BY r.repo_full_name, r.pr_number; diff --git a/packages/db/22_view_pr_linked_issues.sql b/packages/db/22_view_pr_linked_issues.sql index dd77f7a..2d3a4ee 100644 --- a/packages/db/22_view_pr_linked_issues.sql +++ b/packages/db/22_view_pr_linked_issues.sql @@ -1,5 +1,7 @@ -- Joins the closing_issue_numbers array on each PR against actual issue records. -- Provides all raw fields validators need for issue validity checks. +-- issue_author_association is resolved at read time against the live maintainers +-- table, falling back to the stored ingest snapshot for non-maintainers. CREATE OR REPLACE VIEW pr_linked_issues AS SELECT @@ -10,7 +12,7 @@ SELECT p.created_at AS pr_created_at, linked.issue_number, i.author_github_id AS issue_author_github_id, - i.author_association AS issue_author_association, + COALESCE(m.association, i.author_association) AS issue_author_association, i.title AS issue_title, i.state AS issue_state, i.state_reason AS issue_state_reason, @@ -24,4 +26,7 @@ FROM pull_requests p CROSS JOIN LATERAL unnest(p.closing_issue_numbers) AS linked(issue_number) JOIN issues i ON i.repo_full_name = p.repo_full_name - AND i.issue_number = linked.issue_number; + AND i.issue_number = linked.issue_number +LEFT JOIN maintainers m + ON m.github_id = i.author_github_id + AND m.repo_full_name = LOWER(i.repo_full_name); diff --git a/packages/db/24_view_pr_labels_by_actor.sql b/packages/db/24_view_pr_labels_by_actor.sql index 8b10f27..686c0f9 100644 --- a/packages/db/24_view_pr_labels_by_actor.sql +++ b/packages/db/24_view_pr_labels_by_actor.sql @@ -1,9 +1,10 @@ -- Current labels on each PR with actor attribution. -- Collapses label_events to the latest action per (repo, pr, label); only rows -- where the latest action was "labeled" are included (i.e. label still applied). --- actor_association is resolved from contributor_repo_roles (the actor's most --- recently observed role from authored PRs/issues, reviews, or comments in --- this repo). Actors with no stored association evidence return NULL. +-- actor_association is resolved at read time from the live maintainers table: a +-- maintainer (OWNER/MEMBER/COLLABORATOR) gets that role, everyone else NULL. +-- Scoring only tests membership in MAINTAINER_ASSOCIATIONS, so a maintainers-only +-- lookup is lossless — and an indexed PK lookup instead of re-deriving roles. CREATE OR REPLACE VIEW pr_labels_by_actor AS WITH latest_events AS ( @@ -13,11 +14,11 @@ WITH latest_events AS ( le.label_name, le.action, le.actor_github_id, - crr.author_association AS actor_association + m.association AS actor_association FROM label_events le - LEFT JOIN contributor_repo_roles crr - ON crr.author_github_id = le.actor_github_id - AND crr.repo_full_name = le.repo_full_name + LEFT JOIN maintainers m + ON m.github_id = le.actor_github_id + AND m.repo_full_name = LOWER(le.repo_full_name) WHERE le.target_type = 'pr' ORDER BY le.repo_full_name, le.target_number, le.label_name, le.timestamp DESC ) diff --git a/packages/db/25_view_issue_labels_by_actor.sql b/packages/db/25_view_issue_labels_by_actor.sql index f757e0f..aa3a06b 100644 --- a/packages/db/25_view_issue_labels_by_actor.sql +++ b/packages/db/25_view_issue_labels_by_actor.sql @@ -9,11 +9,11 @@ WITH latest_events AS ( le.label_name, le.action, le.actor_github_id, - crr.author_association AS actor_association + m.association AS actor_association FROM label_events le - LEFT JOIN contributor_repo_roles crr - ON crr.author_github_id = le.actor_github_id - AND crr.repo_full_name = le.repo_full_name + LEFT JOIN maintainers m + ON m.github_id = le.actor_github_id + AND m.repo_full_name = LOWER(le.repo_full_name) WHERE le.target_type = 'issue' ORDER BY le.repo_full_name, le.target_number, le.label_name, le.timestamp DESC )