diff --git a/src/doctor.ts b/src/doctor.ts index c48899b..2d1c047 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -245,6 +245,11 @@ function jobSteps(job: Record | undefined) { return Array.isArray(steps) ? steps.flatMap((step) => (objectValue(step) ? [objectValue(step)!] : [])) : []; } +function invokesPatchlaneCommand(job: Record, command: string) { + const pattern = new RegExp(`\\bpatchlane(?:@[^\\s]+)?\\s+${command}\\b`); + return jobSteps(job).some((step) => typeof step.run === 'string' && pattern.test(step.run)); +} + type AuthenticationProvider = 'direct' | 'wrapper'; export function inspectAuthenticatedJob( @@ -336,6 +341,37 @@ export function inspectAuthenticatedJob( return directProvider ? 'direct' : 'wrapper'; } +export function inspectAuthenticatedCommandJob( + workflowFile: string, + workflow: Record, + command: string, + requirements: { contents: 'read' | 'write'; workflows?: boolean; issues?: boolean }, + checks: DoctorCheck[], +): AuthenticationProvider | undefined { + const candidates = Object.entries(objectValue(workflow.jobs) ?? {}).flatMap(([jobName, value]) => { + const job = objectValue(value); + return job && invokesPatchlaneCommand(job, command) ? [{ jobName, job }] : []; + }); + if (candidates.length === 0) { + checks.push({ + severity: 'error', + message: `${workflowFile} must define a job that invokes 'patchlane ${command}'.`, + }); + return undefined; + } + if (candidates.length > 1) { + const jobNames = candidates.map(({ jobName }) => `'${jobName}'`).join(', '); + checks.push({ + severity: 'error', + message: `${workflowFile} must define exactly one job that invokes 'patchlane ${command}'; found ${jobNames}.`, + }); + return undefined; + } + + const [{ jobName, job }] = candidates; + return inspectAuthenticatedJob(workflowFile, jobName, job, requirements, checks); +} + function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined, cwd: string, checks: DoctorCheck[]) { const authenticationProviders: AuthenticationProvider[] = []; const files = workflowFiles(config, sourceSha, cwd); @@ -352,10 +388,10 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined const promotionNotifications = config.notifications?.githubIssues.events.includes('promotion-failed') ?? false; if (!sync?.workflow) checks.push({ severity: 'error', message: 'Missing .github/workflows/sync-upstream.yml.' }); else { - const provider = inspectAuthenticatedJob( + const provider = inspectAuthenticatedCommandJob( '.github/workflows/sync-upstream.yml', - 'fork-sync', - workflowJob(sync.workflow, 'fork-sync'), + sync.workflow, + 'sync', { contents: 'write', workflows: true, issues: syncNotifications }, checks, ); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 24f0383..79a0582 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -3,7 +3,13 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { inspectAuthenticatedJob, inspectGitHubAutomation, runDoctor, type DoctorCheck } from '../src/doctor.js'; +import { + inspectAuthenticatedCommandJob, + inspectAuthenticatedJob, + inspectGitHubAutomation, + runDoctor, + type DoctorCheck, +} from '../src/doctor.js'; import type { PatchlaneConfig } from '../src/config.js'; import { renderPromotionWorkflow, renderSyncWorkflow } from '../src/workflow-templates.js'; @@ -218,6 +224,60 @@ describe('inspectAuthenticatedJob', () => { }); }); +describe('inspectAuthenticatedCommandJob', () => { + function inspectJobs(jobs: Record) { + const checks: DoctorCheck[] = []; + inspectAuthenticatedCommandJob( + '.github/workflows/sync-upstream.yml', + { jobs }, + 'sync', + { contents: 'write', workflows: true, issues: true }, + checks, + ); + return checks; + } + + test('accepts a direct GitHub App action in a custom sync job', () => { + expect(inspectJobs({ sync: authenticatedJob() })).toEqual([]); + }); + + test('accepts a wrapper action in a custom sync job', () => { + expect( + inspectJobs({ + update: authenticatedJob({ + tokenStepId: 'not-adam', + tokenUses: 'adampoit/not-adam@v1', + tokenWith: {}, + }), + }), + ).toEqual([ + { + severity: 'info', + message: expect.stringContaining("job 'update' uses token wrapper 'adampoit/not-adam@v1'"), + }, + ]); + }); + + test('reports a missing sync command job', () => { + expect(inspectJobs({ build: { steps: [{ run: 'npm test' }] } })).toEqual([ + { + severity: 'error', + message: ".github/workflows/sync-upstream.yml must define a job that invokes 'patchlane sync'.", + }, + ]); + }); + + test('reports ambiguous sync command jobs', () => { + expect(inspectJobs({ first: authenticatedJob(), second: authenticatedJob() })).toEqual([ + { + severity: 'error', + message: + ".github/workflows/sync-upstream.yml must define exactly one job that invokes 'patchlane sync'; found 'first', 'second'.", + }, + ]); + }); +}); + describe('inspectGitHubAutomation', () => { function inspect(overrides: Parameters[0] = {}) { const checks: DoctorCheck[] = [];