Skip to content
Merged
131 changes: 127 additions & 4 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hono, type Context } from "hono";

Check warning on line 1 in src/api/routes.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #556.

Check notice on line 1 in src/api/routes.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #556.

Check notice on line 1 in src/api/routes.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/api/routes.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import { z } from "zod";
import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-intelligence";
import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth";
Expand Down Expand Up @@ -192,10 +192,18 @@
buildPreflightResult,
buildQueueHealth,
buildRegistryChangeReport,
buildContributorOpportunities,
buildPublicReadinessScore,
type ContributorOutcomeHistory,
type IssueQualityReport,
type PullRequestMaintainerPacket,
type RoleContext,
} from "../signals/engine";
import {
buildExtensionIssueFit,
buildExtensionIssueBadges,
buildExtensionPrStatus,
} from "../signals/extension-contributor-context";
import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality";
import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
Expand Down Expand Up @@ -689,6 +697,10 @@
if (!identity) return c.json({ error: "unauthorized" }, 401);
if (identity.kind === "session" && !canSessionAccessPath(c.env, identity, c.req.path)) return c.json({ error: "insufficient_role" }, 403);
if (isExtensionScopedSession(identity) && c.req.path !== EXTENSION_PULL_CONTEXT_PATH) return c.json({ error: "insufficient_scope" }, 403);
// Contributor extension tokens are STRICTLY self-only: like the pull-context token above, they are
// confined to their own surface and may not reach any other path (control-panel /v1/app/*, the
// session-mint endpoint, etc.). Without this they would be LESS confined than the maintainer token.
if (isExtensionContributorScopedSession(identity) && !isExtensionContributorContextPath(c.req.path)) return c.json({ error: "insufficient_scope" }, 403);
return next();
});

Expand Down Expand Up @@ -844,15 +856,22 @@
app.post("/v1/auth/extension/session", async (c) => {
const identity = await authenticateRequestIdentity(c);
if (!identity || identity.kind !== "session") return c.json({ error: "browser_session_required" }, 403);
if (isExtensionScopedSession(identity)) return c.json({ error: "browser_session_required" }, 403);
// An extension token (maintainer OR contributor scope) may not mint another — only a full browser
// session can. Without covering the contributor scope here, a contributor token could self-renew an
// unbounded, effectively non-revocable chain of sessions.
if (isExtensionScopedSession(identity) || isExtensionContributorScopedSession(identity)) return c.json({ error: "browser_session_required" }, 403);
const roleSummary = await loadControlPanelRoleSummary(c.env, identity.actor);
if (!roleSummary.roles.some((role) => role === "maintainer" || role === "owner" || role === "operator")) return c.json({ error: "insufficient_role" }, 403);
// Maintainers (own/installed a repo, or operators) get the maintainer pull-context scope; everyone
// else gets the strictly self-only contributor scope (#556). Either way the session is minted from a
// verified browser sign-in, so a non-maintainer can only ever read its OWN contributor data.
const isMaintainer = roleSummary.roles.some((role) => role === "maintainer" || role === "owner" || role === "operator");
const scope = isMaintainer ? EXTENSION_PULL_CONTEXT_SCOPE : EXTENSION_CONTRIBUTOR_CONTEXT_SCOPE;
const githubUser = identity.session.githubUserId === undefined ? { login: identity.session.login } : { login: identity.session.login, id: identity.session.githubUserId };
const { token, session } = await createSessionForGitHubUser(
c.env,
githubUser,
{
scopes: [EXTENSION_PULL_CONTEXT_SCOPE],
scopes: [scope],
metadata: {
source: "browser_extension",
parentSessionId: identity.session.id,
Expand All @@ -862,7 +881,7 @@
await recordRouteProductUsage(c, {
surface: "browser_extension",
eventName: "extension_session_created",
role: "maintainer",
role: isMaintainer ? "maintainer" : "contributor",
identity,
sessionId: session.id,
outcome: "success",
Expand Down Expand Up @@ -2191,6 +2210,88 @@
return c.json(buildLocalDiffPreflightResult(parsed.data, repo, issues, pullRequests, bounties, issueQuality?.report));
});

// ─── Extension contributor-context endpoints (#556) ─────────────────────────────────────────────
// Self-only (requireContributorAccess: actor === login), public-safe, scores returned as BANDS.
// The coarse path allowlist (canSessionAccessPath) only lets the contributor scope reach these paths.
app.get("/v1/extension/contributors/:login/issue-fit", async (c) => {
const login = c.req.param("login");
const unauthorized = await requireContributorAccess(c, login);
if (unauthorized) return unauthorized;
const owner = c.req.query("owner") ?? "";
const repoName = c.req.query("repo") ?? "";
const issueNumber = Number(c.req.query("issueNumber") ?? "");
if (!owner || !repoName || !Number.isInteger(issueNumber) || issueNumber <= 0) return c.json({ error: "valid_owner_repo_issue_required" }, 400);
const repoFullName = `${owner}/${repoName}`;
const [context, repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([
loadContributorFastContext(c.env, login),
getRepository(c.env, repoFullName),
listIssues(c.env, repoFullName),
listPullRequests(c.env, repoFullName),
listBountiesByRepo(c.env, repoFullName),
loadOrComputeIssueQualityResponse(c.env, repoFullName),
]);
if (!repo) return c.json({ error: "repo_not_found" }, 404);
const opportunities = buildContributorOpportunities(context.profile, [repo], issues, pullRequests, bounties, issueQualityMap(repoFullName, issueQuality?.report));
const opportunity = opportunities.find((entry) => entry.issueNumber === issueNumber);
if (!opportunity) return c.json({ repoFullName, issueNumber, eligible: false, reason: "Issue is not an open, unclaimed outside-contributor target right now." }, 200);
return c.json({ eligible: true, ...buildExtensionIssueFit(opportunity) });
});

app.get("/v1/extension/contributors/:login/issue-badges", async (c) => {
const login = c.req.param("login");
const unauthorized = await requireContributorAccess(c, login);
if (unauthorized) return unauthorized;
const owner = c.req.query("owner") ?? "";
const repoName = c.req.query("repo") ?? "";
if (!owner || !repoName) return c.json({ error: "valid_owner_repo_required" }, 400);
const repoFullName = `${owner}/${repoName}`;
const [context, repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([
loadContributorFastContext(c.env, login),
getRepository(c.env, repoFullName),
listIssues(c.env, repoFullName),
listPullRequests(c.env, repoFullName),
listBountiesByRepo(c.env, repoFullName),
loadOrComputeIssueQualityResponse(c.env, repoFullName),
]);
if (!repo) return c.json({ error: "repo_not_found" }, 404);
const opportunities = buildContributorOpportunities(context.profile, [repo], issues, pullRequests, bounties, issueQualityMap(repoFullName, issueQuality?.report));
return c.json({ repoFullName, badges: buildExtensionIssueBadges(opportunities, repoFullName) });
});

app.get("/v1/extension/contributors/:login/pr-status", async (c) => {
const login = c.req.param("login");
const unauthorized = await requireContributorAccess(c, login);
if (unauthorized) return unauthorized;
const owner = c.req.query("owner") ?? "";
const repoName = c.req.query("repo") ?? "";
const pullNumber = Number(c.req.query("pullNumber") ?? "");
if (!owner || !repoName || !Number.isInteger(pullNumber) || pullNumber <= 0) return c.json({ error: "valid_owner_repo_pull_required" }, 400);
const repoFullName = `${owner}/${repoName}`;
const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([
getRepository(c.env, repoFullName),
listIssues(c.env, repoFullName),
listPullRequests(c.env, repoFullName),
listBountiesByRepo(c.env, repoFullName),
loadOrComputeIssueQualityResponse(c.env, repoFullName),
]);
const pr = pullRequests.find((entry) => entry.number === pullNumber);
if (!pr) return c.json({ error: "pull_request_not_found" }, 404);
// Self-only on the PR itself: a contributor reads only their OWN PR's status.
if ((pr.authorLogin ?? "").toLowerCase() !== login.toLowerCase()) return c.json({ error: "forbidden_contributor" }, 403);
const preflight = buildPreflightResult(
{ repoFullName, contributorLogin: login, title: pr.title, body: pr.body ?? undefined, labels: pr.labels, linkedIssues: pr.linkedIssues, authorAssociation: pr.authorAssociation ?? undefined },
repo,
issues,
pullRequests,
bounties,
issueQuality?.report,
);
const collisions = buildCollisionReport(repoFullName, issues, pullRequests);
const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions);
const readiness = buildPublicReadinessScore({ pr, preflight, queueHealth });
return c.json(buildExtensionPrStatus({ repoFullName, pullNumber, readiness }));
});

app.post("/v1/local/branch-analysis", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = localBranchAnalysisSchema.safeParse(body);
Expand Down Expand Up @@ -4078,6 +4179,11 @@
const LINT_PR_TEXT_PATH = "/v1/lint/pr-text";
const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk";
const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop";
// Contributor (miner) side of the extension (#556). Minted for NON-maintainer sign-ins; strictly
// self-only — a token may only reach `/v1/extension/contributors/<self>/*`, enforced by the coarse
// path check below plus `requireContributorAccess` (actor === login) in every handler.
const EXTENSION_CONTRIBUTOR_CONTEXT_SCOPE = "extension:contributor_context";
const EXTENSION_CONTRIBUTOR_CONTEXT_PATH = /^\/v1\/extension\/contributors\/[^/]+\/[^/]+$/;

type ProtectedRouteContext = {
env: Env;
Expand All @@ -4089,6 +4195,19 @@
return identity.kind === "session" && identity.session.scopes.includes(EXTENSION_PULL_CONTEXT_SCOPE);
}

function isExtensionContributorScopedSession(identity: AuthIdentity): boolean {
return identity.kind === "session" && identity.session.scopes.includes(EXTENSION_CONTRIBUTOR_CONTEXT_SCOPE);
}

function isExtensionContributorContextPath(path: string): boolean {
return EXTENSION_CONTRIBUTOR_CONTEXT_PATH.test(path);
}

// Wrap a single repo's issue-quality report in the by-repo map buildContributorOpportunities expects.
function issueQualityMap(repoFullName: string, report: IssueQualityReport | undefined): Map<string, IssueQualityReport> | undefined {
return report ? new Map([[repoFullName, report]]) : undefined;
}

// ─── Authorization model (the miner ⊕ maintainer boundary) ──────────────────────────────────────
// Identity is per-LOGIN; authority is per-REPO. Two independent axes a single session can hold at once:
// • MINER (gittensor contributor): may read ONLY its own contributor/miner data — enforced by
Expand Down Expand Up @@ -4120,6 +4239,9 @@
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
if (path === LINT_PR_TEXT_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
if (path === EXTENSION_PULL_CONTEXT_PATH && isExtensionScopedSession(identity)) return true;
// Contributor extension scope reaches only `/v1/extension/contributors/<login>/*`; the handler's
// requireContributorAccess then enforces actor === login (self-only).
if (isExtensionContributorContextPath(path) && isExtensionContributorScopedSession(identity)) return true;
return false;
}

Expand Down Expand Up @@ -4423,4 +4545,5 @@
buildExtensionPrivateBlockers,
ensureExtensionPublicSafeText,
authenticateRequestIdentity,
issueQualityMap,
};
128 changes: 128 additions & 0 deletions src/signals/extension-contributor-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { ContributorOpportunity, PublicReadinessScore } from "./engine";

Check warning on line 1 in src/signals/extension-contributor-context.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Items reference the same linked issue #556.

Check notice on line 1 in src/signals/extension-contributor-context.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Open PR work references issue #556.

Check notice on line 1 in src/signals/extension-contributor-context.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/signals/extension-contributor-context.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.

// ─── Contributor-context payloads for the browser extension (#556) ───────────────────────────────
// The contributor (miner) side of the extension overlay. Every payload here is PUBLIC-SAFE and self-
// scoped: a miner token reads only its OWN data (enforced at the route via requireContributorAccess),
// numeric private scores are returned as BANDS never raw numbers, and all free-form text is re-checked
// against the forbidden-private-term list before it leaves the server. No UI — just the data shapes the
// rebuilt extension (and any client) renders.

/** Public-safe band for a contributor's own-PR readiness — the raw 0-100 readiness score is private; the
* overlay only ever sees the band. Mirrors the fit ("good"/"caution"/"hold") and slop band ideas. */
export type ContributorReadinessBand = "strong" | "developing" | "early";

export function contributorReadinessBand(total: number): ContributorReadinessBand {
if (total >= 70) return "strong";
if (total >= 45) return "developing";
return "early";
}

// Defense-in-depth public-safe redaction for any free-form text that reaches the contributor overlay.
// The upstream builders are already contributor-facing, but every string is re-checked here and any
// forbidden private term (reward/wallet/key material/raw trust score/etc.) is redacted rather than
// leaked. Kept local (no import) so this module stays cycle-free and the API never 500s on a stray term.
const FORBIDDEN_EXTENSION_TERMS =
/\b(?:rewards?|payouts?|farming|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|score[-\s]?(?:estimate|preview|prediction)s?|estimated[-\s]?scores?|scoreability|private[-\s]?reviewability|reviewability[-\s]?internals?|private[-\s]?rankings?)\b/gi;

export function redactExtensionText(text: string): string {
return text.replace(FORBIDDEN_EXTENSION_TERMS, "[redacted]").replace(/\s+/g, " ").trim();
}

// ── issue-fit: "is this issue a good one for me to pick up?" ──────────────────────────────────────

export type ExtensionIssueFit = {
repoFullName: string;
issueNumber: number;
title: string;
/** Fit band — already a band on the opportunity, never a raw score. */
fit: ContributorOpportunity["fit"];
multiplierTier: ContributorOpportunity["multiplierTier"];
availability: ContributorOpportunity["availability"];
lane: ContributorOpportunity["lane"];
reasons: string[];
warnings: string[];
};

export function buildExtensionIssueFit(opportunity: ContributorOpportunity): ExtensionIssueFit {
return {
repoFullName: opportunity.repoFullName,
issueNumber: opportunity.issueNumber ?? 0,
title: redactExtensionText(opportunity.title),
fit: opportunity.fit,
multiplierTier: opportunity.multiplierTier,
availability: opportunity.availability,
lane: opportunity.lane,
reasons: opportunity.reasons.map(redactExtensionText),
warnings: opportunity.warnings.map(redactExtensionText),
};
}

// ── issue-list badges: per-issue fit badges for an issue-list overlay ─────────────────────────────

export type ExtensionIssueBadge = {
issueNumber: number;
title: string;
fit: ContributorOpportunity["fit"];
multiplierTier: ContributorOpportunity["multiplierTier"];
availability: ContributorOpportunity["availability"];
};

export function buildExtensionIssueBadges(opportunities: ContributorOpportunity[], repoFullName: string): ExtensionIssueBadge[] {
return opportunities
.filter((opportunity) => opportunity.repoFullName.toLowerCase() === repoFullName.toLowerCase() && opportunity.issueNumber !== undefined)
.map((opportunity) => ({
issueNumber: opportunity.issueNumber as number,
title: redactExtensionText(opportunity.title),
fit: opportunity.fit,
multiplierTier: opportunity.multiplierTier,
availability: opportunity.availability,
}));
}

// ── own-PR preflight + review status ──────────────────────────────────────────────────────────────

/** Per-readiness-component band, so the overlay can render a checklist without seeing component scores. */
export type ExtensionReadinessComponentBand = "met" | "partial" | "unmet";

export type ExtensionPrStatusComponent = {
key: PublicReadinessScore["components"][number]["key"];
label: string;
band: ExtensionReadinessComponentBand;
evidence: string;
action: string;
};

export type ExtensionPrStatus = {
repoFullName: string;
pullNumber: number;
/** Overall readiness band — the raw total is never exposed. */
readinessBand: ContributorReadinessBand;
reviewStatus: "ready_for_review" | "in_progress" | "needs_attention";
components: ExtensionPrStatusComponent[];
};

function componentBand(score: number, max: number): ExtensionReadinessComponentBand {
if (max <= 0) return "unmet";
const ratio = score / max;
if (ratio >= 0.85) return "met";
if (ratio >= 0.5) return "partial";
return "unmet";
}

export function buildExtensionPrStatus(args: { repoFullName: string; pullNumber: number; readiness: PublicReadinessScore }): ExtensionPrStatus {
const band = contributorReadinessBand(args.readiness.total);
const reviewStatus = band === "strong" ? "ready_for_review" : band === "developing" ? "in_progress" : "needs_attention";
return {
repoFullName: args.repoFullName,
pullNumber: args.pullNumber,
readinessBand: band,
reviewStatus,
components: args.readiness.components.map((component) => ({
key: component.key,
label: redactExtensionText(component.label),
band: componentBand(component.score, component.max),
evidence: redactExtensionText(component.evidence),
action: redactExtensionText(component.action),
})),
};
}
Loading
Loading