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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,17 @@
},
"likelyReviewablePullRequests": {
"type": "number"
},
"cachedOpenPullRequests": {
"type": "number"
},
"likelyReviewablePullRequestsSource": {
"type": "string",
"enum": [
"cache",
"sampled_cache",
"authoritative"
]
}
},
"required": [
Expand Down
6 changes: 4 additions & 2 deletions apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ gittensory-mcp preflight --login your-login --json`}
<ol>
<li>
<strong>Install the GitHub App.</strong> Choose repositories and approve permissions —
default posture is silence. See <Link to="/docs/github-app">GitHub App setup</Link>.
default posture is silence. Start with the{" "}
<Link to="/docs/github-app">GitHub App first-10-minutes checklist</Link>.
</li>
<li>
<strong>Configure settings.</strong> Opt in to at most one configured label and one sticky
Expand All @@ -122,7 +123,8 @@ gittensory-mcp preflight --login your-login --json`}
</li>
<li>
<strong>Preview the public surface.</strong> Dry-run what would be written to GitHub
without mutating state.
without mutating state. Keep <strong>Gittensory Context</strong> advisory; require{" "}
<strong>Gittensory Gate</strong> only after blocking rules are explicitly configured.
<CodeBlock
lang="http"
code={`POST /v1/repos/:owner/:repo/settings-preview
Expand Down
77 changes: 72 additions & 5 deletions apps/gittensory-ui/src/routes/docs.github-app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";

import { DocsPage } from "@/components/site/docs-page";
import { Callout } from "@/components/site/primitives";
import { CodeBlock, Callout } from "@/components/site/primitives";

const GITHUB_APP_INSTALL_URL = "https://github.com/apps/gittensory/installations/new";

export const Route = createFileRoute("/docs/github-app")({
head: () => ({
Expand Down Expand Up @@ -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."
>
<h2>Install</h2>
<p>
The hosted deployment uses the GitHub App slug <code>gittensory</code>. Start from{" "}
<a href={GITHUB_APP_INSTALL_URL} target="_blank" rel="noreferrer">
the GitHub App install flow
</a>
, then choose only the repositories you want Gittensory to see.
</p>
<ol>
<li>Open the Gittensory GitHub App listing.</li>
<li>Choose the repositories you want to grant access to.</li>
<li>Approve the requested permissions (issues, pulls, checks, metadata).</li>
<li>Open the install flow and pick the owning account.</li>
<li>
Choose selected repositories instead of all repositories unless you are onboarding an org.
</li>
<li>
Approve <code>Metadata: read</code>, <code>Pull requests: read</code>, and{" "}
<code>Issues: write</code>. Enable <code>Checks: write</code> when Context or Gate check
runs are enabled.
</li>
<li>
Keep webhook events enabled for <code>issues</code>, <code>issue_comment</code>,{" "}
<code>pull_request</code>, and <code>repository</code>.
</li>
</ol>

<h2>First 10 minutes</h2>
<ol>
<li>Install the app on one test repository first.</li>
<li>
Confirm the installation appears in the private API, then open its health record.
<CodeBlock
lang="http"
code={`GET /v1/installations
GET /v1/installations/:id/health
GET /v1/installations/:id/repair`}
/>
</li>
<li>
Check repo readiness before enabling public output.
<CodeBlock lang="http" code={`GET /v1/repos/:owner/:repo/registration-readiness`} />
</li>
<li>
Preview the exact public surface without posting to GitHub.
<CodeBlock
lang="http"
code={`POST /v1/repos/:owner/:repo/settings-preview
# body: sample PR fields + desired comment/check/gate settings`}
/>
</li>
<li>
Leave <strong>Gittensory Context</strong> advisory while you tune copy and settings. Make{" "}
<strong>Gittensory Gate</strong> required only after the repo explicitly enables blocking
rules.
</li>
</ol>

<h2>Default posture</h2>
Expand All @@ -59,6 +109,12 @@ function GithubApp() {
protection. <strong>Gittensory Gate</strong> is opt-in and can be made required after a repo
owner chooses blocking rules.
</p>
<p>
Branch protection should require <strong>Gittensory Gate</strong> only after the repo has
verified installation health, previewed the public panel, and configured at least one{" "}
<code>block</code> rule. Do not require <strong>Gittensory Context</strong>; it is there to
inform reviewers, not stop merges.
</p>

<h2>Gate modes</h2>
<p>
Expand All @@ -81,6 +137,17 @@ function GithubApp() {
After installing, verify your install health from the API. The readiness endpoint separates
service health from data quality.
</p>
<p>
If the install route changes, check the deployed <code>GITHUB_APP_SLUG</code> before
publishing setup copy. For the hosted app, the expected slug is <code>gittensory</code>.
</p>

<p>
New maintainers should continue with{" "}
<Link to="/docs/maintainer-workflow">Maintainer workflow</Link> or the{" "}
<Link to="/docs/beta-onboarding">beta onboarding checklist</Link> after the health endpoint
reports clean permissions and events.
</p>

<Callout variant="safety">
Gittensory's GitHub App never requests source push, never stores repository contents, and
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
</p>
<p>
New installations should start with <Link to="/docs/github-app">GitHub App setup</Link>:
install on one repo, verify installation health, preview the public panel, then decide
whether <strong>Gittensory Gate</strong> should become a required check.
</p>
<WorkflowMirror
role="maintainer"
steps={steps}
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export const QueueHealthSchema = z
over30Days: z.number(),
}),
likelyReviewablePullRequests: z.number(),
cachedOpenPullRequests: z.number().optional(),
likelyReviewablePullRequestsSource: z.enum(["cache", "sampled_cache", "authoritative"]).optional(),
}),
findings: z.array(FindingSchema),
})
Expand Down
57 changes: 47 additions & 10 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export type QueueHealth = {
over30Days: number;
};
likelyReviewablePullRequests: number;
cachedOpenPullRequests?: number | undefined;
likelyReviewablePullRequestsSource?: "cache" | "sampled_cache" | "authoritative" | undefined;
};
findings: SignalFinding[];
rankedPullRequests?: {
Expand All @@ -99,6 +101,7 @@ export type QueueHealth = {
export type QueueSignalCounts = {
openIssues?: number | undefined;
openPullRequests?: number | undefined;
likelyReviewablePullRequests?: number | undefined;
};

export type ConfigQuality = {
Expand Down Expand Up @@ -844,10 +847,13 @@ export function buildQueueHealth(
const openPullRequests = pullRequests.filter((pr) => 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) => {
Expand Down Expand Up @@ -910,6 +916,8 @@ export function buildQueueHealth(
collisionClusters: collisions.summary.clusterCount,
ageBuckets,
likelyReviewablePullRequests,
cachedOpenPullRequests: openPullRequests.length,
likelyReviewablePullRequestsSource,
},
findings,
};
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down
32 changes: 32 additions & 0 deletions test/unit/docs-github-app.test.ts
Original file line number Diff line number Diff line change
@@ -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 <strong>Gittensory Gate<\/strong> only after/);
expect(source).toMatch(/Do not require <strong>Gittensory Context<\/strong>/);
});
});
52 changes: 51 additions & 1 deletion test/unit/signals-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
Expand Down Expand Up @@ -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", {
Expand Down
Loading