opencode-magi 0.5.0 → 0.6.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.
- package/dist/config/resolve.js +3 -3
- package/dist/config/validate.js +31 -18
- package/dist/config/worktree.js +6 -0
- package/dist/github/commands.js +153 -45
- package/dist/index.js +29 -3
- package/dist/orchestrator/ci.js +21 -14
- package/dist/orchestrator/findings.js +29 -7
- package/dist/orchestrator/majority.js +1 -1
- package/dist/orchestrator/merge.js +31 -10
- package/dist/orchestrator/model.js +23 -9
- package/dist/orchestrator/review.js +162 -36
- package/dist/orchestrator/run-manager.js +171 -146
- package/dist/orchestrator/triage.js +243 -201
- package/dist/prompts/compose.js +2 -10
- package/dist/prompts/contracts.js +6 -20
- package/dist/prompts/output.js +6 -16
- package/package.json +1 -1
- package/schema.json +3 -3
- package/dist/prompts/templates/triage/action.md +0 -5
package/dist/config/resolve.js
CHANGED
|
@@ -28,7 +28,7 @@ export function reviewerKey(reviewer, index) {
|
|
|
28
28
|
return reviewer.id ?? `reviewer-${index + 1}`;
|
|
29
29
|
}
|
|
30
30
|
export function triageAgentKey(agent, index) {
|
|
31
|
-
return agent.id ?? `
|
|
31
|
+
return agent.id ?? `voter-${index + 1}`;
|
|
32
32
|
}
|
|
33
33
|
export function validateReviewerId(id) {
|
|
34
34
|
return ID_PATTERN.test(id);
|
|
@@ -104,7 +104,7 @@ export function resolveAgents(config) {
|
|
|
104
104
|
triageCreator: creator
|
|
105
105
|
? {
|
|
106
106
|
...creator,
|
|
107
|
-
account: creator.account ??
|
|
107
|
+
account: creator.account ?? "",
|
|
108
108
|
permission: resolveTriageCreatorPermission(agents, creator),
|
|
109
109
|
}
|
|
110
110
|
: undefined,
|
|
@@ -179,7 +179,6 @@ export function resolveRepository(config) {
|
|
|
179
179
|
requiredLabels: config.review?.safety?.requiredLabels ?? [],
|
|
180
180
|
},
|
|
181
181
|
triage: {
|
|
182
|
-
account: config.triage?.account,
|
|
183
182
|
automation: {
|
|
184
183
|
clear: config.triage?.automation?.clear ?? ["triage"],
|
|
185
184
|
close: config.triage?.automation?.close ?? false,
|
|
@@ -193,6 +192,7 @@ export function resolveRepository(config) {
|
|
|
193
192
|
},
|
|
194
193
|
output: config.triage?.output,
|
|
195
194
|
prompts: config.triage?.prompts ?? {},
|
|
195
|
+
reporter: config.triage?.reporter,
|
|
196
196
|
safety: {
|
|
197
197
|
allowAuthors: config.triage?.safety?.allowAuthors ?? [],
|
|
198
198
|
allowMentionActors: config.triage?.safety?.allowMentionActors ?? [],
|
package/dist/config/validate.js
CHANGED
|
@@ -40,6 +40,7 @@ const EDITOR_KEYS = new Set([
|
|
|
40
40
|
"persona",
|
|
41
41
|
]);
|
|
42
42
|
const TRIAGE_AGENT_KEYS = new Set([
|
|
43
|
+
"account",
|
|
43
44
|
"id",
|
|
44
45
|
"model",
|
|
45
46
|
"options",
|
|
@@ -75,7 +76,6 @@ const MERGE_KEYS = new Set([
|
|
|
75
76
|
"prompts",
|
|
76
77
|
]);
|
|
77
78
|
const TRIAGE_KEYS = new Set([
|
|
78
|
-
"account",
|
|
79
79
|
"agents",
|
|
80
80
|
"automation",
|
|
81
81
|
"categories",
|
|
@@ -83,6 +83,7 @@ const TRIAGE_KEYS = new Set([
|
|
|
83
83
|
"creator",
|
|
84
84
|
"output",
|
|
85
85
|
"prompts",
|
|
86
|
+
"reporter",
|
|
86
87
|
"safety",
|
|
87
88
|
"worktree",
|
|
88
89
|
]);
|
|
@@ -135,7 +136,6 @@ const MERGE_PROMPT_KEYS = new Set([
|
|
|
135
136
|
"editGuidelines",
|
|
136
137
|
]);
|
|
137
138
|
const TRIAGE_PROMPT_KEYS = new Set([
|
|
138
|
-
"action",
|
|
139
139
|
"acceptance",
|
|
140
140
|
"category",
|
|
141
141
|
"comment",
|
|
@@ -357,6 +357,9 @@ function validateTriageAgentList(agents, path, errors, catalog) {
|
|
|
357
357
|
errors.push(`${path}[${index}].model is required`);
|
|
358
358
|
validateString(agent.model, `${path}[${index}].model`, errors);
|
|
359
359
|
validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
|
|
360
|
+
if (!agent.account)
|
|
361
|
+
errors.push(`${path}[${index}].account is required`);
|
|
362
|
+
validateString(agent.account, `${path}[${index}].account`, errors);
|
|
360
363
|
validateString(agent.persona, `${path}[${index}].persona`, errors);
|
|
361
364
|
if (agent.options != null && !isPlainObject(agent.options))
|
|
362
365
|
errors.push(`${path}[${index}].options must be an object`);
|
|
@@ -383,12 +386,16 @@ function validateResolvedReviewers(reviewers, path, errors) {
|
|
|
383
386
|
accounts.add(reviewer.account);
|
|
384
387
|
}
|
|
385
388
|
}
|
|
386
|
-
function
|
|
389
|
+
function validateResolvedTriageAgents(agents, path, errors) {
|
|
387
390
|
const keys = new Set();
|
|
391
|
+
const accounts = new Set();
|
|
388
392
|
for (const agent of agents) {
|
|
389
393
|
if (keys.has(agent.key))
|
|
390
394
|
errors.push(`${path} has duplicate agent key: ${agent.key}`);
|
|
391
395
|
keys.add(agent.key);
|
|
396
|
+
if (accounts.has(agent.account))
|
|
397
|
+
errors.push(`${path} has duplicate agent account: ${agent.account}`);
|
|
398
|
+
accounts.add(agent.account);
|
|
392
399
|
}
|
|
393
400
|
}
|
|
394
401
|
function validateEditor(editor, path, errors, catalog) {
|
|
@@ -672,19 +679,26 @@ function validateTriage(config, errors, options) {
|
|
|
672
679
|
validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
|
|
673
680
|
const automation = triage.automation;
|
|
674
681
|
const concurrency = triage.concurrency;
|
|
682
|
+
const creator = triage.creator;
|
|
683
|
+
const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
|
|
675
684
|
const safety = triage.safety;
|
|
676
|
-
if (!triage.account)
|
|
677
|
-
errors.push("triage.account is required");
|
|
678
|
-
validateString(triage.account, "triage.account", errors);
|
|
679
685
|
if (!triage.agents)
|
|
680
686
|
errors.push("triage.agents is required");
|
|
681
687
|
validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
|
|
682
688
|
if (Array.isArray(triage.agents)) {
|
|
683
|
-
|
|
689
|
+
const resolvedTriageAgents = resolveAgents(config).triage ?? [];
|
|
690
|
+
validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
|
|
691
|
+
if (reporter != null &&
|
|
692
|
+
!resolvedTriageAgents.some((agent) => agent.key === reporter)) {
|
|
693
|
+
errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
|
|
694
|
+
}
|
|
684
695
|
}
|
|
685
|
-
|
|
686
|
-
|
|
696
|
+
validateString(triage.reporter, "triage.reporter", errors);
|
|
697
|
+
validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
|
|
698
|
+
if (automation?.create && !creator)
|
|
687
699
|
errors.push("triage.creator is required when triage.automation.create is true");
|
|
700
|
+
if (automation?.create && creator && !creator.account)
|
|
701
|
+
errors.push("triage.creator.account is required when triage.automation.create is true");
|
|
688
702
|
if (automation != null && !isPlainObject(automation)) {
|
|
689
703
|
errors.push("triage.automation must be an object");
|
|
690
704
|
}
|
|
@@ -745,10 +759,10 @@ async function validateAuth(config, exec, errors) {
|
|
|
745
759
|
const agents = resolveAgents(config);
|
|
746
760
|
for (const reviewer of agents.reviewers)
|
|
747
761
|
accounts.add(reviewer.account);
|
|
762
|
+
for (const agent of agents.triage ?? [])
|
|
763
|
+
accounts.add(agent.account);
|
|
748
764
|
if (agents.editor)
|
|
749
765
|
accounts.add(agents.editor.account);
|
|
750
|
-
if (config.triage?.account)
|
|
751
|
-
accounts.add(config.triage.account);
|
|
752
766
|
if (agents.triageCreator?.account)
|
|
753
767
|
accounts.add(agents.triageCreator.account);
|
|
754
768
|
await Promise.all([...accounts].filter(Boolean).map(async (account) => {
|
|
@@ -802,19 +816,18 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
|
|
|
802
816
|
warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
|
|
803
817
|
}
|
|
804
818
|
}));
|
|
805
|
-
|
|
819
|
+
await Promise.all((agents.triage ?? []).map(async (agent) => {
|
|
806
820
|
try {
|
|
807
|
-
const permissions = await fetchPermissions(config, exec,
|
|
821
|
+
const permissions = await fetchPermissions(config, exec, agent.account);
|
|
808
822
|
if (!permissions.pull) {
|
|
809
|
-
errors.push(`GitHub account cannot read repository for issue triage: ${
|
|
823
|
+
errors.push(`GitHub account cannot read repository for issue triage: ${agent.account}`);
|
|
810
824
|
}
|
|
811
825
|
}
|
|
812
826
|
catch (error) {
|
|
813
|
-
warnings.push(`Could not validate repository permissions for GitHub account: ${
|
|
827
|
+
warnings.push(`Could not validate repository permissions for GitHub account: ${agent.account} (${error.message})`);
|
|
814
828
|
}
|
|
815
|
-
}
|
|
816
|
-
if (agents.triageCreator?.account
|
|
817
|
-
agents.triageCreator.account !== config.triage?.account) {
|
|
829
|
+
}));
|
|
830
|
+
if (agents.triageCreator?.account) {
|
|
818
831
|
try {
|
|
819
832
|
const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
|
|
820
833
|
if (!permissions.push) {
|
package/dist/config/worktree.js
CHANGED
|
@@ -17,3 +17,9 @@ export function worktreeBaseDirs(directory, config = {}) {
|
|
|
17
17
|
worktreeBaseDir(directory, config, "issue"),
|
|
18
18
|
];
|
|
19
19
|
}
|
|
20
|
+
export function prRunWorktreeDir(input) {
|
|
21
|
+
return join(worktreeBaseDir(input.directory, input.config, "pr"), String(input.pr), input.runId);
|
|
22
|
+
}
|
|
23
|
+
export function issueRunWorktreeDir(input) {
|
|
24
|
+
return join(worktreeBaseDir(input.directory, input.config, "issue"), String(input.issue), input.runId);
|
|
25
|
+
}
|
package/dist/github/commands.js
CHANGED
|
@@ -20,6 +20,68 @@ function errorText(error) {
|
|
|
20
20
|
.filter((item) => typeof item === "string")
|
|
21
21
|
.join("\n");
|
|
22
22
|
}
|
|
23
|
+
async function localCommitExists(exec, worktreePath, sha) {
|
|
24
|
+
try {
|
|
25
|
+
await exec(`git cat-file -e ${shellQuote(`${sha}^{commit}`)}`, {
|
|
26
|
+
cwd: worktreePath,
|
|
27
|
+
});
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function pullRequestCommitSource(input) {
|
|
35
|
+
if (input.source === "base") {
|
|
36
|
+
return {
|
|
37
|
+
owner: input.repository.github.owner,
|
|
38
|
+
refName: input.meta.baseRefName,
|
|
39
|
+
repo: input.repository.github.repo,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
owner: input.meta.headRepositoryOwner?.login ?? input.repository.github.owner,
|
|
44
|
+
refName: input.meta.headRefName,
|
|
45
|
+
repo: input.meta.headRepository?.name ?? input.repository.github.repo,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function fetchPullRequestCommitSource(input) {
|
|
49
|
+
const commitSource = pullRequestCommitSource(input);
|
|
50
|
+
try {
|
|
51
|
+
await input.exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(input.repository, commitSource.owner, commitSource.repo))} ${shellQuote(`refs/heads/${commitSource.refName}`)}`, { cwd: input.worktreePath });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`Could not fetch ${input.source} ref ${commitSource.refName} for #${input.meta.number}: ${errorText(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function ensurePullRequestCommits(input) {
|
|
58
|
+
const missing = [];
|
|
59
|
+
for (const commit of input.commits) {
|
|
60
|
+
if (!(await localCommitExists(input.exec, input.worktreePath, commit.sha))) {
|
|
61
|
+
missing.push(commit);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const source of new Set(missing.map((commit) => commit.source))) {
|
|
65
|
+
await fetchPullRequestCommitSource({
|
|
66
|
+
exec: input.exec,
|
|
67
|
+
meta: input.meta,
|
|
68
|
+
repository: input.repository,
|
|
69
|
+
source,
|
|
70
|
+
worktreePath: input.worktreePath,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
for (const commit of missing) {
|
|
74
|
+
if (await localCommitExists(input.exec, input.worktreePath, commit.sha)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const source = pullRequestCommitSource({
|
|
78
|
+
meta: input.meta,
|
|
79
|
+
repository: input.repository,
|
|
80
|
+
source: commit.source,
|
|
81
|
+
});
|
|
82
|
+
throw new Error(`${commit.label} commit ${commit.sha} is unavailable after fetching ${commit.source} ref ${source.refName}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
23
85
|
function isCheckoutConfigLockError(error) {
|
|
24
86
|
const text = errorText(error);
|
|
25
87
|
return (/could not lock config file/i.test(text) ||
|
|
@@ -330,16 +392,43 @@ export async function removeIssueLabels(exec, repository, issue, labels, account
|
|
|
330
392
|
return removed;
|
|
331
393
|
}
|
|
332
394
|
export async function fetchPullRequestReviews(exec, repository, pr) {
|
|
333
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
395
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviews(first: 100, after: $cursor) { nodes { author { login } submittedAt state body commit { oid } comments(first: 100) { nodes { body path line startLine } } } pageInfo { hasNextPage endCursor } } } } }`;
|
|
396
|
+
const reviews = [];
|
|
397
|
+
let cursor;
|
|
398
|
+
for (;;) {
|
|
399
|
+
const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
|
|
400
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
|
|
401
|
+
const data = JSON.parse(raw);
|
|
402
|
+
const connection = data.data.repository.pullRequest.reviews;
|
|
403
|
+
reviews.push(...connection.nodes);
|
|
404
|
+
if (!connection.pageInfo?.hasNextPage)
|
|
405
|
+
break;
|
|
406
|
+
cursor = connection.pageInfo.endCursor;
|
|
407
|
+
if (!cursor)
|
|
408
|
+
throw new Error("GitHub reviews page was truncated");
|
|
409
|
+
}
|
|
410
|
+
return reviews.map((review) => ({
|
|
411
|
+
...review,
|
|
412
|
+
comments: review.comments?.nodes ?? [],
|
|
413
|
+
}));
|
|
337
414
|
}
|
|
338
415
|
export async function fetchPullRequestCommits(exec, repository, pr) {
|
|
339
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
416
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { commits(first: 100, after: $cursor) { nodes { commit { oid committedDate parents { totalCount } } } pageInfo { hasNextPage endCursor } } } } }`;
|
|
417
|
+
const commits = [];
|
|
418
|
+
let cursor;
|
|
419
|
+
for (;;) {
|
|
420
|
+
const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
|
|
421
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
|
|
422
|
+
const data = JSON.parse(raw);
|
|
423
|
+
const connection = data.data.repository.pullRequest.commits;
|
|
424
|
+
commits.push(...connection.nodes);
|
|
425
|
+
if (!connection.pageInfo?.hasNextPage)
|
|
426
|
+
break;
|
|
427
|
+
cursor = connection.pageInfo.endCursor;
|
|
428
|
+
if (!cursor)
|
|
429
|
+
throw new Error("GitHub commits page was truncated");
|
|
430
|
+
}
|
|
431
|
+
return commits.map(({ commit }) => ({
|
|
343
432
|
committedDate: commit.committedDate,
|
|
344
433
|
oid: commit.oid,
|
|
345
434
|
parentCount: commit.parents.totalCount,
|
|
@@ -369,38 +458,9 @@ export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
|
|
|
369
458
|
}
|
|
370
459
|
return { author, changedFiles, files, labels };
|
|
371
460
|
}
|
|
372
|
-
export async function waitForChecks(exec, repository, pr, enabled = repository.checks.waitBeforeReview) {
|
|
373
|
-
if (!enabled)
|
|
374
|
-
return undefined;
|
|
375
|
-
const report = {
|
|
376
|
-
attempts: 0,
|
|
377
|
-
excluded: [],
|
|
378
|
-
failed: [],
|
|
379
|
-
rerun: [],
|
|
380
|
-
scopeInside: [],
|
|
381
|
-
scopeOutsideRecovered: [],
|
|
382
|
-
scopeOutsideUnresolved: [],
|
|
383
|
-
};
|
|
384
|
-
try {
|
|
385
|
-
await watchChecks(exec, repository, pr);
|
|
386
|
-
return report;
|
|
387
|
-
}
|
|
388
|
-
catch {
|
|
389
|
-
report.failed = applyCheckExclusions({
|
|
390
|
-
checks: await fetchFailedChecks(exec, repository, pr),
|
|
391
|
-
excluded: report.excluded,
|
|
392
|
-
patterns: repository.checks.exclude,
|
|
393
|
-
});
|
|
394
|
-
return report;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
461
|
export async function watchChecks(exec, repository, pr) {
|
|
398
462
|
await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch`);
|
|
399
463
|
}
|
|
400
|
-
export async function fetchFailedChecks(exec, repository, pr) {
|
|
401
|
-
const checks = await fetchPullRequestChecks(exec, repository, pr);
|
|
402
|
-
return checks.filter((check) => isFailedCheck(check) || isCancelledCheck(check));
|
|
403
|
-
}
|
|
404
464
|
export function isCancelledCheck(check) {
|
|
405
465
|
return check.bucket === "cancel" || check.state === "CANCELLED";
|
|
406
466
|
}
|
|
@@ -472,9 +532,8 @@ export async function fetchMergeQueueRequirement(exec, repository, branch) {
|
|
|
472
532
|
const rules = JSON.parse(raw);
|
|
473
533
|
return rules.some((rule) => rule.type === "merge_queue");
|
|
474
534
|
}
|
|
475
|
-
export async function createWorktree(exec, repository, pr,
|
|
476
|
-
const
|
|
477
|
-
const lockKey = `${repoSpecifier(repository)}:${root}`;
|
|
535
|
+
export async function createWorktree(exec, repository, pr, worktreePath) {
|
|
536
|
+
const lockKey = `${repoSpecifier(repository)}:${dirname(dirname(worktreePath))}`;
|
|
478
537
|
return withWorktreeCreateLock(lockKey, async () => {
|
|
479
538
|
let worktreeAdded = false;
|
|
480
539
|
try {
|
|
@@ -591,6 +650,17 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
|
|
|
591
650
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
592
651
|
}
|
|
593
652
|
}
|
|
653
|
+
export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000) {
|
|
654
|
+
for (;;) {
|
|
655
|
+
const status = await fetchPullRequestMergeStatus(exec, repository, pr);
|
|
656
|
+
if (status.state === "MERGED")
|
|
657
|
+
return "merged";
|
|
658
|
+
if (status.state !== "OPEN" || status.autoMergeRequest == null) {
|
|
659
|
+
return "dequeued";
|
|
660
|
+
}
|
|
661
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
594
664
|
export async function closePullRequest(exec, repository, pr, account) {
|
|
595
665
|
const token = await ghToken(exec, repository, account);
|
|
596
666
|
return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
|
|
@@ -629,11 +699,49 @@ export async function configureGitIdentity(exec, worktreePath, identity) {
|
|
|
629
699
|
}
|
|
630
700
|
}
|
|
631
701
|
export async function fetchUnresolvedThreads(exec, repository, pr, author) {
|
|
632
|
-
const
|
|
633
|
-
const
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
702
|
+
const threadQuery = `query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100, after: $cursor) { nodes { id isResolved comments(first: 100) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } } }`;
|
|
703
|
+
const commentQuery = `query($threadId: ID!, $cursor: String) { node(id: $threadId) { ... on PullRequestReviewThread { comments(first: 100, after: $cursor) { nodes { databaseId author { login } path line body createdAt } pageInfo { hasNextPage endCursor } } } } }`;
|
|
704
|
+
const threads = [];
|
|
705
|
+
let cursor;
|
|
706
|
+
async function fetchRemainingComments(threadId, initialCursor) {
|
|
707
|
+
const comments = [];
|
|
708
|
+
let commentsCursor = initialCursor;
|
|
709
|
+
for (;;) {
|
|
710
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(commentQuery)} -F threadId=${shellQuote(threadId)} -F cursor=${shellQuote(commentsCursor)}`);
|
|
711
|
+
const data = JSON.parse(raw);
|
|
712
|
+
const connection = data.data.node?.comments;
|
|
713
|
+
if (!connection)
|
|
714
|
+
throw new Error("GitHub review thread comments were missing");
|
|
715
|
+
comments.push(...connection.nodes);
|
|
716
|
+
if (!connection.pageInfo?.hasNextPage)
|
|
717
|
+
break;
|
|
718
|
+
const nextCursor = connection.pageInfo.endCursor;
|
|
719
|
+
if (!nextCursor)
|
|
720
|
+
throw new Error("GitHub review thread comments page was truncated");
|
|
721
|
+
commentsCursor = nextCursor;
|
|
722
|
+
}
|
|
723
|
+
return comments;
|
|
724
|
+
}
|
|
725
|
+
for (;;) {
|
|
726
|
+
const cursorFlag = cursor ? ` -F cursor=${shellQuote(cursor)}` : "";
|
|
727
|
+
const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(threadQuery)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F pr=${pr}${cursorFlag}`);
|
|
728
|
+
const data = JSON.parse(raw);
|
|
729
|
+
const connection = data.data.repository.pullRequest.reviewThreads;
|
|
730
|
+
for (const thread of connection.nodes) {
|
|
731
|
+
const commentsCursor = thread.comments.pageInfo?.endCursor;
|
|
732
|
+
if (thread.comments.pageInfo?.hasNextPage) {
|
|
733
|
+
if (!commentsCursor)
|
|
734
|
+
throw new Error("GitHub review thread comments page was truncated");
|
|
735
|
+
thread.comments.nodes.push(...(await fetchRemainingComments(thread.id, commentsCursor)));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
threads.push(...connection.nodes);
|
|
739
|
+
if (!connection.pageInfo?.hasNextPage)
|
|
740
|
+
break;
|
|
741
|
+
cursor = connection.pageInfo.endCursor;
|
|
742
|
+
if (!cursor)
|
|
743
|
+
throw new Error("GitHub review threads page was truncated");
|
|
744
|
+
}
|
|
637
745
|
return threads.flatMap((thread) => {
|
|
638
746
|
if (thread.isResolved || !thread.comments.nodes.length)
|
|
639
747
|
return [];
|
package/dist/index.js
CHANGED
|
@@ -96,6 +96,7 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
|
|
|
96
96
|
const configOverrides = {};
|
|
97
97
|
const prTokens = [];
|
|
98
98
|
let sync = false;
|
|
99
|
+
let timeoutMs;
|
|
99
100
|
for (let index = 0; index < tokens.length; index++) {
|
|
100
101
|
const token = tokens[index];
|
|
101
102
|
if (token === "--dry-run") {
|
|
@@ -110,6 +111,11 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
|
|
|
110
111
|
case "--language":
|
|
111
112
|
setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
|
|
112
113
|
break;
|
|
114
|
+
case "--timeout":
|
|
115
|
+
timeoutMs =
|
|
116
|
+
parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 0) *
|
|
117
|
+
1_000;
|
|
118
|
+
break;
|
|
113
119
|
case "--merge":
|
|
114
120
|
case "--no-merge":
|
|
115
121
|
setConfigOverride(configOverrides, [command, "automation", "merge"], token === "--merge");
|
|
@@ -151,13 +157,20 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
|
|
|
151
157
|
prTokens.push(token);
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
configOverrides,
|
|
162
|
+
dryRun,
|
|
163
|
+
prs: parsePrs(prTokens.join(" ")),
|
|
164
|
+
sync,
|
|
165
|
+
timeoutMs,
|
|
166
|
+
};
|
|
155
167
|
}
|
|
156
168
|
export function parseIssueRunArguments(value, dryRun = false) {
|
|
157
169
|
const tokens = value.split(/[\s,]+/).filter(Boolean);
|
|
158
170
|
const configOverrides = {};
|
|
159
171
|
const issueTokens = [];
|
|
160
172
|
let sync = false;
|
|
173
|
+
let timeoutMs;
|
|
161
174
|
for (let index = 0; index < tokens.length; index++) {
|
|
162
175
|
const token = tokens[index];
|
|
163
176
|
if (token === "--dry-run") {
|
|
@@ -172,6 +185,11 @@ export function parseIssueRunArguments(value, dryRun = false) {
|
|
|
172
185
|
case "--language":
|
|
173
186
|
setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
|
|
174
187
|
break;
|
|
188
|
+
case "--timeout":
|
|
189
|
+
timeoutMs =
|
|
190
|
+
parseIntegerFlag(nextFlagValue(tokens, ++index, token), token, 0) *
|
|
191
|
+
1_000;
|
|
192
|
+
break;
|
|
175
193
|
case "--close":
|
|
176
194
|
case "--no-close":
|
|
177
195
|
setConfigOverride(configOverrides, ["triage", "automation", "close"], token === "--close");
|
|
@@ -210,6 +228,7 @@ export function parseIssueRunArguments(value, dryRun = false) {
|
|
|
210
228
|
dryRun,
|
|
211
229
|
issues: parseIssues(issueTokens.join(" ")),
|
|
212
230
|
sync,
|
|
231
|
+
timeoutMs,
|
|
213
232
|
};
|
|
214
233
|
}
|
|
215
234
|
function nextFlagValue(tokens, index, flag) {
|
|
@@ -306,6 +325,10 @@ function prMarkdownLink(repository, pr) {
|
|
|
306
325
|
const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}`;
|
|
307
326
|
return `[#${pr}](${url})`;
|
|
308
327
|
}
|
|
328
|
+
export function formatRunStartMessage(command, repository, pr) {
|
|
329
|
+
const action = command === "merge" ? "merge flow" : "reviewing";
|
|
330
|
+
return `Started ${action} ${prMarkdownLink(repository, pr)}.`;
|
|
331
|
+
}
|
|
309
332
|
function issueMarkdownLink(repository, issue) {
|
|
310
333
|
const host = repository.github.host || "github.com";
|
|
311
334
|
const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/issues/${issue}`;
|
|
@@ -482,11 +505,12 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
482
505
|
parentSessionId: context.sessionID,
|
|
483
506
|
signal: context.abort,
|
|
484
507
|
sync,
|
|
508
|
+
timeoutMs: parsed.timeoutMs,
|
|
485
509
|
}), { signal: context.abort });
|
|
486
510
|
if (sync)
|
|
487
511
|
return syncResult(runManager, states);
|
|
488
512
|
return states
|
|
489
|
-
.map((state) =>
|
|
513
|
+
.map((state) => formatRunStartMessage("merge", repository, state.pr))
|
|
490
514
|
.join("\n");
|
|
491
515
|
},
|
|
492
516
|
}),
|
|
@@ -523,11 +547,12 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
523
547
|
parentSessionId: context.sessionID,
|
|
524
548
|
signal: context.abort,
|
|
525
549
|
sync,
|
|
550
|
+
timeoutMs: parsed.timeoutMs,
|
|
526
551
|
}), { signal: context.abort });
|
|
527
552
|
if (sync)
|
|
528
553
|
return syncResult(runManager, states);
|
|
529
554
|
return states
|
|
530
|
-
.map((state) =>
|
|
555
|
+
.map((state) => formatRunStartMessage("review", repository, state.pr))
|
|
531
556
|
.join("\n");
|
|
532
557
|
},
|
|
533
558
|
}),
|
|
@@ -567,6 +592,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
567
592
|
repository,
|
|
568
593
|
signal: context.abort,
|
|
569
594
|
sync,
|
|
595
|
+
timeoutMs: parsed.timeoutMs,
|
|
570
596
|
}), { signal: context.abort });
|
|
571
597
|
if (sync)
|
|
572
598
|
return syncResult(runManager, states);
|
package/dist/orchestrator/ci.js
CHANGED
|
@@ -176,7 +176,15 @@ function ciFailureContextForClassified(items, classified) {
|
|
|
176
176
|
return "";
|
|
177
177
|
return [
|
|
178
178
|
"CI has scope-in failures that may be caused by this PR.",
|
|
179
|
-
"
|
|
179
|
+
"Treat these failures as blocking review issues until the checks pass.",
|
|
180
|
+
[
|
|
181
|
+
"Do not approve this PR while this ci_failure_context is present.",
|
|
182
|
+
"Return CHANGES_REQUESTED and include a finding for each failing CI check.",
|
|
183
|
+
].join(" "),
|
|
184
|
+
[
|
|
185
|
+
"Still inspect the PR diff before reporting findings.",
|
|
186
|
+
"If a CI failure does not map to an exact changed line, anchor the finding to the nearest responsible or first relevant changed line.",
|
|
187
|
+
].join(" "),
|
|
180
188
|
"",
|
|
181
189
|
...sections,
|
|
182
190
|
].join("\n\n");
|
|
@@ -343,6 +351,7 @@ async function classifyChecks(input) {
|
|
|
343
351
|
}
|
|
344
352
|
},
|
|
345
353
|
options: reviewer.options,
|
|
354
|
+
parentSessionId: input.parentSessionId,
|
|
346
355
|
parse: (text) => {
|
|
347
356
|
const output = parseCiClassificationOutput(text);
|
|
348
357
|
for (const check of output.checks) {
|
|
@@ -366,18 +375,20 @@ async function classifyChecks(input) {
|
|
|
366
375
|
const rawPath = input.outputDir
|
|
367
376
|
? join(input.outputDir, `${reviewer.key}.ci-classification.raw.txt`)
|
|
368
377
|
: undefined;
|
|
369
|
-
const
|
|
378
|
+
const checks = result.value.checks.map((check) => ({
|
|
379
|
+
classification: check.classification,
|
|
380
|
+
name: check.name,
|
|
381
|
+
reason: check.reason,
|
|
382
|
+
}));
|
|
370
383
|
if (rawPath)
|
|
371
384
|
await writeFile(rawPath, result.raw);
|
|
372
|
-
run.
|
|
385
|
+
run.checks = checks;
|
|
373
386
|
run.rawPath = rawPath;
|
|
374
|
-
run.reason = check?.reason;
|
|
375
387
|
run.sessionId = result.sessionId;
|
|
376
388
|
run.status = "completed";
|
|
377
389
|
await input.onClassifierProgress?.({
|
|
378
|
-
|
|
390
|
+
checks,
|
|
379
391
|
rawPath,
|
|
380
|
-
reason: check?.reason ?? "No classification reason was provided.",
|
|
381
392
|
reviewer: reviewer.key,
|
|
382
393
|
sessionId: result.sessionId,
|
|
383
394
|
type: "classifier_completed",
|
|
@@ -392,22 +403,20 @@ async function classifyChecks(input) {
|
|
|
392
403
|
reviewer: reviewer.key,
|
|
393
404
|
type: "classifier_failed",
|
|
394
405
|
});
|
|
395
|
-
|
|
406
|
+
throw error;
|
|
396
407
|
}
|
|
397
408
|
}, { signal: input.signal });
|
|
398
409
|
const threshold = majorityThreshold(reviewers.length);
|
|
399
410
|
return {
|
|
400
411
|
classified: input.checks.map((item) => {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
const check = vote.output?.checks.find((output) => output.name === item.check.name);
|
|
412
|
+
const checkVotes = votes.map((vote) => {
|
|
413
|
+
const check = vote.output.checks.find((output) => output.name === item.check.name);
|
|
404
414
|
return {
|
|
405
415
|
classification: check?.classification ?? "SCOPE_IN",
|
|
406
416
|
reason: check?.reason ?? "Missing classification; treated as scope-in.",
|
|
407
417
|
reviewer: vote.reviewer,
|
|
408
418
|
};
|
|
409
419
|
});
|
|
410
|
-
const failures = votes.filter((vote) => !vote.output);
|
|
411
420
|
const scopeIn = checkVotes.filter((vote) => vote.classification === "SCOPE_IN");
|
|
412
421
|
const scopeOut = checkVotes.filter((vote) => vote.classification === "SCOPE_OUT");
|
|
413
422
|
const classification = scopeOut.length >= threshold
|
|
@@ -421,9 +430,6 @@ async function classifyChecks(input) {
|
|
|
421
430
|
const reasons = checkVotes
|
|
422
431
|
.filter((vote) => vote.classification === classification)
|
|
423
432
|
.map((vote) => `${vote.reviewer}: ${vote.reason}`);
|
|
424
|
-
for (const failure of failures) {
|
|
425
|
-
reasons.push(`${failure.reviewer}: classifier failed; vote ignored`);
|
|
426
|
-
}
|
|
427
433
|
return {
|
|
428
434
|
check: item.check,
|
|
429
435
|
classification,
|
|
@@ -505,6 +511,7 @@ export async function waitForChecksWithClassification(input) {
|
|
|
505
511
|
directory: input.directory,
|
|
506
512
|
onClassifierProgress: input.onClassifierProgress,
|
|
507
513
|
outputDir: input.outputDir,
|
|
514
|
+
parentSessionId: input.parentSessionId,
|
|
508
515
|
pr: input.pr,
|
|
509
516
|
repairAttempts: input.repairAttempts,
|
|
510
517
|
repository: input.repository,
|