import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, symlinkSync, writeFileSync } from 'fs'; import { execFileSync } from 'child_process'; import { tmpdir } from 'os'; import { join } from 'path'; import { findPrForBranch, findMergedPrForBranch, findClosedPrForBranch } from '../server/jobs.js'; // findPrForBranch needs a real git repo to get past its guards; everything // about the ACCOUNT WALK is injected, so these never touch GitHub. let REPO, REPO2; // A token in the environment changes which account gh would pick, so the walk // must be exercised without one. CI runners commonly export GITHUB_TOKEN. const savedEnv = {}; beforeEach(() => { for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) { savedEnv[key] = process.env[key]; delete process.env[key]; } for (const set of [0, 1]) { const dir = mkdtempSync(join(tmpdir(), 'a007-prlookup-')); execFileSync('git', ['init', '-q', dir]); execFileSync('git', ['-C', dir, 'config', 'user.name', 'x']); execFileSync('git', ['-C', dir, 'config', 'user.email', 'x@x']); execFileSync('git', ['-C', dir, 'commit', '-q', '--allow-empty', '-m', 'i']); if (set === 0) REPO = dir; else REPO2 = dir; } }); afterEach(() => { for (const [key, value] of Object.entries(savedEnv)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); const PR = { url: 'https://gh/o/r/pull/7', number: 7, isDraft: false }; // Records every attempt so ordering and dedup are observable. `visible` names // the one account that can see the repo; undefined means nobody can. function ghFake({ accounts = ['active-acct', 'other-acct'], visible = null, noTokenFor = [] } = {}) { const calls = []; return { calls, listAccounts: async () => accounts, tokenFor: async (login) => (noTokenFor.includes(login) ? null : `token-for-${login}`), prList: async (_repo, _branch, token) => { // undefined token = whatever gh is signed in as, i.e. the active account. const who = token ? String(token).replace('token-for-', '') : accounts[0]; calls.push(who); // visible: null means no account can see this repo. if (who !== visible) { return { error: `Could not resolve to a Repository with the name 'org/repo'` }; } return { pr: PR }; }, }; } describe('findPrForBranch across several gh accounts', () => { it('uses the signed-in account when it can see the repo', async () => { const gh = ghFake({ visible: 'active-acct' }); const result = await findPrForBranch(REPO, 'feat/x', gh); expect(result.pr).toEqual(PR); expect(gh.calls).toEqual(['active-acct']); // active account first, no walk }); it('falls through to another account when the signed-in one cannot see the repo', async () => { // The real case: one machine, two accounts, and the repo belongs to an org // only the second one is a member of. const gh = ghFake({ visible: 'other-acct' }); const result = await findPrForBranch(REPO, 'feat/x', gh); expect(result.pr).toEqual(PR); expect(result.error).toBeUndefined(); expect(gh.calls).toEqual(['active-acct', 'other-acct']); }); it('remembers which account worked, so the next check is one call', async () => { const first = ghFake({ visible: 'other-acct' }); await findPrForBranch(REPO, 'feat/x', first); expect(first.calls).toHaveLength(2); const second = ghFake({ visible: 'other-acct' }); await findPrForBranch(REPO, 'feat/x', second); expect(second.calls).toEqual(['other-acct']); // straight to the winner }); it('remembers per repo, not globally', async () => { await findPrForBranch(REPO, 'feat/x', ghFake({ visible: 'other-acct' })); const fresh = ghFake({ visible: 'active-acct' }); await findPrForBranch(REPO2, 'feat/x', fresh); expect(fresh.calls[0]).toBe('active-acct'); }); it('re-walks if the remembered account stops working', async () => { await findPrForBranch(REPO, 'feat/x', ghFake({ visible: 'other-acct' })); // Access moves to the active account. const moved = ghFake({ visible: 'active-acct' }); const result = await findPrForBranch(REPO, 'feat/x', moved); expect(result.pr).toEqual(PR); expect(moved.calls).toEqual(['other-acct', 'active-acct']); }); it('reports failure only when EVERY account failed, naming what it tried', async () => { const gh = ghFake({ visible: null }); const result = await findPrForBranch(REPO, 'feat/x', gh); expect(result.pr).toBeNull(); expect(result.error).toMatch(/Could not resolve to a Repository/); // Named accounts first, then one last tokenless attempt. expect(result.error).toMatch(/tried 3 accounts: active-acct, other-acct, signed-in account/); // Named by login, not "signed-in account" — the user needs to know which // credentials to fix. // Named accounts first, then one last tokenless attempt. expect(result.error).toMatch(/tried 3 accounts: active-acct, other-acct, signed-in account/); }); it('never attempts the same NAMED account twice', async () => { // The remembered account also appears in the account list; it must not get // a second turn on the walk. The final tokenless attempt is deliberately // separate — gh may resolve it to an account already tried, which is the // accepted cost of also covering env tokens and enterprise hosts. await findPrForBranch(REPO, 'feat/x', ghFake({ visible: 'other-acct' })); const gh = ghFake({ visible: null }); await findPrForBranch(REPO, 'feat/x', gh); const named = gh.calls.slice(0, -1); // drop the trailing tokenless attempt expect(named).toEqual([...new Set(named)]); expect(named).toContain('other-acct'); expect(named).toContain('active-acct'); }); it('skips an account whose token cannot be read', async () => { const gh = ghFake({ visible: 'other-acct', noTokenFor: ['other-acct'] }); const result = await findPrForBranch(REPO, 'feat/x', gh); expect(result.pr).toBeNull(); expect(gh.calls).not.toContain('other-acct'); }); it('treats an empty result as no PR yet, not an error, and stops there', async () => { const calls = []; const result = await findPrForBranch(REPO, 'feat/x', { listAccounts: async () => ['a', 'b'], tokenFor: async (l) => `token-for-${l}`, prList: async (_r, _b, token) => { calls.push(token || 'ambient'); return { pr: null }; }, }); expect(result).toEqual({ pr: null }); expect(result.error).toBeUndefined(); expect(calls).toEqual(['token-for-a']); // a successful query ends the walk }); it('does not shell out at all for a path that is not a repo', async () => { const calls = []; const gh = { listAccounts: async () => ['a'], tokenFor: async () => 't', prList: async () => { calls.push(1); return { pr: PR }; } }; expect(await findPrForBranch('/no/such/path', 'feat/x', gh)).toEqual({ pr: null }); expect(await findPrForBranch(REPO, '', gh)).toEqual({ pr: null }); expect(calls).toHaveLength(0); }); }); // The merged lookup shares the account walk, so it inherits every case above. // These pin the parts that are its own: that it walks at all, that it keeps the // discriminated return (no PR yet vs nobody can see the repo), and that its // failure message names the query it ran. describe('findMergedPrForBranch', () => { const MERGED = { url: 'https://gh/o/r/pull/7', number: 7, mergedAt: '2026-08-28T10:00:00Z' }; it('walks the accounts and returns the merged PR', async () => { const calls = []; const result = await findMergedPrForBranch(REPO, 'feat/x', { listAccounts: async () => ['active-acct', 'other-acct'], tokenFor: async (login) => `token-for-${login}`, prList: async (_r, _b, token) => { const who = String(token).replace('token-for-', ''); calls.push(who); return who === 'other-acct' ? { pr: MERGED } : { error: 'Could not resolve to a Repository' }; }, }); expect(result.pr).toEqual(MERGED); expect(calls).toEqual(['active-acct', 'other-acct']); }); it('treats "not merged" as an answer, not a failure', async () => { const result = await findMergedPrForBranch(REPO, 'feat/x', { listAccounts: async () => ['a'], tokenFor: async () => 't', prList: async () => ({ pr: null }), }); expect(result).toEqual({ pr: null }); expect(result.error).toBeUndefined(); }); it('names the merged query when every account fails', async () => { // Otherwise a merge-check failure is textually identical to an open-PR // check failure, and the card cannot say which question went unanswered. const result = await findMergedPrForBranch(REPO, 'feat/x', ghFake({ visible: null })); expect(result.pr).toBeNull(); expect(result.error).toMatch(/tried 3 accounts/); }); it('does not shell out at all for a path that is not a repo', async () => { const calls = []; const gh = { listAccounts: async () => ['a'], tokenFor: async () => 't', prList: async () => { calls.push(1); return { pr: MERGED }; } }; expect(await findMergedPrForBranch('/no/such/path', 'feat/x', gh)).toEqual({ pr: null }); expect(await findMergedPrForBranch(REPO, '', gh)).toEqual({ pr: null }); expect(calls).toHaveLength(0); }); }); describe('findClosedPrForBranch', () => { it('reports an error when every account fails', async () => { const failed = await findClosedPrForBranch(REPO, 'feat/x', ghFake({ visible: null })); expect(failed.pr).toBeNull(); expect(failed.error).toMatch(/tried 3 accounts/); }); it('hands the card\'s PR number to the query', async () => { const asked = []; const result = await findClosedPrForBranch(REPO, 'feat/x', { listAccounts: async () => ['a'], tokenFor: async () => 't', prNumber: 7, prList: async (_r, _b, _t, number) => { asked.push(number); return { pr: { url: 'u', number } }; }, }); expect(result.pr).toEqual({ url: 'u', number: 7 }); expect(asked).toEqual([7]); }); }); // A new install often has git but no gh: the card says that, not spawn gh ENOENT. describe('without gh', () => { it.skipIf(process.platform === 'win32')('says gh is not installed and where to get it', async () => { const bin = mkdtempSync(join(tmpdir(), 'a007-nogh-')); symlinkSync(execFileSync('which', ['git'], { encoding: 'utf8' }).trim(), join(bin, 'git')); const path = process.env.PATH; process.env.PATH = bin; try { const { pr, error } = await findPrForBranch(REPO, 'main', { listAccounts: async () => [], tokenFor: async () => null }); expect(pr).toBeNull(); expect(error).toBe('"gh" is not installed, or not on the PATH Agent 007 was started with. Install GitHub CLI: https://cli.github.com, then run gh auth login'); } finally { process.env.PATH = path; } }); it.skipIf(process.platform === 'win32')('with gh there, passes its own error on with the accounts tried', async () => { const bin = mkdtempSync(join(tmpdir(), 'a007-stubgh-')); symlinkSync(execFileSync('which', ['git'], { encoding: 'utf8' }).trim(), join(bin, 'git')); writeFileSync(join(bin, 'gh'), '#!/bin/sh\necho "no git remotes found" >&2\nexit 1\n', { mode: 0o755 }); const path = process.env.PATH; process.env.PATH = `${bin}:/bin`; try { const { error } = await findPrForBranch(REPO, 'main', { listAccounts: async () => [], tokenFor: async () => null }); expect(error).toBe('no git remotes found (tried 1 account: signed-in account)'); } finally { process.env.PATH = path; } }); });