test/pr-lookup.test.js
Rama main · réplica de lectura del repositorio original.
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; }
});
});