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
7 changes: 6 additions & 1 deletion src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
* Plan the maintainer auto-maintain actions for one PR. Returns a COHERENT set (never both approve and
* request-changes; never both merge and close), each entry already filtered to an acting autonomy class.
* Ordered least → most irreversible: label, then the review, then the disposition.
*/

Check notice on line 99 in src/settings/agent-actions.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 99 in src/settings/agent-actions.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
export function planAgentMaintenanceActions(input: AgentActionPlanInput): PlannedAgentAction[] {
const actions: PlannedAgentAction[] = [];
const autoMaintain = input.autoMaintain ?? DEFAULT_AUTO_MAINTAIN_POLICY;
Expand Down Expand Up @@ -124,7 +124,12 @@
const gatePassing = input.conclusion === "success";
// A changed path matching a hard guardrail forces manual review: suppress the irreversible dispositions
// (merge / close) AND the auto-approve that could later satisfy a merge. label + request_changes still run.
const guardrailHit = changedPathsHittingGuardrail(input.changedPaths, input.hardGuardrailGlobs).length > 0;
// Fail SAFE on UNKNOWN paths: when guardrails are configured but the changed-file set is empty (cache not
// yet/no-longer populated), we cannot prove the PR doesn't touch a guarded path, so treat it as a hit —
// never auto-merge/close a PR whose diff we don't know. Repos with no guardrails stay permissive.
const guardrailHit =
input.hardGuardrailGlobs.length > 0 &&
(input.changedPaths.length === 0 || changedPathsHittingGuardrail(input.changedPaths, input.hardGuardrailGlobs).length > 0);
// Auto-merge-ready ONLY when the gate passes AND CI is green AND no guarded path is touched. A red, pending,
// or unverified CI is never approved/merged.
const readyToMerge = gatePassing && ciPassed && !guardrailHit;
Expand Down
17 changes: 17 additions & 0 deletions test/unit/agent-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
expect(plan).not.toContain("merge");
});

it("labels a guarded passing PR `needs-human-review` (NOT `ready-to-merge`) and still does not merge it", () => {

Check notice on line 145 in test/unit/agent-actions.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 145 in test/unit/agent-actions.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
// A guardrail-hit PR that otherwise passes is withheld from auto-merge → the `ready-to-merge` label
// would be misleading. It must carry the distinct `needs-human-review` label instead, and never merge.
const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { label: "auto", merge: "auto" }, ...guarded, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } }));
Expand All @@ -169,6 +169,23 @@
// A clean, non-guarded passing PR keeps the `ready-to-merge` label (the auto-merge it promises happens).
expect(plan.find((a) => a.actionClass === "label")?.label).toBe(AGENT_LABEL_READY);
});

it("fails SAFE on UNKNOWN paths: with guardrails configured but no changed-file set, suppress merge/close/approve", () => {
// The changed-file cache can be empty (fresh PR before backfill) or stale; we cannot prove the PR
// doesn't touch a guarded path, so it must fall through to a human (label/request_changes still run).
const merge = classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto", label: "auto" }, changedPaths: [], hardGuardrailGlobs: ["src/scoring/**", "scripts/**"], pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })));
expect(merge).not.toContain("merge");
expect(merge).toContain("label");
const close = classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], changedPaths: [], hardGuardrailGlobs: ["src/scoring/**"], pr: { labels: [], slopRisk: 95 } })));
expect(close).not.toContain("close");
const approve = classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { approve: "auto" }, changedPaths: [], hardGuardrailGlobs: ["src/scoring/**"], pr: { labels: [] } })));
expect(approve).not.toContain("approve");
});

it("stays permissive on empty paths when the repo has NO guardrails configured (opted out)", () => {
const plan = classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, changedPaths: [], hardGuardrailGlobs: [], pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })));
expect(plan).toContain("merge");
});
});

describe("owner-PR guard: never auto-close the repo owner's own PRs", () => {
Expand Down
Loading