Skip to content
Merged
Show file tree
Hide file tree
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
16 changes: 14 additions & 2 deletions packages/das/src/api/miners/miners.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions packages/das/src/api/repos/repos.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down
18 changes: 9 additions & 9 deletions packages/das/src/api/repos/repos.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
136 changes: 136 additions & 0 deletions packages/das/src/maintainer/maintainer-populate.service.ts
Original file line number Diff line number Diff line change
@@ -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<Repo>,
) {}

// 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<void> {
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<void> {
// 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<string, MaintainerEntry> {
const ownerLogin = repoFullName.split("/")[0].toLowerCase();
const roles = new Map<string, MaintainerEntry>();
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;
}
}
160 changes: 0 additions & 160 deletions packages/das/src/maintainer/maintainer-role-reconcile.service.ts

This file was deleted.

Loading
Loading