From f9364c86e309b418c72420e933573e1c746684e5 Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Sat, 18 Jul 2026 07:23:45 -0700 Subject: [PATCH 1/3] Add configurable automation failure notifications --- docs/configuration.md | 23 +++ src/cli.ts | 70 +++++++- src/config.ts | 63 +++++++ src/doctor.ts | 54 +++++- src/integration-sync.ts | 1 + src/notify.ts | 274 +++++++++++++++++++++++++++++++ src/workflow-templates.ts | 130 +++++++++++++-- tests/config.test.ts | 35 ++++ tests/doctor.test.ts | 21 +++ tests/notify.test.ts | 174 ++++++++++++++++++++ tests/workflow-templates.test.ts | 46 ++++++ 11 files changed, 874 insertions(+), 17 deletions(-) create mode 100644 src/notify.ts create mode 100644 tests/notify.test.ts create mode 100644 tests/workflow-templates.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 2b4dc4b..b5c9590 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,12 @@ patchRefs: ciWorkflow: CI allowedWorkflows: - ci.yml +notifications: + githubIssues: + assignees: [maintainer] + labels: [patchlane, automation-failure] + events: [sync-failed, ci-failed, promotion-failed] + closeOnRecovery: true ``` | Field | Required | Description | @@ -29,6 +35,7 @@ allowedWorkflows: | `patchRefs` | yes | Independent patch branches applied in order | | `ciWorkflow` | recommended | Exact existing CI workflow name used by `workflow_run` | | `allowedWorkflows` | yes | Repository workflow filenames permitted alongside generated ones | +| `notifications` | no | Automation failure notification providers | Patchlane implicitly adds its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows to the allowlist. Configure only repository-specific workflows such as CI; use an empty list when no additional workflows are expected. Doctor, every sync mode, and promotion reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. @@ -41,6 +48,8 @@ Supported sources: CLI flags and environment variables override config values for one run. Legacy `UPSTREAM_OWNER`, `UPSTREAM_REPO`, `UPSTREAM_REF`, `RELEASE_SELECTOR`, `BASE_BRANCH`, `SYNC_BRANCH`, and `PATCH_REFS` variables remain supported for migration. +GitHub issue notifications are optional. Patchlane keeps one issue per failure event, updates it on repeated failures, assigns configured users individually, and closes it after a successful run when `closeOnRecovery` is enabled. The generated workflows request `issues: write` only when they handle an enabled GitHub issue event. Notification API errors are warnings and do not replace the sync, CI, or promotion result. + ## Commands ### Initialize files @@ -105,6 +114,19 @@ npx patchlane promote --expected-sync-sha= Promotion verifies that the tested SHA is still the current sync-branch head before updating the generated base branch with force-with-lease. +### Report an automation result + +Generated workflows invoke this command automatically: + +```bash +npx patchlane notify --event=sync-failed +npx patchlane notify --event=ci-failed +npx patchlane notify --event=promotion-failed +npx patchlane notify --event=sync-failed --recovered +``` + +The repository defaults to `GITHUB_REPOSITORY` in Actions or the GitHub `origin` remote locally. Structured context can be supplied with flags or the `PATCHLANE_STATUS`, `PATCHLANE_RUN_URL`, `UPSTREAM_SHA`, `SYNC_SHA`, `FAILED_PATCH_REF`, `FAILED_COMMIT`, `CONFLICT_PATHS`, and `APPLIED_PATCH_REFS` environment variables. + ### Install agent skills ```bash @@ -135,6 +157,7 @@ Patchlane writes these outputs when `GITHUB_OUTPUT` is available: - `status` - `sync_branch` - `sync_sha` +- `upstream_sha` - `applied_refs` - `failed_bookmark` - `failed_commit` diff --git a/src/cli.ts b/src/cli.ts index 0e2d722..5716c04 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,18 +2,19 @@ import cac from 'cac'; import { installPatchlaneAgents } from './agents-install.js'; import { bootstrapPatchlane } from './bootstrap.js'; -import { loadPatchlaneConfig } from './config.js'; +import { loadPatchlaneConfig, NOTIFICATION_EVENTS, type NotificationEvent } from './config.js'; import { runDoctor } from './doctor.js'; import { initializePatchlane } from './init.js'; import { runIntegrationSync } from './integration-sync.js'; import { getPackageVersion } from './package-version.js'; +import { runNotification } from './notify.js'; import { runPromoteSync } from './promote-sync.js'; import { parseUpstreamSource } from './upstream-source.js'; const cli = cac('patchlane'); let config: ReturnType; -if (['sync', 'promote'].includes(process.argv[2] ?? '')) { +if (['sync', 'promote', 'notify'].includes(process.argv[2] ?? '')) { try { config = loadPatchlaneConfig(); } catch (error) { @@ -190,6 +191,71 @@ 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('--status ', 'Failure status', { default: env('PATCHLANE_STATUS') }) + .option('--run-url ', 'Workflow run URL', { + default: env( + 'PATCHLANE_RUN_URL', + process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID + ? `${env('GITHUB_SERVER_URL', 'https://github.com')}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` + : undefined, + ), + }) + .option('--upstream-source ', 'Configured upstream source', { + default: env('UPSTREAM_SOURCE', config?.source), + }) + .option('--upstream-sha ', 'Resolved upstream SHA', { default: env('UPSTREAM_SHA') }) + .option('--sync-sha ', 'Generated or tested sync SHA', { default: env('SYNC_SHA') }) + .option('--base-branch ', 'Fork base branch', { + default: env('BASE_BRANCH', config?.baseBranch), + }) + .option('--sync-branch ', 'Generated sync branch', { + default: env('SYNC_BRANCH', config?.syncBranch), + }) + .option('--failed-patch-ref ', 'Failing patch ref', { default: env('FAILED_PATCH_REF') }) + .option('--failed-commit ', 'Failing patch commit', { default: env('FAILED_COMMIT') }) + .option('--conflict-paths ', 'Newline- or comma-delimited conflict paths', { + default: env('CONFLICT_PATHS'), + }) + .option('--applied-patch-refs ', 'Newline- or comma-delimited applied patch refs', { + default: env('APPLIED_PATCH_REFS'), + }) + .action((args) => { + if (!config) { + process.stderr.write('Missing .patchlane.yml. Run `npx patchlane init` first.\n'); + process.exitCode = 1; + return; + } + if (!NOTIFICATION_EVENTS.includes(args.event as NotificationEvent)) { + process.stderr.write(`Invalid notification event '${String(args.event ?? '')}'.\n`); + process.exitCode = 1; + return; + } + const result = runNotification({ + config, + event: args.event as NotificationEvent, + recovered: args.recovered === true, + repository: args.repository, + status: args.status, + runUrl: args.runUrl, + upstreamSource: args.upstreamSource, + upstreamSha: args.upstreamSha, + syncSha: args.syncSha, + baseBranch: args.baseBranch, + syncBranch: args.syncBranch, + failedPatchRef: args.failedPatchRef, + failedCommit: args.failedCommit, + conflictPaths: args.conflictPaths, + appliedPatchRefs: args.appliedPatchRefs, + }); + if (result.status !== 'failed') process.stdout.write(`Notification status: ${result.status}\n`); + }); + cli.command('promote', 'Promote tested sync branch onto base branch') .option('--expected-sync-sha ', 'Tested commit SHA', { default: env('EXPECTED_SYNC_SHA'), diff --git a/src/config.ts b/src/config.ts index 8c0a671..4a97fc9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,17 @@ import { parseUpstreamSource } from './upstream-source.js'; export const PATCHLANE_CONFIG_FILE = '.patchlane.yml'; +export const NOTIFICATION_EVENTS = ['sync-failed', 'ci-failed', 'promotion-failed'] as const; + +export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number]; + +export type GithubIssueNotifications = { + assignees: string[]; + labels: string[]; + events: NotificationEvent[]; + closeOnRecovery: boolean; +}; + export type PatchlaneConfig = { upstreamOwner: string; upstreamRepo: string; @@ -14,6 +25,9 @@ export type PatchlaneConfig = { patchRefs: string[]; ciWorkflow?: string; allowedWorkflows: string[]; + notifications?: { + githubIssues: GithubIssueNotifications; + }; }; function isPlainObject(value: unknown): value is Record { @@ -28,6 +42,53 @@ function requireString(config: Record, key: string) { return value.trim(); } +function parseStringArray(value: unknown, field: string) { + if (!Array.isArray(value)) throw new Error(`Patchlane config field '${field}' must be an array.`); + const values = value.map((item) => { + if (typeof item !== 'string' || !item.trim()) { + throw new Error(`Patchlane config field '${field}' must contain only non-empty strings.`); + } + return item.trim(); + }); + if (new Set(values).size !== values.length) { + throw new Error(`Patchlane config field '${field}' must not contain duplicates.`); + } + return values; +} + +function parseNotifications(value: unknown): PatchlaneConfig['notifications'] { + if (value === undefined) return undefined; + if (!isPlainObject(value) || !isPlainObject(value.githubIssues)) { + throw new Error("Patchlane config field 'notifications.githubIssues' must be a YAML object."); + } + const provider = value.githubIssues; + const assignees = parseStringArray(provider.assignees ?? [], 'notifications.githubIssues.assignees'); + const labels = parseStringArray(provider.labels ?? [], 'notifications.githubIssues.labels'); + const rawEvents = parseStringArray(provider.events, 'notifications.githubIssues.events'); + if (!rawEvents.length) { + throw new Error("Patchlane config field 'notifications.githubIssues.events' must not be empty."); + } + const events = rawEvents.map((event) => { + if (!(NOTIFICATION_EVENTS as readonly string[]).includes(event)) { + throw new Error( + `Patchlane config field 'notifications.githubIssues.events' contains invalid event '${event}'.`, + ); + } + return event as NotificationEvent; + }); + if (provider.closeOnRecovery !== undefined && typeof provider.closeOnRecovery !== 'boolean') { + throw new Error("Patchlane config field 'notifications.githubIssues.closeOnRecovery' must be a boolean."); + } + return { + githubIssues: { + assignees, + labels, + events, + closeOnRecovery: provider.closeOnRecovery ?? false, + }, + }; +} + export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { if (!isPlainObject(value)) throw new Error('Patchlane config must be a YAML object.'); if (value.version !== 1) throw new Error("Patchlane config field 'version' must be 1."); @@ -73,6 +134,7 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { patchRefs, ciWorkflow: typeof ciWorkflow === 'string' ? ciWorkflow.trim() : undefined, allowedWorkflows, + notifications: parseNotifications(value.notifications), }; } @@ -112,6 +174,7 @@ export function serializePatchlaneConfig(config: PatchlaneConfig) { patchRefs: config.patchRefs, ...(config.ciWorkflow ? { ciWorkflow: config.ciWorkflow } : {}), allowedWorkflows: config.allowedWorkflows, + ...(config.notifications ? { notifications: config.notifications } : {}), }); } diff --git a/src/doctor.ts b/src/doctor.ts index d953c41..c267bae 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -53,6 +53,11 @@ function remoteUrl(cwd: string, remote: string) { return result.status === 0 ? result.stdout : undefined; } +function githubRepository(remote: string | undefined) { + const match = remote?.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/); + return match ? `${match[1]}/${match[2]}` : undefined; +} + function resolveRelease(config: PatchlaneConfig, selector: string, cwd: string) { const repo = `${config.upstreamOwner}/${config.upstreamRepo}`; if (selector === 'latest') { @@ -224,13 +229,13 @@ function configuredBranches(workflow: Record) { return Array.isArray(branches) ? branches.filter((branch): branch is string => typeof branch === 'string') : []; } -function hasWriteContents(workflow: Record) { +function hasWritePermission(workflow: Record, permission: string) { const permissions = workflow.permissions; return ( typeof permissions === 'object' && permissions !== null && !Array.isArray(permissions) && - (permissions as Record).contents === 'write' + (permissions as Record)[permission] === 'write' ); } @@ -245,15 +250,37 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined const ci = parsed.find((file) => workflowName(file.workflow) === config.ciWorkflow); if (!sync?.workflow) checks.push({ severity: 'error', message: 'Missing .github/workflows/sync-upstream.yml.' }); - else if (!hasWriteContents(sync.workflow)) { - checks.push({ severity: 'error', message: 'The sync workflow must grant contents: write.' }); + else { + if (!hasWritePermission(sync.workflow, 'contents')) { + checks.push({ severity: 'error', message: 'The sync workflow must grant contents: write.' }); + } + if ( + config.notifications?.githubIssues.events.includes('sync-failed') && + !hasWritePermission(sync.workflow, 'issues') + ) { + checks.push({ + severity: 'error', + message: 'The sync workflow must grant issues: write for GitHub issue notifications.', + }); + } } if (!promotion?.workflow) { checks.push({ severity: 'error', message: 'Missing .github/workflows/promote-tested-sync.yml.' }); } else { - if (!hasWriteContents(promotion.workflow)) { + if (!hasWritePermission(promotion.workflow, 'contents')) { checks.push({ severity: 'error', message: 'The promotion workflow must grant contents: write.' }); } + if ( + config.notifications?.githubIssues.events.some((event) => + ['ci-failed', 'promotion-failed'].includes(event), + ) && + !hasWritePermission(promotion.workflow, 'issues') + ) { + checks.push({ + severity: 'error', + message: 'The promotion workflow must grant issues: write for GitHub issue notifications.', + }); + } const workflowRun = eventConfig(promotion.workflow, 'workflow_run'); const workflows = typeof workflowRun === 'object' && workflowRun !== null && !Array.isArray(workflowRun) @@ -318,6 +345,22 @@ function inspectPatchRefs(config: PatchlaneConfig, sourceSha: string | undefined } } +function inspectNotificationAssignees(config: PatchlaneConfig, cwd: string, checks: DoctorCheck[]) { + const assignees = config.notifications?.githubIssues.assignees ?? []; + const repository = githubRepository(remoteUrl(cwd, 'origin')); + if (!assignees.length || !repository || run('gh', ['auth', 'status'], cwd).status !== 0) return; + + for (const assignee of assignees) { + const result = run('gh', ['api', `repos/${repository}/assignees/${assignee}`, '--silent'], cwd); + if (result.status !== 0) { + checks.push({ + severity: 'warning', + message: `Notification user '${assignee}' could not be verified as assignable to '${repository}'.`, + }); + } + } +} + function inspectBootstrap(config: PatchlaneConfig, cwd: string, checks: DoctorCheck[]) { const remoteBase = `refs/remotes/origin/${config.baseBranch}`; if (git(['rev-parse', '--verify', '--quiet', remoteBase], cwd).status !== 0) return; @@ -348,6 +391,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport { checks.push({ severity: 'info', message: `Resolved ${config.source} to ${resolved.label} @ ${resolved.sha}.` }); inspectPatchRefs(config, resolved?.sha, cwd, checks); inspectWorkflows(config, resolved?.sha, cwd, checks); + inspectNotificationAssignees(config, cwd, checks); inspectBootstrap(config, cwd, checks); return printReport( diff --git a/src/integration-sync.ts b/src/integration-sync.ts index 839c491..0f755a9 100644 --- a/src/integration-sync.ts +++ b/src/integration-sync.ts @@ -328,6 +328,7 @@ export function runIntegrationSync(options: IntegrationSyncOptions) { ) { fail(`Upstream ref '${upstreamRef}' was not fetched from ${upstreamRemoteName}.`); } + writeOutput('upstream_sha', git(['rev-parse', `${upstreamBase}^{commit}`]).stdout.trim()); const patchRefs = parsePatchRefs(patchRefsRaw); if (!patchRefs.length) fail('PATCH_REFS did not contain any patch branch names.'); diff --git a/src/notify.ts b/src/notify.ts new file mode 100644 index 0000000..0cac5d9 --- /dev/null +++ b/src/notify.ts @@ -0,0 +1,274 @@ +import { spawnSync } from 'node:child_process'; +import type { NotificationEvent, PatchlaneConfig } from './config.js'; + +type GithubIssue = { + number: number; + state: 'open' | 'closed'; + title?: string; + body?: string; + html_url?: string; + pull_request?: unknown; +}; + +type NotificationDependencies = { + github?: (args: string[]) => string; + now?: () => Date; +}; + +export type NotificationOptions = { + config: PatchlaneConfig; + event: NotificationEvent; + recovered?: boolean; + repository?: string; + status?: string; + runUrl?: string; + upstreamSource?: string; + upstreamSha?: string; + syncSha?: string; + baseBranch?: string; + syncBranch?: string; + failedPatchRef?: string; + failedCommit?: string; + conflictPaths?: string; + appliedPatchRefs?: string; +}; + +export type NotificationResult = + | { status: 'disabled' | 'not-configured' | 'closed-without-notification' } + | { status: 'created' | 'updated' | 'reopened' | 'closed'; issueNumber: number; url?: string } + | { status: 'failed'; error: string }; + +function runGithub(args: string[]) { + const result = spawnSync('gh', args, { encoding: 'utf8', env: process.env }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join('\n') || + `gh exited with status ${result.status ?? 1}`, + ); + } + 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', + 'ci-failed': 'CI failed', + 'promotion-failed': 'Promotion failed', + }[event]; +} + +function marker(repository: string, event: NotificationEvent) { + return ``; +} + +function code(value: string) { + return `\`${value.replaceAll('`', "'")}\``; +} + +function values(value?: string) { + return (value ?? '') + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function issueBody(options: NotificationOptions, repository: string, observedAt: string) { + const lines = [ + marker(repository, options.event), + '', + `## ${eventName(options.event)}`, + '', + `- Status: ${code(options.status || 'failed')}`, + `- Repository: ${code(repository)}`, + `- Latest observed: ${observedAt}`, + ]; + if (options.runUrl) lines.push(`- Workflow run: ${options.runUrl}`); + if (options.upstreamSource) lines.push(`- Upstream source: ${code(options.upstreamSource)}`); + if (options.upstreamSha) lines.push(`- Resolved upstream SHA: ${code(options.upstreamSha)}`); + if (options.syncSha) lines.push(`- Sync SHA: ${code(options.syncSha)}`); + if (options.baseBranch) lines.push(`- Base branch: ${code(options.baseBranch)}`); + if (options.syncBranch) lines.push(`- Sync branch: ${code(options.syncBranch)}`); + if (options.failedPatchRef) lines.push(`- Failing patch ref: ${code(options.failedPatchRef)}`); + if (options.failedCommit) lines.push(`- Failing commit: ${code(options.failedCommit)}`); + + const conflicts = values(options.conflictPaths); + if (conflicts.length) { + lines.push('', '### Conflict paths', '', ...conflicts.map((path) => `- ${code(path)}`)); + } + const applied = values(options.appliedPatchRefs); + if (applied.length) { + lines.push('', '### Applied patch refs', '', ...applied.map((ref) => `- ${code(ref)}`)); + } + + lines.push('', '### Reproduction', ''); + if (options.event === 'sync-failed') lines.push('```bash', 'npx patchlane sync --dry-run', '```'); + else if (options.event === 'promotion-failed') { + lines.push('```bash', `npx patchlane promote --expected-sync-sha=${options.syncSha || ''}`, '```'); + } else { + lines.push('Re-run the failed CI workflow from the workflow run linked above.'); + } + return `${lines.join('\n')}\n`; +} + +function parseIssuePages(output: string) { + const parsed = JSON.parse(output) as unknown; + if (!Array.isArray(parsed)) throw new Error('GitHub returned an invalid issue list.'); + const items = parsed.flatMap((page) => (Array.isArray(page) ? page : [page])) as GithubIssue[]; + return items.filter((issue) => !issue.pull_request); +} + +function field(name: string, value: string) { + return ['-f', `${name}=${value}`]; +} + +function warn(message: string) { + process.stderr.write(`Patchlane notification warning: ${message}\n`); +} + +export function runNotification( + options: NotificationOptions, + dependencies: NotificationDependencies = {}, +): NotificationResult { + const provider = options.config.notifications?.githubIssues; + if (!provider) return { status: 'not-configured' }; + 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 }; + } + + const github = dependencies.github ?? runGithub; + const observedAt = (dependencies.now?.() ?? new Date()).toISOString(); + const issueMarker = marker(repository, options.event); + + try { + const issues = parseIssuePages( + github(['api', '--paginate', '--slurp', `repos/${repository}/issues?state=all&per_page=100`]), + ).filter((issue) => issue.body?.includes(issueMarker)); + const openIssue = issues.find((issue) => issue.state === 'open'); + + if (options.recovered) { + if (!openIssue) return { status: 'closed-without-notification' }; + github([ + 'api', + '--method', + 'POST', + `repos/${repository}/issues/${openIssue.number}/comments`, + ...field( + 'body', + `Recovered at ${observedAt}.${options.runUrl ? `\n\nWorkflow run: ${options.runUrl}` : ''}`, + ), + ]); + github([ + 'api', + '--method', + 'PATCH', + `repos/${repository}/issues/${openIssue.number}`, + ...field('state', 'closed'), + ]); + return { status: 'closed', issueNumber: openIssue.number, url: openIssue.html_url }; + } + + const title = `[Patchlane] ${eventName(options.event)} in ${repository}`; + const body = issueBody(options, repository, observedAt); + let issue = openIssue; + let status: 'created' | 'updated' | 'reopened'; + + if (issue) { + github([ + 'api', + '--method', + 'PATCH', + `repos/${repository}/issues/${issue.number}`, + ...field('title', title), + ...field('body', body), + ]); + github([ + 'api', + '--method', + 'POST', + `repos/${repository}/issues/${issue.number}/comments`, + ...field( + 'body', + `Failure observed again at ${observedAt}.${options.runUrl ? `\n\nWorkflow run: ${options.runUrl}` : ''}`, + ), + ]); + status = 'updated'; + } else { + issue = issues.find((candidate) => candidate.state === 'closed'); + if (issue) { + github([ + 'api', + '--method', + 'PATCH', + `repos/${repository}/issues/${issue.number}`, + ...field('state', 'open'), + ...field('title', title), + ...field('body', body), + ]); + status = 'reopened'; + } else { + issue = JSON.parse( + github([ + 'api', + '--method', + 'POST', + `repos/${repository}/issues`, + ...field('title', title), + ...field('body', body), + ]), + ) as GithubIssue; + status = 'created'; + } + } + + if (provider.labels.length) { + try { + github([ + 'api', + '--method', + 'POST', + `repos/${repository}/issues/${issue.number}/labels`, + ...provider.labels.flatMap((label) => field('labels[]', label)), + ]); + } catch (error) { + warn(`Could not apply labels: ${error instanceof Error ? error.message : String(error)}`); + } + } + for (const assignee of provider.assignees) { + try { + github([ + 'api', + '--method', + 'POST', + `repos/${repository}/issues/${issue.number}/assignees`, + ...field('assignees[]', assignee), + ]); + } catch (error) { + warn(`Could not assign '${assignee}': ${error instanceof Error ? error.message : String(error)}`); + } + } + + return { status, issueNumber: issue.number, url: issue.html_url }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warn(message); + return { status: 'failed', error: message }; + } +} diff --git a/src/workflow-templates.ts b/src/workflow-templates.ts index e272ab7..678b290 100644 --- a/src/workflow-templates.ts +++ b/src/workflow-templates.ts @@ -1,6 +1,21 @@ -import type { PatchlaneConfig } from './config.js'; +import type { NotificationEvent, PatchlaneConfig } from './config.js'; + +function hasEvent(config: PatchlaneConfig, event: NotificationEvent) { + return config.notifications?.githubIssues.events.includes(event) ?? false; +} + +function closeOnRecovery(config: PatchlaneConfig) { + return config.notifications?.githubIssues.closeOnRecovery ?? false; +} + +function permissions(needsIssues: boolean) { + return `permissions: + contents: write${needsIssues ? '\n issues: write' : ''}`; +} export function renderSyncWorkflow(config: PatchlaneConfig, packageVersion: string) { + const notifyFailures = hasEvent(config, 'sync-failed'); + const notifyRecovery = notifyFailures && closeOnRecovery(config); return `name: Sync Upstream Integration on: @@ -24,8 +39,7 @@ on: required: false default: "" -permissions: - contents: write +${permissions(notifyFailures)} jobs: fork-sync: @@ -40,17 +54,52 @@ jobs: with: node-version: "22" - - name: Run patchlane sync + - name: Run patchlane sync${notifyFailures ? '\n id: sync' : ''} run: npx patchlane@${packageVersion} sync env: UPSTREAM_SOURCE: \${{ inputs.source }} PATCH_REFS: \${{ inputs.patch_refs }} NO_PUSH: \${{ inputs.no_push || false }} -`; +${ + notifyFailures + ? ` + - name: Notify maintainers of sync failure + if: failure() + continue-on-error: true + run: npx patchlane@${packageVersion} notify --event=sync-failed + env: + PATCHLANE_STATUS: \${{ steps.sync.outputs.status || 'failed' }} + UPSTREAM_SHA: \${{ steps.sync.outputs.upstream_sha }} + SYNC_SHA: \${{ steps.sync.outputs.sync_sha }} + FAILED_PATCH_REF: \${{ steps.sync.outputs.failed_bookmark }} + FAILED_COMMIT: \${{ steps.sync.outputs.failed_commit }} + CONFLICT_PATHS: \${{ steps.sync.outputs.conflicted_paths }} + APPLIED_PATCH_REFS: \${{ steps.sync.outputs.applied_refs }} +` + : '' +}${ + notifyRecovery + ? ` + - name: Close recovered sync notification + if: success() + continue-on-error: true + run: npx patchlane@${packageVersion} notify --event=sync-failed --recovered + env: + PATCHLANE_STATUS: \${{ steps.sync.outputs.status }} + UPSTREAM_SHA: \${{ steps.sync.outputs.upstream_sha }} + SYNC_SHA: \${{ steps.sync.outputs.sync_sha }} +` + : '' + }`; } export function renderPromotionWorkflow(config: PatchlaneConfig, packageVersion: string) { const ciWorkflow = config.ciWorkflow ?? 'Fork CI'; + const notifyCi = hasEvent(config, 'ci-failed'); + const notifyPromotion = hasEvent(config, 'promotion-failed'); + const notifyCiRecovery = notifyCi && closeOnRecovery(config); + const notifyPromotionRecovery = notifyPromotion && closeOnRecovery(config); + const needsIssues = notifyCi || notifyPromotion; return `name: Promote Tested Sync Branch on: @@ -58,8 +107,7 @@ on: workflows: ["${ciWorkflow.replaceAll('"', '\\"')}"] types: [completed] -permissions: - contents: write +${permissions(needsIssues)} jobs: promote: @@ -76,10 +124,72 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" - - - name: Run patchlane promote +${ + notifyCiRecovery + ? ` + - name: Close recovered CI notification + continue-on-error: true + run: npx patchlane@${packageVersion} notify --event=ci-failed --recovered + env: + PATCHLANE_STATUS: \${{ github.event.workflow_run.conclusion }} + PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} + SYNC_SHA: \${{ github.event.workflow_run.head_sha }} +` + : '' +} + - name: Run patchlane promote${notifyPromotion ? '\n id: promote' : ''} run: npx patchlane@${packageVersion} promote env: EXPECTED_SYNC_SHA: \${{ github.event.workflow_run.head_sha }} -`; +${ + notifyPromotion + ? ` + - name: Notify maintainers of promotion failure + if: failure() + continue-on-error: true + run: npx patchlane@${packageVersion} notify --event=promotion-failed + env: + PATCHLANE_STATUS: \${{ steps.promote.outputs.status || 'failed' }} + PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} + SYNC_SHA: \${{ github.event.workflow_run.head_sha }} +` + : '' +}${ + notifyPromotionRecovery + ? ` + - name: Close recovered promotion notification + if: success() + continue-on-error: true + run: npx patchlane@${packageVersion} notify --event=promotion-failed --recovered + env: + PATCHLANE_STATUS: \${{ steps.promote.outputs.status }} + PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} + SYNC_SHA: \${{ github.event.workflow_run.head_sha }} +` + : '' + }${ + notifyCi + ? ` + notify-ci-failure: + if: >- + github.event.workflow_run.conclusion != 'success' && + github.event.workflow_run.head_branch == '${config.syncBranch}' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Notify maintainers of CI failure + continue-on-error: true + run: npx patchlane@${packageVersion} notify --event=ci-failed + env: + PATCHLANE_STATUS: \${{ github.event.workflow_run.conclusion }} + PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} + SYNC_SHA: \${{ github.event.workflow_run.head_sha }} +` + : '' + }`; } diff --git a/tests/config.test.ts b/tests/config.test.ts index eae3024..2e123f5 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -23,6 +23,41 @@ test('parses Patchlane configuration', () => { }); }); +test('parses GitHub issue notification configuration', () => { + const parsed = parsePatchlaneConfig({ + version: 1, + upstream: 'example/upstream', + source: 'branch:main', + patchRefs: ['patch/sync'], + allowedWorkflows: ['ci.yml'], + notifications: { + githubIssues: { + assignees: ['adampoit'], + labels: ['patchlane', 'automation-failure'], + events: ['sync-failed', 'ci-failed', 'promotion-failed'], + closeOnRecovery: true, + }, + }, + }); + + expect(parsed.notifications?.githubIssues).toEqual({ + assignees: ['adampoit'], + labels: ['patchlane', 'automation-failure'], + events: ['sync-failed', 'ci-failed', 'promotion-failed'], + closeOnRecovery: true, + }); + expect(() => + parsePatchlaneConfig({ + version: 1, + upstream: 'example/upstream', + source: 'branch:main', + patchRefs: ['patch/sync'], + allowedWorkflows: ['ci.yml'], + notifications: { githubIssues: { events: ['unknown-event'] } }, + }), + ).toThrow(/invalid event 'unknown-event'/); +}); + test('parses and validates allowed workflow filenames', () => { expect( parsePatchlaneConfig({ diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 06a9657..a3741c2 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -178,6 +178,27 @@ test('reports a ready configuration and required bootstrap', () => { expect(report.checks).toContainEqual( expect.objectContaining({ severity: 'warning', message: expect.stringContaining('Initial bootstrap') }), ); + + writeFileSync( + path.join(forkWork, '.patchlane.yml'), + [ + 'version: 1', + 'upstream: example/upstream', + 'source: branch:main', + 'patchRefs: [patch/sync]', + 'ciWorkflow: Existing CI', + 'allowedWorkflows: [ci.yml]', + 'notifications:', + ' githubIssues:', + ' events: [sync-failed, ci-failed]', + '', + ].join('\n'), + ); + const notificationReport = runDoctor({ cwd: forkWork, json: true }); + expect(notificationReport.ok).toBe(false); + expect(notificationReport.checks).toContainEqual( + expect.objectContaining({ severity: 'error', message: expect.stringContaining('issues: write') }), + ); } finally { rmSync(tempRoot, { force: true, recursive: true }); } diff --git a/tests/notify.test.ts b/tests/notify.test.ts new file mode 100644 index 0000000..9b8da53 --- /dev/null +++ b/tests/notify.test.ts @@ -0,0 +1,174 @@ +import { expect, test, vi } from 'vitest'; +import type { PatchlaneConfig } from '../src/config.js'; +import { runNotification } from '../src/notify.js'; + +type Issue = { + number: number; + state: 'open' | 'closed'; + title: string; + body: string; + html_url: string; + labels: string[]; + assignees: string[]; + comments: string[]; +}; + +function config(assignees = ['maintainer']): PatchlaneConfig { + return { + upstreamOwner: 'upstream', + upstreamRepo: 'project', + source: 'release:latest', + baseBranch: 'main', + syncBranch: 'sync/integration', + patchRefs: ['patch/product'], + ciWorkflow: 'CI', + allowedWorkflows: ['ci.yml'], + notifications: { + githubIssues: { + assignees, + labels: ['patchlane'], + events: ['sync-failed', 'ci-failed', 'promotion-failed'], + closeOnRecovery: true, + }, + }, + }; +} + +function githubState(invalidAssignee?: string) { + const issues: Issue[] = []; + function fields(args: string[]) { + const result = new Map(); + for (let index = 0; index < args.length; index++) { + if (args[index] !== '-f') continue; + const value = args[++index] ?? ''; + const separator = value.indexOf('='); + const key = value.slice(0, separator); + const values = result.get(key) ?? []; + values.push(value.slice(separator + 1)); + result.set(key, values); + } + return result; + } + const github = (args: string[]) => { + const methodIndex = args.indexOf('--method'); + const method = methodIndex < 0 ? 'GET' : args[methodIndex + 1]; + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''; + const input = fields(args); + if (method === 'GET') return JSON.stringify([issues]); + if (method === 'POST' && endpoint.endsWith('/issues')) { + const issue: Issue = { + number: issues.length + 1, + state: 'open', + title: input.get('title')?.[0] ?? '', + body: input.get('body')?.[0] ?? '', + html_url: `https://example.test/issues/${issues.length + 1}`, + labels: [], + assignees: [], + comments: [], + }; + issues.push(issue); + return JSON.stringify(issue); + } + const number = Number(endpoint.match(/issues\/(\d+)/)?.[1]); + const issue = issues.find((candidate) => candidate.number === number); + if (!issue) throw new Error(`Issue ${number} not found`); + if (method === 'PATCH') { + const state = input.get('state')?.[0]; + if (state === 'open' || state === 'closed') issue.state = state; + issue.title = input.get('title')?.[0] ?? issue.title; + issue.body = input.get('body')?.[0] ?? issue.body; + return JSON.stringify(issue); + } + if (endpoint.endsWith('/comments')) { + issue.comments.push(input.get('body')?.[0] ?? ''); + return '{}'; + } + if (endpoint.endsWith('/labels')) { + issue.labels.push(...(input.get('labels[]') ?? [])); + return '{}'; + } + if (endpoint.endsWith('/assignees')) { + const assignee = input.get('assignees[]')?.[0] ?? ''; + if (assignee === invalidAssignee) throw new Error('Validation Failed'); + issue.assignees.push(assignee); + return '{}'; + } + throw new Error(`Unsupported request: ${args.join(' ')}`); + }; + return { github, issues }; +} + +test('creates, assigns, and updates one issue for repeated failures', () => { + const state = githubState(); + const dependencies = { github: state.github, now: () => new Date('2026-01-02T03:04:05Z') }; + const options = { + config: config(), + event: 'sync-failed' as const, + repository: 'fork/project', + status: 'conflicted', + runUrl: 'https://github.com/fork/project/actions/runs/123', + failedPatchRef: 'patch/product', + failedCommit: 'abc123', + conflictPaths: 'src/example.ts', + }; + + expect(runNotification(options, dependencies)).toMatchObject({ status: 'created', issueNumber: 1 }); + expect(state.issues).toHaveLength(1); + expect(state.issues[0]).toMatchObject({ labels: ['patchlane'], assignees: ['maintainer'] }); + expect(state.issues[0]?.body).toContain('patchlane-notification:fork/project:sync-failed'); + expect(state.issues[0]?.body).toContain('npx patchlane sync --dry-run'); + + expect(runNotification(options, dependencies)).toMatchObject({ status: 'updated', issueNumber: 1 }); + expect(state.issues).toHaveLength(1); + expect(state.issues[0]?.comments[0]).toContain('Failure observed again'); +}); + +test('closes on recovery and reopens the same issue if failure recurs', () => { + const state = githubState(); + const dependencies = { github: state.github, now: () => new Date('2026-01-02T03:04:05Z') }; + const options = { + config: config(), + event: 'ci-failed' as const, + repository: 'fork/project', + status: 'failure', + }; + + runNotification(options, dependencies); + expect(runNotification({ ...options, recovered: true }, dependencies)).toMatchObject({ status: 'closed' }); + expect(state.issues[0]?.state).toBe('closed'); + expect(runNotification(options, dependencies)).toMatchObject({ status: 'reopened' }); + expect(state.issues).toHaveLength(1); + expect(state.issues[0]?.state).toBe('open'); +}); + +test('keeps the issue when assignment fails and reports notification API failures', () => { + const warning = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const state = githubState('invalid-user'); + const result = runNotification( + { + config: config(['valid-user', 'invalid-user']), + event: 'promotion-failed', + repository: 'fork/project', + syncSha: 'abc123', + }, + { github: state.github }, + ); + expect(result).toMatchObject({ status: 'created' }); + expect(state.issues[0]?.assignees).toEqual(['valid-user']); + expect(warning).toHaveBeenCalledWith(expect.stringContaining("Could not assign 'invalid-user'")); + + expect( + runNotification( + { config: config(), event: 'sync-failed', repository: 'fork/project' }, + { + github: () => { + throw new Error('API unavailable'); + }, + }, + ), + ).toEqual({ status: 'failed', error: 'API unavailable' }); + } finally { + warning.mockRestore(); + } +}); diff --git a/tests/workflow-templates.test.ts b/tests/workflow-templates.test.ts new file mode 100644 index 0000000..dd20577 --- /dev/null +++ b/tests/workflow-templates.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'vitest'; +import type { PatchlaneConfig } from '../src/config.js'; +import { renderPromotionWorkflow, renderSyncWorkflow } from '../src/workflow-templates.js'; + +const config: PatchlaneConfig = { + upstreamOwner: 'example', + upstreamRepo: 'upstream', + source: 'release:latest', + baseBranch: 'main', + syncBranch: 'sync/integration', + patchRefs: ['patch/product'], + ciWorkflow: 'Fork CI', + allowedWorkflows: ['ci.yml'], + notifications: { + githubIssues: { + assignees: ['maintainer'], + labels: ['patchlane'], + events: ['sync-failed', 'ci-failed', 'promotion-failed'], + closeOnRecovery: true, + }, + }, +}; + +test('renders failure and recovery notifications with minimal permissions', () => { + const sync = renderSyncWorkflow(config, '1.2.3'); + expect(sync).toContain('issues: write'); + expect(sync).toContain('if: failure()'); + expect(sync).toContain('notify --event=sync-failed'); + expect(sync).toContain('notify --event=sync-failed --recovered'); + expect(sync).toContain('FAILED_PATCH_REF: ${{ steps.sync.outputs.failed_bookmark }}'); + + const promotion = renderPromotionWorkflow(config, '1.2.3'); + expect(promotion).toContain('notify-ci-failure:'); + expect(promotion).toContain("github.event.workflow_run.conclusion != 'success'"); + expect(promotion).toContain('notify --event=ci-failed'); + expect(promotion).toContain('notify --event=promotion-failed'); + expect(promotion).toContain('notify --event=promotion-failed --recovered'); +}); + +test('does not add notification permissions or steps when notifications are omitted', () => { + const withoutNotifications = { ...config, notifications: undefined }; + expect(renderSyncWorkflow(withoutNotifications, '1.2.3')).not.toContain('issues: write'); + expect(renderSyncWorkflow(withoutNotifications, '1.2.3')).not.toContain(' patchlane@1.2.3 notify'); + expect(renderPromotionWorkflow(withoutNotifications, '1.2.3')).not.toContain('issues: write'); + expect(renderPromotionWorkflow(withoutNotifications, '1.2.3')).not.toContain(' patchlane@1.2.3 notify'); +}); From 91b36b3f12e087aaeb39ea4b158524aa4b3c5c34 Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Sat, 18 Jul 2026 08:04:32 -0700 Subject: [PATCH 2/3] Document optional notification migration --- docs/migrations.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/migrations.md b/docs/migrations.md index 5e19b3c..ef57758 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -64,6 +64,36 @@ npx patchlane@VERSION bootstrap --wait This validates the allowlist before publishing `sync/integration`, waits for CI on the exact published SHA, revalidates that SHA, and then promotes it. Confirm afterward that the generated base contains the intended workflow set and that scheduled syncs use the new Patchlane version. +### 5. Optionally enable maintainer notifications + +Failure notifications are opt-in. Existing configurations that omit `notifications` keep their current behavior and require no notification-specific migration. + +To enable notifications, add the provider configuration to the patch branch that owns `.patchlane.yml`: + +```yaml +notifications: + githubIssues: + assignees: + - maintainer + labels: + - patchlane + - automation-failure + events: + - sync-failed + - ci-failed + - promotion-failed + closeOnRecovery: true +``` + +Update both generated workflow files from the vNext templates in the same patch: + +- `sync-upstream.yml` must grant `issues: write`, report `sync-failed` after a failed sync, and optionally report recovery after success. +- `promote-tested-sync.yml` must grant `issues: write`, report unsuccessful CI workflow runs, report failed promotions, and optionally report each recovery. + +Do not add only the configuration block: existing workflow files do not invoke `patchlane notify`, and `patchlane doctor` reports missing `issues: write` permissions. Keep notification steps on `continue-on-error` so a GitHub API or assignment failure cannot replace the original automation result. + +Run `doctor`, then roll the configuration and workflow changes forward through `bootstrap --wait` as described above. Confirm that configured labels exist and that each assignee can be assigned to issues in the fork. + ## 0.4 Existing Patchlane forks can migrate without rebuilding their patch strategy or interrupting scheduled syncs. Legacy workflow environment variables remain supported, so migration can be rolled out through the existing sync and promotion flow. From 6683234fa6f2a073d9f5a8a3c839accff7715fbe Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Sat, 18 Jul 2026 08:41:23 -0700 Subject: [PATCH 3/3] Escape sync branch in workflow expressions --- src/workflow-templates.ts | 9 +++++++-- tests/workflow-templates.test.ts | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/workflow-templates.ts b/src/workflow-templates.ts index 678b290..54271ab 100644 --- a/src/workflow-templates.ts +++ b/src/workflow-templates.ts @@ -13,6 +13,10 @@ function permissions(needsIssues: boolean) { contents: write${needsIssues ? '\n issues: write' : ''}`; } +function githubExpressionString(value: string) { + return value.replaceAll("'", "''"); +} + export function renderSyncWorkflow(config: PatchlaneConfig, packageVersion: string) { const notifyFailures = hasEvent(config, 'sync-failed'); const notifyRecovery = notifyFailures && closeOnRecovery(config); @@ -100,6 +104,7 @@ export function renderPromotionWorkflow(config: PatchlaneConfig, packageVersion: const notifyCiRecovery = notifyCi && closeOnRecovery(config); const notifyPromotionRecovery = notifyPromotion && closeOnRecovery(config); const needsIssues = notifyCi || notifyPromotion; + const syncBranch = githubExpressionString(config.syncBranch); return `name: Promote Tested Sync Branch on: @@ -113,7 +118,7 @@ jobs: promote: if: >- github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == '${config.syncBranch}' + github.event.workflow_run.head_branch == '${syncBranch}' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -173,7 +178,7 @@ ${ notify-ci-failure: if: >- github.event.workflow_run.conclusion != 'success' && - github.event.workflow_run.head_branch == '${config.syncBranch}' + github.event.workflow_run.head_branch == '${syncBranch}' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/tests/workflow-templates.test.ts b/tests/workflow-templates.test.ts index dd20577..9355800 100644 --- a/tests/workflow-templates.test.ts +++ b/tests/workflow-templates.test.ts @@ -37,6 +37,13 @@ test('renders failure and recovery notifications with minimal permissions', () = expect(promotion).toContain('notify --event=promotion-failed --recovered'); }); +test('escapes the sync branch in GitHub Actions expressions', () => { + const promotion = renderPromotionWorkflow({ ...config, syncBranch: "sync/' || true || 'integration" }, '1.2.3'); + const escapedCondition = "github.event.workflow_run.head_branch == 'sync/'' || true || ''integration'"; + expect(promotion.split(escapedCondition)).toHaveLength(3); + expect(promotion).not.toContain("head_branch == 'sync/' || true"); +}); + test('does not add notification permissions or steps when notifications are omitted', () => { const withoutNotifications = { ...config, notifications: undefined }; expect(renderSyncWorkflow(withoutNotifications, '1.2.3')).not.toContain('issues: write');