Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ function jobSteps(job: Record<string, unknown> | undefined) {
return Array.isArray(steps) ? steps.flatMap((step) => (objectValue(step) ? [objectValue(step)!] : [])) : [];
}

function invokesPatchlaneCommand(job: Record<string, unknown>, 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(
Expand Down Expand Up @@ -336,6 +341,37 @@ export function inspectAuthenticatedJob(
return directProvider ? 'direct' : 'wrapper';
}

export function inspectAuthenticatedCommandJob(
workflowFile: string,
workflow: Record<string, unknown>,
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);
Expand All @@ -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,
);
Expand Down
62 changes: 61 additions & 1 deletion tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -218,6 +224,60 @@ describe('inspectAuthenticatedJob', () => {
});
});

describe('inspectAuthenticatedCommandJob', () => {
function inspectJobs(jobs: Record<string, unknown>) {
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<typeof automationRunner>[0] = {}) {
const checks: DoctorCheck[] = [];
Expand Down