opencode-magi 0.4.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/README.md +53 -45
- 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 +167 -92
- package/dist/index.js +69 -4
- package/dist/orchestrator/ci.js +21 -14
- package/dist/orchestrator/findings.js +28 -7
- package/dist/orchestrator/inline-comments.js +0 -6
- package/dist/orchestrator/majority.js +1 -1
- package/dist/orchestrator/merge.js +46 -47
- package/dist/orchestrator/model.js +23 -9
- package/dist/orchestrator/report.js +7 -18
- package/dist/orchestrator/review-context.js +37 -4
- package/dist/orchestrator/review.js +174 -61
- package/dist/orchestrator/run-manager.js +209 -138
- package/dist/orchestrator/triage.js +243 -201
- package/dist/prompts/compose.js +2 -10
- package/dist/prompts/contracts.js +36 -57
- package/dist/prompts/output.js +28 -56
- package/dist/prompts/templates/merge/edit.md +1 -2
- package/dist/prompts/templates/review/close-reconsideration.md +1 -0
- package/dist/prompts/templates/review/rereview.md +3 -0
- package/dist/prompts/templates/review/review.md +4 -0
- package/package.json +1 -1
- package/schema.json +3 -3
- package/dist/prompts/templates/triage/action.md +0 -5
package/README.md
CHANGED
|
@@ -61,26 +61,33 @@ Add the following content to the configuration file.
|
|
|
61
61
|
```json
|
|
62
62
|
{
|
|
63
63
|
"$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
{
|
|
67
|
-
"
|
|
68
|
-
"
|
|
64
|
+
"agents": {
|
|
65
|
+
"refs": {
|
|
66
|
+
"account-1": {
|
|
67
|
+
"model": "openai/gpt-5.5",
|
|
68
|
+
"account": "account-1"
|
|
69
69
|
},
|
|
70
|
-
{
|
|
71
|
-
"
|
|
72
|
-
"
|
|
70
|
+
"account-2": {
|
|
71
|
+
"model": "anthropic/claude-opus-4-7",
|
|
72
|
+
"account": "account-2"
|
|
73
73
|
},
|
|
74
|
-
{
|
|
75
|
-
"
|
|
76
|
-
"
|
|
74
|
+
"account-3": {
|
|
75
|
+
"model": "opencode/kimi-k2-6",
|
|
76
|
+
"account": "account-3"
|
|
77
77
|
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"review": {
|
|
81
|
+
"agents": [
|
|
82
|
+
{ "ref": "account-1" },
|
|
83
|
+
{ "ref": "account-2" },
|
|
84
|
+
{ "ref": "account-3" }
|
|
78
85
|
]
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
```
|
|
82
89
|
|
|
83
|
-
`review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
|
|
90
|
+
After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
|
|
84
91
|
|
|
85
92
|
#### Set project config
|
|
86
93
|
|
|
@@ -101,53 +108,54 @@ Add the following content to the configuration file.
|
|
|
101
108
|
"owner": "your-owner",
|
|
102
109
|
"repo": "your-repo"
|
|
103
110
|
},
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
{
|
|
107
|
-
"
|
|
108
|
-
"
|
|
111
|
+
"agents": {
|
|
112
|
+
"refs": {
|
|
113
|
+
"account-1": {
|
|
114
|
+
"model": "openai/gpt-5.5",
|
|
115
|
+
"account": "account-1"
|
|
109
116
|
},
|
|
110
|
-
{
|
|
111
|
-
"
|
|
112
|
-
"
|
|
117
|
+
"account-2": {
|
|
118
|
+
"model": "anthropic/claude-opus-4-7",
|
|
119
|
+
"account": "account-2"
|
|
113
120
|
},
|
|
114
|
-
{
|
|
115
|
-
"
|
|
116
|
-
"
|
|
121
|
+
"account-3": {
|
|
122
|
+
"model": "opencode/kimi-k2-6",
|
|
123
|
+
"account": "account-3"
|
|
124
|
+
},
|
|
125
|
+
"account-4": {
|
|
126
|
+
"model": "openai/gpt-5.5",
|
|
127
|
+
"account": "account-4",
|
|
128
|
+
"author": {
|
|
129
|
+
"name": "account-4",
|
|
130
|
+
"email": "your-email@example.com"
|
|
131
|
+
}
|
|
117
132
|
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"review": {
|
|
136
|
+
"agents": [
|
|
137
|
+
{ "ref": "account-1" },
|
|
138
|
+
{ "ref": "account-2" },
|
|
139
|
+
{ "ref": "account-3" }
|
|
118
140
|
]
|
|
119
141
|
},
|
|
120
142
|
"merge": {
|
|
121
|
-
"editor": {
|
|
122
|
-
"account": "your-editor-account",
|
|
123
|
-
"model": "openai/gpt-5.5",
|
|
124
|
-
"author": {
|
|
125
|
-
"name": "your-account",
|
|
126
|
-
"email": "your-email@example.com"
|
|
127
|
-
}
|
|
128
|
-
}
|
|
143
|
+
"editor": { "ref": "account-4" }
|
|
129
144
|
},
|
|
130
145
|
"triage": {
|
|
131
|
-
"account": "
|
|
146
|
+
"account": "account-5",
|
|
132
147
|
"agents": [
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
"id": "maintenance",
|
|
139
|
-
"model": "anthropic/claude-opus-4-7"
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
"id": "product",
|
|
143
|
-
"model": "opencode/kimi-k2-6"
|
|
144
|
-
}
|
|
148
|
+
{ "ref": "account-1" },
|
|
149
|
+
{ "ref": "account-2" },
|
|
150
|
+
{ "ref": "account-3" }
|
|
145
151
|
]
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
```
|
|
149
155
|
|
|
150
|
-
|
|
156
|
+
Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` override fields from the preset.
|
|
157
|
+
|
|
158
|
+
After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
|
|
151
159
|
|
|
152
160
|
#### Validate config
|
|
153
161
|
|
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) ||
|
|
@@ -246,6 +308,12 @@ function duplicateReferences(text) {
|
|
|
246
308
|
refs.add(Number(match[1]));
|
|
247
309
|
return [...refs];
|
|
248
310
|
}
|
|
311
|
+
function issueTitleSearchQuery(title, fallback) {
|
|
312
|
+
return (title
|
|
313
|
+
.replaceAll(/[^\p{L}\p{N}_]+/gu, " ")
|
|
314
|
+
.replaceAll(/\s+/g, " ")
|
|
315
|
+
.trim() || fallback);
|
|
316
|
+
}
|
|
249
317
|
async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
|
|
250
318
|
const raw = await exec(`gh issue view ${number} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,state,body,createdAt`).catch(() => undefined);
|
|
251
319
|
if (!raw)
|
|
@@ -254,7 +322,7 @@ async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
|
|
|
254
322
|
return { ...data, whyCandidate };
|
|
255
323
|
}
|
|
256
324
|
export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
|
|
257
|
-
const query = issue.title;
|
|
325
|
+
const query = issueTitleSearchQuery(issue.title, String(issue.number));
|
|
258
326
|
const explicitCandidates = await Promise.all(duplicateReferences(issue.body)
|
|
259
327
|
.filter((number) => number !== issue.number)
|
|
260
328
|
.map((number) => fetchIssueCandidate(exec, repository, number, "Issue body explicitly references a duplicate target.")));
|
|
@@ -324,16 +392,43 @@ export async function removeIssueLabels(exec, repository, issue, labels, account
|
|
|
324
392
|
return removed;
|
|
325
393
|
}
|
|
326
394
|
export async function fetchPullRequestReviews(exec, repository, pr) {
|
|
327
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}));
|
|
331
414
|
}
|
|
332
415
|
export async function fetchPullRequestCommits(exec, repository, pr) {
|
|
333
|
-
const query = `query($owner: String!, $repo: String!, $pr: Int
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
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 }) => ({
|
|
337
432
|
committedDate: commit.committedDate,
|
|
338
433
|
oid: commit.oid,
|
|
339
434
|
parentCount: commit.parents.totalCount,
|
|
@@ -363,38 +458,9 @@ export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
|
|
|
363
458
|
}
|
|
364
459
|
return { author, changedFiles, files, labels };
|
|
365
460
|
}
|
|
366
|
-
export async function waitForChecks(exec, repository, pr, enabled = repository.checks.waitBeforeReview) {
|
|
367
|
-
if (!enabled)
|
|
368
|
-
return undefined;
|
|
369
|
-
const report = {
|
|
370
|
-
attempts: 0,
|
|
371
|
-
excluded: [],
|
|
372
|
-
failed: [],
|
|
373
|
-
rerun: [],
|
|
374
|
-
scopeInside: [],
|
|
375
|
-
scopeOutsideRecovered: [],
|
|
376
|
-
scopeOutsideUnresolved: [],
|
|
377
|
-
};
|
|
378
|
-
try {
|
|
379
|
-
await watchChecks(exec, repository, pr);
|
|
380
|
-
return report;
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
report.failed = applyCheckExclusions({
|
|
384
|
-
checks: await fetchFailedChecks(exec, repository, pr),
|
|
385
|
-
excluded: report.excluded,
|
|
386
|
-
patterns: repository.checks.exclude,
|
|
387
|
-
});
|
|
388
|
-
return report;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
461
|
export async function watchChecks(exec, repository, pr) {
|
|
392
462
|
await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch`);
|
|
393
463
|
}
|
|
394
|
-
export async function fetchFailedChecks(exec, repository, pr) {
|
|
395
|
-
const checks = await fetchPullRequestChecks(exec, repository, pr);
|
|
396
|
-
return checks.filter((check) => isFailedCheck(check) || isCancelledCheck(check));
|
|
397
|
-
}
|
|
398
464
|
export function isCancelledCheck(check) {
|
|
399
465
|
return check.bucket === "cancel" || check.state === "CANCELLED";
|
|
400
466
|
}
|
|
@@ -466,9 +532,8 @@ export async function fetchMergeQueueRequirement(exec, repository, branch) {
|
|
|
466
532
|
const rules = JSON.parse(raw);
|
|
467
533
|
return rules.some((rule) => rule.type === "merge_queue");
|
|
468
534
|
}
|
|
469
|
-
export async function createWorktree(exec, repository, pr,
|
|
470
|
-
const
|
|
471
|
-
const lockKey = `${repoSpecifier(repository)}:${root}`;
|
|
535
|
+
export async function createWorktree(exec, repository, pr, worktreePath) {
|
|
536
|
+
const lockKey = `${repoSpecifier(repository)}:${dirname(dirname(worktreePath))}`;
|
|
472
537
|
return withWorktreeCreateLock(lockKey, async () => {
|
|
473
538
|
let worktreeAdded = false;
|
|
474
539
|
try {
|
|
@@ -509,9 +574,6 @@ export async function postCloseComment(exec, repository, pr, account, body) {
|
|
|
509
574
|
await rm(payloadPath, { force: true });
|
|
510
575
|
}
|
|
511
576
|
}
|
|
512
|
-
function isInlineFinding(finding) {
|
|
513
|
-
return finding.line != null;
|
|
514
|
-
}
|
|
515
577
|
function findingComment(finding) {
|
|
516
578
|
const comment = {
|
|
517
579
|
body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
|
|
@@ -525,54 +587,18 @@ function findingComment(finding) {
|
|
|
525
587
|
}
|
|
526
588
|
return comment;
|
|
527
589
|
}
|
|
528
|
-
function
|
|
529
|
-
return
|
|
530
|
-
|
|
531
|
-
`
|
|
532
|
-
` Fix: ${finding.fix}`,
|
|
533
|
-
].join("\n");
|
|
534
|
-
}
|
|
535
|
-
function findingLocation(finding) {
|
|
536
|
-
if (finding.line == null)
|
|
537
|
-
return finding.path;
|
|
538
|
-
if (finding.startLine == null)
|
|
539
|
-
return `${finding.path}:${finding.line}`;
|
|
540
|
-
return `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
541
|
-
}
|
|
542
|
-
function findingSummary(finding) {
|
|
543
|
-
return [
|
|
544
|
-
`- ${findingLocation(finding)}: ${finding.issue}`,
|
|
545
|
-
` Fix: ${finding.fix}`,
|
|
546
|
-
]
|
|
547
|
-
.filter(Boolean)
|
|
548
|
-
.join("\n");
|
|
549
|
-
}
|
|
550
|
-
function changesRequestedBody(findings, requirementFindings) {
|
|
551
|
-
const inlineFindings = findings.filter(isInlineFinding);
|
|
552
|
-
const fileLevelFindings = findings.filter((finding) => !isInlineFinding(finding));
|
|
553
|
-
const sections = [];
|
|
554
|
-
if (inlineFindings.length) {
|
|
555
|
-
sections.push(["Inline findings:", ...inlineFindings.map(findingSummary)].join("\n"));
|
|
556
|
-
}
|
|
557
|
-
if (fileLevelFindings.length) {
|
|
558
|
-
sections.push(["File-level findings:", ...fileLevelFindings.map(findingSummary)].join("\n"));
|
|
559
|
-
}
|
|
560
|
-
if (requirementFindings.length) {
|
|
561
|
-
sections.push([
|
|
562
|
-
"Requirement findings:",
|
|
563
|
-
...requirementFindings.map(requirementFindingSummary),
|
|
564
|
-
].join("\n"));
|
|
565
|
-
}
|
|
566
|
-
return sections.join("\n\n");
|
|
590
|
+
function changesRequestedBody(findings) {
|
|
591
|
+
return findings.length === 1
|
|
592
|
+
? "Changes requested: 1 inline comment."
|
|
593
|
+
: `Changes requested: ${findings.length} inline comments.`;
|
|
567
594
|
}
|
|
568
|
-
export async function postChangesRequested(exec, repository, pr, account, findings
|
|
595
|
+
export async function postChangesRequested(exec, repository, pr, account, findings) {
|
|
569
596
|
const token = await ghToken(exec, repository, account);
|
|
570
597
|
const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
|
|
571
|
-
const
|
|
572
|
-
const body = changesRequestedBody(findings, requirementFindings);
|
|
598
|
+
const body = changesRequestedBody(findings);
|
|
573
599
|
await writeFile(payloadPath, JSON.stringify({
|
|
574
600
|
body,
|
|
575
|
-
comments:
|
|
601
|
+
comments: findings.map(findingComment),
|
|
576
602
|
event: "REQUEST_CHANGES",
|
|
577
603
|
}));
|
|
578
604
|
try {
|
|
@@ -624,6 +650,17 @@ export async function waitForMergeQueue(exec, repository, pr, intervalMs = 30_00
|
|
|
624
650
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
625
651
|
}
|
|
626
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
|
+
}
|
|
627
664
|
export async function closePullRequest(exec, repository, pr, account) {
|
|
628
665
|
const token = await ghToken(exec, repository, account);
|
|
629
666
|
return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
|
|
@@ -662,11 +699,49 @@ export async function configureGitIdentity(exec, worktreePath, identity) {
|
|
|
662
699
|
}
|
|
663
700
|
}
|
|
664
701
|
export async function fetchUnresolvedThreads(exec, repository, pr, author) {
|
|
665
|
-
const
|
|
666
|
-
const
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
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
|
+
}
|
|
670
745
|
return threads.flatMap((thread) => {
|
|
671
746
|
if (thread.isResolved || !thread.comments.nodes.length)
|
|
672
747
|
return [];
|