Files
devclaw-gitea/lib/services/review.ts

135 lines
4.8 KiB
TypeScript

/**
* review.ts — Poll review-type states for PR status changes.
*
* Scans review states in the workflow and transitions issues
* whose PR check condition (merged/approved) is met.
* Called by the heartbeat service during its periodic sweep.
*/
import type { IssueProvider } from "../providers/provider.js";
import { PrState } from "../providers/provider.js";
import {
Action,
ReviewCheck,
WorkflowEvent,
StateType,
type WorkflowConfig,
type StateConfig,
} from "../workflow.js";
import { runCommand } from "../run-command.js";
import { log as auditLog } from "../audit.js";
/**
* Scan review-type states and transition issues whose PR check condition is met.
* Returns the number of transitions made.
*/
export async function reviewPass(opts: {
workspaceDir: string;
groupId: string;
workflow: WorkflowConfig;
provider: IssueProvider;
repoPath: string;
gitPullTimeoutMs?: number;
}): Promise<number> {
const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts;
let transitions = 0;
// Find all review-type states
const reviewStates = Object.entries(workflow.states)
.filter(([, s]) => s.type === StateType.REVIEW) as [string, StateConfig][];
for (const [stateKey, state] of reviewStates) {
if (!state.on || !state.check) continue;
const issues = await provider.listIssuesByLabel(state.label);
for (const issue of issues) {
const status = await provider.getPrStatus(issue.iid);
const conditionMet =
(state.check === ReviewCheck.PR_MERGED && status.state === PrState.MERGED) ||
(state.check === ReviewCheck.PR_APPROVED && (status.state === PrState.APPROVED || status.state === PrState.MERGED));
if (!conditionMet) continue;
// Find the success transition (first event that isn't BLOCKED or MERGE_FAILED)
const successEvent = Object.keys(state.on).find(
(e) => e !== WorkflowEvent.BLOCKED && e !== WorkflowEvent.MERGE_FAILED,
);
if (!successEvent) continue;
const transition = state.on[successEvent];
const targetKey = typeof transition === "string" ? transition : transition.target;
const actions = typeof transition === "object" ? transition.actions : undefined;
const targetState = workflow.states[targetKey];
if (!targetState) continue;
// Execute transition actions — mergePr is critical (aborts on failure)
let aborted = false;
if (actions) {
for (const action of actions) {
switch (action) {
case Action.MERGE_PR:
try {
await provider.mergePr(issue.iid);
} catch (err) {
// Merge failed → fire MERGE_FAILED transition (developer fixes conflicts)
await auditLog(workspaceDir, "review_merge_failed", {
groupId,
issueId: issue.iid,
from: state.label,
error: (err as Error).message ?? String(err),
});
const failedTransition = state.on[WorkflowEvent.MERGE_FAILED];
if (failedTransition) {
const failedKey = typeof failedTransition === "string" ? failedTransition : failedTransition.target;
const failedState = workflow.states[failedKey];
if (failedState) {
await provider.transitionLabel(issue.iid, state.label, failedState.label);
await auditLog(workspaceDir, "review_transition", {
groupId,
issueId: issue.iid,
from: state.label,
to: failedState.label,
reason: "merge_failed",
});
transitions++;
}
}
aborted = true;
}
break;
case Action.GIT_PULL:
try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ }
break;
case Action.CLOSE_ISSUE:
await provider.closeIssue(issue.iid);
break;
case Action.REOPEN_ISSUE:
await provider.reopenIssue(issue.iid);
break;
}
if (aborted) break;
}
}
if (aborted) continue; // skip normal transition, move to next issue
// Transition label
await provider.transitionLabel(issue.iid, state.label, targetState.label);
await auditLog(workspaceDir, "review_transition", {
groupId,
issueId: issue.iid,
from: state.label,
to: targetState.label,
check: state.check,
prState: status.state,
prUrl: status.url,
});
transitions++;
}
}
return transitions;
}