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
4 changes: 2 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,8 @@ export type {
AutoResolveContext
} from './queue/taskQueue.js';

export { areAllChecksPassing, buildRedisRuntimeConfig, closeUltrafixStateRedis, getCurrentPRHead, getCheckRunsStatus, getCheckRunsStatusForRepo, summarizeCheckSignals, getActiveTasksForPR, hasActiveTasksForPR, type CheckRunsStatus, type ActivePRWork, type ActivePRTask, type ActivePRQueuedJob } from './webhook/checkRunHelpers.js';
export { handleCheckRunEvent, handleCheckSuiteEvent, handleStatusEvent, reevaluatePRAutoMerge, setUltrafixCheckRunHook, triggerUltrafixCheckRunHook, type StatusEventPayload } from './webhook/checkRunHandler.js';
export { areAllChecksPassing, getCiMergeReadiness, NO_CI_GRACE_MS, type CiMergeReadiness, buildRedisRuntimeConfig, closeUltrafixStateRedis, getCurrentPRHead, getCheckRunsStatus, getCheckRunsStatusForRepo, summarizeCheckSignals, getActiveTasksForPR, hasActiveTasksForPR, type CheckRunsStatus, type ActivePRWork, type ActivePRTask, type ActivePRQueuedJob } from './webhook/checkRunHelpers.js';
export { handleCheckRunEvent, handleCheckSuiteEvent, handleStatusEvent, reevaluatePRAutoMerge, setUltrafixCheckRunHook, triggerUltrafixCheckRunHook, type AutoMergeReevaluationOptions, type StatusEventPayload } from './webhook/checkRunHandler.js';
export { recoverCiFailureFollowups } from './webhook/ciFailureRecovery.js';
export * from './webhook/ciFailureFollowup.js';
export * from './webhook/closedPullRequestCi.js';
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/services/autoMergeGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,23 @@ async function resolveMergeMethod(octokit: AutoMergeGateOctokit, owner: string,
return 'SQUASH';
}

/**
* The merge method for a direct merge: the policy's method, otherwise the same
* repository-supported default {@link gateAutoMergeArming} arms with.
*/
export async function resolveDirectMergeMethod(input: {
owner: string; repo: string; method?: AutoMergePolicyMethod;
}, deps: AutoMergeGateDependencies = {}): Promise<AutoMergePolicyMethod> {
if (input.method) return input.method;
let octokit: AutoMergeGateOctokit;
try {
octokit = await gateOctokit(deps);
} catch {
return 'squash';
}
return (await resolveMergeMethod(octokit, input.owner, input.repo)).toLowerCase() as AutoMergePolicyMethod;
}

/** Find the task whose timeline records this decision. */
async function resolveDecisionTaskId(database: Knex, repository: string, numbers: number[]): Promise<string | null> {
const row = await database('tasks').where({ repository }).whereIn('issue_number', numbers)
Expand Down
39 changes: 27 additions & 12 deletions packages/core/src/webhook/checkRunHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
deleteBranch,
getFirstCommitMessage,
getCurrentPRHead,
areAllChecksPassing,
getCiMergeReadiness,
getPRAutoMergeInfo,
linkedIssueHasAutoMergeLabel,
hasActiveTasksForPR,
Expand All @@ -25,7 +25,7 @@ import {
import type { CheckRunEvent, CheckSuiteEvent } from '@octokit/webhooks-types';
import { getNonBlockingChecksForRepository } from '../daemon/configLoader.js';
import { isNonBlockingCheck } from './nonBlockingChecks.js';
import { evaluatePullRequestAutoMerge } from '../services/autoMergeGate.js';
import { evaluatePullRequestAutoMerge, resolveDirectMergeMethod } from '../services/autoMergeGate.js';
import type { AutoMergePolicyMethod } from '../workflow/autoMergePolicy.js';

export interface StatusEventPayload {
Expand Down Expand Up @@ -177,10 +177,15 @@ async function performMergeAndPostActions(ctx: PRMergeContext, mergeMethod: Auto
/**
* Processes a single PR for potential auto-merge.
*/
async function processPRAutoMerge(ctx: PRContext, headSha: string): Promise<void> {
async function processPRAutoMerge(ctx: PRContext, headSha: string, options: { ignoreWorkIds?: readonly string[] } = {}): Promise<void> {
const { owner, repoName, prNumber, log } = ctx;
const prInfo = await getPRAutoMergeInfo(owner, repoName, prNumber);

if (prInfo.state !== undefined && prInfo.state !== 'open') {
log.debug({ owner, repoName, prNumber, state: prInfo.state }, 'PR is not open, skipping auto-merge');
return;
}

if (prInfo.isDraft) {
log.debug({ owner, repoName, prNumber }, 'PR is a draft, skipping auto-merge');
return;
Expand All @@ -197,9 +202,10 @@ async function processPRAutoMerge(ctx: PRContext, headSha: string): Promise<void
return;
}

const allChecksPassing = await areAllChecksPassing(owner, repoName, headSha);
if (!allChecksPassing) {
log.debug({ owner, repoName, prNumber }, 'Not all checks are passing yet, skipping merge');
// Passing CI, or no CI at all for this head, lets the merge proceed.
const ci = await getCiMergeReadiness(owner, repoName, headSha, { prNumber });
if (!ci.ready) {
log.debug({ owner, repoName, prNumber, reason: ci.reason }, 'CI does not allow merging yet, skipping merge');
return;
}

Expand All @@ -220,7 +226,7 @@ async function processPRAutoMerge(ctx: PRContext, headSha: string): Promise<void

// Check for active tasks (e.g., followup processing) before merging
const repository = `${owner}/${repoName}`;
const { hasActive, activeTasks, queuedJobs } = await hasActiveTasksForPR(repository, prNumber);
const { hasActive, activeTasks, queuedJobs } = await hasActiveTasksForPR(repository, prNumber, options.ignoreWorkIds);
if (hasActive) {
log.info({
owner,
Expand All @@ -247,26 +253,35 @@ async function processPRAutoMerge(ctx: PRContext, headSha: string): Promise<void
return;
}

log.info({ owner, repoName, prNumber, headSha }, 'All checks passing for auto-merge PR, attempting to merge');
// A policy without a method merges like the arming gate would arm: squash,
// unless the repository disallows it.
const mergeMethod = await resolveDirectMergeMethod({ owner, repo: repoName, method: decision.method });
log.info({ owner, repoName, prNumber, headSha, ci: ci.reason, mergeMethod }, 'CI allows merging the auto-merge PR, attempting to merge');
// GitHub refuses the merge if the head moved after the evaluation.
await performMergeAndPostActions(mergeCtx, decision.method, pullRequest.headSha);
await performMergeAndPostActions(mergeCtx, mergeMethod, pullRequest.headSha);
}

export interface AutoMergeReevaluationOptions {
correlationId: string;
/** Task/job IDs of the caller's own still-running work, which must not block its merge. */
ignoreWorkIds?: readonly string[];
}

export async function reevaluatePRAutoMerge(
owner: string,
repoName: string,
prNumber: number,
correlationId: string,
options: AutoMergeReevaluationOptions,
): Promise<void> {
const log = logger.withCorrelation(correlationId);
const log = logger.withCorrelation(options.correlationId);
const headSha = await getCurrentPRHead(owner, repoName, prNumber);
if (!headSha) {
log.debug({ owner, repoName, prNumber }, 'Auto-merge re-evaluation skipped: PR head SHA unavailable');
return;
}

const ctx: PRContext = { owner, repoName, prNumber, log };
await processPRAutoMerge(ctx, headSha);
await processPRAutoMerge(ctx, headSha, options);
}

/**
Expand Down
101 changes: 97 additions & 4 deletions packages/core/src/webhook/checkRunHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,13 @@ async function getCommitStatusInfo(octokit: any, owner: string, repoName: string
/**
* Fetch both CI signal types for a commit: check runs and legacy commit
* statuses. Either may be unavailable; only when both fail is this an error.
* `complete` is false when one of the two requests failed.
*/
async function fetchCheckSignals(
owner: string,
repoName: string,
ref: string,
): Promise<{ checkRuns: CheckRunRecord[]; commitStatus: CommitStatusInfo }> {
): Promise<{ checkRuns: CheckRunRecord[]; commitStatus: CommitStatusInfo; complete: boolean }> {
const octokit = await getAuthenticatedOctokit();
const [checkRunsResult, commitStatusResult] = await Promise.allSettled([
octokit.request('GET /repos/{owner}/{repo}/commits/{ref}/check-runs', {
Expand Down Expand Up @@ -286,7 +287,11 @@ async function fetchCheckSignals(
);
}

return { checkRuns, commitStatus };
return {
checkRuns,
commitStatus,
complete: checkRunsResult.status === 'fulfilled' && commitStatusResult.status === 'fulfilled',
};
}

export interface CheckRunsStatus {
Expand Down Expand Up @@ -457,6 +462,87 @@ export async function areAllChecksPassing(
}
}

/** How long a PR head may go without any CI signal before ProPR treats the repository as having no CI for it. */
export const NO_CI_GRACE_MS = 2 * 60_000;

const HEAD_OBSERVATION_KEY_PREFIX = 'auto-merge:head-observed';
const HEAD_OBSERVATION_TTL_SECONDS = 30 * 24 * 60 * 60;

export type HeadObservationStore = Pick<Redis, 'get' | 'set' | 'del'>;

function headObservationKey(owner: string, repoName: string, prNumber: number): string {
return `${HEAD_OBSERVATION_KEY_PREFIX}:${owner}:${repoName}:${prNumber}`;
}

/**
* When ProPR first saw `headSha` as the head of the PR without CI signal. The
* first call for a head records `now`; a different head starts a new
* observation. Concurrent writers can only move the observation later.
*/
export async function observePRHead(
store: HeadObservationStore,
head: { owner: string; repoName: string; prNumber: number; headSha: string },
now: number,
): Promise<number> {
const { owner, repoName, prNumber, headSha } = head;
const key = headObservationKey(owner, repoName, prNumber);
const raw = await store.get(key);
if (raw) {
const observation = JSON.parse(raw) as { sha?: unknown; observedAt?: unknown };
if (observation.sha === headSha && typeof observation.observedAt === 'number' && Number.isFinite(observation.observedAt)) {
return observation.observedAt;
}
}
await store.set(key, JSON.stringify({ sha: headSha, observedAt: now }), 'EX', HEAD_OBSERVATION_TTL_SECONDS);
return now;
}

export interface CiMergeReadiness {
ready: boolean;
reason: 'checks_passing' | 'checks_not_passing' | 'no_ci' | 'waiting_for_ci' | 'unavailable';
}

/**
* Whether CI allows merging `ref`, the current head of the PR: every blocking
* check passed, or no CI ran for it at all. No CI counts only when both CI
* endpoints answered, and only once ProPR has seen the head without signal for
* {@link NO_CI_GRACE_MS}, so checks that have not registered yet after a push
* are not mistaken for missing CI. The commit date says nothing about when it
* was pushed, so the grace runs from ProPR's own first observation.
*/
export async function getCiMergeReadiness(
owner: string,
repoName: string,
ref: string,
options: {
/** The PR whose current head is `ref`. */
prNumber: number;
now?: number;
loadNonBlockingChecks?: (owner: string, repo: string) => Promise<string[]>;
observations?: HeadObservationStore;
},
): Promise<CiMergeReadiness> {
const { prNumber, now = Date.now(), loadNonBlockingChecks = getNonBlockingChecksForRepository } = options;
try {
const { checkRuns, commitStatus, complete } = await fetchCheckSignals(owner, repoName, ref);
// An unreadable endpoint may hide pending or failing CI, so neither the
// passing signals of the other endpoint nor their absence prove anything.
if (!complete) return { ready: false, reason: 'unavailable' };
if (checkRuns.length > 0 || commitStatus.totalCount > 0) {
// The PR's head now has CI, so any earlier no-CI observation is over:
// a later return to that earlier head must wait out a fresh grace period.
await (options.observations ?? getUltrafixStateRedis()).del(headObservationKey(owner, repoName, prNumber));
const summary = summarizeCheckSignals(checkRuns, commitStatus, await loadNonBlockingChecks(owner, repoName));
return summary.allPassing ? { ready: true, reason: 'checks_passing' } : { ready: false, reason: 'checks_not_passing' };
}
const observedAt = await observePRHead(options.observations ?? getUltrafixStateRedis(), { owner, repoName, prNumber, headSha: ref }, now);
return now - observedAt >= NO_CI_GRACE_MS ? { ready: true, reason: 'no_ci' } : { ready: false, reason: 'waiting_for_ci' };
} catch (error) {
logger.warn({ owner, repoName, ref, error: (error as Error).message }, 'Failed to check CI merge readiness');
return { ready: false, reason: 'unavailable' };
}
}

export interface PRAutoMergeInfo {
hasLabel: boolean;
hasUltrafixLabel?: boolean;
Expand All @@ -468,6 +554,8 @@ export interface PRAutoMergeInfo {
headBranch: string;
mergeable: boolean | null;
mergeableState: string;
/** GitHub PR state (`open` or `closed`); absent when the PR could not be read. */
state?: string;
}

const ULTRAFIX_STATE_KEY_PREFIX = 'ultrafix:state';
Expand Down Expand Up @@ -651,6 +739,7 @@ export async function getPRAutoMergeInfo(owner: string, repoName: string, prNumb
: 'unknown';

return {
state: prResponse.data.state,
hasLabel,
hasUltrafixLabel,
hasActiveUltrafixLoop: ultrafixState?.active ?? false,
Expand Down Expand Up @@ -843,13 +932,17 @@ export async function getActiveTasksForPR(
*/
export async function hasActiveTasksForPR(
repository: string,
prNumber: number
prNumber: number,
ignoreWorkIds: readonly string[] = [],
): Promise<{
hasActive: boolean;
activeTasks: Array<{ taskId: string; state: string }>;
queuedJobs: Array<{ jobId: string; state: string }>;
}> {
const { activeTasks, queuedJobs } = await getActiveTasksForPR(repository, prNumber);
const ignored = new Set(ignoreWorkIds);
const work = await getActiveTasksForPR(repository, prNumber);
const activeTasks = work.activeTasks.filter(task => !ignored.has(task.taskId));
const queuedJobs = work.queuedJobs.filter(job => !ignored.has(job.jobId));
return {
hasActive: activeTasks.length > 0 || queuedJobs.length > 0,
activeTasks,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/webhook/webhookHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ async function handleUltrafixLabelRemoval(payload: unknown, eventType: WebhookEv
const prNumber = 'pull_request' in event ? event.pull_request.number : event.issue.number;
const log = logger.withCorrelation(correlationId);
if (await clearStateForCurrentUltrafixLabelRemoval(owner, repo, prNumber, log)) {
await reevaluatePRAutoMerge(owner, repo, prNumber, correlationId);
await reevaluatePRAutoMerge(owner, repo, prNumber, { correlationId });
log.info({ owner, repo, prNumber }, `Cleared ultrafix loop state after ${eventType === 'pull_request' ? 'PR' : 'issue'} ultrafix label removal`);
}
}
Expand Down
12 changes: 11 additions & 1 deletion packages/core/test/autoMergeGate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ await mock.module('../src/auth/githubAuth.js', { namedExports: { getAuthenticate
await mock.module('../src/config/planIssueManager.js', { namedExports: { findPlanIssueByRepoAndPR: async () => planIssue } });

const {
gateAutoMergeArming, reevaluateArmedAutoMergeOnNewHead, handleAutoMergePolicyPullRequestEvent, loadBaseAutoMergePolicy, recordAutoMergeDecisionEvent,
gateAutoMergeArming, resolveDirectMergeMethod, reevaluateArmedAutoMergeOnNewHead, handleAutoMergePolicyPullRequestEvent, loadBaseAutoMergePolicy, recordAutoMergeDecisionEvent,
} = await import('../src/services/autoMergeGate.js');
type GateOctokit = import('../src/services/autoMergeGate.js').AutoMergeGateOctokit;

Expand Down Expand Up @@ -143,6 +143,16 @@ test('without a configured method the repository default is used', async () => {
assert.equal(result.mergeMethod, 'MERGE');
});

test('a direct merge without a configured method uses the same repository default as arming', async () => {
const mergeOnly = fakeGitHub({ workflowByRef: {}, files: [], headSha: 'h', baseRef: 'main', headRef: 'f', repo: { allow_squash_merge: false, allow_merge_commit: true } });
assert.equal(await resolveDirectMergeMethod({ owner: 'acme', repo: 'repo' }, { octokit: mergeOnly.octokit }), 'merge');
const rebaseOnly = fakeGitHub({ workflowByRef: {}, files: [], headSha: 'h', baseRef: 'main', headRef: 'f', repo: { allow_squash_merge: false, allow_merge_commit: false } });
assert.equal(await resolveDirectMergeMethod({ owner: 'acme', repo: 'repo' }, { octokit: rebaseOnly.octokit }), 'rebase');
const defaults = fakeGitHub({ workflowByRef: {}, files: [], headSha: 'h', baseRef: 'main', headRef: 'f' });
assert.equal(await resolveDirectMergeMethod({ owner: 'acme', repo: 'repo' }, { octokit: defaults.octokit }), 'squash');
assert.equal(await resolveDirectMergeMethod({ owner: 'acme', repo: 'repo', method: 'rebase' }, { octokit: mergeOnly.octokit }), 'rebase', 'a configured method wins');
});

test('the skipped event names matching paths and finds the task by issue number', async () => {
const github = fakeGitHub({ workflowByRef: {}, files: [{ filename: 'docs/new.md', previous_filename: '.propr/old.md' }], headSha: 'h', baseRef: 'main', headRef: 'f' });
const result = await gateAutoMergeArming({ owner: 'acme', repo: 'repo', prNumber: 70, opportunity: 'ultrafix_goal', issueNumber: 7, log }, { octokit: github.octokit, database });
Expand Down
3 changes: 3 additions & 0 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { sweepPushRescues } from './daemon/rescueRefSweep.js';
import { scheduleAgentRunSweeps } from './agentRunScheduler.js';
import { scheduleUltrafixResumeSweep } from './daemon/ultrafixResumeSweep.js';
import { scheduleEpicMergeProgressRetrySweep } from './daemon/epicMergeProgressRetrySweep.js';
import { scheduleAutoMergeSweep } from './daemon/autoMergeSweep.js';
import {
clearUltrafixStateIfCurrent,
hasUltrafixAutomaticWork,
Expand Down Expand Up @@ -243,6 +244,7 @@ async function startDaemon(options: DaemonOptions = {}): Promise<void> {
// without the API server and with polling off does not wait for a webhook.
const ultrafixResumeSweepInterval = scheduleUltrafixResumeSweep(redisClient);
const epicMergeProgressRetryInterval = scheduleEpicMergeProgressRetrySweep();
const autoMergeSweepInterval = scheduleAutoMergeSweep();

const repos = getRepos();
await reconcileTaskIntentsSafely(redisClient, repos);
Expand Down Expand Up @@ -462,6 +464,7 @@ async function startDaemon(options: DaemonOptions = {}): Promise<void> {
clearInterval(pushRescueSweepInterval);
clearInterval(ultrafixResumeSweepInterval);
clearInterval(epicMergeProgressRetryInterval);
clearInterval(autoMergeSweepInterval);
clearInterval(mergeConflictSweepInterval);
stopManagedDiagnostics();
await stopAgentRunSweeps();
Expand Down
Loading
Loading