Skip to content

feat: pick which GitHub account a project talks to, and approve pull requests from lich - #107

Merged
omartelo merged 3 commits into
mainfrom
feat/project-github-account
Jul 27, 2026
Merged

omartelo merged 3 commits into
mainfrom
feat/project-github-account

Conversation

@omartelo

@omartelo omartelo commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator

Why

gh keeps one active account per host, globally. On a machine signed in as two GitHub accounts, a private repository only the second one can see answers every lookup with:

gh pr view: GraphQL: Could not resolve to a Repository with the name 'acme/private'. (repository)

GitHub does not distinguish 404 from 403, so the message reads as "this repository does not exist" — and lich pasted it on screen verbatim, command prefix and all. The screen looked broken on a repository that was fine.

What this does

A project names the GitHub account its calls run as. vcs.account in the store (project-scoped — gh already has a global answer, and this exists to override it for one repository), resolved to a token via gh auth token --user <login> and passed to gh in GH_TOKEN. Every gh call routes through one seam (Service.gh), so the badge, the list, the detail, the checks, the diff, the merge and the PR checkout all answer as the same account. Unset, nothing changes.

A checkout is addressed by path everywhere in the project services, so the path → project mapping is resolved in the store: the project's own directory, or a session's (a worktree lives outside the project directory, so only its session row ties it back). No RPC signature changed.

A new Version Control section in Settings picks the account, filled from gh auth status.

The badge was shelling out to gh on its own, outside the seam — it would have kept answering as gh's active account while the screen beside it used the project's, vanishing on exactly the repositories the picker exists for. It takes the same path now.

Neither tool's stderr reaches the screen anymore. gherror.go and giterror.go each hold a table of the failures lich can actually hit; toolerror.go holds what they share. gh's GraphQL envelopes, git's fatal: lines, and — when git refuses by exit status alone (check-ref-format) — the literal words exit status 1 are replaced by a sentence that says what to do. The raw text goes to lich's log, which is where it was always the more useful half.

the tool said the screen says
GraphQL: Could not resolve to a Repository with the name '…' GitHub does not show this repository to the account lich is using here. Choose the account that can see it in Settings → Version Control.
To get started with GitHub CLI, please run: gh auth login gh is not signed in to GitHub. Run gh auth login in a terminal.
git check-ref-format: exit status 1 "has space" is not a name git accepts for a branch.
fatal: '…' contains modified or untracked files, use --force to delete it That worktree has uncommitted changes. Remove it from the sidebar to discard them.

Approving a pull request no longer means opening the browser. An Approve button sits beside Merge and files the approving review through the project's account. Once it lands the button reads Approved and stops offering itself — GitHub would take a second review, but nobody means to send one. The gate is not the merge gate: a draft and a conflicting pull request can both still be reviewed, only one already merged or closed cannot. Reviews with a body and requesting changes are deliberately absent — a review comment belongs to the line it is about, and lich has nowhere to attach one yet.

Ceiling

The account governs what lich reads from GitHub, not what git does: a push still rides the remote's ssh key and signs with the global user.email, so a PR can be read by one account and its commits land under another with no error anywhere. Documented as a Known Ceiling; per-worktree core.sshCommand + user.email is the upgrade path.

Test plan

  • End-to-end against the real repository that produced the bug, driven over the RPC on an isolated instance: before — Could not resolve to a Repository; after setting the account — the list of open PRs.
  • project.GitHubAccounts against the real gh auth status returns ["omartelo", "marcelo-filho_snk"].
  • Every git failure path exercised over the RPC against real git (invalid name, taken name, non-repository, unknown worktree) — each answers with its sentence, and the raw stderr is in the log.
  • Settings pane smoked in headless Chromium (the frontend gate is node-only and cannot render): the section renders, the picker opens with gh's active account + both logins, the stored value is selected, no page errors.
  • TestGHMessageLeaksNothing / TestGitMessageLeaksNothing assert the rule, not just the cases: no message may contain graphql, http 4xx, exit status, fatal:, hint:, --force.
  • TestRunGitMessage runs real git against a non-repository and asserts the sentence — the whole path, not just the table.
  • go test ./..., gofmt -l ., go vet ./... clean; frontend biome check (0 errors), vitest 468 passed, tsc --noEmit clean, vite build ok.
  • Cross-compile loop for linux/darwin/windows (build + vet).
  • Open in Session was the bug homologation found: gh pr checkout runs inside the worktree it just created, which has no session row yet, so the account resolved to none and the checkout fell back to gh's active one. Fixed by separating "where the command runs" from "whose account it runs as" (ghFor); the regression test fails without the fix with token "".
  • Approve verified by hand in the dev instance, and its one real refusal was triggered against this very PR to read GitHub's exact wording: Review Can not approve your own pull request.
  • Merge failures were exercised through their message table, not against a live refusal from GitHub.

Five backend tests changed: they asserted the command prefix and the tool's own words, which is precisely what leaked. Each now asserts the message passes through unchanged.

🤖 Generated with Claude Code

omartelo added 3 commits July 27, 2026 12:15
gh keeps one active account per host, globally. A repository only a
second account can see answers every lookup with "Could not resolve to a
Repository" — the same thing GitHub says for a repository that does not
exist — so the pull request screen read as broken on any project the
active account cannot see.

A project now names the account its GitHub calls run as: vcs.account in
the store, resolved to a token through `gh auth token --user` and handed
to gh in GH_TOKEN. Every gh call goes through one seam (Service.gh), so
the badge, the list, the detail, the checks, the diff, the merge and the
PR checkout all answer as the same account. Left unset, nothing changes.
The picker is a new Version Control section in Settings, filled from
`gh auth status`.

The badge was the one caller that shelled out to gh on its own; it now
takes the same path, which is what made it obey the project's account.

The account governs what lich reads from GitHub, not what git does — a
push still rides the remote's ssh key and the global user.email. That
gap is a Known Ceiling now.

While the screen was the subject: neither tool's stderr reaches it
anymore. gh's GraphQL envelopes and git's "fatal:" lines (and, when git
refuses by exit status alone, the words "exit status 1") are replaced by
a sentence per failure lich can hit — the invisible repository points at
the account picker, a signed-out gh names `gh auth login`, a dirty
worktree says where to remove it. The raw text goes to lich's log, which
is where it was always the more useful half.
Open in Session failed on exactly the repositories the account picker
exists for: "GitHub does not show this repository to the account lich is
using here."

`gh pr checkout` runs inside the worktree it just created, and the
account is resolved from the directory gh runs in. That worktree has no
session row yet — the frontend opens one only after this call returns —
so the store had no project to map it to, the token came back empty, and
the checkout fell back to gh's active account.

The account is a property of the project, not of the directory a command
happens to run in, so the two are now separate arguments (ghFor): the
checkout runs in the worktree and answers to the project.
Approving was the one step of a review that still meant opening the
browser. An Approve button now sits beside Merge and files the approving
review through gh, as the account the project talks to.

Once the review lands the button reads Approved and stops offering
itself: GitHub would take a second one, but nobody means to send it. The
gate is not the merge gate — a draft and a conflicting pull request can
both still be reviewed, only one that is already merged or closed cannot.

GitHub refuses an account approving its own pull request, so that refusal
joins the table and reads as a sentence.

Reviews with a body and requesting changes are deliberately absent: a
review comment belongs to the line it is about, and there is nowhere to
attach one yet.
@omartelo omartelo changed the title feat: pick which GitHub account a project talks to feat: pick which GitHub account a project talks to, and approve pull requests from lich Jul 27, 2026
@omartelo
omartelo merged commit fd679eb into main Jul 27, 2026
3 checks passed
@omartelo
omartelo deleted the feat/project-github-account branch July 27, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant