GitHub — connect a repo to your app
prompts/pkg/llms/github.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
An app that works with somebody's GitHub repo — answers its pull requests,
sorts its issues, reads its files, shows its commits — connects that repo in
one click through the vibes.diy GitHub App. The person presses Connect,
ticks the repos this app gets on a vibes.diy page, and lands back in your app. From then on
backend.js reads and writes those repos with ctx.github.fetch, and GitHub's
events for them arrive at your /_api/github-webhook route.
The platform's GitHub App holds the credential, so the app carries no GitHub key of its own.
The whole integration is a link, a declaration and ctx.github.fetch; the
person's consent and the credential both live on the platform. (An app built
without this skill asked its owner to register a GitHub OAuth App and paste its
client secret into the vibe's secrets — the GitHub App replaces every step of
that.)
Which lane — signal words
| The ask | Lane | What you write |
|---|---|---|
| "my repo", "my GitHub repo", pull requests, PRs, code review, issues, commits, files, a README, "a GitHub bot", "when someone opens a PR" | The repo lane — this skill | the /github/install link, config.github, ctx.github.fetch, the /github-webhook route |
| the visitor's GitHub profile — their name, avatar, bio next to their entries | Connections (connections skill) |
config.connections with provider: "github", scopes: ["read:user"], and useConnection |
| public GitHub data nobody signs in for — stars or releases of a public repo, a public user's repos | Plain ctx.fetch |
ctx.fetch("https://api.github.com/repos/nodejs/node") in backend.js, no connect step |
Repos, pull requests and issues always ride the repo lane, even when the ask also says "sign in with GitHub": the Connections lane carries the profile, and the GitHub App carries the repos. An app can use both side by side.
The three pieces
1. Declare it — backend.js
backend.js declares config.github with bind and a why sentence the
visitor reads on the platform's consent page before GitHub opens, and the
permissions the app uses in each connected repo:
jsexport const config = {
github: {
bind: "anyone",
why: "Leaves a short note on each new pull request in the repos you pick.",
permissions: ["pull_requests:write"],
},
};
bind: "anyone"lets every signed-in visitor connect their own repos — the shape for a tool other people use (a reviewer, a triager, a board anyone brings their repos to).bind: "owner"(the default) keeps connecting to the app's owner — the shape for one team's own repo.whyis one static sentence, 280 characters at most, written to the visitor in the app's own voice ("Sorts the issues people open in your repos with a label."). It is required with"anyone"; write it for"owner"too, so a remix that opens up to visitors already says what it does.- A malformed
config.githubrefuses the push, with a message naming the field.
config.github.permissions lists every GitHub permission the app's ctx.github.fetch calls use
— and only those. The platform lets each call through when its permission is
declared, and the consent page lists the declaration in plain words, so the
person sees exactly what the app does in their repos. The names are GitHub's
own, and :write includes :read:
| Permission | The consent page says the app can | Calls it covers |
|---|---|---|
contents:read |
read its code | /contents, /readme, /git, /commits, /compare, /branches, /tags, /releases (GET) |
contents:write |
read and change its code | the same paths, writing; merging a pull request |
pull_requests:read |
read its pull requests | /pulls, /pulls/{n}, /pulls/{n}/files, /reviews, /comments (GET) |
pull_requests:write |
review, comment on, edit and close pull requests | reviews, review comments, requested reviewers, comments and labels on a pull request's conversation, and editing a pull request (title, body, base, open or closed); updating a pull request's branch also needs contents:write |
issues:read |
read its issues | /issues, /issues/{n}, /labels (GET) |
issues:write |
label and comment on issues | opening, editing, labelling, assigning and commenting on issues |
statuses:read |
read whether commits are passing | /commits/{ref}/status, /commits/{ref}/statuses |
statuses:write |
mark commits as passing or failing | POST /statuses/{sha} |
checks:read |
read check results on commits | /check-runs, /check-suites, /commits/{ref}/check-runs (GET) |
checks:write |
post check results on commits | creating and updating check runs |
actions:read |
read its Actions runs | /actions/runs, /actions/workflows, /actions/jobs, /actions/artifacts (GET) |
- A repo's own record (
GET /repos/{owner}/{repo}) and/installation/repositoriesneed no permission; every app has them. - A pull request's conversation comments and labels live at
/repos/{owner}/{repo}/issues/{number}/commentsand…/labelson GitHub, andpull_requests:writecovers them when{number}is a pull request. An app that comments on issues too declaresissues:write. - Leaving
permissionsout gives["contents:read", "pull_requests:write"]. Write the list anyway, so the consent page names what this app does. - When a new version of the app declares more, each person who connected a repo keeps the list they agreed to until they press Connect again; the app's owner gets the new list right away.
2. Connect it — App.jsx
The Connect button is a link to buildGithubInstallUrl() from use-vibes, opened in a new tab
with target="_blank" rel="noopener". buildGithubInstallUrl() takes no
arguments and returns the platform's absolute
/github/install?vibe=<ownerHandle>/<appSlug> address for the app that is
running — on https://vibes.diy, or on the preview the app runs under — so the
link stays right in every copy and remix of the app. The app runs on its own
subdomain, so a path starting at /github/install would point at the app
itself, and the new tab gives the platform's consent page a whole window of its
own:
jsximport { buildGithubInstallUrl } from "use-vibes";
<a href={buildGithubInstallUrl()} target="_blank" rel="noopener">Connect a GitHub repo</a>
The app's owner goes straight to GitHub. A visitor first sees the platform's
consent page quoting your why; its Continue button carries the visitor on to
GitHub to sign in (and add the GitHub App if it is new to them), then to a
vibes.diy page where they tick the repos THIS app gets — nothing starts ticked,
so each app gets exactly the repos chosen for it. The platform builds every
step after /github/install itself, so the app's one link is
buildGithubInstallUrl() — GitHub's own install page is reached only through
that door. When the new tab finishes, the person comes back to the app's tab,
so re-read the repo list when the window regains focus.
Connecting needs a signed-in visitor, so a signed-out visitor gets the sign-in
ask (requestLogin from useViewer) in that spot.
3. Call and listen — backend.js
Every call to a connected repo goes through ctx.github.fetch — GitHub's
REST API, called as the GitHub App installation that covers that repo:
jsconst res = await ctx.github.fetch(`/repos/${repo}/pulls/${number}/files?per_page=100`);
const files = await res.json();
await ctx.github.fetch(`/repos/${repo}/pulls/${number}/reviews`, {
method: "POST",
body: { commit_id: sha, event: "COMMENT", body: "Looks tidy." },
});
ctx.github.fetch attaches the App's credential itself, so a repo call carries only its path, method and body.
path starts at /: a /repos/{owner}/{repo}/… path from the permissions table above, for a repo connected to
this app, plus /installation/repositories, which lists exactly the repos
connected to this app. An app sees only the repos connected to it, so build
every repo list from routes() or that listing. Methods are GET, POST, PUT, PATCH
and DELETE; an object body is sent as JSON. It resolves a normal Response
(GitHub's own 404 or 422 included — read res.ok and res.status), and it
rejects with an Error whose err.reason names what happened:
not_connected (no repo connected yet), unmodeled_endpoint (a path outside
those, or a repo this app has not connected), permission_not_declared (the
call needs a permission config.github.permissions leaves out — add it),
permission_not_consented (declared, and the person connected the repo before
the app asked for it — show them Connect again with the
buildGithubInstallUrl() link), permission_not_granted (GitHub itself
refused: the vibes.diy GitHub App does not hold that permission yet on that
account, which the account owner accepts on GitHub),
unsupported_method, body_too_large, upstream_error. Branch on
err.reason and tell the person which one it was.
ctx.github.routes() lists the connected repos and who connected each —
[{ repo, accountLogin, binderHandle, installationId }], spending no GitHub
call. It is how a route answers "which of these repos are mine?": keep the rows
whose binderHandle is ctx.userInfo.userHandle. Check a repo name the page
sends against that list before building a path from it.
A delivery arrives as POST /github-webhook with the event name in x-vibes-github-event
(pull_request, issues, push, …) and GitHub's delivery id in
x-vibes-github-delivery. The platform has already verified it came from
GitHub, so the headers are trustworthy and the body is GitHub's payload.
- A delivery runs as the person who connected the repo.
ctx.userInfois them, and itsctx.db.putwrites passaccess.jsas them, withuser.backend === "fetch"— so a delivery can keep its own records in that person's channel, and the page shows them live. - Claim each unit of work before spending on it. GitHub can deliver the
same event twice, and a pull request is pushed to many times. Put a record
whose
_idnames the work — the repo, the number and the head commit, or the delivery id — withifRev: null; the first delivery writes it, and a repeat meetserr.code === "conflict"and answers200having done nothing. - A
4xxanswer ends the delivery; a thrown handler marks it failed.
Automatic work starts with the repo's team: read author_association off the delivery.
A delivery runs as the person who connected the repo, so the work it takes on
by itself spends their credits (ctx.callAI included) and writes under their
name, and on a public repo anyone can open a pull request. GitHub stamps every
pull request, issue and comment with its author's place in the repo, so the
delivery already carries the answer, with no extra call:
payload.pull_request.author_association, payload.issue.author_association,
payload.comment.author_association. OWNER, MEMBER and COLLABORATOR are
the repo's team; every other value (CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR,
FIRST_TIMER, NONE, MANNEQUIN) is anyone.
- Each repo gets a
whosetting,"team"or"anyone", starting at"team". The person who connected the repo saves it, and the page shows it as two plain choices: People on the repo's team and Anyone who opens a pull request (for an issue bot, Anyone who opens an issue). An app whose whole job is greeting newcomers starts at"anyone", with the same two choices. - Work from beyond the chosen circle waits for its person. The delivery
records it in their channel, so it shows up live on their page, with a
one-tap Review it now: an
apiFetchroute, so the work runs as them and the pull request hears from the app the moment they press it. - Issue and comment bots read the same stamp off
payload.issueandpayload.comment, and honour the same setting.
Opening the app and spending on the work are separate dials. An app can be
open for anyone to visit and read, while its costly work (a ctx.callAI
review, a comment posted to GitHub) runs only for the people who chose it. Set
each dial on its own:
| Dial | Who it decides | Where it lives |
|---|---|---|
| Open the app and read its records | visitors | publishing (apps are public by default) and access.js channels and grants |
| Connect a repo | signed-in visitors | config.github.bind |
| Set off the costly work | pull request, issue and comment authors, and page callers | the repo's who setting on deliveries; a ctx.userInfo check against binderHandle on every route that spends |
So a review bot stays published and public, with a landing page that explains
it and a shared board that anyone can browse, while a stranger's pull request
or an anonymous page visit sets off no AI call. A route that only reads (a
public board, the page's own listing) spends nothing and can answer anyone. A
route that spends (ctx.callAI, a POST through ctx.github.fetch) first
checks that ctx.userInfo?.userHandle is the binderHandle of the repo it
touches, and answers everyone else with a plain 403 reason. Keep the app
open; put the gate on the spend.
A complete example — pull request size notes
Each new pull request in a connected repo gets a short review comment saying how many lines it changes; past a size the person picks, the note adds where to start reading. Anyone can connect their own repos, set their own size, and see their notes land live. Pull requests from the repo's team get their note right away; one from anyone else waits on the person's page with Review it now, until they switch the repo to Anyone who opens a pull request.
backend.js
jsexport const config = {
github: {
bind: "anyone",
why: "Leaves a short note on each new pull request in the repos you pick, saying how big the change is.",
// Reads each pull request and its files, and posts one review.
permissions: ["pull_requests:write"],
},
};
const DB = "sizeNotes";
const DEFAULT_BIG_AT = 400;
const PR_ACTIONS = ["opened", "reopened", "synchronize"];
// The author_association values GitHub gives the people on a repo's team.
const TEAM = ["OWNER", "MEMBER", "COLLABORATOR"];
const DEFAULT_WHO = "team";
// What each ctx.github refusal means to the person, in a line they can act on.
const REASONS = {
permission_not_consented: "press Connect again to let this app review pull requests here",
permission_not_declared: "this app's backend needs to declare that GitHub permission",
permission_not_granted: "the vibes.diy GitHub App needs permission to review pull requests",
};
function json(data, status) {
return new Response(JSON.stringify(data), { status: status ?? 200, headers: { "content-type": "application/json" } });
}
function settingsId(repo) {
return `settings:${repo.toLowerCase()}`;
}
// The repos the signed-in caller connected to this app.
async function myRepos(ctx) {
const me = ctx.userInfo?.userHandle;
if (me === undefined) return [];
const routes = await ctx.github.routes();
return routes.filter((r) => r.binderHandle === me);
}
async function writeNote(ctx, repo, pr, bigAt) {
const res = await ctx.github.fetch(`/repos/${repo}/pulls/${pr.number}/files?per_page=100`);
if (res.ok === false) return { status: "failed", reason: `GitHub answered ${res.status} for the file list` };
const files = await res.json();
const lines = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
const largest = [...files].sort((a, b) => b.changes - a.changes)[0];
const body =
lines >= bigAt && largest !== undefined
? `This pull request changes ${lines} lines across ${files.length} files. A good place to start reading: \`${largest.filename}\`.`
: `This pull request changes ${lines} lines across ${files.length} files.`;
const posted = await ctx.github.fetch(`/repos/${repo}/pulls/${pr.number}/reviews`, {
method: "POST",
body: { commit_id: pr.head.sha, event: "COMMENT", body },
});
if (posted.ok === false) return { status: "failed", reason: `GitHub answered ${posted.status} to the note` };
const review = await posted.json();
return { status: "done", lines, files: files.length, reviewUrl: review.html_url };
}
function noteId(repo, pr) {
return `note:${repo.toLowerCase()}#${pr.number}@${pr.head.sha}`;
}
function noteRecord(repo, pr, binderHandle, status) {
return { type: "note", repo, number: pr.number, title: pr.title, url: pr.html_url, author: pr.user?.login, binderHandle, status, at: Date.now() };
}
// The person's `who` setting decides whose pull requests get a note by themselves.
function takesOn(settings, pr) {
const who = settings?.who ?? DEFAULT_WHO;
return who === "anyone" || TEAM.includes(pr.author_association);
}
async function finishNote(ctx, id, repo, pr, bigAt) {
const outcome = await writeNote(ctx, repo, pr, bigAt).catch((err) => ({
status: "failed",
reason: REASONS[err.reason] ?? String(err.reason ?? err.message),
}));
const claimed = await ctx.db.get(id, { db: DB });
await ctx.db.put({ ...claimed, ...outcome }, { db: DB, id, ifRev: claimed._rev });
return { ok: outcome.status === "done", ...outcome };
}
async function noteOnPull(ctx, repo, pr, binderHandle) {
const settings = await ctx.db.get(settingsId(repo), { db: DB });
// From the team, or the person opened it to anyone: the note runs now. Otherwise it waits for them.
const status = takesOn(settings, pr) ? "running" : "waiting";
// One record per pull request and head commit: the first delivery claims it, a repeat steps aside.
const id = noteId(repo, pr);
try {
await ctx.db.put(noteRecord(repo, pr, binderHandle, status), { db: DB, id, ifRev: null });
} catch (err) {
if (err.code === "conflict") return { ok: true, skipped: "already-claimed" };
throw err;
}
if (status === "waiting") return { ok: true, waiting: pr.author_association };
return finishNote(ctx, id, repo, pr, settings?.bigAt ?? DEFAULT_BIG_AT);
}
// "Review it now": the person who connected the repo takes on a waiting pull request, as themselves.
async function reviewNow(request, ctx) {
const input = await request.json();
const mine = await myRepos(ctx);
const bound = mine.find((r) => r.repo.toLowerCase() === String(input.repo).toLowerCase());
if (bound === undefined) return json({ ok: false, reason: "Connect this repo first, and its pull requests open here." }, 404);
const res = await ctx.github.fetch(`/repos/${bound.repo}/pulls/${Number(input.number)}`);
if (res.ok === false) return json({ ok: false, reason: `GitHub answered ${res.status} for that pull request` }, 422);
const pr = await res.json();
const id = noteId(bound.repo, pr);
const held = await ctx.db.get(id, { db: DB });
if (held !== null && held.status !== "waiting") return json({ ok: true, skipped: "already-claimed" });
const record = held === null ? noteRecord(bound.repo, pr, bound.binderHandle, "running") : { ...held, status: "running" };
try {
await ctx.db.put(record, { db: DB, id, ifRev: held === null ? null : held._rev });
} catch (err) {
if (err.code === "conflict") return json({ ok: true, skipped: "already-claimed" });
throw err;
}
const settings = await ctx.db.get(settingsId(bound.repo), { db: DB });
const outcome = await finishNote(ctx, id, bound.repo, pr, settings?.bigAt ?? DEFAULT_BIG_AT);
return json(outcome, outcome.ok ? 200 : 422);
}
async function onDelivery(request, ctx) {
const event = request.headers.get("x-vibes-github-event");
if (event !== "pull_request") return json({ ok: true, ignored: event });
const payload = await request.json();
if (PR_ACTIONS.includes(payload.action) === false) return json({ ok: true, ignored: payload.action });
const repo = payload.repository?.full_name;
const pr = payload.pull_request;
if (typeof repo !== "string" || pr === undefined) return json({ ok: false, reason: "this delivery carries no pull request" }, 400);
// The delivery runs as the person who connected this repo.
const binderHandle = ctx.userInfo?.userHandle;
if (binderHandle === undefined) return json({ ok: false, reason: "this delivery arrived without its person" }, 422);
ctx.log("info", "pull request", {
repo,
number: pr.number,
author: pr.author_association,
delivery: request.headers.get("x-vibes-github-delivery"),
});
const outcome = await noteOnPull(ctx, repo, pr, binderHandle);
return json(outcome, outcome.ok || outcome.skipped ? 200 : 422);
}
async function listRepos(ctx) {
const rows = [];
for (const r of await myRepos(ctx)) {
const settings = await ctx.db.get(settingsId(r.repo), { db: DB });
rows.push({ repo: r.repo, bigAt: settings?.bigAt ?? DEFAULT_BIG_AT, who: settings?.who ?? DEFAULT_WHO });
}
return json({ ok: true, repos: rows });
}
async function saveSettings(request, ctx) {
const input = await request.json();
const mine = await myRepos(ctx);
const bound = mine.find((r) => r.repo.toLowerCase() === String(input.repo).toLowerCase());
if (bound === undefined) return json({ ok: false, reason: "Connect this repo first, and its settings open here." }, 404);
const bigAt = Math.max(1, Math.round(Number(input.bigAt) || DEFAULT_BIG_AT));
const who = input.who === "anyone" ? "anyone" : "team";
const existing = await ctx.db.get(settingsId(bound.repo), { db: DB });
await ctx.db.put(
{ type: "settings", repo: bound.repo, binderHandle: bound.binderHandle, bigAt, who },
{ db: DB, id: settingsId(bound.repo), ifRev: existing === null ? null : existing._rev }
);
return json({ ok: true, bigAt, who });
}
export async function fetch(request, ctx) {
const route = `${request.method} ${new URL(request.url).pathname}`;
const run = async () => {
if (route === "POST /github-webhook") return onDelivery(request, ctx);
if (route === "GET /repos") return listRepos(ctx);
if (route === "POST /settings") return saveSettings(request, ctx);
if (route === "POST /review") return reviewNow(request, ctx);
return json({ ok: false, reason: "not found" }, 404);
};
return run().catch((err) => {
ctx.log("error", "route failed", { route, reason: err.reason ?? err.code, message: String(err.message ?? err) });
return json({ ok: false, reason: err.reason ?? "error" }, err.reason === "not_connected" ? 409 : 500);
});
}
access.js
js// Notes and settings are written by backend.js: settings by POST /settings, notes by the GitHub
// delivery (which runs as the person who connected the repo) and by POST /review. Each lands in
// that person's channel.
export default function (doc, oldDoc, user, ctx) {
if (doc.type === "note" || doc.type === "settings") {
ctx.requireBackend("fetch");
const binder = doc.binderHandle;
if (typeof binder !== "string" || binder === "") throw { forbidden: "missing binderHandle" };
// write-once: a record stays with the person who connected its repo
if (oldDoc && oldDoc.binderHandle !== binder) throw { forbidden: "binderHandle is fixed" };
if (binder !== user.userHandle) throw { forbidden: "not your repo" };
const ch = `repo-of:${binder}`;
return { channels: [ch], grant: { users: { [binder]: [ch] } } };
}
throw { forbidden: "unknown document type" };
}
App.jsx
jsximport React, { useCallback, useEffect, useState } from "react";
import { apiFetch, buildGithubInstallUrl, useViewer } from "use-vibes";
import { useFireproof } from "use-fireproof";
import { Badge, Button, Field, Heading, Input, RowCard, Skeleton, Stack, Text } from "@vibes.diy/pouf";
import { RadioGroup } from "@vibes.diy/pouf/interactive";
const TONE = { done: "up", failed: "warn", running: "info", waiting: "info" };
const WHO_CHOICES = [
{ value: "team", label: "People on the repo's team" },
{ value: "anyone", label: "Anyone who opens a pull request" },
];
function badgeText(n) {
if (n.status === "done") return `${n.lines} lines`;
if (n.status === "failed") return n.reason;
if (n.status === "waiting") return `From ${n.author ?? "someone new"}`;
return "Writing…";
}
function NoteRow({ note }) {
const [busy, setBusy] = useState(false);
// Runs as the person who connected the repo; the record turns to "done" live when the note lands.
async function reviewNow() {
setBusy(true);
await apiFetch("/review", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ repo: note.repo, number: note.number }),
});
setBusy(false);
}
return (
<RowCard>
<a className="underline" href={note.reviewUrl ?? note.url} target="_blank" rel="noreferrer">
#{note.number} {note.title}
</a>
<Badge tone={TONE[note.status] ?? "info"}>{badgeText(note)}</Badge>
{note.status === "waiting" && (
<Button type="button" size="sm" loading={busy} onClick={reviewNow}>
Review it now
</Button>
)}
</RowCard>
);
}
// The live ledger: each note record is written by the delivery itself, so new ones appear as
// pull requests open.
function RecentNotes({ repo }) {
const { useLiveQuery } = useFireproof("sizeNotes");
const { docs } = useLiveQuery("repo", { key: repo });
const notes = docs.filter((d) => d.type === "note").sort((a, b) => b.at - a.at).slice(0, 10);
if (notes.length === 0) return <Text muted>Open a pull request and its note shows up here and on GitHub.</Text>;
return (
<Stack gap={2}>
{notes.map((n) => (
<NoteRow key={n._id} note={n} />
))}
</Stack>
);
}
function RepoPanel({ row }) {
const [bigAt, setBigAt] = useState(String(row.bigAt));
const [who, setWho] = useState(row.who);
const [message, setMessage] = useState("");
async function save() {
setMessage("Saving…");
const res = await apiFetch("/settings", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ repo: row.repo, bigAt: Number(bigAt), who }),
});
const body = await res.json();
setMessage(res.ok ? "Saved. The next pull request uses it." : body.reason);
}
return (
<Stack gap={3}>
<Heading level={2}>
<a href={`https://github.com/${row.repo}`} target="_blank" rel="noreferrer">
{row.repo}
</a>
</Heading>
<Field label="Suggest where to start reading at this many changed lines">
{(id, describedBy) => <Input id={id} describedBy={describedBy} value={bigAt} onChange={setBigAt} />}
</Field>
<RadioGroup label="Whose pull requests get a note right away" value={who} onChange={setWho} options={WHO_CHOICES} />
<Button type="button" size="sm" onClick={save}>
Save
</Button>
{message !== "" && <Text size="sm" muted>{message}</Text>}
<RecentNotes repo={row.repo} />
</Stack>
);
}
export default function App() {
const { viewer, isViewerPending, requestLogin } = useViewer();
const [state, setState] = useState({ loading: true, repos: [] });
const load = useCallback(async () => {
const res = await apiFetch("/repos");
const body = await res.json();
setState({ loading: false, repos: body.repos ?? [] });
}, []);
useEffect(() => {
if (isViewerPending === false) load();
}, [isViewerPending, viewer, load]);
// Connecting finishes in the tab the link opened; re-read the list when the person comes back.
useEffect(() => {
window.addEventListener("focus", load);
return () => window.removeEventListener("focus", load);
}, [load]);
return (
<main className="min-h-screen bg-background text-foreground font-sans mx-auto max-w-2xl p-6">
<Stack gap={5}>
<Heading level={1}>Pull request size notes</Heading>
<Text>
Connect a GitHub repo and each new pull request gets a short note on how big it is, with a place to start reading when it runs long. Your team's pull
requests get theirs right away, and the rest are one tap away here.
</Text>
{viewer && (
<a className="inline-block rounded-md bg-foreground px-4 py-2 font-bold text-background" href={buildGithubInstallUrl()} target="_blank" rel="noopener">
Connect a GitHub repo
</a>
)}
{isViewerPending === false && !viewer && (
<Button type="button" onClick={requestLogin}>
Sign in to connect a repo
</Button>
)}
{state.loading ? <Skeleton /> : state.repos.map((row) => <RepoPanel key={row.repo} row={row} />)}
</Stack>
</main>
);
}
GET /repos, POST /settings and the delivery each run as a signed-in
person, so their ctx.db reads of sizeNotes come back filtered to that
person's own channels — the same records their page sees — with no extra
config. Before writing, a route still checks the repo against the ones
ctx.github.routes() says the caller connected. A pull request from beyond
the repo's who setting is claimed as a waiting record, and Review it
now calls POST /review, which runs the same note as the person who pressed
it. The page reads its notes
through useLiveQuery, which shows each person the records in their own
channel.
Good to know
- The page calls its routes with
apiFetchfromuse-vibes, so the route runs as the signed-in visitor andctx.userInfosays whose repos to list. - The repo's team comes first. Every app that acts on pull requests,
issues or comments by itself reads
author_associationand starts each repo atwho: "team"(OWNER,MEMBER,COLLABORATOR), with a two-choice control that opens it to anyone. Everything else waits on the connector's page with Review it now, so their credits and their name go where they choose. - The app stays open; the spend has its own gate. Who can visit and read
the app is publishing plus
access.js. Who sets off AI calls and GitHub posts is the repo'swhosetting on deliveries and abinderHandlecheck on every spending route. Keep the app published and gate the spend. - Declare what the app uses, and show each refusal. A call outside the
declaration answers
permission_not_declared; one the person has not agreed to yet answerspermission_not_consented, and pressing Connect again shows them the new list.permission_not_grantedmeans GitHub itself refused, because the vibes.diy GitHub App does not hold that permission on that account yet. Record the reason on the person's record and show it, so they see exactly what happened and what one tap fixes it. - Each app gets the repos chosen for it. The person ticks repos per app, and each app's connection stands on its own. One repo can feed up to ten apps, each hearing its deliveries with its own connector. While that switches on, a repo another app already has is left out with a note to try again soon. Say near the Connect button that the person picks which repos this app gets.
- Connecting a repo takes write access to it on GitHub. The picker offers the repos the person can push to, and shows the rest greyed out. Suggest that someone with read-only access ask a maintainer to connect the repo.
- Repo names are case-insensitive on GitHub. Lower-case them in
_ids and comparisons, as the example does, and keep GitHub's spelling for display. - Taking a repo off this app: the person presses Connect again and unticks it; their other apps keep it. Removing the GitHub App on GitHub disconnects the repo from every app.
- More on
ctx—ctx.db,ctx.log,ifRev,apiFetch— lives in thebackendskill.