agentme 0.35.1 → 0.36.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.
Files changed (19) hide show
  1. package/.xdrs/agentme/bdrs/operations/401-epic-feature-story-planning.md +1 -1
  2. package/.xdrs/agentme/edrs/application/skills/250-github-connector/SKILL.md +187 -0
  3. package/.xdrs/agentme/edrs/application/skills/250-github-connector/SKILL.test.md +118 -0
  4. package/.xdrs/agentme/edrs/application/skills/251-azure-devops-connector/SKILL.md +205 -0
  5. package/.xdrs/agentme/edrs/application/skills/251-azure-devops-connector/SKILL.test.md +114 -0
  6. package/.xdrs/agentme/edrs/index.md +3 -0
  7. package/.xdrs/agentme/edrs/principles/017-skill-testing.md +3 -0
  8. package/.xdrs/agentme/edrs/principles/skills/150-refine-plan-mode/SKILL.md +25 -8
  9. package/.xdrs/agentme/edrs/principles/skills/150-refine-plan-mode/SKILL.test.md +27 -3
  10. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/Makefile +8 -0
  11. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/SKILL.md +633 -0
  12. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/SKILL.test.md +174 -0
  13. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-azure-devops.js +219 -0
  14. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-azure-devops.test.js +253 -0
  15. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-github.js +237 -0
  16. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-github.test.js +272 -0
  17. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/update-section.js +246 -0
  18. package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/update-section.test.js +199 -0
  19. package/package.json +1 -1
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Applies pr-owner-assistant tracking-file drafts to a live GitHub PR, using the `gh`
6
+ * commands documented in 250-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 251-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 };
@@ -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! (pr-owner-assistant skill - using defaults)
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. (pr-owner-assistant skill - using
133
+ defaults)
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! (pr-owner-assistant skill - using defaults)', 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
+ });