GitHub tools: repo reads + issue writes from any agent
Every agent with a tool loop can read the org's repositories and read/write their issues through OpenSwitchboard's own GitHub App credential — over the REST API from the bot process, with no workspace — so a plain mention answers "open an issue on the switchboard app" and "how does our resident system work?" instead of bouncing the user to a directive, and the research agent never again reports our private repo as "inaccessible" after a public-web 404.
- Code:
src/execution/githubApi.ts(theGithubApiseam;RestGithubApi— REST + the one GraphQLdeleteIssuemutation, read-scoped token for reads and write-scoped for writes;InMemoryGithubApi),src/tools/github.ts(the tengithub_*tools,GithubCapability,GITHUB_READ_TOOLS/GITHUB_ISSUE_WRITE_TOOLS),src/tools/workspace.ts(ToolContext.github,TOOLSETSincl. the newassistantset),src/agents/registry.ts(generalonassistant, prompts),src/core/dispatch/run.ts(githubCapabilityFor— the per-run capability with the requesting user'scanUseRepogate; injected into every tool context, ship children included),src/execution/githubApp.ts(issues:readin the read-scoped permission set). - Tests:
src/execution/githubApi.test.ts,src/tools/github.test.ts,src/agents/registry.test.ts,src/core/dispatcher.test.ts(theself-description … and the github_* toolsblock).
Behavior
- The seam, two implementations (invariant 2/5):
GithubApicoverslistRepos(the installation's repositories),readFile/listTree/searchCode(repo reads at any ref),listIssues/getIssue(issues, never pull requests;getIssueincludes up to 30 comments),createIssue/updateIssue/commentIssue/deleteIssue.RestGithubApispeaksapi.github.comfrom the bot process — nevergh, never a clone — and mints the App token per call class: reads on the read-scoped token, writes on the write-scoped one, so a read tool cannot write even under prompt injection. The read-scoped permission set gainsissues: readfor this (execution.md item 5). Every non-2xx is aGithubApiErrorcarrying the HTTP status and GitHub's own message; a missing credential is a 401 before any request. Bodies over GitHub's 65536-char limit are clipped with a visible note; files are read as decoded UTF-8 (binary → a note, >200k chars → clipped, >1 MB → re-read via the raw media type as a stream that is cancelled once the clip is in hand, so a multi-MB blob costs the clip's memory, not its own).listIssuesreads 100-row pages (at most 3) untillimitissues are collected or the list ends — GitHub interleaves pull requests in that endpoint and they are dropped, so one page can hold fewer issues than rows.deleteIssueresolves the node id over REST and runs the GraphQLdeleteIssuemutation; aFORBIDDEN/ "not authorized" answer is a 403, a PR number is refused as not-an-issue. Known limit: GitHub grants issue deletion only to a repository admin's USER credential — an App installation gets "Viewer not authorized to delete" even withissues: write— so with the production credentialdeleteIssuealways fails closed; the seam keeps the method because aGH_TOKENof an admin (the non-App fallback) can delete.InMemoryGithubApi(repos with files + issues; unknown repo → 404 like GitHub; issues listed newest-updated first on a deterministic clock, as GitHub'ssort=updated&direction=descdoes) is the second implementation and the test double. - Ten tools, reads vs writes. Reads —
github_repos,github_file,github_tree,github_search_code,github_issue_list,github_issue_get— aresideEffectFree(the runner may run several from one turn concurrently). Writes —github_issue_create,github_issue_update(title/body/state/labels/assignees; closing isstate: closed),github_issue_comment,github_issue_delete(permanent; the description tells the model to use it only on an explicit delete request, and that the App credential cannot delete — a 403 from GitHub becomes "issue deletion is not available to Switchboard's GitHub App credential … Nothing was changed. Offer to close it instead …") — run strictly in order. Every tool answers a string, never throws: no capability → "GitHub tools are not available in this context."; a malformedrepo/number/missing field → a one-line correction naming the expectation; a 404 is worded as what it usually is ("the repo is outside the Switchboard GitHub App installation (github_repos lists the reachable ones), or the path/ref/number does not exist"); 401/403/timeouts each their own line. Successful writes report exactly what happened with the number and URL (Opened owner/name#41: title\n<url>) so the answer can quote it. - Writes are gated per repo by the requesting user. The dispatcher builds the run's
GithubCapabilityas{ api, canWrite: (repo) => config.canUseRepo(msg.userId, repo) }— the samerestrict.reposgate (open unless listed) that admits a user to a repo's resident — so an issue write from a plain mention is authorized like a coding run on that repo. A refused write returns "you are not allowed to write to<repo>(it is restricted and you hold no grant for it) — say so to the user instead of retrying" before any API call; reads on the same repo are unaffected (reads are bounded by the installation, not the allowlist). - Enablement by toolset: the reads join every toolset with a tool loop —
full(coding),readonly(review),web(research), and the newassistant; the issue writes joinassistantandfullonly — the read-only review agent and the research agent never mutate GitHub.nonestays empty. generalbecomes the assistant (agent-general.md): toolsetassistant= the GitHub tools +web_fetch+update_status— no shell, no file writes, noweb_search, no verdict/PR submission — with 8 turns (a repo read is repos → tree → file; an issue action one or two calls) and the same 5-minute budget. Its prompt names the tools, tells it to resolve a loosely-named repo withgithub_repos(or the thread) rather than asking, to read a repo before answering about it, to report exactly what a tool did and never claim an unperformed action, and to redirect code changes / PR reviews / web research toagent:coding/agent:review/agent:research. It still declares noresources— the tools are REST in the bot process, so a general ask never provisions a workspace (agent-general item 4).researchreads repos too (web-tools.md item 5): its toolset gains the reads (not the writes) and its prompt names them, tells it to read a github.com URL of ours withgithub_file/github_tree(web_fetch cannot see private repos), and forbids concluding a repo is inaccessible from a public-web 404.- Requires the App installation to cover the repo (execution.md item 5): a repo outside
repository_selection: "selected"404s on every call — the tool wording says so and points atgithub_repos. The App needs Contents (read for the read tools), Issues (read + write), Metadata; the installation grantsissues: writeandcontents: writeover the repos it covers. - Every call is measured (tracing.md item 23). The
github_*tools see the REST client as a view under their own call's span, so each request is agithub.restspan naming its route word (never the repo, path or query) and each token mint agithub.token_mintspan; the run's timeline counts them inside the tool call, and the log carries them.
Validation criteria
| Criterion | Proof |
|---|---|
A GitHub error body is redacted before it is sliced into the thrown message, in every client that slices one (RestGithubApi, pulls, comments, the App token mint, the issue tracker) — resident-repos item 62's GitHub half | [unit] src/execution/githubApi.test.ts::*::a failure body is redacted before it is sliced into the error (resident-repos item 62's GitHub half); src/execution/githubPulls.test.ts::*::redacts a credential in the failure body before slicing it into the error (item 62); src/execution/githubApp.test.ts::*::redacts a credential in the mint failure body before slicing it into the error (item 62); src/execution/githubIssues.test.ts::*::redacts a credential in the failure body before slicing it into the error (item 62) |
RestGithubApi reads: bearer = read token, accept headers (raw for big files, text-match for search), decoded/clipped/binary/raw content (the raw stream is cancelled at the clip), directory-vs-file 400s naming the other tool, URL-encoded paths, search scoping + limit clamp, repo paging, issue list drops PRs + query params and pages until limit issues (3-page cap), getIssue comments only when >0 and PR refused, non-2xx → GithubApiError with status + GitHub's message, no credential → 401 with no request | [unit] src/execution/githubApi.test.ts::RestGithubApi — reads use the read token::* |
RestGithubApi writes: bearer = write token; create omits empty label/assignee lists and clips a 70k body; update PATCHes only the given fields; comment returns the URL; delete = REST node id (read) → GraphQL mutation (write), FORBIDDEN → 403, PR → 400 | [unit] src/execution/githubApi.test.ts::RestGithubApi — writes use the write token::* |
InMemoryGithubApi mirrors the contract (files/trees/search, sequential numbers, patch, comment count, delete recorded, unknown repo 404, newest-updated-first listing) | [unit] src/execution/githubApi.test.ts::InMemoryGithubApi::* |
Tools: unavailable without the capability (all ten, no throw); repos/file/tree/search/issue_list/issue_get renderings; 404 wording; slug/number/required-field corrections; reads sideEffectFree, writes not | [unit] src/tools/github.test.ts::github_* reads::* |
| Writes: create → number + URL, update patches (state=closed) and refuses an empty patch, comment URL, delete permanent; the per-repo gate refuses all four BEFORE the API and reads still work; outside-installation 404 wording on a write | [unit] src/tools/github.test.ts::github_issue_* writes::* |
Toolsets: reads in full/readonly/web/assistant; writes only in assistant + full; assistant = web_fetch + update_status + GitHub only; none empty | [unit] src/tools/github.test.ts::toolset wiring::*, src/tools/web.test.ts::toolset + agent wiring::* |
general: assistant toolset, 8 turns, 5 min; prompt names the tools, forbids claiming unperformed actions, redirects coding/review/research; research prompt names the read tools and forbids the public-404 conclusion | [unit] src/agents/registry.test.ts::general*, ::research's prompt* |
| Dispatcher: a plain mention's run offers the github_* tools (no bash), an issue is created on the injected API, the reply carries the tool's number + URL; a restricted repo refuses an ungranted user before the API and admits a listed one | [unit] src/core/dispatcher.test.ts::self-description in the system prompt … and the github_* tools::a plain mention opens an issue*, ::the issue write is gated* |
Read-scoped token requests issues: read alongside contents/pull_requests/actions/checks/metadata | [unit] src/execution/githubApp.test.ts::mints a READ-scoped token* |
RestGithubApi live against the App credential: listRepos (the installation's repositories), listTree/readFile on one of them (a known file, its byte count), searchCode, listIssues/getIssue, then create → close → comment on a labelled smoke issue; deleteIssue → "Viewer not authorized to delete" (the App cannot delete; delete the smoke issue by hand) | [agent] A scratch script driving RestGithubApi with the App credentials from a dev checkout; re-run after any change to the REST paths. |
Live: @switchboard open an issue on the switchboard app with the title "foo" and the body "bar" (no directive) → the reply quotes <owner>/<repo>#<n> + URL and the issue exists; @switchboard close it in the thread → closed; @switchboard delete issue #<n> on <owner>/<repo> → the honest refusal (App cannot delete) with the offer to close | [agent] Run in a channel the bot is in after the bot deploy; verify on GitHub. |
Live: @switchboard agent:research find and explain how OpenSwitchboard's resident system works and how to configure priority repositories → the answer describes residents/onboarding/the cap from docs/reference/specs/resident-repos.md (cited by path), never "repo is private, inaccessible" | [agent] Same channel. |
8: a tool's requests are github.rest spans under its call with the route word, the mint a github.token_mint child | [unit] src/execution/githubApi.test.ts::RestGithubApi.withSpan::*, src/runner.test.ts::model turn and tool spans (docs/reference/specs/tracing.md)::a tool call's github capability is the client's withSpan view… |