From 2070e67934f67ee1e31e9977dcb1ba3cd2a2a5d4 Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Sat, 18 Jul 2026 18:24:57 -0700 Subject: [PATCH] Resolve fork repository from push remote --- docs/configuration.md | 2 + src/bootstrap.ts | 24 +++++------ src/cli.ts | 18 ++++++++- src/github-repository.ts | 49 ++++++++++++++++++++++ src/notify.ts | 30 +++++++------- src/verify-auth.ts | 16 ++++---- tests/bootstrap.test.ts | 38 ++++++++--------- tests/github-repository.test.ts | 72 +++++++++++++++++++++++++++++++++ tests/verify-auth.test.ts | 13 ++++-- 9 files changed, 202 insertions(+), 60 deletions(-) create mode 100644 src/github-repository.ts create mode 100644 tests/github-repository.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index de1a234..4b59af7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,6 +97,8 @@ npx patchlane verify-auth This dispatches `sync-upstream.yml` with `no_push=true`, finds the newly created run, and waits for it to finish. A successful run validates the uploaded App key, installation, requested permissions, API access, authenticated checkout, and Patchlane rebuild without changing a branch. Use `--timeout ` and `--poll-interval ` to override the 60-second discovery timeout and 2-second polling interval. The equivalent environment variables are `PATCHLANE_AUTH_TIMEOUT_SECONDS` and `PATCHLANE_AUTH_POLL_INTERVAL_SECONDS`. +GitHub operations resolve the fork from the configured `origin` push URL, independently of `gh repo set-default`. Use `--origin-remote-name ` for a differently named push remote or `--repository ` to override repository detection. + ### Validate or publish a sync ```bash diff --git a/src/bootstrap.ts b/src/bootstrap.ts index fd3ac77..3bf3c51 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,5 +1,6 @@ import { loadPatchlaneConfig } from './config.js'; import { runDoctor } from './doctor.js'; +import { resolveForkRepository } from './github-repository.js'; import { runIntegrationSync } from './integration-sync.js'; import { runPromoteSync } from './promote-sync.js'; import { git, run } from './subprocess.js'; @@ -11,6 +12,8 @@ const CI_PROGRESS_INTERVAL_MS = 60_000; type BootstrapOptions = { publish?: boolean; wait?: boolean; + repository?: string; + originRemoteName?: string; ciTimeoutSeconds?: number; ciPollIntervalSeconds?: number; cwd?: string; @@ -136,7 +139,7 @@ export async function bootstrapPatchlane(options: BootstrapOptions = {}) { const report = runDoctor({ cwd }); if (!report.ok) throw new Error('Fix the Patchlane doctor errors before bootstrapping.'); - const originRemoteName = process.env.ORIGIN_REMOTE_NAME ?? 'origin'; + const originRemoteName = options.originRemoteName ?? process.env.ORIGIN_REMOTE_NAME ?? 'origin'; const syncOptions = { upstreamOwner: config.upstreamOwner, upstreamRepo: config.upstreamRepo, @@ -157,7 +160,12 @@ export async function bootstrapPatchlane(options: BootstrapOptions = {}) { return { status: 'validated' as const }; } - process.stdout.write(`Publishing ${config.syncBranch} for CI.\n`); + const repository = resolveForkRepository({ + cwd, + repository: options.repository, + originRemoteName, + }); + process.stdout.write(`Publishing ${config.syncBranch} to ${repository} for CI.\n`); runIntegrationSync(syncOptions); const syncSha = publishedSyncSha(originRemoteName, config.syncBranch, cwd); if (!options.wait) { @@ -179,15 +187,7 @@ export async function bootstrapPatchlane(options: BootstrapOptions = {}) { DEFAULT_CI_POLL_INTERVAL_SECONDS, 'CI poll interval', ); - const repositoryResult = run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], cwd); - if (repositoryResult.status !== 0 || !repositoryResult.stdout) { - throw new Error( - `Could not determine the GitHub repository: ${repositoryResult.stderr || repositoryResult.stdout || 'unknown error'}`, - ); - } - const repository = repositoryResult.stdout; - - process.stdout.write(`Waiting for '${ciWorkflow}' to test ${syncSha}.\n`); + process.stdout.write(`Waiting for '${ciWorkflow}' to test ${syncSha} in ${repository}.\n`); const runId = await waitForCiRun( repository, ciWorkflow, @@ -197,7 +197,7 @@ export async function bootstrapPatchlane(options: BootstrapOptions = {}) { timeoutSeconds, pollIntervalSeconds, ); - const watch = run('gh', ['run', 'watch', runId, '--exit-status'], cwd); + const watch = run('gh', ['run', 'watch', runId, '--repo', repository, '--exit-status'], cwd); if (watch.stdout) process.stdout.write(`${watch.stdout}\n`); if (watch.stderr) process.stderr.write(`${watch.stderr}\n`); if (watch.status !== 0) throw new Error(`CI run ${runId} did not succeed; refusing to promote.`); diff --git a/src/cli.ts b/src/cli.ts index 685fdee..06ad6ae 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -76,6 +76,10 @@ cli.command('init', 'Create Patchlane config and workflow files') cli.command('bootstrap', 'Validate and publish the first generated sync branch') .option('--publish', 'Publish the generated sync branch after validation') .option('--wait', 'Publish, wait for CI, and perform the initial promotion') + .option('--repository ', 'Fork GitHub repository; inferred from the origin push target') + .option('--origin-remote-name ', 'Name of the origin remote', { + default: env('ORIGIN_REMOTE_NAME', 'origin'), + }) .option('--ci-timeout ', 'Maximum time to wait for the CI run to appear', { default: env('PATCHLANE_CI_TIMEOUT_SECONDS'), }) @@ -86,6 +90,8 @@ cli.command('bootstrap', 'Validate and publish the first generated sync branch') void bootstrapPatchlane({ publish: args.publish === true, wait: args.wait === true, + repository: args.repository, + originRemoteName: args.originRemoteName, ciTimeoutSeconds: args.ciTimeout === undefined ? undefined : Number(args.ciTimeout), ciPollIntervalSeconds: args.ciPollInterval === undefined ? undefined : Number(args.ciPollInterval), }).catch((error: unknown) => { @@ -102,6 +108,10 @@ cli.command('doctor', 'Check Patchlane configuration without changing repository }); cli.command('verify-auth', 'Dispatch a no-push sync to verify GitHub App authentication') + .option('--repository ', 'Fork GitHub repository; inferred from the origin push target') + .option('--origin-remote-name ', 'Name of the origin remote', { + default: env('ORIGIN_REMOTE_NAME', 'origin'), + }) .option('--timeout ', 'Maximum time to wait for the workflow run to appear', { default: env('PATCHLANE_AUTH_TIMEOUT_SECONDS'), }) @@ -110,6 +120,8 @@ cli.command('verify-auth', 'Dispatch a no-push sync to verify GitHub App authent }) .action((args) => { void verifyGitHubAuth({ + repository: args.repository, + originRemoteName: args.originRemoteName, timeoutSeconds: args.timeout === undefined ? undefined : Number(args.timeout), pollIntervalSeconds: args.pollInterval === undefined ? undefined : Number(args.pollInterval), }).catch((error: unknown) => { @@ -212,8 +224,9 @@ cli.command('sync', 'Rebuild integration branch from upstream and patches') cli.command('notify', 'Create, update, or close an automation failure issue') .option('--event ', 'Notification event: sync-failed, ci-failed, or promotion-failed') .option('--recovered', 'Close the open notification after recovery') - .option('--repository ', 'GitHub repository; defaults to the current fork', { - default: env('GITHUB_REPOSITORY'), + .option('--repository ', 'Fork GitHub repository; inferred from the origin push target') + .option('--origin-remote-name ', 'Name of the origin remote', { + default: env('ORIGIN_REMOTE_NAME', 'origin'), }) .option('--status ', 'Failure status', { default: env('PATCHLANE_STATUS') }) .option('--run-url ', 'Workflow run URL', { @@ -259,6 +272,7 @@ cli.command('notify', 'Create, update, or close an automation failure issue') event: args.event as NotificationEvent, recovered: args.recovered === true, repository: args.repository, + originRemoteName: args.originRemoteName, status: args.status, runUrl: args.runUrl, upstreamSource: args.upstreamSource, diff --git a/src/github-repository.ts b/src/github-repository.ts new file mode 100644 index 0000000..4a7ac9e --- /dev/null +++ b/src/github-repository.ts @@ -0,0 +1,49 @@ +import { run } from './subprocess.js'; + +type ForkRepositoryOptions = { + cwd: string; + repository?: string; + originRemoteName?: string; +}; + +function repositoryName(value: string | undefined) { + const normalized = value?.trim().replace(/\.git$/, ''); + return normalized && /^[^\s/]+\/[^\s/]+$/.test(normalized) ? normalized : undefined; +} + +export function githubRepositoryFromRemote(remoteUrl: string | undefined) { + const normalized = remoteUrl?.trim(); + if (!normalized) return undefined; + + try { + const url = new URL(normalized); + if (!['github.com', 'www.github.com'].includes(url.hostname.toLowerCase())) return undefined; + return repositoryName(url.pathname.replace(/^\//, '')); + } catch { + const scpMatch = normalized.match(/^(?:[^@\s]+@)?github\.com:([^/\s:]+)\/([^/\s]+?)(?:\.git)?\/?$/i); + return scpMatch ? `${scpMatch[1]}/${scpMatch[2]}` : undefined; + } +} + +export function resolveForkRepository(options: ForkRepositoryOptions) { + if (options.repository !== undefined) { + const explicit = repositoryName(options.repository); + if (!explicit) throw new Error(`Invalid GitHub repository '${options.repository}'; expected owner/repo.`); + return explicit; + } + + const originRemoteName = options.originRemoteName ?? 'origin'; + const remote = run('git', ['remote', 'get-url', '--push', originRemoteName], options.cwd); + if (remote.status === 0) { + const repository = githubRepositoryFromRemote(remote.stdout); + if (repository) return repository; + } + + const environmentRepository = repositoryName(process.env.GITHUB_REPOSITORY); + if (environmentRepository) return environmentRepository; + + throw new Error( + `Could not determine the fork GitHub repository from the '${originRemoteName}' push target. ` + + `Configure that remote to point at GitHub or pass --repository=owner/repo.`, + ); +} diff --git a/src/notify.ts b/src/notify.ts index 0cac5d9..190cf5b 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import type { NotificationEvent, PatchlaneConfig } from './config.js'; +import { resolveForkRepository } from './github-repository.js'; type GithubIssue = { number: number; @@ -22,6 +23,8 @@ export type NotificationOptions = { repository?: string; status?: string; runUrl?: string; + cwd?: string; + originRemoteName?: string; upstreamSource?: string; upstreamSha?: string; syncSha?: string; @@ -50,17 +53,6 @@ function runGithub(args: string[]) { return result.stdout; } -function parseRepositoryFromOrigin() { - const result = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }); - if (result.status !== 0) return undefined; - const match = result.stdout.trim().match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/); - return match ? `${match[1]}/${match[2]}` : undefined; -} - -function currentRepository(explicit?: string) { - return explicit || process.env.GITHUB_REPOSITORY || parseRepositoryFromOrigin(); -} - function eventName(event: NotificationEvent) { return { 'sync-failed': 'Sync failed', @@ -146,11 +138,17 @@ export function runNotification( if (!provider.events.includes(options.event)) return { status: 'disabled' }; if (options.recovered && !provider.closeOnRecovery) return { status: 'closed-without-notification' }; - const repository = currentRepository(options.repository); - if (!repository) { - const error = 'Could not determine the current GitHub repository.'; - warn(error); - return { status: 'failed', error }; + let repository: string; + try { + repository = resolveForkRepository({ + cwd: options.cwd ?? process.cwd(), + repository: options.repository, + originRemoteName: options.originRemoteName ?? process.env.ORIGIN_REMOTE_NAME, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warn(message); + return { status: 'failed', error: message }; } const github = dependencies.github ?? runGithub; diff --git a/src/verify-auth.ts b/src/verify-auth.ts index 740a304..d704b52 100644 --- a/src/verify-auth.ts +++ b/src/verify-auth.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { loadPatchlaneConfig } from './config.js'; +import { resolveForkRepository } from './github-repository.js'; import { run } from './subprocess.js'; const DEFAULT_TIMEOUT_SECONDS = 60; @@ -7,6 +8,8 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 2; type VerifyAuthOptions = { cwd?: string; + repository?: string; + originRemoteName?: string; timeoutSeconds?: number; pollIntervalSeconds?: number; verificationId?: string; @@ -83,13 +86,11 @@ export async function verifyGitHubAuth(options: VerifyAuthOptions = {}) { const config = loadPatchlaneConfig(cwd); if (!config) throw new Error('Missing .patchlane.yml. Run `npx patchlane init` first.'); - const repositoryResult = run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], cwd); - if (repositoryResult.status !== 0 || !repositoryResult.stdout) { - throw new Error( - `Could not determine the GitHub repository: ${repositoryResult.stderr || repositoryResult.stdout || 'unknown error'}`, - ); - } - const repository = repositoryResult.stdout; + const repository = resolveForkRepository({ + cwd, + repository: options.repository, + originRemoteName: options.originRemoteName ?? process.env.ORIGIN_REMOTE_NAME, + }); const timeoutSeconds = positiveSeconds(options.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 'Auth timeout'); const pollIntervalSeconds = positiveSeconds( options.pollIntervalSeconds, @@ -98,6 +99,7 @@ export async function verifyGitHubAuth(options: VerifyAuthOptions = {}) { ); const verificationId = options.verificationId ?? randomUUID(); const expectedTitle = `Verify Patchlane authentication (${verificationId})`; + process.stdout.write(`Dispatching the Patchlane authentication check in ${repository}.\n`); const dispatch = run( 'gh', [ diff --git a/tests/bootstrap.test.ts b/tests/bootstrap.test.ts index 3460124..91ee2db 100644 --- a/tests/bootstrap.test.ts +++ b/tests/bootstrap.test.ts @@ -2,12 +2,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { bootstrapPatchlane } from '../src/bootstrap.js'; import { loadPatchlaneConfig, type PatchlaneConfig } from '../src/config.js'; import { runDoctor } from '../src/doctor.js'; +import { resolveForkRepository } from '../src/github-repository.js'; import { runIntegrationSync } from '../src/integration-sync.js'; import { runPromoteSync } from '../src/promote-sync.js'; import { git, run, type CommandResult } from '../src/subprocess.js'; vi.mock('../src/config.js', () => ({ loadPatchlaneConfig: vi.fn() })); vi.mock('../src/doctor.js', () => ({ runDoctor: vi.fn() })); +vi.mock('../src/github-repository.js', () => ({ resolveForkRepository: vi.fn() })); vi.mock('../src/integration-sync.js', () => ({ runIntegrationSync: vi.fn() })); vi.mock('../src/promote-sync.js', () => ({ runPromoteSync: vi.fn() })); vi.mock('../src/subprocess.js', () => ({ git: vi.fn(), run: vi.fn() })); @@ -49,6 +51,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(loadPatchlaneConfig).mockReturnValue(config); vi.mocked(runDoctor).mockReturnValue({ ok: true, checks: [] }); + vi.mocked(resolveForkRepository).mockReturnValue(repository); vi.mocked(git).mockReturnValue(`${syncSha}\t${syncRef}`); vi.mocked(run).mockReturnValue(commandResult()); }); @@ -93,7 +96,6 @@ describe('bootstrapPatchlane', () => { test('queries Actions directly until the exact CI run appears and then promotes', async () => { vi.useFakeTimers(); vi.mocked(run) - .mockReturnValueOnce(commandResult({ stdout: repository })) .mockReturnValueOnce(runs()) .mockReturnValueOnce( runs( @@ -113,15 +115,14 @@ describe('bootstrapPatchlane', () => { await vi.advanceTimersByTimeAsync(5_000); await expect(result).resolves.toEqual({ status: 'promoted', syncSha, runId: '42' }); - expect(run).toHaveBeenCalledTimes(4); - expect(run).toHaveBeenNthCalledWith( - 1, - 'gh', - ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], + expect(resolveForkRepository).toHaveBeenCalledWith({ cwd, - ); + repository: undefined, + originRemoteName: 'origin', + }); + expect(run).toHaveBeenCalledTimes(3); expect(run).toHaveBeenNthCalledWith( - 2, + 1, 'gh', [ 'api', @@ -131,7 +132,12 @@ describe('bootstrapPatchlane', () => { ], cwd, ); - expect(run).toHaveBeenNthCalledWith(4, 'gh', ['run', 'watch', '42', '--exit-status'], cwd); + expect(run).toHaveBeenNthCalledWith( + 3, + 'gh', + ['run', 'watch', '42', '--repo', repository, '--exit-status'], + cwd, + ); expect(runPromoteSync).toHaveBeenCalledWith({ expectedSyncSha: syncSha, allowedWorkflows: config.allowedWorkflows, @@ -144,7 +150,6 @@ describe('bootstrapPatchlane', () => { test('performs a final lookup at the timeout deadline', async () => { vi.useFakeTimers(); vi.mocked(run) - .mockReturnValueOnce(commandResult({ stdout: repository })) .mockReturnValueOnce(runs()) .mockReturnValueOnce(runs()) .mockReturnValueOnce(runs(matchingRun())) @@ -164,9 +169,7 @@ describe('bootstrapPatchlane', () => { test('times out with query details when no CI run starts', async () => { vi.useFakeTimers(); - vi.mocked(run) - .mockReturnValueOnce(commandResult({ stdout: repository })) - .mockReturnValue(runs()); + vi.mocked(run).mockReturnValue(runs()); const result = bootstrapPatchlane({ cwd, publish: true, @@ -184,15 +187,13 @@ describe('bootstrapPatchlane', () => { await vi.runAllTimersAsync(); await rejection; - expect(run).toHaveBeenCalledTimes(4); + expect(run).toHaveBeenCalledTimes(3); expect(runPromoteSync).not.toHaveBeenCalled(); }); test('reports persistent GitHub lookup failures instead of hiding them', async () => { vi.useFakeTimers(); - vi.mocked(run) - .mockReturnValueOnce(commandResult({ stdout: repository })) - .mockReturnValue(commandResult({ status: 1, stderr: 'HTTP 503' })); + vi.mocked(run).mockReturnValue(commandResult({ status: 1, stderr: 'HTTP 503' })); const result = bootstrapPatchlane({ cwd, publish: true, @@ -208,14 +209,13 @@ describe('bootstrapPatchlane', () => { test('refuses to promote when CI fails', async () => { vi.mocked(run) - .mockReturnValueOnce(commandResult({ stdout: repository })) .mockReturnValueOnce(runs(matchingRun())) .mockReturnValueOnce(commandResult({ status: 1, stderr: 'CI failed' })); await expect(bootstrapPatchlane({ cwd, publish: true, wait: true })).rejects.toThrow( 'CI run 42 did not succeed; refusing to promote.', ); - expect(run).toHaveBeenLastCalledWith('gh', ['run', 'watch', '42', '--exit-status'], cwd); + expect(run).toHaveBeenLastCalledWith('gh', ['run', 'watch', '42', '--repo', repository, '--exit-status'], cwd); expect(runPromoteSync).not.toHaveBeenCalled(); }); }); diff --git a/tests/github-repository.test.ts b/tests/github-repository.test.ts new file mode 100644 index 0000000..2634bb5 --- /dev/null +++ b/tests/github-repository.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { githubRepositoryFromRemote, resolveForkRepository } from '../src/github-repository.js'; +import { run, type CommandResult } from '../src/subprocess.js'; + +vi.mock('../src/subprocess.js', () => ({ run: vi.fn() })); + +const cwd = '/tmp/patchlane-repository'; + +function result(overrides: Partial = {}): CommandResult { + return { status: 0, stdout: '', stderr: '', ...overrides }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv('GITHUB_REPOSITORY', ''); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('githubRepositoryFromRemote', () => { + test.each([ + ['https://github.com/fork/project.git', 'fork/project'], + ['git@github.com:fork/project.git', 'fork/project'], + ['ssh://git@github.com/fork/project', 'fork/project'], + ['https://token@github.com/fork/project.git', 'fork/project'], + ])('parses %s', (remote, repository) => { + expect(githubRepositoryFromRemote(remote)).toBe(repository); + }); + + test('rejects non-GitHub hosts', () => { + expect(githubRepositoryFromRemote('https://notgithub.com/fork/project.git')).toBeUndefined(); + }); +}); + +describe('resolveForkRepository', () => { + test("uses the origin push target instead of gh's configured default", () => { + vi.mocked(run).mockReturnValue(result({ stdout: 'https://github.com/fork/project.git' })); + + expect(resolveForkRepository({ cwd })).toBe('fork/project'); + expect(run).toHaveBeenCalledOnce(); + expect(run).toHaveBeenCalledWith('git', ['remote', 'get-url', '--push', 'origin'], cwd); + }); + + test('uses a configured remote name', () => { + vi.mocked(run).mockReturnValue(result({ stdout: 'git@github.com:fork/project.git' })); + + expect(resolveForkRepository({ cwd, originRemoteName: 'publish' })).toBe('fork/project'); + expect(run).toHaveBeenCalledWith('git', ['remote', 'get-url', '--push', 'publish'], cwd); + }); + + test('allows an explicit repository override without inspecting remotes', () => { + expect(resolveForkRepository({ cwd, repository: 'other/fork' })).toBe('other/fork'); + expect(run).not.toHaveBeenCalled(); + }); + + test('falls back to the Actions repository when the push target is unavailable', () => { + vi.stubEnv('GITHUB_REPOSITORY', 'actions/fork'); + vi.mocked(run).mockReturnValue(result({ status: 2, stderr: 'No such remote' })); + + expect(resolveForkRepository({ cwd })).toBe('actions/fork'); + }); + + test('reports how to configure an unresolved repository', () => { + vi.mocked(run).mockReturnValue(result({ stdout: 'https://example.com/fork/project.git' })); + + expect(() => resolveForkRepository({ cwd })).toThrow( + "Could not determine the fork GitHub repository from the 'origin' push target", + ); + }); +}); diff --git a/tests/verify-auth.test.ts b/tests/verify-auth.test.ts index 2329e5d..db401fb 100644 --- a/tests/verify-auth.test.ts +++ b/tests/verify-auth.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { loadPatchlaneConfig, type PatchlaneConfig } from '../src/config.js'; +import { resolveForkRepository } from '../src/github-repository.js'; import { run, type CommandResult } from '../src/subprocess.js'; import { verifyGitHubAuth } from '../src/verify-auth.js'; vi.mock('../src/config.js', () => ({ loadPatchlaneConfig: vi.fn() })); +vi.mock('../src/github-repository.js', () => ({ resolveForkRepository: vi.fn() })); vi.mock('../src/subprocess.js', () => ({ run: vi.fn() })); const cwd = '/tmp/patchlane-auth'; @@ -34,6 +36,7 @@ function verificationRun(databaseId = 11) { beforeEach(() => { vi.resetAllMocks(); vi.mocked(loadPatchlaneConfig).mockReturnValue(config); + vi.mocked(resolveForkRepository).mockReturnValue(repository); }); afterEach(() => { @@ -43,7 +46,6 @@ afterEach(() => { describe('verifyGitHubAuth', () => { test('dispatches and watches a correlated no-push workflow run', async () => { vi.mocked(run) - .mockReturnValueOnce(result({ stdout: repository })) .mockReturnValueOnce(result()) .mockReturnValueOnce(runs(verificationRun(), { databaseId: 10, displayTitle: 'Sync Upstream Integration' })) .mockReturnValueOnce(result({ stdout: 'Authentication passed' })); @@ -53,8 +55,13 @@ describe('verifyGitHubAuth', () => { repository, runId: '11', }); + expect(resolveForkRepository).toHaveBeenCalledWith({ + cwd, + repository: undefined, + originRemoteName: undefined, + }); expect(run).toHaveBeenNthCalledWith( - 2, + 1, 'gh', [ 'workflow', @@ -77,7 +84,6 @@ describe('verifyGitHubAuth', () => { test('waits for GitHub to expose the correlated run', async () => { vi.useFakeTimers(); vi.mocked(run) - .mockReturnValueOnce(result({ stdout: repository })) .mockReturnValueOnce(result()) .mockReturnValueOnce(runs({ databaseId: 10, displayTitle: 'Sync Upstream Integration' })) .mockReturnValueOnce(runs(verificationRun())) @@ -94,7 +100,6 @@ describe('verifyGitHubAuth', () => { test('reports a failed authentication workflow', async () => { vi.mocked(run) - .mockReturnValueOnce(result({ stdout: repository })) .mockReturnValueOnce(result()) .mockReturnValueOnce(runs(verificationRun())) .mockReturnValueOnce(result({ status: 1, stderr: 'token creation failed' }));