import { createHash } from 'node:crypto'; import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { canonicalJson } from '../control-plane/snapshots.ts'; import { atomicJson, immutableJson, readJson } from './durable-json.ts'; export const CONFLICT_APPROVAL_SCHEMA_VERSION = 'patchpaw.conflict-approval.v1'; export type ConflictApprovalStatus = 'accepted' | 'rejected' | 'stale'; export type ConflictApprovalPhase = | 'accepted' | 'claimed' | 'repairing' | 'verification_passed' | 'committing' | 'committed' | 'pushing' | 'remote_confirmed' | 'publication_pending' | 'completed' | 'interrupted'; export type ConflictApprovalRejectionCode = | 'approval_after_close' | 'approval_already_processed' | 'no_pending_proposal' | 'proposal_not_published' | 'proposal_not_current' | 'proposal_publication_missing' | 'proposal_stale' | 'unauthorized_actor' | 'approval_event_invalid' | 'snapshot_missing' | 'snapshot_corrupt' | 'snapshot_hash_mismatch' | 'command_missing' | 'command_read_only' | 'command_disabled' | 'workspace_missing' | 'workspace_changed' | 'workspace_owned' | 'git_facts_changed' | 'pr_closed' | 'repair_interrupted'; const QUALIFIED_APPROVAL_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); export interface ConflictApprovalRecord { schema_version: typeof CONFLICT_APPROVAL_SCHEMA_VERSION; approval_id: string; idempotency_key: string; source_event_id: string; source_comment_id: number; source_comment_url: string; source_comment_created_at?: string; author: string; /** Numeric identity is retained for GitLab live permission rechecks. */ author_id?: string; /** GitHub keeps author_association semantics; GitLab stores its project role here. */ platform?: 'github' | 'gitlab'; author_association: string; received_at: string; verified_at: string; repair_execution_id: string; proposal_id: string; proposal_revision: number; proposal_hash: string; proposal_publication_delivery_id: string; proposal_publication_remote_id?: number; pr_head_sha: string; pr_head_ref: string; pr_head_repo: string; current_base_tip_sha: string; base_ref: string; command_snapshot_id: string; command_snapshot_sha256: string; status: ConflictApprovalStatus; rejection_code?: ConflictApprovalRejectionCode; phase: ConflictApprovalPhase; claim_run_id?: string; repair_run_id?: string; claimed_at?: string; repair_started_at?: string; verification_at?: string; commit_sha?: string; pushed_at?: string; remote_head_sha?: string; final_publication_delivery_id?: string; final_publication_remote_id?: number; final_published_at?: string; updated_at: string; } export function conflictApprovalDirectory(statePath: string) { return `${statePath}.conflict-approvals`; } export function conflictApprovalRecordPath(statePath: string, approvalId: string) { if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(approvalId)) throw new Error('Conflict approval id is not a safe runtime identifier'); return join(conflictApprovalDirectory(statePath), `approval-${approvalId}.json`); } export function conflictApprovalCurrentPath(statePath: string) { return join(conflictApprovalDirectory(statePath), 'approval-current.json'); } export function conflictApprovalIdempotencyKey(input: { sourceCommentId: number; proposalHash: string; repairExecutionId: string }) { return `approval:${input.sourceCommentId}:${input.proposalHash}:${input.repairExecutionId}`; } export function approvalDeliverySemanticKey(approval: Pick) { return `conflict-repair:${approval.proposal_id}:v${approval.proposal_revision}:${approval.proposal_hash}`; } function digest(value: unknown) { return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); } function isUtcTimestamp(value: unknown) { return typeof value === 'string' && /T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) && Number.isFinite(Date.parse(value)); } function assertRecord(value: ConflictApprovalRecord) { if (value.schema_version !== CONFLICT_APPROVAL_SCHEMA_VERSION) throw new Error('Conflict approval schema is unsupported'); if (!value.approval_id || !value.idempotency_key || !value.source_event_id || !Number.isSafeInteger(value.source_comment_id) || value.source_comment_id < 1) { throw new Error('Conflict approval identity is incomplete'); } if (value.idempotency_key !== conflictApprovalIdempotencyKey({ sourceCommentId: value.source_comment_id, proposalHash: value.proposal_hash, repairExecutionId: value.repair_execution_id })) { throw new Error('Conflict approval idempotency binding is invalid'); } try { if (new URL(value.source_comment_url).protocol !== 'https:') throw new Error(); } catch { throw new Error('Conflict approval source URL is invalid'); } if (!value.author || !value.author_association) throw new Error('Conflict approval actor identity is incomplete'); if (value.status === 'accepted') { const qualified = value.platform === 'gitlab' ? ['DEVELOPER', 'MAINTAINER', 'OWNER'].includes(value.author_association.toUpperCase()) : QUALIFIED_APPROVAL_ASSOCIATIONS.has(value.author_association.toUpperCase()); if (!qualified) throw new Error('Conflict approval actor is not qualified'); } if (!value.proposal_id || !Number.isSafeInteger(value.proposal_revision) || value.proposal_revision < 1 || !value.proposal_hash) { throw new Error('Conflict approval proposal identity is incomplete'); } if (!value.repair_execution_id || !value.command_snapshot_id || !value.command_snapshot_sha256) { throw new Error('Conflict approval snapshot identity is incomplete'); } if (!value.proposal_publication_delivery_id || !value.pr_head_sha || !value.pr_head_ref || !value.pr_head_repo || !value.current_base_tip_sha || !value.base_ref) { throw new Error('Conflict approval publication or Git binding is incomplete'); } if (!['accepted', 'rejected', 'stale'].includes(value.status)) throw new Error('Conflict approval status is invalid'); if (!['accepted', 'claimed', 'repairing', 'verification_passed', 'committing', 'committed', 'pushing', 'remote_confirmed', 'publication_pending', 'completed', 'interrupted'].includes(value.phase)) { throw new Error('Conflict approval phase is invalid'); } if (!isUtcTimestamp(value.received_at) || !isUtcTimestamp(value.verified_at) || !isUtcTimestamp(value.updated_at)) throw new Error('Conflict approval timestamps are incomplete'); if (value.source_comment_created_at !== undefined && !isUtcTimestamp(value.source_comment_created_at)) throw new Error('Conflict approval source comment time is invalid'); if (value.claimed_at !== undefined && !isUtcTimestamp(value.claimed_at)) throw new Error('Conflict approval claim time is invalid'); if (value.repair_started_at !== undefined && !isUtcTimestamp(value.repair_started_at)) throw new Error('Conflict approval repair start time is invalid'); if (value.verification_at !== undefined && !isUtcTimestamp(value.verification_at)) throw new Error('Conflict approval verification time is invalid'); if (value.pushed_at !== undefined && !isUtcTimestamp(value.pushed_at)) throw new Error('Conflict approval push time is invalid'); if (value.final_published_at !== undefined && !isUtcTimestamp(value.final_published_at)) throw new Error('Conflict approval final publication time is invalid'); if (value.proposal_publication_remote_id !== undefined && (!Number.isSafeInteger(value.proposal_publication_remote_id) || value.proposal_publication_remote_id < 1)) throw new Error('Conflict approval proposal receipt is invalid'); if (value.final_publication_remote_id !== undefined && (!Number.isSafeInteger(value.final_publication_remote_id) || value.final_publication_remote_id < 1)) throw new Error('Conflict approval final receipt is invalid'); return value; } async function listRecordPaths(statePath: string) { try { return (await readdir(conflictApprovalDirectory(statePath))) .filter(name => name !== 'approval-current.json' && /^approval-[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.json$/.test(name)) .map(name => join(conflictApprovalDirectory(statePath), name)); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } } export async function listConflictApprovals(statePath: string) { const records: ConflictApprovalRecord[] = []; for (const path of await listRecordPaths(statePath)) { const record = await readJson(path); if (record) records.push(assertRecord(record)); } return records.sort((left, right) => left.source_comment_id - right.source_comment_id || left.received_at.localeCompare(right.received_at)); } export async function readConflictApproval(statePath: string, approvalId: string) { const record = await readJson(conflictApprovalRecordPath(statePath, approvalId)); return record ? assertRecord(record) : null; } export async function readConflictApprovalByIdempotency(statePath: string, idempotencyKey: string) { return (await listConflictApprovals(statePath)).find(record => record.idempotency_key === idempotencyKey) ?? null; } export function isUnfinishedConflictApproval(record: ConflictApprovalRecord) { return record.status === 'accepted' && !['completed', 'interrupted'].includes(record.phase); } export async function readUnfinishedConflictApproval(statePath: string) { const records = await listConflictApprovals(statePath); return records.reverse().find(isUnfinishedConflictApproval) ?? null; } export async function saveConflictApproval(statePath: string, input: ConflictApprovalRecord) { const record = assertRecord(input); const path = conflictApprovalRecordPath(statePath, record.approval_id); const existing = await readJson(path); if (existing) { const current = assertRecord(existing); if (current.idempotency_key !== record.idempotency_key) throw new Error('Conflict approval id is already bound to another event'); await atomicJson(path, record); } else { await immutableJson(path, record); } await atomicJson(conflictApprovalCurrentPath(statePath), { schema_version: `${CONFLICT_APPROVAL_SCHEMA_VERSION}:current`, approval_id: record.approval_id, idempotency_key: record.idempotency_key, status: record.status, phase: record.phase, updated_at: record.updated_at, }); return readConflictApproval(statePath, record.approval_id); } export async function updateConflictApproval(statePath: string, approvalId: string, patch: Partial) { const current = await readConflictApproval(statePath, approvalId); if (!current) throw new Error(`Conflict approval is missing: ${approvalId}`); return saveConflictApproval(statePath, { ...current, ...patch, updated_at: new Date().toISOString() }); } export function newConflictApproval(input: Omit) { const idempotency_key = conflictApprovalIdempotencyKey({ sourceCommentId: input.source_comment_id, proposalHash: input.proposal_hash, repairExecutionId: input.repair_execution_id }); const approval_id = `approval-${digest(idempotency_key).slice(0, 32)}`; return { schema_version: CONFLICT_APPROVAL_SCHEMA_VERSION, ...input, approval_id, idempotency_key, updated_at: new Date().toISOString() } satisfies ConflictApprovalRecord; }