opencode-magi 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,12 +20,12 @@ function requiredErrors(config, { creator = false, editor = false, github = true
20
20
  required(github, config.github.owner, "github.owner"),
21
21
  required(github, config.github.repo, "github.repo"),
22
22
  required(reviewers, config.review.reviewers, "review.reviewers"),
23
- ...(config.review.reviewers ?? []).map((reviewer, index) => required(reviewers || !!reviewer, reviewer.model, `review.reviewers[${index}].model`)),
23
+ ...(config.review.reviewers ?? []).map((reviewer, index) => required(reviewers, reviewer.model, `review.reviewers[${index}].model`)),
24
24
  required(editor, config.merge.editor, "merge.editor"),
25
25
  required(editor, config.merge.editor?.model, "merge.editor.model"),
26
26
  required(editor, config.merge.editor?.author, "merge.editor.author"),
27
27
  required(voters, config.triage.voters, "triage.voters"),
28
- ...(config.triage.voters ?? []).map((voter, index) => required(voters || !!voter, voter.model, `triage.voters[${index}].model`)),
28
+ ...(config.triage.voters ?? []).map((voter, index) => required(voters, voter.model, `triage.voters[${index}].model`)),
29
29
  required(creator, config.triage.creator, "triage.creator"),
30
30
  required(creator, config.triage.creator?.model, "triage.creator.model"),
31
31
  required(creator, config.triage.creator?.author, "triage.creator.author"),
@@ -88,23 +88,18 @@ async function authErrors(config, exec) {
88
88
  return filterEmpty([await authError(config, exec, config.account)]);
89
89
  }
90
90
  else {
91
- const accounts = {
92
- ...Object.fromEntries(config.review.reviewers?.map(({ account }, index) => [
93
- account,
94
- `review.reviewers[${index}]`,
95
- ]) ?? []),
96
- ...Object.fromEntries(config.triage.voters?.map(({ account }, index) => [
97
- account,
98
- `triage.voters[${index}]`,
99
- ]) ?? []),
100
- };
101
- if (config.merge.editor)
102
- accounts[config.merge.editor.account] = "merge.editor";
103
- if (config.triage.creator)
104
- accounts[config.triage.creator.account] = "triage.creator";
91
+ const reviewerAccounts = config.review.reviewers?.map(({ account }) => account) ?? [];
92
+ const voterAccounts = config.triage.voters?.map(({ account }) => account) ?? [];
93
+ const accounts = new Set(filterEmpty([
94
+ ...reviewerAccounts,
95
+ ...voterAccounts,
96
+ config.merge.editor?.account,
97
+ config.triage.creator?.account,
98
+ ]));
105
99
  return filterEmpty([
106
- ...duplicateErrors(Object.keys(accounts), (value) => `${accounts[value]} has duplicate account: ${value}`),
107
- ...(await Promise.all(Object.keys(accounts).map((account) => authError(config, exec, account)))),
100
+ ...duplicateErrors(reviewerAccounts, (value) => `review.reviewers has duplicate account: ${value}`),
101
+ ...duplicateErrors(voterAccounts, (value) => `triage.voters has duplicate account: ${value}`),
102
+ ...(await Promise.all([...accounts].map((account) => authError(config, exec, account)))),
108
103
  ]);
109
104
  }
110
105
  }
package/dist/constant.js CHANGED
@@ -60,11 +60,7 @@ export const DEFAULT_CONFIG = {
60
60
  queue: false,
61
61
  },
62
62
  output: ".magi/runs/pr",
63
- safety: {
64
- allowAuthors: [],
65
- blockedPaths: [],
66
- requiredLabels: [],
67
- },
63
+ safety: [],
68
64
  worktree: ".magi/worktrees/pr",
69
65
  },
70
66
  triage: {
@@ -95,9 +95,7 @@ function createSyntheticThreads() {
95
95
  }));
96
96
  }
97
97
  function addSyntheticReplies(threads) {
98
- const output = this.state.editor?.outputs?.at(-1);
99
- if (!output)
100
- throw new MagiError("blocked", "Editor output not found.");
98
+ const output = this.state.editor.outputs.at(-1);
101
99
  return threads.map((thread) => ({
102
100
  ...thread,
103
101
  comments: [
@@ -183,12 +183,6 @@ async function setAccount() {
183
183
  await this.exec(command("git", "config", "user.email", quote(this.state.editor.author.email)), options);
184
184
  }
185
185
  async function push() {
186
- if (!this.state.editor?.account)
187
- throw new MagiError("blocked", "Editor account not found.");
188
- if (!this.state.pr?.metadata)
189
- throw new MagiError("blocked", "PR metadata not found.");
190
- if (!this.state.worktree)
191
- throw new MagiError("blocked", "PR worktree not found.");
192
186
  const token = await this.magi.getGhToken(this.state.editor.account, this.context.abort);
193
187
  const url = `https://${this.config.github.host}/${this.state.pr.metadata.head.repo.owner.login}/${this.state.pr.metadata.head.repo.name}.git`;
194
188
  const ref = `HEAD:refs/heads/${this.state.pr.metadata.head.ref}`;
@@ -208,8 +202,6 @@ async function push() {
208
202
  return getMetadata.call(this);
209
203
  }
210
204
  async function getConflictedFiles() {
211
- if (!this.state.worktree)
212
- throw new MagiError("blocked", "PR worktree not found.");
213
205
  const options = { cwd: this.state.worktree.path, signal: this.context.abort };
214
206
  try {
215
207
  await this.exec(command("git", "merge", "--no-commit", "--no-ff", "FETCH_HEAD"), options);
@@ -127,7 +127,7 @@ export const merge = function (magi) {
127
127
  await run.editCycles(async () => editCycle(run));
128
128
  const automation = await run.automate();
129
129
  if (automation === "CONFLICT" &&
130
- run.config.merge.automation.conflict) {
130
+ run.isAutomationEnabled(run.config.merge.automation.conflict)) {
131
131
  await run.editCycles(async (cycle) => editCycle(run, { conflict: true, cycle }));
132
132
  await run.automate();
133
133
  }
@@ -1,6 +1,7 @@
1
1
  import { MagiError } from "@/magi";
2
2
  import { Prompt } from "@/prompts";
3
- import { command, filterEmpty, isArray, isObject, isString, loop, marker, omitNullish, quote, retry, wait, Worker, } from "@/utils";
3
+ import { command, filterEmpty, ignoreError, isArray, isBoolean, isObject, isString, loop, marker, omitNullish, quote, retry, wait, Worker, } from "@/utils";
4
+ import { getConditionErrors } from "./check";
4
5
  const events = {
5
6
  APPROVED: "APPROVE",
6
7
  CHANGES_REQUESTED: "REQUEST_CHANGES",
@@ -181,9 +182,8 @@ export async function automate() {
181
182
  throw new MagiError("blocked", "PR verdict not found.");
182
183
  if (!["APPROVED", "CLOSED"].includes(this.state.pr.verdict))
183
184
  return "SKIPPED";
184
- const automation = this.config[this.state.command].automation;
185
185
  const action = this.state.pr.verdict === "APPROVED" ? "merge" : "close";
186
- if (!automation[action]) {
186
+ if (!isAutomationEnabled.call(this, this.config[this.state.command].automation[action])) {
187
187
  await this.updateState({ pr: { automation: "SKIPPED" } });
188
188
  await this.updateEvent(`Skipped ${action} automation.`);
189
189
  return "SKIPPED";
@@ -233,7 +233,24 @@ export async function automate() {
233
233
  const octokit = await this.magi.createOctokit(this.config, this.context.abort, account);
234
234
  const graphql = this.magi.createGraphql(octokit);
235
235
  const enqueuedAt = new Date().toISOString();
236
- await graphql.enqueuePullRequest({ id: this.state.pr.metadata.node_id });
236
+ try {
237
+ await ignoreError(() => graphql.enqueuePullRequest({ id: this.state.pr.metadata.node_id }), (e) => {
238
+ const message = e instanceof Error ? e.message : String(e);
239
+ return /pull request is already in the queue/i.test(message);
240
+ });
241
+ }
242
+ catch (e) {
243
+ const message = e instanceof Error ? e.message : String(e);
244
+ if (!/pull request is closed/i.test(message))
245
+ throw e;
246
+ const { repository } = await graphql.mergeQueueStatus({
247
+ owner: this.config.github.owner,
248
+ pr: this.number,
249
+ repo: this.config.github.repo,
250
+ });
251
+ if (repository?.pullRequest?.state !== "MERGED")
252
+ throw e;
253
+ }
237
254
  await this.updateEvent(`Waiting for merge queue.`);
238
255
  const result = await waitMergeQueue.call(this, enqueuedAt);
239
256
  if (result === "CONFLICT")
@@ -328,6 +345,18 @@ export async function automate() {
328
345
  await this.updateEvent(`Finished ${action} automation.`);
329
346
  return action === "merge" ? "MERGED" : "CLOSED";
330
347
  }
348
+ export function isAutomationEnabled(conditions) {
349
+ if (isBoolean(conditions))
350
+ return conditions;
351
+ const edited = !this.state.dryRun &&
352
+ this.state.editor?.outputs?.some(({ commitSha }) => !!commitSha);
353
+ return conditions.every(([expected, condition]) => {
354
+ const errors = getConditionErrors(condition, this.state.pr.metadata, this.state.pr.files ?? []);
355
+ const matches = !errors.length &&
356
+ (!("edited" in condition) || condition.edited === !!edited);
357
+ return matches === expected;
358
+ });
359
+ }
331
360
  function hasFailedChecks(checks) {
332
361
  if (!isArray(checks))
333
362
  return false;
@@ -388,8 +417,11 @@ async function waitMergeQueue(enqueuedAt, leftMergeQueue = false, retries = 0) {
388
417
  const attempt = retries + 1;
389
418
  const nextEnqueuedAt = new Date().toISOString();
390
419
  await this.updateEvent(`Attempt ${attempt} failed to merge from the merge queue. Retrying...`);
391
- await this.graphql.enqueuePullRequest({
420
+ await ignoreError(() => this.graphql.enqueuePullRequest({
392
421
  id: this.state.pr.metadata.node_id,
422
+ }), (e) => {
423
+ const message = e instanceof Error ? e.message : String(e);
424
+ return /pull request is already in the queue/i.test(message);
393
425
  });
394
426
  return await waitMergeQueue.call(this, nextEnqueuedAt, false, attempt);
395
427
  }
@@ -401,8 +433,6 @@ async function waitMergeQueue(enqueuedAt, leftMergeQueue = false, retries = 0) {
401
433
  async function isConflict() {
402
434
  if (!this.state.worktree)
403
435
  return false;
404
- if (!this.state.pr?.metadata)
405
- throw new MagiError("blocked", "PR metadata not found.");
406
436
  const options = { cwd: this.state.worktree.path, signal: this.context.abort };
407
437
  const status = await this.exec(command("git", "status", "--porcelain"), options);
408
438
  if (status)
@@ -1,7 +1,7 @@
1
1
  import picomatch from "picomatch";
2
2
  import { MagiError } from "@/magi";
3
3
  import { Prompt } from "@/prompts";
4
- import { command, filterEmpty, isNumber, omitNullish, quote, retry, Worker, } from "@/utils";
4
+ import { command, filterEmpty, isBoolean, isNumber, omitNullish, quote, retry, Worker, } from "@/utils";
5
5
  const ci = {
6
6
  isExcluded(exclude, { name }) {
7
7
  return exclude.some((pattern) => {
@@ -22,6 +22,65 @@ const ci = {
22
22
  return !this.isFailed(check) && !this.isPassed(check);
23
23
  },
24
24
  };
25
+ function matchesValues(filter, values) {
26
+ if (!filter)
27
+ return true;
28
+ if ("include" in filter &&
29
+ !values.some((value) => filter.include.includes(value)))
30
+ return false;
31
+ return !("exclude" in filter &&
32
+ values.some((value) => filter.exclude.includes(value)));
33
+ }
34
+ function matchesPatterns(filter, values) {
35
+ if (!filter)
36
+ return true;
37
+ if ("include" in filter) {
38
+ const isIncluded = picomatch(filter.include, { dot: true });
39
+ if (!values.some((value) => isIncluded(value)))
40
+ return false;
41
+ }
42
+ if ("exclude" in filter) {
43
+ const isExcluded = picomatch(filter.exclude, { dot: true });
44
+ if (values.some((value) => isExcluded(value)))
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ export function getConditionErrors(condition, metadata, files) {
50
+ const errors = [];
51
+ const branches = condition.branches
52
+ ? {
53
+ base: "base" in condition.branches ? condition.branches.base : undefined,
54
+ head: "head" in condition.branches ? condition.branches.head : undefined,
55
+ }
56
+ : {};
57
+ if (!matchesValues(condition.authors, [metadata.user.login]))
58
+ errors.push(`Author does not match safety filter: ${metadata.user.login}.`);
59
+ if (!matchesPatterns(branches.base, [metadata.base.ref]))
60
+ errors.push(`Base branch does not match safety filter: ${metadata.base.ref}.`);
61
+ if (!matchesPatterns(branches.head, [metadata.head.ref]))
62
+ errors.push(`Head branch does not match safety filter: ${metadata.head.ref}.`);
63
+ if (!matchesValues(condition.labels, metadata.labels.map(({ name }) => name)))
64
+ errors.push(`Labels do not match safety filter.`);
65
+ if (!matchesPatterns(condition.paths, files))
66
+ errors.push(`Paths do not match safety filter.`);
67
+ if (isNumber(condition.maxChangedFiles) &&
68
+ metadata.changed_files > condition.maxChangedFiles)
69
+ errors.push(`Changed files exceed limit: ${metadata.changed_files} > ${condition.maxChangedFiles}.`);
70
+ return errors;
71
+ }
72
+ function getSafetyErrors(conditions, metadata, files) {
73
+ if (isBoolean(conditions))
74
+ return conditions ? [] : ["Safety is disabled."];
75
+ return conditions.flatMap(([expected, condition], index) => {
76
+ const errors = getConditionErrors(condition, metadata, files);
77
+ if (!errors.length === expected)
78
+ return [];
79
+ if (expected)
80
+ return errors;
81
+ return [`Safety condition ${index + 1} unexpectedly matched.`];
82
+ });
83
+ }
25
84
  export async function checkPr() {
26
85
  this.context.abort.throwIfAborted();
27
86
  await this.updateState({ status: "running" });
@@ -31,26 +90,7 @@ export async function checkPr() {
31
90
  throw new MagiError("blocked", `PR is not open.`);
32
91
  if (metadata.draft)
33
92
  throw new MagiError("blocked", `PR is a draft.`);
34
- const errors = [];
35
- if (this.config.review.safety.allowAuthors.length)
36
- if (!this.config.review.safety.allowAuthors.includes(metadata.user.login))
37
- errors.push(`Author is not allowed: ${metadata.user.login}.`);
38
- if (this.config.review.safety.requiredLabels.length) {
39
- const missingLabels = this.config.review.safety.requiredLabels.filter((label) => !metadata.labels.some(({ name }) => name === label));
40
- if (missingLabels.length)
41
- errors.push(`Required labels missing: ${missingLabels.join(", ")}.`);
42
- }
43
- if (isNumber(this.config.review.safety.maxChangedFiles) &&
44
- metadata.changed_files > this.config.review.safety.maxChangedFiles)
45
- errors.push(`Changed files exceed limit: ${metadata.changed_files} > ${this.config.review.safety.maxChangedFiles}.`);
46
- if (this.config.review.safety.blockedPaths.length) {
47
- const isBlocked = picomatch(this.config.review.safety.blockedPaths, {
48
- dot: true,
49
- });
50
- const blocked = files.filter((file) => isBlocked(file));
51
- if (blocked.length)
52
- errors.push(`Blocked paths changed: ${blocked.join(", ")}.`);
53
- }
93
+ const errors = getSafetyErrors(this.config.review.safety, metadata, files);
54
94
  if (errors.length)
55
95
  throw new MagiError("blocked", `PR is safety blocked. ${errors.join(" ")}`);
56
96
  if (this.config.mode === "single") {
@@ -69,8 +69,6 @@ export async function checkExistingReviews() {
69
69
  for (const [id, reviewer] of Object.entries(reviewers)) {
70
70
  if (reviewer.status !== "skip")
71
71
  continue;
72
- if (!reviewer.review)
73
- throw new MagiError("blocked", `No review found for reviewer ${id}.`);
74
72
  const single = this.config.mode === "single";
75
73
  const author = single ? this.config.account : reviewer.account;
76
74
  const hasUserReply = threads.some(({ comments, isResolved }) => {
@@ -86,7 +84,7 @@ export async function checkExistingReviews() {
86
84
  const last = comments.at(-1);
87
85
  return (!!last &&
88
86
  last.author?.login !== author &&
89
- (!reviewer.review?.submitted_at ||
87
+ (!reviewer.review.submitted_at ||
90
88
  last.createdAt.localeCompare(reviewer.review.submitted_at) > 0));
91
89
  });
92
90
  if (hasUserReply) {
@@ -243,8 +241,6 @@ export async function getInlineCommentTargets() {
243
241
  return inlineCommentTargets;
244
242
  }
245
243
  async function hasCommit(sha) {
246
- if (!this.state.worktree)
247
- throw new MagiError("blocked", "PR worktree not found.");
248
244
  try {
249
245
  await this.exec(command("git", "cat-file", "-e", quote(`${sha}^{commit}`)), {
250
246
  cwd: this.state.worktree.path,
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { MagiError } from "@/magi";
3
3
  import { createExecWithGitHubApiRetry, quote } from "@/utils";
4
- import { automate, postReviews } from "./action";
4
+ import { automate, isAutomationEnabled, postReviews } from "./action";
5
5
  import { checkCi, checkPr, classifyChecks, rerunChecks } from "./check";
6
6
  import { checkExistingReviews, fetchReviewContext } from "./context";
7
7
  import { createReport } from "./report";
@@ -62,6 +62,7 @@ export class Review {
62
62
  validateFindings = validateFindings;
63
63
  reconsiderClose = reconsiderClose;
64
64
  postReviews = postReviews;
65
+ isAutomationEnabled = isAutomationEnabled;
65
66
  automate = automate;
66
67
  createReport = createReport;
67
68
  async cleanup() {
@@ -12,6 +12,15 @@ export function wait(ms = 0, signal) {
12
12
  signal?.addEventListener("abort", abort, { once: true });
13
13
  });
14
14
  }
15
+ export async function ignoreError(cb, shouldIgnore) {
16
+ try {
17
+ return await cb();
18
+ }
19
+ catch (e) {
20
+ if (!shouldIgnore(e))
21
+ throw e;
22
+ }
23
+ }
15
24
  export async function loop(cb, ms = 0) {
16
25
  for (;;) {
17
26
  const value = await cb();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
5
5
  "license": "MIT",
6
6
  "author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",
@@ -28,40 +28,40 @@
28
28
  "schema.json"
29
29
  ],
30
30
  "dependencies": {
31
- "@opencode-ai/plugin": "^1.18.1",
32
- "@opencode-ai/sdk": "^1.18.1",
31
+ "@opencode-ai/plugin": "^1.18.4",
32
+ "@opencode-ai/sdk": "^1.18.4",
33
33
  "ajv": "^8.20.0",
34
- "graphql": "^16.14.0",
35
- "graphql-tag": "^2.12.6",
34
+ "graphql": "^17.0.2",
35
+ "graphql-tag": "^2.12.7",
36
36
  "octokit": "^5.0.5",
37
- "picomatch": "^4.0.4",
38
- "valibot": "^1.4.1"
37
+ "picomatch": "^4.0.5",
38
+ "valibot": "^1.4.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@changesets/changelog-github": "^0.7.0",
42
- "@changesets/cli": "^2.30.0",
43
- "@commitlint/cli": "^21.0.1",
44
- "@commitlint/config-conventional": "^21.0.1",
45
- "@graphql-codegen/cli": "^7.1.1",
42
+ "@changesets/cli": "^2.31.1",
43
+ "@commitlint/cli": "^21.2.1",
44
+ "@commitlint/config-conventional": "^21.2.0",
45
+ "@graphql-codegen/cli": "^7.2.0",
46
46
  "@graphql-codegen/typescript-generic-sdk": "^5.0.1",
47
- "@graphql-codegen/typescript-operations": "^6.0.3",
47
+ "@graphql-codegen/typescript-operations": "^6.1.1",
48
48
  "@limegrass/eslint-plugin-import-alias": "^1.6.1",
49
49
  "@octokit/graphql-schema": "^15.26.1",
50
50
  "@octokit/plugin-throttling": "^11.0.3",
51
51
  "@stylistic/eslint-plugin": "^5.10.0",
52
- "@types/node": "^25.9.1",
52
+ "@types/node": "^26.1.1",
53
53
  "@types/picomatch": "^4.0.3",
54
- "@typescript/native-preview": "7.0.0-dev.20260525.1",
55
- "@vitest/coverage-v8": "^4.1.7",
56
- "@vitest/ui": "^4.1.7",
57
- "eslint-plugin-perfectionist": "^5.9.0",
54
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
55
+ "@vitest/coverage-v8": "^4.1.10",
56
+ "@vitest/ui": "^4.1.10",
57
+ "eslint-plugin-perfectionist": "^5.10.0",
58
58
  "eslint-plugin-unused-imports": "^4.4.1",
59
- "lefthook": "^2.1.8",
60
- "oxfmt": "^0.51.0",
61
- "oxlint": "^1.66.0",
62
- "oxlint-tsgolint": "^0.23.0",
59
+ "lefthook": "^2.1.10",
60
+ "oxfmt": "^0.60.0",
61
+ "oxlint": "^1.75.0",
62
+ "oxlint-tsgolint": "^7.0.2001",
63
63
  "rimraf": "^6.1.3",
64
- "vitest": "^4.1.7"
64
+ "vitest": "^4.1.10"
65
65
  },
66
66
  "scripts": {
67
67
  "clean": "rimraf node_modules dist coverage",
@@ -74,6 +74,7 @@
74
74
  "lint:fix": "oxlint . --max-warnings=0 --fix",
75
75
  "type:check": "tsgo --noEmit",
76
76
  "test": "vitest run --passWithNoTests",
77
+ "test:coverage": "vitest run --coverage --coverage.reporter=html && open coverage/index.html",
77
78
  "test:dev": "vitest --watch --ui --passWithNoTests",
78
79
  "quality": "pnpm format:check && pnpm lint:check && pnpm type:check && pnpm test",
79
80
  "release": "changeset publish",
package/schema.json CHANGED
@@ -174,21 +174,125 @@
174
174
  "variant": { "type": "string" }
175
175
  }
176
176
  },
177
+ "safetyFilter": {
178
+ "type": "object",
179
+ "additionalProperties": false,
180
+ "minProperties": 1,
181
+ "properties": {
182
+ "exclude": { "type": "array", "items": { "type": "string" } },
183
+ "include": { "type": "array", "items": { "type": "string" } }
184
+ }
185
+ },
186
+ "safetyBranchFilter": {
187
+ "type": "object",
188
+ "additionalProperties": false,
189
+ "minProperties": 1,
190
+ "properties": {
191
+ "base": { "$ref": "#/$defs/safetyFilter" },
192
+ "head": { "$ref": "#/$defs/safetyFilter" }
193
+ }
194
+ },
195
+ "safety": {
196
+ "type": "object",
197
+ "additionalProperties": false,
198
+ "properties": {
199
+ "authors": { "$ref": "#/$defs/safetyFilter" },
200
+ "branches": { "$ref": "#/$defs/safetyBranchFilter" },
201
+ "labels": { "$ref": "#/$defs/safetyFilter" },
202
+ "maxChangedFiles": { "type": "integer", "minimum": 0 },
203
+ "paths": { "$ref": "#/$defs/safetyFilter" }
204
+ }
205
+ },
206
+ "conditions": {
207
+ "oneOf": [
208
+ { "type": "boolean" },
209
+ {
210
+ "type": "array",
211
+ "items": {
212
+ "type": "array",
213
+ "minItems": 2,
214
+ "maxItems": 2,
215
+ "prefixItems": [{ "type": "boolean" }, { "$ref": "#/$defs/safety" }]
216
+ }
217
+ }
218
+ ]
219
+ },
220
+ "automationSafety": {
221
+ "type": "object",
222
+ "additionalProperties": false,
223
+ "properties": {
224
+ "authors": { "$ref": "#/$defs/safetyFilter" },
225
+ "branches": { "$ref": "#/$defs/safetyBranchFilter" },
226
+ "labels": { "$ref": "#/$defs/safetyFilter" },
227
+ "paths": { "$ref": "#/$defs/safetyFilter" }
228
+ }
229
+ },
230
+ "automationConditions": {
231
+ "oneOf": [
232
+ { "type": "boolean" },
233
+ {
234
+ "type": "array",
235
+ "items": {
236
+ "type": "array",
237
+ "minItems": 2,
238
+ "maxItems": 2,
239
+ "prefixItems": [
240
+ { "type": "boolean" },
241
+ { "$ref": "#/$defs/automationSafety" }
242
+ ]
243
+ }
244
+ }
245
+ ]
246
+ },
247
+ "mergeAutomationConditions": {
248
+ "oneOf": [
249
+ { "type": "boolean" },
250
+ {
251
+ "type": "array",
252
+ "items": {
253
+ "type": "array",
254
+ "minItems": 2,
255
+ "maxItems": 2,
256
+ "prefixItems": [
257
+ { "type": "boolean" },
258
+ { "$ref": "#/$defs/mergeAutomationSafety" }
259
+ ]
260
+ }
261
+ }
262
+ ]
263
+ },
264
+ "mergeAutomationSafety": {
265
+ "type": "object",
266
+ "additionalProperties": false,
267
+ "properties": {
268
+ "authors": { "$ref": "#/$defs/safetyFilter" },
269
+ "branches": { "$ref": "#/$defs/safetyBranchFilter" },
270
+ "edited": { "type": "boolean" },
271
+ "labels": { "$ref": "#/$defs/safetyFilter" },
272
+ "paths": { "$ref": "#/$defs/safetyFilter" }
273
+ }
274
+ },
177
275
  "automation": {
178
276
  "type": "object",
179
277
  "additionalProperties": false,
180
278
  "properties": {
181
- "merge": { "type": "boolean", "default": true },
182
- "close": { "type": "boolean" }
279
+ "merge": { "$ref": "#/$defs/automationConditions", "default": true },
280
+ "close": { "$ref": "#/$defs/automationConditions", "default": false }
183
281
  }
184
282
  },
185
283
  "mergeAutomation": {
186
284
  "type": "object",
187
285
  "additionalProperties": false,
188
286
  "properties": {
189
- "merge": { "type": "boolean", "default": true },
190
- "close": { "type": "boolean", "default": false },
191
- "conflict": { "type": "boolean", "default": false }
287
+ "merge": {
288
+ "$ref": "#/$defs/mergeAutomationConditions",
289
+ "default": true
290
+ },
291
+ "close": {
292
+ "$ref": "#/$defs/mergeAutomationConditions",
293
+ "default": false
294
+ },
295
+ "conflict": { "$ref": "#/$defs/automationConditions", "default": false }
192
296
  }
193
297
  },
194
298
  "reviewChecks": {
@@ -222,16 +326,6 @@
222
326
  "runs": { "type": "integer", "minimum": 1, "default": 3 }
223
327
  }
224
328
  },
225
- "safety": {
226
- "type": "object",
227
- "additionalProperties": false,
228
- "properties": {
229
- "allowAuthors": { "type": "array", "items": { "type": "string" } },
230
- "blockedPaths": { "type": "array", "items": { "type": "string" } },
231
- "maxChangedFiles": { "type": "integer", "minimum": 0 },
232
- "requiredLabels": { "type": "array", "items": { "type": "string" } }
233
- }
234
- },
235
329
  "reviewPrompts": {
236
330
  "type": "object",
237
331
  "additionalProperties": false,
@@ -376,7 +470,7 @@
376
470
  },
377
471
  "prompts": { "$ref": "#/$defs/reviewPrompts" },
378
472
  "checks": { "$ref": "#/$defs/reviewChecks" },
379
- "safety": { "$ref": "#/$defs/safety" },
473
+ "safety": { "$ref": "#/$defs/conditions" },
380
474
  "automation": { "$ref": "#/$defs/automation" },
381
475
  "concurrency": { "$ref": "#/$defs/concurrency" },
382
476
  "merge": { "$ref": "#/$defs/reviewMerge" },