Files

297 lines
8.4 KiB
TypeScript

import type {
CIAutomationQueueRemovalCause,
TaskCIPRAutomationState,
TaskPR,
TaskPRAutomationOptions,
} from "@/lib/types/github";
const DEFAULT_AUTO_FIX_MAX_ROUNDS = 10;
export const DISABLED_PR_AUTOMATION_OPTIONS: Omit<
TaskPRAutomationOptions,
"task_id" | "repository_id" | "pr_number" | "created_at" | "updated_at"
> = {
auto_fix_enabled: false,
auto_merge_enabled: false,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
};
export type CIAutomationQueueStatus =
| "none"
| "queued"
| "removed_actionable"
| "removed_not_actionable"
| "repair_requested"
| "waiting_for_commit"
| "waiting_for_checks";
export type CIAutomationQueueRecoveryState = {
context: "normal" | "queued" | "recovery";
status: CIAutomationQueueStatus;
removalCause: CIAutomationQueueRemovalCause;
repairAccepted: boolean;
waitingForCommit: boolean;
};
const ACTIONABLE_QUEUE_REMOVAL_CAUSES: CIAutomationQueueRemovalCause[] = [
"checks_failed",
"checks_timed_out",
"conflict",
];
function normalizedQueueRemovalReason(value: string | undefined): string {
return (value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
const QUEUE_REMOVAL_CAUSE_ALIASES: Record<string, CIAutomationQueueRemovalCause> = {
checks_failed: "checks_failed",
checks_timed_out: "checks_timed_out",
conflict: "conflict",
manual: "manual",
branch_protection: "branch_protection",
check_failed: "checks_failed",
checks_failure: "checks_failed",
ci_checks_failed: "checks_failed",
ci_check_failed: "checks_failed",
checks_failed_on_merge_group: "checks_failed",
checks_failed_on_merge_queue: "checks_failed",
ci_checks_failed_on_merge_group: "checks_failed",
ci_checks_failed_on_merge_queue: "checks_failed",
check_timed_out: "checks_timed_out",
checks_timeout: "checks_timed_out",
timeout: "checks_timed_out",
timed_out: "checks_timed_out",
checks_timed_out_on_merge_group: "checks_timed_out",
merge_conflict: "conflict",
merge_conflicts: "conflict",
unmergeable: "conflict",
removed_manually: "manual",
user_removed: "manual",
branch_protection_failed: "branch_protection",
required_branch_protection: "branch_protection",
};
function normalizeQueueRemovalCause(
value: string | undefined,
pr?: TaskPR,
): CIAutomationQueueRemovalCause {
const normalized = normalizedQueueRemovalReason(value);
const knownCause = QUEUE_REMOVAL_CAUSE_ALIASES[normalized];
if (knownCause) {
return knownCause;
}
if (pr) {
const mergeableState = pr.mergeable_state?.trim().toLowerCase() ?? "";
const queueState = pr.merge_queue_state?.trim().toLowerCase() ?? "";
if (
mergeableState === "dirty" ||
mergeableState.includes("conflict") ||
queueState === "unmergeable" ||
queueState.includes("unmergeable")
) {
return "conflict";
}
}
return "unknown";
}
function hasActiveQueueEntry(pr: TaskPR): boolean {
return pr.state === "open" && Boolean(pr.merge_queue_state?.trim());
}
function isReadyForMerge(pr: TaskPR): boolean {
if (pr.state !== "open" || pr.checks_state !== "success" || pr.mergeable_state !== "clean") {
return false;
}
if (
pr.review_state === "changes_requested" ||
pr.pending_review_count > 0 ||
pr.unresolved_review_threads > 0
) {
return false;
}
return pr.required_reviews == null || pr.review_count >= pr.required_reviews;
}
function queueStateForRemoval(
pr: TaskPR,
options: Pick<TaskPRAutomationOptions, "auto_fix_enabled" | "auto_merge_enabled">,
state: TaskCIPRAutomationState | undefined,
removalID: string,
): CIAutomationQueueRecoveryState {
const stateCause = normalizeQueueRemovalCause(state?.last_queue_removal_cause);
const removalCause =
stateCause !== "unknown"
? stateCause
: normalizeQueueRemovalCause(pr.merge_queue_last_removal_reason, pr);
const actionable = ACTIONABLE_QUEUE_REMOVAL_CAUSES.includes(removalCause);
const headContext = queueHeadContext(pr, state, removalID);
if (headContext.repairAccepted) {
return {
context: "recovery",
status: "repair_requested",
removalCause,
repairAccepted: true,
waitingForCommit: options.auto_merge_enabled,
};
}
if (options.auto_merge_enabled && headContext.sameHead) {
return {
context: "recovery",
status: "waiting_for_commit",
removalCause,
repairAccepted: false,
waitingForCommit: true,
};
}
if (shouldWaitForNewHead(pr, options, headContext)) {
return {
context: "recovery",
status: "waiting_for_checks",
removalCause,
repairAccepted: false,
waitingForCommit: false,
};
}
return {
context: "recovery",
status: actionable ? "removed_actionable" : "removed_not_actionable",
removalCause,
repairAccepted: false,
waitingForCommit: false,
};
}
function queueHeadContext(
pr: TaskPR,
state: TaskCIPRAutomationState | undefined,
removalID: string,
): {
currentHead: string;
lastAttemptHead: string;
sameHead: boolean;
repairAccepted: boolean;
} {
const currentHead = pr.head_sha?.trim() ?? "";
const lastAttemptHead = state?.last_queue_attempt_head_sha?.trim() ?? "";
const sameHead = Boolean(currentHead && lastAttemptHead && currentHead === lastAttemptHead);
return {
currentHead,
lastAttemptHead,
sameHead,
repairAccepted: state?.last_queue_fix_event_id === removalID && sameHead,
};
}
function shouldWaitForNewHead(
pr: TaskPR,
options: Pick<TaskPRAutomationOptions, "auto_merge_enabled">,
headContext: ReturnType<typeof queueHeadContext>,
): boolean {
return Boolean(
options.auto_merge_enabled &&
headContext.lastAttemptHead &&
headContext.currentHead &&
!headContext.sameHead &&
!isReadyForMerge(pr),
);
}
export function deriveCIAutomationQueueState(
pr: TaskPR,
options: Pick<TaskPRAutomationOptions, "auto_fix_enabled" | "auto_merge_enabled">,
state: TaskCIPRAutomationState | undefined,
): CIAutomationQueueRecoveryState {
if (hasActiveQueueEntry(pr)) {
return {
context: "queued",
status: "queued",
removalCause: "unknown",
repairAccepted: false,
waitingForCommit: false,
};
}
const removalID = pr.merge_queue_last_removal_id?.trim() ?? "";
if (!removalID) {
return {
context: "normal",
status: "none",
removalCause: "unknown",
repairAccepted: false,
waitingForCommit: false,
};
}
return queueStateForRemoval(pr, options, state, removalID);
}
/**
* Selects the given PR's own automation switches out of the task-scoped
* pr_options array. Falls back to all-off defaults when the PR has no
* stored row yet (never enabled), mirroring findCIAutomationStateForPR.
*/
export function findPRAutomationOptionsForPR(
options: TaskPRAutomationOptions[] | undefined,
pr: TaskPR,
): TaskPRAutomationOptions {
const repositoryID = pr.repository_id ?? "";
const found = options?.find(
(option) => option.pr_number === pr.pr_number && option.repository_id === repositoryID,
);
if (found) return found;
return {
task_id: pr.task_id,
repository_id: repositoryID,
pr_number: pr.pr_number,
created_at: "",
updated_at: "",
...DISABLED_PR_AUTOMATION_OPTIONS,
};
}
export type AutoFixRoundInfo = {
current: number;
max: number;
exhausted: boolean;
};
export function findCIAutomationStateForPR(
states: TaskCIPRAutomationState[] | undefined,
pr: TaskPR,
): TaskCIPRAutomationState | undefined {
const repositoryID = pr.repository_id ?? "";
return states?.find(
(state) => state.pr_number === pr.pr_number && state.repository_id === repositoryID,
);
}
export function autoFixRoundForState(
state: TaskCIPRAutomationState | undefined,
maxRounds: number | null | undefined,
): AutoFixRoundInfo {
const max = normalizeAutoFixMaxRounds(maxRounds);
const current = clampAutoFixRound(state?.auto_fix_round_count, max);
return {
current,
max,
exhausted: Boolean(state?.auto_fix_exhausted_at),
};
}
export function normalizeAutoFixMaxRounds(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_AUTO_FIX_MAX_ROUNDS;
return Math.max(1, Math.trunc(value));
}
export function clampAutoFixRound(value: number | null | undefined, maxRounds: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.min(maxRounds, Math.max(0, Math.trunc(value)));
}