Skip to content

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 (the GithubApi seam; RestGithubApi — REST + the one GraphQL deleteIssue mutation, read-scoped token for reads and write-scoped for writes; InMemoryGithubApi), src/tools/github.ts (the ten github_* tools, GithubCapability, GITHUB_READ_TOOLS / GITHUB_ISSUE_WRITE_TOOLS), src/tools/workspace.ts (ToolContext.github, TOOLSETS incl. the new assistant set), src/agents/registry.ts (general on assistant, prompts), src/core/dispatch/run.ts (githubCapabilityFor — the per-run capability with the requesting user's canUseRepo gate; injected into every tool context, ship children included), src/execution/githubApp.ts (issues:read in 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 (the self-description … and the github_* tools block).

Behavior

  1. The seam, two implementations (invariant 2/5): GithubApi covers listRepos (the installation's repositories), readFile / listTree / searchCode (repo reads at any ref), listIssues / getIssue (issues, never pull requests; getIssue includes up to 30 comments), createIssue / updateIssue / commentIssue / deleteIssue. RestGithubApi speaks api.github.com from the bot process — never gh, 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 gains issues: read for this (execution.md item 5). Every non-2xx is a GithubApiError carrying 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). listIssues reads 100-row pages (at most 3) until limit issues 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. deleteIssue resolves the node id over REST and runs the GraphQL deleteIssue mutation; a FORBIDDEN / "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 with issues: write — so with the production credential deleteIssue always fails closed; the seam keeps the method because a GH_TOKEN of 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's sort=updated&direction=desc does) is the second implementation and the test double.
  2. Ten tools, reads vs writes. Reads — github_repos, github_file, github_tree, github_search_code, github_issue_list, github_issue_get — are sideEffectFree (the runner may run several from one turn concurrently). Writes — github_issue_create, github_issue_update (title/body/state/labels/assignees; closing is state: 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 malformed repo/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.
  3. Writes are gated per repo by the requesting user. The dispatcher builds the run's GithubCapability as { api, canWrite: (repo) => config.canUseRepo(msg.userId, repo) } — the same restrict.repos gate (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).
  4. Enablement by toolset: the reads join every toolset with a tool loop — full (coding), readonly (review), web (research), and the new assistant; the issue writes join assistant and full only — the read-only review agent and the research agent never mutate GitHub. none stays empty.
  5. general becomes the assistant (agent-general.md): toolset assistant = the GitHub tools + web_fetch + update_status — no shell, no file writes, no web_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 with github_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 to agent:coding / agent:review / agent:research. It still declares no resources — the tools are REST in the bot process, so a general ask never provisions a workspace (agent-general item 4).
  6. research reads 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 with github_file/github_tree (web_fetch cannot see private repos), and forbids concluding a repo is inaccessible from a public-web 404.
  7. 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 at github_repos. The App needs Contents (read for the read tools), Issues (read + write), Metadata; the installation grants issues: write and contents: write over the repos it covers.
  8. 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 a github.rest span naming its route word (never the repo, path or query) and each token mint a github.token_mint span; the run's timeline counts them inside the tool call, and the log carries them.

Validation criteria

CriterionProof
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…