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
30 changes: 30 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,23 @@ GITTENSORY_REVIEW_DRAFT=false
# # /setup returns 400; with it, /setup needs ?token=<value> (or an
# # x-setup-token / Bearer header) so a freshly-booted, not-yet-configured
# # instance can't be driven through App creation by a random visitor.
# ADMIN_GITHUB_LOGINS=your-github-login # REQUIRED for control-panel ("operator") access. A comma- or
# # whitespace-separated allowlist of GitHub logins, case-insensitive.
# # FAIL-CLOSED: unset/empty means NOBODY can sign into the dashboard,
# # even the person who set up this instance — this is not a bug, add
# # your own login here right after first-run setup. Also exempts these
# # logins from the agent's own-PR auto-close rules (fleet-operator
# # identity) and lets them bypass per-repo MCP scope.
# MCP_READ_REPO_ALLOWLIST= # scopes the shared GITTENSORY_MCP_TOKEN identity's READ-only MCP
# # tools (repo context, issue quality, watch subscriptions) to these
# # owner/repo entries (comma/whitespace-separated). FAIL-CLOSED:
# # unset/empty grants no repo access. `*` or `all` is an explicit
# # escape hatch for full unscoped read access, incl. cross-repo
# # contributor/operator tools that have no single repo to scope against.
# MCP_ACTUATION_REPO_ALLOWLIST= # same fail-closed/wildcard model as MCP_READ_REPO_ALLOWLIST, but for
# # WRITE (merge/close/approve) MCP tools — kept separate so read and
# # write trust can differ. Unrelated to ADMIN_GITHUB_LOGINS, which
# # scopes dashboard sign-in, not the shared MCP token's per-repo reach.
# PORT=8787
# DATABASE_PATH=/data/gittensory.sqlite # SQLite file on the mounted data volume; all migrations auto-apply
# POSTGRES_PASSWORD=change-this-long-random-value # used by the --profile postgres / --profile pgbouncer services
Expand Down Expand Up @@ -184,8 +201,16 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS=1200 # TTL for required-status branch protection reads.
# GITHUB_METADATA_CACHE_TTL_SECONDS=600 # TTL for stable repo/user/installation metadata reads.
# GITHUB_COMMIT_CACHE_TTL_SECONDS=900 # TTL for bare /commits/{ref} resolves; dedups the two hourly upstream ref→SHA reads.
# GITHUB_WEBHOOK_MAX_BODY_BYTES=1048576 # max accepted GitHub webhook body size, in bytes (default 1 MiB).
# # Larger deliveries are rejected before parsing. Raise only if you
# # see legitimate webhooks rejected for size (e.g. a `push` event
# # with a large `commits` array from a big force-push or merge);
# # most installs never need to touch this.
# QDRANT_URL= # set to http://qdrant:6333 to use Qdrant as the RAG vector store
# # (--profile qdrant). Overrides the built-in sqlite-vec / pgvector.
# BROWSER_WS_ENDPOINT= # ws:// URL of a browserless/chrome instance for visual-review
# # screenshot capture. Unset = visual review is fully inert (no
# # screenshots, no error) — this feature is entirely optional.
# DISCORD_WEBHOOK_URL= # one Discord channel for per-action notifications (merged/closed/
# # manual) on ANY repo you review. Unset = no Discord notifications.
# # Collection and schema are auto-created at startup. Off when unset.
Expand Down Expand Up @@ -324,6 +349,11 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# # merged decision. single = one reviewer's verdict (auto when 1).
# AI_ON_MERGE=either # synthesis merge rule: either (block if EITHER reviewer flags) |
# # both (block only when both do). Ignored unless AI_COMBINE=synthesis.
# AI_DAILY_NEURON_BUDGET=10000000 # daily spend cap (Cloudflare Workers AI "neurons") shared by AI
# # summaries, the free consensus-defect reviewer pair, and the
# # AI-slop scorer. Default 10,000,000/day; clamped to [0, 10000000].
# # Only meters FREE Workers-AI calls — BYOK/provider calls above bill
# # the maintainer's own account and are not counted against this.
# Ollama reviewer (AI_PROVIDER=ollama). Defaults: OLLAMA_AI_BASE_URL=http://localhost:11434/v1,
# OLLAMA_AI_MODEL=llama3.1, no API key. Set the base URL to http://ollama:11434/v1 when using the compose
# --profile ollama service.
Expand Down
46 changes: 40 additions & 6 deletions apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,52 @@ function SelfHostingGithubApp() {
]}
/>

<h2>One-click App creation (recommended for a Direct App)</h2>
<p>
Before the App exists (no <code>GITHUB_APP_ID</code> set yet), the self-host serves a setup
wizard at <code>GET /setup</code>. It renders a form that POSTs a GitHub App{" "}
<em>manifest</em> — the exact permission and event set below, pre-filled — to GitHub's own
App-creation flow. GitHub creates the App with the correct configuration in one step and
redirects back to exchange credentials automatically; there is no manual permission
checklist to get right or wrong. The route is disabled once an App is configured, so it
can't rebind a live install.
</p>
<CodeBlock
filename=".env"
code={`PUBLIC_API_ORIGIN=https://reviews.example.com # exact public URL, embedded in the manifest
SELFHOST_SETUP_TOKEN=change-this-long-random-value # unlocks /setup for a freshly-booted instance`}
/>
<CodeBlock
lang="bash"
code={`open "https://reviews.example.com/setup?token=<SELFHOST_SETUP_TOKEN>"`}
/>
<Callout variant="note">
Manual App creation (below) is still fully supported — for an air-gapped instance, a
stricter change-review process, or simply a preference for reviewing every permission by
hand before it exists. Whichever path you take, the resulting App needs the SAME
permissions: this doc's manual list is kept in sync with the wizard's manifest and checked
in CI, so the two can't silently drift apart.
</Callout>

<h2>Direct App permissions</h2>
<ul>
<li>Pull requests: read/write.</li>
<li>Checks: read/write.</li>
<li>Issues: read/write.</li>
<li>Contents: read. Add write only if the self-host should merge.</li>
<li>Pull requests: write.</li>
<li>
Checks: write — the gate posts a check-run; <code>checks: read</code> alone 403s that
write (silently fails the first review with no obvious cause).
</li>
<li>Issues: write.</li>
<li>
Contents: write — required for BOTH merging and the auto-maintain{" "}
<code>update_branch</code> action. <code>contents: read</code> looks sufficient at
creation time but silently breaks auto-merge later with no error surfaced in the UI; there
is no lesser permission that keeps merge/update-branch working.
</li>
<li>Commit statuses: read.</li>
<li>Metadata: read.</li>
</ul>
<p>
Events should include pull request, pull request review, push, issues, check suite, check
run, and status.
Events: pull request, pull request review, push, issues, check suite, check run, and status.
</p>

<h2>Direct App env</h2>
Expand Down
19 changes: 19 additions & 0 deletions apps/gittensory-ui/src/routes/docs.self-hosting-security.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ function SelfHostingSecurity() {
</li>
</ul>

<h2>Control-panel access</h2>
<p>
GitHub sign-in to the control panel (the maintainer/owner dashboard) is gated by{" "}
<code>ADMIN_GITHUB_LOGINS</code> — a comma- or whitespace-separated, case-insensitive
allowlist of GitHub logins.
</p>
<CodeBlock
filename=".env"
code={`ADMIN_GITHUB_LOGINS=your-github-login,a-second-maintainer`}
/>
<Callout variant="warn" title="Fail-closed by design">
Unset or empty means NOBODY gets control-panel access — not even the person who just
finished setup. This is intentional, not a bug: add your own GitHub login here right after
first-run setup, or you will sign in successfully and see zero privileges with no
explanation. The same allowlist also exempts these logins from the agent's own-PR auto-close
rules and lets them bypass per-repo MCP scope (<code>MCP_READ_REPO_ALLOWLIST</code> /{" "}
<code>MCP_ACTUATION_REPO_ALLOWLIST</code>).
</Callout>

<h2>AI credential boundaries</h2>
<Callout variant="warn" title="Subscription CLI credentials">
CLI auth files can be readable by the runtime. Do not mount a prompt-readable Claude Code or
Expand Down
52 changes: 52 additions & 0 deletions test/unit/setup-wizard-docs-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildManifest } from "../../src/selfhost/setup-wizard";

const DOCS_PATH = "apps/gittensory-ui/src/routes/docs.self-hosting-github-app.tsx";

// docs.self-hosting-github-app.tsx claims its manual permission list "is generated from the identical
// source the wizard's manifest uses, so the two can never drift apart again" (#2542). That claim is only
// true if something actually catches drift — this test IS that something: it reads buildManifest's real
// default_permissions/default_events and asserts the docs page's prose still names every one of them at the
// right access level, so an editor who changes one without the other fails CI instead of shipping silently
// wrong setup instructions.
const PERMISSION_LABELS: Record<string, string> = {
pull_requests: "Pull requests",
checks: "Checks",
issues: "Issues",
contents: "Contents",
statuses: "Commit statuses",
metadata: "Metadata",
};

describe("self-host GitHub App manifest <-> docs parity (#2542)", () => {
it("the docs page's manual permission list names every buildManifest permission at the correct access level", () => {
const manifest = buildManifest("https://example.com", "state") as { default_permissions: Record<string, string> };
const docsSource = readFileSync(DOCS_PATH, "utf8");

expect(Object.keys(manifest.default_permissions).sort()).toEqual(Object.keys(PERMISSION_LABELS).sort());
for (const [key, level] of Object.entries(manifest.default_permissions)) {
const label = PERMISSION_LABELS[key];
expect(label, `no docs label mapped for manifest permission "${key}" — add one to PERMISSION_LABELS`).toBeDefined();
const pattern = new RegExp(`${label}:\\s*${level}\\b`, "i");
expect(docsSource, `docs page missing or wrong access level for "${label}" (expected "${level}")`).toMatch(pattern);
}
});

it("the docs page's events sentence names every buildManifest default_event, and only those", () => {
// Scoped to the "Events: ...." sentence specifically — a bare substring search (e.g. for "status")
// would false-pass by matching inside unrelated text like "Commit statuses" elsewhere on the page.
const manifest = buildManifest("https://example.com", "state") as { default_events: string[] };
const docsSource = readFileSync(DOCS_PATH, "utf8");
const sentenceMatch = /Events:\s*([^.]+)\./.exec(docsSource);
expect(sentenceMatch, "docs page has no \"Events: ...\" sentence to check").not.toBeNull();
const eventsSentence = sentenceMatch![1]!;
const docsEvents = eventsSentence
.split(",")
.map((s) => s.replace(/\band\b/i, "").trim().toLowerCase())
.filter(Boolean);

const manifestEvents = manifest.default_events.map((e) => e.replace(/_/g, " "));
expect(docsEvents.sort()).toEqual(manifestEvents.sort());
});
});
Loading