From 503173c4e7ffce7e4f45d12d8105548f9c5341c3 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:20:30 -0700 Subject: [PATCH] fix(signals): stabilize pr queue readiness --- README.md | 2 +- apps/gittensory-ui/public/openapi.json | 11 +++ .../src/routes/docs.beta-onboarding.tsx | 6 +- .../src/routes/docs.github-app.tsx | 77 +++++++++++++++++-- .../src/routes/docs.maintainer-workflow.tsx | 7 +- src/openapi/schemas.ts | 2 + src/signals/engine.ts | 57 +++++++++++--- test/unit/docs-github-app.test.ts | 32 ++++++++ test/unit/signals-coverage.test.ts | 52 ++++++++++++- test/unit/signals-v2.test.ts | 6 +- 10 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 test/unit/docs-github-app.test.ts diff --git a/README.md b/README.md index f967c660b3..baf04774d4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ It is not a Gittensor explorer, public leaderboard, reward-farming bot, or auton | MCP package | Local stdio tools for Codex, Claude Desktop, Cursor, and other MCP clients. | [MCP client setup](https://gittensory.aethereal.dev/docs/mcp-clients) | | Web app | Operator UI, docs, API browser, roadmap, and workflow views. | [gittensory.aethereal.dev](https://gittensory.aethereal.dev/) | | Worker API | Protected Cloudflare Worker API with OpenAPI metadata. | [OpenAPI JSON](https://gittensory-api.aethereal.dev/openapi.json) | -| GitHub App | Quiet maintainer automation for installed repos. | [GitHub App docs](https://gittensory.aethereal.dev/docs/github-app) | +| GitHub App | Quiet maintainer automation for installed repos. | [Install](https://github.com/apps/gittensory/installations/new) and [setup docs](https://gittensory.aethereal.dev/docs/github-app) | ## Install MCP diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 8b93fbc23d..875fb10ad3 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -755,6 +755,17 @@ }, "likelyReviewablePullRequests": { "type": "number" + }, + "cachedOpenPullRequests": { + "type": "number" + }, + "likelyReviewablePullRequestsSource": { + "type": "string", + "enum": [ + "cache", + "sampled_cache", + "authoritative" + ] } }, "required": [ diff --git a/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx b/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx index e7c47470a6..48ad0062de 100644 --- a/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx +++ b/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx @@ -113,7 +113,8 @@ gittensory-mcp preflight --login your-login --json`}
  1. Install the GitHub App. Choose repositories and approve permissions — - default posture is silence. See GitHub App setup. + default posture is silence. Start with the{" "} + GitHub App first-10-minutes checklist.
  2. Configure settings. Opt in to at most one configured label and one sticky @@ -122,7 +123,8 @@ gittensory-mcp preflight --login your-login --json`}
  3. Preview the public surface. Dry-run what would be written to GitHub - without mutating state. + without mutating state. Keep Gittensory Context advisory; require{" "} + Gittensory Gate only after blocking rules are explicitly configured. ({ @@ -33,10 +35,58 @@ function GithubApp() { description="Install Gittensory on a repo, then choose whether it should stay advisory or enforce repo-configured PR quality rules." >

    Install

    +

    + The hosted deployment uses the GitHub App slug gittensory. Start from{" "} + + the GitHub App install flow + + , then choose only the repositories you want Gittensory to see. +

      -
    1. Open the Gittensory GitHub App listing.
    2. -
    3. Choose the repositories you want to grant access to.
    4. -
    5. Approve the requested permissions (issues, pulls, checks, metadata).
    6. +
    7. Open the install flow and pick the owning account.
    8. +
    9. + Choose selected repositories instead of all repositories unless you are onboarding an org. +
    10. +
    11. + Approve Metadata: read, Pull requests: read, and{" "} + Issues: write. Enable Checks: write when Context or Gate check + runs are enabled. +
    12. +
    13. + Keep webhook events enabled for issues, issue_comment,{" "} + pull_request, and repository. +
    14. +
    + +

    First 10 minutes

    +
      +
    1. Install the app on one test repository first.
    2. +
    3. + Confirm the installation appears in the private API, then open its health record. + +
    4. +
    5. + Check repo readiness before enabling public output. + +
    6. +
    7. + Preview the exact public surface without posting to GitHub. + +
    8. +
    9. + Leave Gittensory Context advisory while you tune copy and settings. Make{" "} + Gittensory Gate required only after the repo explicitly enables blocking + rules. +

    Default posture

    @@ -59,6 +109,12 @@ function GithubApp() { protection. Gittensory Gate is opt-in and can be made required after a repo owner chooses blocking rules.

    +

    + Branch protection should require Gittensory Gate only after the repo has + verified installation health, previewed the public panel, and configured at least one{" "} + block rule. Do not require Gittensory Context; it is there to + inform reviewers, not stop merges. +

    Gate modes

    @@ -81,6 +137,17 @@ function GithubApp() { After installing, verify your install health from the API. The readiness endpoint separates service health from data quality.

    +

    + If the install route changes, check the deployed GITHUB_APP_SLUG before + publishing setup copy. For the hosted app, the expected slug is gittensory. +

    + +

    + New maintainers should continue with{" "} + Maintainer workflow or the{" "} + beta onboarding checklist after the health endpoint + reports clean permissions and events. +

    Gittensory's GitHub App never requests source push, never stores repository contents, and diff --git a/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx b/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx index d56af2546b..c93af46ff2 100644 --- a/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx +++ b/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { DocsPage } from "@/components/site/docs-page"; import { CodeBlock, Callout } from "@/components/site/primitives"; @@ -130,6 +130,11 @@ GET /v1/repos/:owner/:repo/registration-readiness`} Each step on the right is what you see in the repo; the matching step on the left is what the contributor is doing privately via MCP at the same point.

    +

    + New installations should start with GitHub App setup: + install on one repo, verify installation health, preview the public panel, then decide + whether Gittensory Gate should become a required check. +

    pr.state === "open"); const openIssueCount = Math.max(openIssues.length, countOverrides.openIssues ?? 0); const openPullRequestCount = Math.max(openPullRequests.length, countOverrides.openPullRequests ?? 0); + const likelyReviewablePullRequestsSource = + countOverrides.likelyReviewablePullRequests !== undefined ? "authoritative" : openPullRequestCount > openPullRequests.length ? "sampled_cache" : "cache"; const unlinkedPullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length === 0); const stalePullRequests = openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14); const maintainerAuthoredPullRequests = openPullRequests.filter((pr) => isMaintainerAssociation(pr.authorAssociation)); - const likelyReviewablePullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length > 0 && daysSince(pr.updatedAt ?? pr.createdAt) < 30).length; + const cachedLikelyReviewablePullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length > 0 && daysSince(pr.updatedAt ?? pr.createdAt) < 30).length; + const likelyReviewablePullRequests = Math.min(openPullRequestCount, Math.max(cachedLikelyReviewablePullRequests, countOverrides.likelyReviewablePullRequests ?? 0)); const ageBuckets = { under7Days: openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) < 7).length, days7To30: openPullRequests.filter((pr) => { @@ -910,6 +916,8 @@ export function buildQueueHealth( collisionClusters: collisions.summary.clusterCount, ageBuckets, likelyReviewablePullRequests, + cachedOpenPullRequests: openPullRequests.length, + likelyReviewablePullRequestsSource, }, findings, }; @@ -3450,7 +3458,7 @@ export function buildPublicReadinessScore(args: { const scopedOverlapCount = args.scopedOverlapCount ?? 0; const reviewLoadScore = reviewLoadComponentScore(args.preflight.reviewBurden); const validation = validationComponent(args.pr, args.preflight); - const queueScore = queuePressureComponentScore(args.queueHealth.level); + const queuePressure = queuePressureComponent(args.queueHealth); const components: PublicReadinessScore["components"] = [ { key: "traceability", @@ -3505,10 +3513,10 @@ export function buildPublicReadinessScore(args: { { key: "queue_pressure", label: "Open PR queue", - score: queueScore, - max: 10, - evidence: `${args.queueHealth.signals.openPullRequests} open PR(s), ${args.queueHealth.signals.likelyReviewablePullRequests} likely reviewable.`, - action: queueScore >= 8 ? "No action." : "Expect slower review.", + score: queuePressure.score, + max: queuePressure.max, + evidence: queuePressure.evidence, + action: queuePressure.action, }, ]; return { @@ -3821,10 +3829,39 @@ function validationComponent(pr: PullRequestRecord, preflight: PreflightResult): return { score: 12, evidence: "Cached preflight status needs author follow-up.", action: "Add validation note." }; } -function queuePressureComponentScore(level: QueueHealth["level"]): number { - if (level === "low") return 10; - if (level === "medium") return 8; - if (level === "high") return 5; +function queuePressureComponent(queueHealth: QueueHealth): { score: number; max: 10; evidence: string; action: string } { + const signals = queueHealth.signals; + const openPullRequests = Math.max(0, signals.openPullRequests); + const cachedOpenPullRequests = Math.max(0, signals.cachedOpenPullRequests ?? signals.ageBuckets.under7Days + signals.ageBuckets.days7To30 + signals.ageBuckets.over30Days); + const likelyReviewablePullRequests = Math.max(0, Math.min(openPullRequests, signals.likelyReviewablePullRequests)); + const sampledLikelyReviewable = signals.likelyReviewablePullRequestsSource === "sampled_cache" || (signals.likelyReviewablePullRequestsSource === undefined && cachedOpenPullRequests < openPullRequests); + const score = queuePressureScore(openPullRequests); + const likelyEvidence = + openPullRequests === 0 + ? "0 likely reviewable" + : sampledLikelyReviewable + ? cachedOpenPullRequests > 0 + ? `${likelyReviewablePullRequests} likely reviewable in ${cachedOpenPullRequests} cached PR(s); full queue reviewability is sampled` + : "likely-reviewable count unavailable from cached PR metadata" + : `${likelyReviewablePullRequests} likely reviewable`; + const detailParts = [ + `${openPullRequests} open PR(s)`, + likelyEvidence, + signals.stalePullRequests > 0 ? `${signals.stalePullRequests} stale` : undefined, + signals.unlinkedPullRequests > 0 ? `${signals.unlinkedPullRequests} unlinked` : undefined, + ].filter(Boolean); + return { + score, + max: 10, + evidence: `${detailParts.join(", ")}.`, + action: score >= 8 ? "No action." : "Expect slower review.", + }; +} + +function queuePressureScore(openPullRequests: number): number { + if (openPullRequests <= 4) return 10; + if (openPullRequests <= 8) return 8; + if (openPullRequests <= 13) return 5; return 3; } diff --git a/test/unit/docs-github-app.test.ts b/test/unit/docs-github-app.test.ts new file mode 100644 index 0000000000..7be8a3f7da --- /dev/null +++ b/test/unit/docs-github-app.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const GITHUB_APP_DOCS_PATH = resolve( + import.meta.dirname, + "../../apps/gittensory-ui/src/routes/docs.github-app.tsx", +); + +describe("docs GitHub App setup page", () => { + const source = readFileSync(GITHUB_APP_DOCS_PATH, "utf8"); + + it("documents the install route, permissions, events, and setup verification", () => { + expect(source).toMatch(/https:\/\/github\.com\/apps\/gittensory\/installations\/new/); + expect(source).toMatch(/Metadata: read/); + expect(source).toMatch(/Pull requests: read/); + expect(source).toMatch(/Issues: write/); + expect(source).toMatch(/Checks: write/); + expect(source).toMatch(/issue_comment/); + expect(source).toMatch(/pull_request/); + expect(source).toMatch(/GET \/v1\/installations/); + expect(source).toMatch(/GET \/v1\/repos\/:owner\/:repo\/registration-readiness/); + expect(source).toMatch(/POST \/v1\/repos\/:owner\/:repo\/settings-preview/); + }); + + it("keeps Context advisory and Gate opt-in before branch protection", () => { + expect(source).toMatch(/Gittensory Context<\/strong> is advisory/); + expect(source).toMatch(/Gittensory Gate<\/strong> is opt-in/); + expect(source).toMatch(/should require Gittensory Gate<\/strong> only after/); + expect(source).toMatch(/Do not require Gittensory Context<\/strong>/); + }); +}); diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index fb68648923..d479d63f19 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1039,7 +1039,7 @@ describe("signal coverage edge cases", () => { expect(comment).toContain("> | Linked issue | ✅ No-issue rationale | PR body explains why no issue is linked. | No action. |"); expect(comment).toContain("> | Review load | ❌ 8/20 |"); expect(comment).toContain("> | Validation evidence | ❌ 5/25 | Cached preflight status is hold. | Fix blocker. |"); - expect(comment).toContain("> | Open PR queue | ❌ 3/10 |"); + expect(comment).toContain("> | Open PR queue | ❌ 3/10 | 16 open PR(s), 0 likely reviewable, 16 unlinked. | Expect slower review. |"); expect(comment).toContain("> | Gate result | ⚠️ Skipped | PR closed before full evaluation. | No action. |"); expect(comment).toContain("[JSONbored](https://github.com/JSONbored)"); expect(comment).toContain("[Gittensor profile](https://gittensor.io/miners/details?githubId=49853598)"); @@ -1112,6 +1112,56 @@ describe("signal coverage edge cases", () => { expect(scoreComponent(weak, "queue_pressure")).toMatchObject({ score: 3, action: "Expect slower review." }); }); + it("keeps the public PR queue row coherent for zero and sampled queue evidence", () => { + const directRepo = repo("owner/queue-panel"); + const currentPr = pr(directRepo.fullName, 43, "Fix queue display", { + body: "Fixes #7\n\nValidation: npm test", + linkedIssues: [7], + }); + const preflight = buildPreflightResult( + { repoFullName: directRepo.fullName, title: currentPr.title, body: currentPr.body ?? undefined, labels: currentPr.labels, linkedIssues: currentPr.linkedIssues }, + directRepo, + [], + [currentPr], + ); + const zeroEvidenceCriticalQueue: QueueHealth = { + ...queueHealthFixture(directRepo.fullName, "critical"), + signals: { + ...queueHealthFixture(directRepo.fullName, "critical").signals, + openPullRequests: 0, + unlinkedPullRequests: 0, + stalePullRequests: 0, + ageBuckets: { under7Days: 0, days7To30: 0, over30Days: 0 }, + likelyReviewablePullRequests: 0, + }, + }; + const zeroScore = buildPublicReadinessScore({ + pr: currentPr, + preflight: { ...preflight, status: "ready", reviewBurden: "low", findings: [] }, + queueHealth: zeroEvidenceCriticalQueue, + }); + expect(scoreComponent(zeroScore, "queue_pressure")).toMatchObject({ + score: 10, + evidence: "0 open PR(s), 0 likely reviewable.", + action: "No action.", + }); + + const sampledQueue = buildQueueHealth( + directRepo, + [], + [currentPr], + buildCollisionReport(directRepo.fullName, [], [currentPr]), + { openPullRequests: 25 }, + ); + const sampledScore = buildPublicReadinessScore({ + pr: currentPr, + preflight: { ...preflight, status: "ready", reviewBurden: "low", findings: [] }, + queueHealth: sampledQueue, + }); + expect(scoreComponent(sampledScore, "queue_pressure")).toMatchObject({ score: 3, action: "Expect slower review." }); + expect(scoreComponent(sampledScore, "queue_pressure").evidence).toContain("1 likely reviewable in 1 cached PR(s); full queue reviewability is sampled"); + }); + it("filters disabled linked-issue findings and uses fallback next steps when the panel is clean", () => { const directRepo = repo("owner/clean-panel"); const currentPr = pr(directRepo.fullName, 50, "Fix documented bug", { diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index d40463d986..6bd0169fbf 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -151,12 +151,15 @@ describe("v2 signal builders", () => { const sampledIssues = issues.slice(0, 1); const sampledPullRequests = pullRequests.slice(0, 1); const report = buildCollisionReport(repo.fullName, sampledIssues, sampledPullRequests, []); - const health = buildQueueHealth(repo, sampledIssues, sampledPullRequests, report, { openIssues: 2912, openPullRequests: 169 }); + const health = buildQueueHealth(repo, sampledIssues, sampledPullRequests, report, { openIssues: 2912, openPullRequests: 169, likelyReviewablePullRequests: 42 }); const intake = buildContributorIntakeHealth(repo, sampledIssues, sampledPullRequests, repo.fullName, report, { openIssues: 2912, openPullRequests: 169 }); const lane = buildMaintainerLaneReport(repo, sampledIssues, sampledPullRequests, repo.fullName, report, { openIssues: 2912, openPullRequests: 169 }); expect(health.signals.openIssues).toBe(2912); expect(health.signals.openPullRequests).toBe(169); + expect(health.signals.likelyReviewablePullRequests).toBe(42); + expect(health.signals.cachedOpenPullRequests).toBe(1); + expect(health.signals.likelyReviewablePullRequestsSource).toBe("authoritative"); expect(intake.queueHealth.signals.openIssues).toBe(2912); expect(lane.queueHealth.signals.openPullRequests).toBe(169); }); @@ -170,6 +173,7 @@ describe("v2 signal builders", () => { expect(issueOnly.signals.openPullRequests).toBe(pullRequests.length); expect(prOnly.signals.openIssues).toBe(issues.length); expect(prOnly.signals.openPullRequests).toBe(25); + expect(prOnly.signals.likelyReviewablePullRequestsSource).toBe("sampled_cache"); }); it("adds queue age buckets and likely-reviewable counts", () => {