From 978bfb0df87775f0ce707254a1f4f5aa7848f665 Mon Sep 17 00:00:00 2001 From: barneyjm Date: Mon, 24 Aug 2026 10:12:27 -0400 Subject: [PATCH] Fix the code-contribution fork fallback (v0.5.1) `gh repo fork --remote --remote-name contribfork` is rejected by gh: "the --remote flag is unsupported when a repository argument is provided". Runners hit this whenever the direct push to the contrib repo was refused, so the PR never opened and the contribution degraded to inline code only. Fork without touching remotes and wire the fork up with plain git instead. Verified against real gh 2.88.1 + GitHub: direct push 403 -> gh repo fork --clone=false -> git remote add contribfork -> push to the fork, all clean. Also stop swallowing the direct-push error. Previously only the fork failure surfaced, hiding why the push was refused in the first place; the runner now reports both causes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WoAJRWLbAW5pakUUzEzcjV --- package-lock.json | 4 ++-- package.json | 2 +- src/code-contrib.ts | 30 ++++++++++++++++++++++++------ test/code-contrib.test.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index e714474..bc84337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "givework", - "version": "0.4.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "givework", - "version": "0.4.0", + "version": "0.5.1", "license": "Apache-2.0", "dependencies": { "@hono/node-server": "^1.13.7", diff --git a/package.json b/package.json index 34e2d13..41473bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "givework", - "version": "0.5.0", + "version": "0.5.1", "type": "module", "description": "Volunteer your AI agent's spare capacity to open mathematics", "license": "Apache-2.0", diff --git a/src/code-contrib.ts b/src/code-contrib.ts index b090e33..e3fb3ec 100644 --- a/src/code-contrib.ts +++ b/src/code-contrib.ts @@ -112,15 +112,33 @@ export async function publishCodeContribution( await run('git', ['commit', '-s', '-m', message], dir); // Direct push if this volunteer has rights on the contrib repo; fall back - // to the fork flow (gh handles creating/reusing the fork) otherwise. + // to the fork flow otherwise. let head = branch; try { await run('git', ['push', '-u', 'origin', branch], dir); - } catch { - await run('gh', ['repo', 'fork', repo, '--remote', '--remote-name', 'contribfork'], dir); - await run('git', ['push', '-u', 'contribfork', branch], dir); - const login = (await run('gh', ['api', 'user', '--jq', '.login'], dir)).trim(); - head = `${login}:${branch}`; + } catch (pushErr) { + try { + // `gh repo fork --remote` is rejected by gh ("the --remote flag + // is unsupported when a repository argument is provided"), so fork + // without touching remotes and wire the fork up with plain git. + await run('gh', ['repo', 'fork', repo, '--clone=false'], dir); + const login = (await run('gh', ['api', 'user', '--jq', '.login'], dir)).trim(); + const name = repo.split('/')[1] ?? repo; + await run( + 'git', + ['remote', 'add', 'contribfork', `https://github.com/${login}/${name}.git`], + dir, + ); + await run('git', ['push', '-u', 'contribfork', branch], dir); + head = `${login}:${branch}`; + } catch (forkErr) { + // Both causes, or the runner only ever sees the fork error and the + // reason the direct push was refused stays invisible. + throw new Error( + `direct push refused (${(pushErr as Error).message}); ` + + `fork fallback failed (${(forkErr as Error).message})`, + ); + } } const body = diff --git a/test/code-contrib.test.ts b/test/code-contrib.test.ts index c90b3e5..d8be992 100644 --- a/test/code-contrib.test.ts +++ b/test/code-contrib.test.ts @@ -71,8 +71,10 @@ describe('publishCodeContribution', () => { it('falls back to the fork flow when direct push is refused', async () => { const calls: string[] = []; + const argv: string[][] = []; const run = async (cmd: string, args: string[]) => { calls.push(`${cmd} ${args.slice(0, 2).join(' ')}`); + argv.push([cmd, ...args]); if (cmd === 'git' && args[0] === 'push' && args.includes('origin')) throw new Error('permission denied'); if (cmd === 'gh' && args[0] === 'api') return 'volunteer-login\n'; @@ -83,5 +85,34 @@ describe('publishCodeContribution', () => { expect(pub.branch.startsWith('volunteer-login:contrib/ffff0000-')).toBe(true); expect(calls).toContain('gh repo fork'); expect(pub.pr_url).toBe('https://github.com/o/r/pull/9'); + + // gh refuses --remote/--remote-name alongside a repository argument. + const fork = argv.find((a) => a[0] === 'gh' && a[1] === 'repo' && a[2] === 'fork'); + expect(fork).toBeDefined(); + expect(fork).toContain('o/r'); + expect(fork?.some((a) => a.startsWith('--remote'))).toBe(false); + // The fork remote is wired up with plain git instead. + const remote = argv.find((a) => a[0] === 'git' && a[1] === 'remote'); + expect(remote).toEqual([ + 'git', + 'remote', + 'add', + 'contribfork', + 'https://github.com/volunteer-login/r.git', + ]); + expect(argv.some((a) => a[0] === 'git' && a[1] === 'push' && a.includes('contribfork'))).toBe( + true, + ); + }); + + it('reports both causes when the fork fallback also fails', async () => { + const run = async (cmd: string, args: string[]) => { + if (cmd === 'git' && args[0] === 'push') throw new Error('could not read Username'); + if (cmd === 'gh' && args[0] === 'repo') throw new Error('gh repo exited 1: HTTP 404'); + return ''; + }; + await expect( + publishCodeContribution(cc, { taskId: 'ffff0000-x', repo: 'o/r', run }), + ).rejects.toThrow(/direct push refused .*could not read Username.*fork fallback failed .*404/s); }); });