agentme 0.35.2 → 0.37.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/.filedist-package.yml +1 -1
- package/.xdrs/agentme/bdrs/index.md +2 -1
- package/.xdrs/agentme/bdrs/operations/{401-epic-feature-story-planning.md → 401-plan-epic-feature-story.md} +2 -2
- package/.xdrs/agentme/bdrs/operations/402-digital-product-roles.md +15 -19
- package/.xdrs/agentme/bdrs/operations/403-product-team-composition.md +3 -0
- package/.xdrs/agentme/bdrs/operations/404-team-roles-and-specialists.md +61 -82
- package/.xdrs/agentme/bdrs/operations/405-digital-product-roles-raci.md +131 -0
- package/.xdrs/agentme/bdrs/principles/002-okr-framework.md +1 -1
- package/.xdrs/agentme/bdrs/principles/003-product-delivery-value-flow.md +3 -2
- package/.xdrs/agentme/edrs/application/101-javascript-project-tooling.md +1 -1
- package/.xdrs/agentme/edrs/application/102-golang-project-tooling.md +1 -1
- package/.xdrs/agentme/edrs/application/103-python-project-tooling.md +1 -1
- package/.xdrs/agentme/edrs/application/127-external-system-adapter-skills.md +2 -0
- package/.xdrs/agentme/edrs/application/skills/azure-devops-connector/SKILL.md +202 -0
- package/.xdrs/agentme/edrs/application/skills/azure-devops-connector/SKILL.test.md +114 -0
- package/.xdrs/agentme/edrs/application/skills/{051-create-golang-project → create-golang-project}/SKILL.md +1 -1
- package/.xdrs/agentme/edrs/application/skills/create-golang-project/SKILL.test.md +72 -0
- package/.xdrs/agentme/edrs/application/skills/{050-create-javascript-project → create-javascript-project}/SKILL.md +23 -24
- package/.xdrs/agentme/edrs/application/skills/create-javascript-project/SKILL.test.md +72 -0
- package/.xdrs/agentme/edrs/application/skills/{052-create-python-project → create-python-project}/SKILL.md +1 -1
- package/.xdrs/agentme/edrs/application/skills/create-python-project/SKILL.test.md +79 -0
- package/.xdrs/agentme/edrs/application/skills/github-connector/SKILL.md +184 -0
- package/.xdrs/agentme/edrs/application/skills/github-connector/SKILL.test.md +118 -0
- package/.xdrs/agentme/edrs/application/skills/{200-run-skill-tests → run-skill-tests}/SKILL.md +4 -4
- package/.xdrs/agentme/edrs/application/skills/{200-run-skill-tests → run-skill-tests}/SKILL.test.md +7 -7
- package/.xdrs/agentme/edrs/application/skills/{010-select-relevant-xdrs → select-relevant-xdrs}/SKILL.md +1 -1
- package/.xdrs/agentme/edrs/application/skills/select-relevant-xdrs/SKILL.test.md +73 -0
- package/.xdrs/agentme/edrs/governance/502-contributing-guide-requirements.md +1 -1
- package/.xdrs/agentme/edrs/index.md +16 -8
- package/.xdrs/agentme/edrs/operations/402-dora-metrics-framework.md +82 -0
- package/.xdrs/agentme/edrs/operations/403-dora-deployment-frequency.md +37 -0
- package/.xdrs/agentme/edrs/operations/404-dora-lead-time-for-changes.md +41 -0
- package/.xdrs/agentme/edrs/operations/405-dora-change-failure-rate.md +42 -0
- package/.xdrs/agentme/edrs/operations/406-dora-time-to-restore-service.md +42 -0
- package/.xdrs/agentme/edrs/platform/301-monorepo-structure.md +1 -1
- package/.xdrs/agentme/edrs/platform/skills/{053-monorepo-setup → monorepo-setup}/SKILL.md +15 -15
- package/.xdrs/agentme/edrs/platform/skills/monorepo-setup/SKILL.test.md +80 -0
- package/.xdrs/agentme/edrs/principles/012-continuous-xdr-enrichment.md +1 -1
- package/.xdrs/agentme/edrs/principles/017-skill-testing.md +7 -4
- package/.xdrs/agentme/edrs/principles/articles/001-continuous-xdr-improvement.md +1 -1
- package/.xdrs/agentme/edrs/principles/skills/{150-refine-plan-mode → refine-plan-mode}/SKILL.md +27 -16
- package/.xdrs/agentme/edrs/principles/skills/{150-refine-plan-mode → refine-plan-mode}/SKILL.test.md +28 -4
- package/.xdrs/agentme/edrs/principles/skills/{151-refine-user-story → refine-user-story}/SKILL.md +4 -11
- package/.xdrs/agentme/edrs/principles/skills/{151-refine-user-story → refine-user-story}/SKILL.test.md +17 -17
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/Makefile +8 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/SKILL.md +662 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/SKILL.test.md +165 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-azure-devops.js +219 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-azure-devops.test.js +253 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.js +237 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.test.js +272 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/update-section.js +327 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/update-section.test.js +306 -0
- package/package.json +2 -2
package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Applies resolve-pr-comments tracking-file drafts to a live GitHub PR, using the `gh`
|
|
6
|
+
* commands documented in github-connector's "Writing data" section. Delegates every
|
|
7
|
+
* tracking-file read/write to the sibling `update-section.js` script so there is exactly
|
|
8
|
+
* one place that understands the file format. Mirrors post-replies-azure-devops.js's
|
|
9
|
+
* interface and verify-after-write discipline for parity across both connectors.
|
|
10
|
+
*
|
|
11
|
+
* Every write is confirmed by an independent read-back before the tracking file is ever
|
|
12
|
+
* marked `pending-reply: applied` -- not because `gh` is known to exit 0 without persisting
|
|
13
|
+
* (unlike `az rest`, see azure-devops-connector's Known Issues), but so both scripts
|
|
14
|
+
* make the same guarantee and a GraphQL mutation's rarer partial-failure shape is still
|
|
15
|
+
* caught.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* post-replies-github.js --pr-url <url> <tracking-file> [--dry-run] [--only <id>[,<id>...]]
|
|
19
|
+
*
|
|
20
|
+
* <url> is the PR's GitHub URL; owner/repo/PR number are all parsed from it.
|
|
21
|
+
* Processes every section whose `pending-reply:` is currently `drafted`, or exactly the
|
|
22
|
+
* `--only` ids (regardless of their current `pending-reply` state) when given. For each:
|
|
23
|
+
* skips posting if the exact reply text is already present (verified idempotency, never
|
|
24
|
+
* assumed), otherwise posts `reply-draft` per the section's `id` kind (`issue-comment` via
|
|
25
|
+
* the issue-comments endpoint, `review-comment` via the review-comments endpoint with
|
|
26
|
+
* `in_reply_to`) and verifies via a follow-up read; then, if `resolve-on-apply: true` and
|
|
27
|
+
* the kind is `review-comment` (the only resolvable kind), resolves the review thread via
|
|
28
|
+
* the `resolveReviewThread` GraphQL mutation and verifies `isResolved` too. Only after these
|
|
29
|
+
* checks pass does it mark `pending-reply: applied` (and `status: resolved`) via
|
|
30
|
+
* update-section.js. `GH_BIN` (default `gh`) overrides the CLI binary invoked, for testing
|
|
31
|
+
* with a stub.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { execFileSync } = require('child_process');
|
|
37
|
+
|
|
38
|
+
const UPDATE_SECTION = path.join(__dirname, 'update-section.js');
|
|
39
|
+
const [GH_BIN, ...GH_PREFIX_ARGS] = (process.env.GH_BIN || 'gh').split(' ');
|
|
40
|
+
|
|
41
|
+
function parsePrUrl(url) {
|
|
42
|
+
const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(url);
|
|
43
|
+
if (!m) throw new Error(`unrecognized GitHub PR URL: ${url}`);
|
|
44
|
+
return { owner: m[1], repo: m[2], pr: m[3] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Rejoins hand-wrapped lines within a paragraph into one logical line, preserving genuine
|
|
48
|
+
// blank-line paragraph breaks -- tolerant of drafts written either wrapped or unwrapped.
|
|
49
|
+
function joinReplyDraft(rawText) {
|
|
50
|
+
const paragraphs = [];
|
|
51
|
+
let cur = [];
|
|
52
|
+
for (const line of rawText.split('\n')) {
|
|
53
|
+
if (line.trim() === '') {
|
|
54
|
+
if (cur.length) {
|
|
55
|
+
paragraphs.push(cur.join(' '));
|
|
56
|
+
cur = [];
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
cur.push(line.trimEnd());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (cur.length) paragraphs.push(cur.join(' '));
|
|
63
|
+
return paragraphs.join('\n\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const args = { file: null, prUrl: null, dryRun: false, only: null };
|
|
68
|
+
const rest = [];
|
|
69
|
+
for (let i = 0; i < argv.length; i++) {
|
|
70
|
+
const a = argv[i];
|
|
71
|
+
if (a === '--pr-url') args.prUrl = argv[++i];
|
|
72
|
+
else if (a === '--dry-run') args.dryRun = true;
|
|
73
|
+
else if (a === '--only') {
|
|
74
|
+
args.only = argv[++i]
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
} else rest.push(a);
|
|
79
|
+
}
|
|
80
|
+
[args.file] = rest;
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runUpdateSection(cmdArgs, input) {
|
|
85
|
+
return execFileSync('node', [UPDATE_SECTION, ...cmdArgs], { input, encoding: 'utf8' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function listCandidateSections(file, only) {
|
|
89
|
+
const stdout = runUpdateSection(['list', file]);
|
|
90
|
+
const rows = [];
|
|
91
|
+
let cur = null;
|
|
92
|
+
for (const line of stdout.split('\n')) {
|
|
93
|
+
const idMatch = /^- id: (.*)$/.exec(line);
|
|
94
|
+
if (idMatch) {
|
|
95
|
+
if (cur) rows.push(cur);
|
|
96
|
+
cur = { id: idMatch[1] };
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!cur) continue;
|
|
100
|
+
const fieldMatch = /^ {2}([\w-]+): ?(.*)$/.exec(line);
|
|
101
|
+
if (fieldMatch) cur[fieldMatch[1]] = fieldMatch[2];
|
|
102
|
+
}
|
|
103
|
+
if (cur) rows.push(cur);
|
|
104
|
+
return rows.filter((r) => (only ? only.includes(r.id) : r['pending-reply'] === 'drafted'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function gh(args) {
|
|
108
|
+
return execFileSync(GH_BIN, [...GH_PREFIX_ARGS, ...args], { stdio: 'pipe' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ghJson(args) {
|
|
112
|
+
return JSON.parse(gh(args).toString());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function commentsEndpoint(ctx, kind) {
|
|
116
|
+
const { owner, repo, pr } = ctx;
|
|
117
|
+
return kind === 'review-comment' ? `repos/${owner}/${repo}/pulls/${pr}/comments` : `repos/${owner}/${repo}/issues/${pr}/comments`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findReviewThread(ctx, rootCommentId) {
|
|
121
|
+
const query = `query($owner:String!,$repo:String!,$pr:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$pr){reviewThreads(first:100){nodes{id isResolved comments(first:1){nodes{databaseId}}}}}}}`;
|
|
122
|
+
const out = ghJson(['api', 'graphql', '-f', `query=${query}`, '-f', `owner=${ctx.owner}`, '-f', `repo=${ctx.repo}`, '-F', `pr=${ctx.pr}`]);
|
|
123
|
+
const nodes = out.data.repository.pullRequest.reviewThreads.nodes;
|
|
124
|
+
return nodes.find((n) => String(n.comments.nodes[0] && n.comments.nodes[0].databaseId) === String(rootCommentId));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveReviewThread(nodeId) {
|
|
128
|
+
const mutation = `mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}`;
|
|
129
|
+
return ghJson(['api', 'graphql', '-f', `query=${mutation}`, '-f', `id=${nodeId}`]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applySection(ctx, file, row) {
|
|
133
|
+
const m = /^(issue-comment|review-comment|review-summary)\/(\d+)$/.exec(row.id);
|
|
134
|
+
if (!m) return { id: row.id, ok: false, error: `id is not a <kind>/<numeric-id> value: ${row.id}` };
|
|
135
|
+
const [, kind, numericId] = m;
|
|
136
|
+
const resolveOnApply = row['resolve-on-apply'] === 'true' && kind === 'review-comment';
|
|
137
|
+
const rawDraft = runUpdateSection(['get', file, row.id, 'reply-draft']).replace(/\n$/, '');
|
|
138
|
+
const replyText = joinReplyDraft(rawDraft);
|
|
139
|
+
const endpoint = commentsEndpoint(ctx, kind);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const isReviewComment = kind === 'review-comment';
|
|
143
|
+
const before = ghJson(['api', endpoint, '--paginate']);
|
|
144
|
+
let alreadyPosted = isReviewComment
|
|
145
|
+
? before.some((c) => c.body === replyText && String(c.in_reply_to_id) === numericId)
|
|
146
|
+
: before.some((c) => c.body === replyText);
|
|
147
|
+
|
|
148
|
+
if (!alreadyPosted) {
|
|
149
|
+
const postArgs = ['api', endpoint, '-f', `body=${replyText}`];
|
|
150
|
+
if (isReviewComment) postArgs.push('-F', `in_reply_to=${numericId}`);
|
|
151
|
+
gh(postArgs);
|
|
152
|
+
|
|
153
|
+
// A 2xx exit is not proof the write persisted -- always read back to confirm.
|
|
154
|
+
const after = ghJson(['api', endpoint, '--paginate']);
|
|
155
|
+
alreadyPosted = isReviewComment
|
|
156
|
+
? after.some((c) => c.body === replyText && String(c.in_reply_to_id) === numericId)
|
|
157
|
+
: after.some((c) => c.body === replyText);
|
|
158
|
+
if (!alreadyPosted) {
|
|
159
|
+
return { id: row.id, ok: false, error: 'gh api exited 0 but reply missing on read-back verification' };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let resolved = false;
|
|
164
|
+
if (resolveOnApply) {
|
|
165
|
+
const thread = findReviewThread(ctx, numericId);
|
|
166
|
+
if (!thread) return { id: row.id, ok: false, error: `no review thread found rooted at comment ${numericId}` };
|
|
167
|
+
if (thread.isResolved) {
|
|
168
|
+
resolved = true;
|
|
169
|
+
} else {
|
|
170
|
+
resolveReviewThread(thread.id);
|
|
171
|
+
const after = findReviewThread(ctx, numericId);
|
|
172
|
+
resolved = Boolean(after && after.isResolved);
|
|
173
|
+
if (!resolved) {
|
|
174
|
+
return { id: row.id, ok: false, error: 'resolveReviewThread exited 0 but isResolved false on read-back verification' };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Only ever mark the tracking file after independent verification above, never on a
|
|
180
|
+
// bare `gh` exit code.
|
|
181
|
+
runUpdateSection(['set', file, row.id, 'pending-reply', 'applied']);
|
|
182
|
+
if (resolved) runUpdateSection(['set', file, row.id, 'status', 'resolved']);
|
|
183
|
+
return { id: row.id, ok: true, resolved };
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return { id: row.id, ok: false, error: String(e.stderr || e.message) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function main(argv) {
|
|
190
|
+
const args = parseArgs(argv);
|
|
191
|
+
if (!args.file || !args.prUrl) {
|
|
192
|
+
console.error('Usage: post-replies-github.js --pr-url <url> <tracking-file> [--dry-run] [--only <id>[,<id>...]]');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const ctx = parsePrUrl(args.prUrl);
|
|
197
|
+
|
|
198
|
+
const rows = listCandidateSections(args.file, args.only);
|
|
199
|
+
if (rows.length === 0) {
|
|
200
|
+
console.log('No sections to apply (nothing pending-reply: drafted).');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const results = [];
|
|
205
|
+
if (args.dryRun) {
|
|
206
|
+
for (const row of rows) {
|
|
207
|
+
const rawDraft = runUpdateSection(['get', args.file, row.id, 'reply-draft']).replace(/\n$/, '');
|
|
208
|
+
console.log(`\n=== ${row.id} (resolve=${row['resolve-on-apply'] === 'true'}) ===\n${joinReplyDraft(rawDraft)}`);
|
|
209
|
+
results.push({ id: row.id, ok: true, dryRun: true });
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
const result = applySection(ctx, args.file, row);
|
|
214
|
+
results.push(result);
|
|
215
|
+
if (result.ok) {
|
|
216
|
+
console.log(`OK ${result.id} replied+verified${result.resolved ? ' + resolved+verified' : ''}`);
|
|
217
|
+
} else {
|
|
218
|
+
console.error(`FAIL ${result.id}: ${result.error}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
console.log('\n--- SUMMARY ---');
|
|
224
|
+
console.log(JSON.stringify(results, null, 2));
|
|
225
|
+
if (results.some((r) => !r.ok)) process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (require.main === module) {
|
|
229
|
+
try {
|
|
230
|
+
main(process.argv.slice(2));
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(`Error: ${err.message}`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { parsePrUrl, joinReplyDraft };
|
package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.test.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { execFileSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const SCRIPT = path.join(__dirname, 'post-replies-github.js');
|
|
11
|
+
const { parsePrUrl, joinReplyDraft } = require('./post-replies-github.js');
|
|
12
|
+
|
|
13
|
+
const PR_URL = 'https://github.com/acme/widgets/pull/482';
|
|
14
|
+
|
|
15
|
+
// Fake `gh` used via GH_BIN: reads/writes a JSON file (FAKE_GH_STATE) to simulate PR state,
|
|
16
|
+
// so tests never touch the network. Supports the `api <path> [-f k=v...] [-F k=v...]
|
|
17
|
+
// [--paginate]` and `api graphql -f query=<...> [-f/-F ...]` shapes the real script calls.
|
|
18
|
+
const FAKE_GH_SRC = `
|
|
19
|
+
'use strict';
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const statePath = process.env.FAKE_GH_STATE;
|
|
22
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
|
|
25
|
+
function fieldFlags(flag) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
if (argv[i] === flag) {
|
|
29
|
+
const raw = argv[i + 1];
|
|
30
|
+
const eq = raw.indexOf('=');
|
|
31
|
+
out[raw.slice(0, eq)] = raw.slice(eq + 1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function save() { fs.writeFileSync(statePath, JSON.stringify(state)); }
|
|
37
|
+
|
|
38
|
+
if (argv[1] === 'graphql') {
|
|
39
|
+
const f = fieldFlags('-f');
|
|
40
|
+
const query = f.query;
|
|
41
|
+
if (query.includes('resolveReviewThread')) {
|
|
42
|
+
const nodeId = f.id;
|
|
43
|
+
const entry = Object.values(state.threads).find((t) => t.nodeId === nodeId);
|
|
44
|
+
if (!entry) { console.error('unknown thread node ' + nodeId); process.exit(1); }
|
|
45
|
+
if (!(state.resolveBehavior && state.resolveBehavior[nodeId] === 'false-positive')) {
|
|
46
|
+
entry.isResolved = true;
|
|
47
|
+
save();
|
|
48
|
+
}
|
|
49
|
+
process.stdout.write(JSON.stringify({ data: { resolveReviewThread: { thread: { isResolved: entry.isResolved } } } }));
|
|
50
|
+
} else if (query.includes('reviewThreads')) {
|
|
51
|
+
const nodes = Object.entries(state.threads).map(([rootId, t]) => ({
|
|
52
|
+
id: t.nodeId,
|
|
53
|
+
isResolved: t.isResolved,
|
|
54
|
+
comments: { nodes: [{ databaseId: Number(rootId) }] },
|
|
55
|
+
}));
|
|
56
|
+
process.stdout.write(JSON.stringify({ data: { repository: { pullRequest: { reviewThreads: { nodes } } } } }));
|
|
57
|
+
} else {
|
|
58
|
+
console.error('unrecognized graphql query');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
const restPath = argv[1];
|
|
63
|
+
const f = fieldFlags('-f');
|
|
64
|
+
const cap = fieldFlags('-F');
|
|
65
|
+
const isPost = argv.includes('-f') || argv.includes('-F');
|
|
66
|
+
|
|
67
|
+
if (/\\/issues\\/\\d+\\/comments$/.test(restPath)) {
|
|
68
|
+
if (!isPost) {
|
|
69
|
+
process.stdout.write(JSON.stringify(state.issueComments));
|
|
70
|
+
} else {
|
|
71
|
+
if (state.failPost && state.failPost.includes('issue')) {
|
|
72
|
+
console.error('simulated hard POST failure for issue comment');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
if (!(state.postBehavior && state.postBehavior.issue === 'false-positive')) {
|
|
76
|
+
state.issueComments.push({ id: state.issueComments.length + 1, body: f.body });
|
|
77
|
+
save();
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(JSON.stringify({ id: state.issueComments.length }));
|
|
80
|
+
}
|
|
81
|
+
} else if (/\\/pulls\\/\\d+\\/comments$/.test(restPath)) {
|
|
82
|
+
if (!isPost) {
|
|
83
|
+
process.stdout.write(JSON.stringify(state.reviewComments));
|
|
84
|
+
} else {
|
|
85
|
+
const key = cap.in_reply_to;
|
|
86
|
+
if (state.failPost && state.failPost.includes(key)) {
|
|
87
|
+
console.error('simulated hard POST failure for ' + key);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (!(state.postBehavior && state.postBehavior[key] === 'false-positive')) {
|
|
91
|
+
state.reviewComments.push({ id: state.reviewComments.length + 1, body: f.body, in_reply_to_id: Number(key) });
|
|
92
|
+
save();
|
|
93
|
+
}
|
|
94
|
+
process.stdout.write(JSON.stringify({ id: state.reviewComments.length }));
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
console.error('unsupported fake gh call: ' + restPath);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
const FIXTURE = `# PR #482
|
|
104
|
+
|
|
105
|
+
### Rename the exported helper
|
|
106
|
+
id: review-comment/91234
|
|
107
|
+
status: open
|
|
108
|
+
source: (PR conversation)
|
|
109
|
+
comment-raw: |
|
|
110
|
+
suggestion: rename this to something clearer.
|
|
111
|
+
replies-raw:
|
|
112
|
+
possible-follow-ups:
|
|
113
|
+
- fix: rename per the suggestion
|
|
114
|
+
action: fix
|
|
115
|
+
resolve-on-apply: true
|
|
116
|
+
pending-reply: drafted
|
|
117
|
+
reply-draft: |
|
|
118
|
+
Fixed -- renamed per your suggestion, thanks! (resolve-pr-comments - guided)
|
|
119
|
+
|
|
120
|
+
### General question about the release plan
|
|
121
|
+
id: issue-comment/555
|
|
122
|
+
status: open
|
|
123
|
+
source: (PR conversation)
|
|
124
|
+
comment-raw: |
|
|
125
|
+
What's the plan for releasing this?
|
|
126
|
+
replies-raw:
|
|
127
|
+
possible-follow-ups:
|
|
128
|
+
action: reply
|
|
129
|
+
resolve-on-apply: false
|
|
130
|
+
pending-reply: drafted
|
|
131
|
+
reply-draft: |
|
|
132
|
+
Targeting the next minor release once CI is green. (resolve-pr-comments -
|
|
133
|
+
guided)
|
|
134
|
+
|
|
135
|
+
### Already handled comment, should never be touched
|
|
136
|
+
id: review-comment/77777
|
|
137
|
+
status: open
|
|
138
|
+
source: (PR conversation)
|
|
139
|
+
comment-raw: |
|
|
140
|
+
Unrelated comment still awaiting triage.
|
|
141
|
+
replies-raw:
|
|
142
|
+
possible-follow-ups:
|
|
143
|
+
action:
|
|
144
|
+
resolve-on-apply: false
|
|
145
|
+
pending-reply: none
|
|
146
|
+
reply-draft: |
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
function setup(state) {
|
|
150
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-replies-gh-test-'));
|
|
151
|
+
const file = path.join(dir, 'review-pr-482.md');
|
|
152
|
+
fs.writeFileSync(file, FIXTURE);
|
|
153
|
+
const fakeGh = path.join(dir, 'fake-gh.js');
|
|
154
|
+
fs.writeFileSync(fakeGh, FAKE_GH_SRC);
|
|
155
|
+
const statePath = path.join(dir, 'state.json');
|
|
156
|
+
fs.writeFileSync(statePath, JSON.stringify(state));
|
|
157
|
+
return { file, statePath, env: { ...process.env, GH_BIN: `node ${fakeGh}`, FAKE_GH_STATE: statePath } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function run(args, env) {
|
|
161
|
+
try {
|
|
162
|
+
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', env });
|
|
163
|
+
return { status: 0, stdout };
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { status: err.status, stdout: err.stdout, stderr: err.stderr };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getField(file, id, field) {
|
|
170
|
+
return execFileSync('node', [path.join(__dirname, 'update-section.js'), 'get', file, id, field], { encoding: 'utf8' }).trim();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test('parsePrUrl extracts owner/repo/pr from a GitHub PR URL', () => {
|
|
174
|
+
assert.deepEqual(parsePrUrl(PR_URL), { owner: 'acme', repo: 'widgets', pr: '482' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('parsePrUrl rejects an unrecognized URL rather than guessing', () => {
|
|
178
|
+
assert.throws(() => parsePrUrl('https://example.com/not/github'), /unrecognized GitHub PR URL/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('joinReplyDraft rejoins hand-wrapped lines within a paragraph, preserving paragraph breaks', () => {
|
|
182
|
+
const raw = 'Line one\nstill line one.\n\nSecond paragraph\non two lines.';
|
|
183
|
+
assert.equal(joinReplyDraft(raw), 'Line one still line one.\n\nSecond paragraph on two lines.');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('posts a review-comment reply, verifies via read-back, resolves the thread, and marks applied', () => {
|
|
187
|
+
const { file, env } = setup({
|
|
188
|
+
issueComments: [],
|
|
189
|
+
reviewComments: [],
|
|
190
|
+
threads: { 91234: { nodeId: 'PRT_1', isResolved: false } },
|
|
191
|
+
});
|
|
192
|
+
const { status, stdout } = run(['--pr-url', PR_URL, '--only', 'review-comment/91234', file], env);
|
|
193
|
+
assert.equal(status, 0, stdout);
|
|
194
|
+
assert.match(stdout, /OK {3}review-comment\/91234 replied\+verified \+ resolved\+verified/);
|
|
195
|
+
assert.equal(getField(file, 'review-comment/91234', 'pending-reply'), 'applied');
|
|
196
|
+
assert.equal(getField(file, 'review-comment/91234', 'status'), 'resolved');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('posts an issue-comment reply, verifies via read-back, and never attempts a resolve', () => {
|
|
200
|
+
const { file, env } = setup({ issueComments: [], reviewComments: [], threads: {} });
|
|
201
|
+
const { status, stdout } = run(['--pr-url', PR_URL, '--only', 'issue-comment/555', file], env);
|
|
202
|
+
assert.equal(status, 0, stdout);
|
|
203
|
+
assert.match(stdout, /OK {3}issue-comment\/555 replied\+verified$/m);
|
|
204
|
+
assert.equal(getField(file, 'issue-comment/555', 'pending-reply'), 'applied');
|
|
205
|
+
assert.equal(getField(file, 'issue-comment/555', 'status'), 'open');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('REGRESSION: does not mark pending-reply applied when the POST exits 0 but the reply never persists', () => {
|
|
209
|
+
const { file, env } = setup({
|
|
210
|
+
issueComments: [],
|
|
211
|
+
reviewComments: [],
|
|
212
|
+
threads: { 91234: { nodeId: 'PRT_1', isResolved: false } },
|
|
213
|
+
postBehavior: { 91234: 'false-positive' },
|
|
214
|
+
});
|
|
215
|
+
const { status, stderr } = run(['--pr-url', PR_URL, '--only', 'review-comment/91234', file], env);
|
|
216
|
+
assert.equal(status, 1);
|
|
217
|
+
assert.match(stderr, /FAIL review-comment\/91234: gh api exited 0 but reply missing on read-back verification/);
|
|
218
|
+
assert.equal(getField(file, 'review-comment/91234', 'pending-reply'), 'drafted');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('REGRESSION: does not mark status resolved when the mutation exits 0 but isResolved never persists', () => {
|
|
222
|
+
const { file, env } = setup({
|
|
223
|
+
issueComments: [],
|
|
224
|
+
reviewComments: [],
|
|
225
|
+
threads: { 91234: { nodeId: 'PRT_1', isResolved: false } },
|
|
226
|
+
resolveBehavior: { PRT_1: 'false-positive' },
|
|
227
|
+
});
|
|
228
|
+
const { status, stderr } = run(['--pr-url', PR_URL, '--only', 'review-comment/91234', file], env);
|
|
229
|
+
assert.equal(status, 1);
|
|
230
|
+
assert.match(stderr, /FAIL review-comment\/91234: resolveReviewThread exited 0 but isResolved false on read-back verification/);
|
|
231
|
+
assert.equal(getField(file, 'review-comment/91234', 'pending-reply'), 'drafted');
|
|
232
|
+
assert.equal(getField(file, 'review-comment/91234', 'status'), 'open');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('skips re-posting when the exact reply is already present, but still marks applied', () => {
|
|
236
|
+
const { file, env } = setup({
|
|
237
|
+
issueComments: [],
|
|
238
|
+
reviewComments: [{ id: 1, body: 'Fixed -- renamed per your suggestion, thanks! (resolve-pr-comments - guided)', in_reply_to_id: 91234 }],
|
|
239
|
+
threads: { 91234: { nodeId: 'PRT_1', isResolved: false } },
|
|
240
|
+
// If the script incorrectly tries to POST again, this makes it fail loudly.
|
|
241
|
+
failPost: ['91234'],
|
|
242
|
+
});
|
|
243
|
+
const { status, stdout } = run(['--pr-url', PR_URL, '--only', 'review-comment/91234', file], env);
|
|
244
|
+
assert.equal(status, 0, stdout);
|
|
245
|
+
assert.equal(getField(file, 'review-comment/91234', 'pending-reply'), 'applied');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('never processes a section whose pending-reply is not drafted, when --only is not given', () => {
|
|
249
|
+
const { file, env } = setup({
|
|
250
|
+
issueComments: [],
|
|
251
|
+
reviewComments: [],
|
|
252
|
+
threads: {
|
|
253
|
+
91234: { nodeId: 'PRT_1', isResolved: false },
|
|
254
|
+
// 77777 deliberately absent: pending-reply is 'none' for it, so a correct run never
|
|
255
|
+
// references it at all -- if it did, the fake gh would fail with "no review thread
|
|
256
|
+
// found" and this test would catch it via the failPost trap below.
|
|
257
|
+
},
|
|
258
|
+
failPost: ['77777'],
|
|
259
|
+
});
|
|
260
|
+
const { status, stdout } = run(['--pr-url', PR_URL, file], env);
|
|
261
|
+
assert.equal(status, 0, stdout);
|
|
262
|
+
assert.equal(getField(file, 'review-comment/77777', 'pending-reply'), 'none');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('--dry-run prints drafts without invoking gh or changing the tracking file', () => {
|
|
266
|
+
const { file, env } = setup({ issueComments: [], reviewComments: [], threads: {} }); // any gh call would throw
|
|
267
|
+
const { status, stdout } = run(['--pr-url', PR_URL, '--dry-run', file], env);
|
|
268
|
+
assert.equal(status, 0, stdout);
|
|
269
|
+
assert.match(stdout, /=== review-comment\/91234 \(resolve=true\) ===/);
|
|
270
|
+
assert.match(stdout, /=== issue-comment\/555 \(resolve=false\) ===/);
|
|
271
|
+
assert.equal(getField(file, 'review-comment/91234', 'pending-reply'), 'drafted');
|
|
272
|
+
});
|