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,174 @@
1
+ ---
2
+ skill: 400-pr-owner-assistant
3
+ skill-version: "2.3.0"
4
+ ---
5
+
6
+ ## Test Scenarios
7
+
8
+ ### Scenario 1: End-to-end GitHub PR review, happy path
9
+
10
+ **Trigger / Input**
11
+
12
+ "Let's review this PR: https://github.com/acme/widgets/pull/482" (run from a clean, up to
13
+ date local clone of `acme/widgets`, which has 2 open comments).
14
+
15
+ **Expected Behaviour**
16
+
17
+ The skill: (1) parses the URL host as `github.com` and activates `github-connector`; (2)
18
+ Phase 1 fetches PR #482 metadata and every comment via the connector's read commands; (3)
19
+ Phase 2 confirms the local repo matches `acme/widgets` and offers to check out the PR
20
+ branch; (4) Phase 3 creates `.tmp/review-pr-482.md` with one templated section per open
21
+ comment, fully populated before any question is asked -- writing the summary automatically
22
+ without asking for confirmation, rendering each section's `source` field as a clickable
23
+ markdown link to the matching file in the local checkout (never a provider URL),
24
+ populating `source-lines` with the commented line(s) plus padding read from the checked-out
25
+ file, `diff-hunk-raw` verbatim from the connector, `author-raw` verbatim from the connector,
26
+ `comment-url` verbatim from the connector, `possible-user-intention` (an under-20-word,
27
+ silently generated read of the author's likely concern) alongside the existing `type`, and
28
+ `possible-follow-ups` (2-4 candidate next actions), and recording the verbatim comment text
29
+ and any existing replies under `comment-raw`/`replies-raw`; (5) Phase 4 opens noting every
30
+ drafted item is only ever sent later, during Phase 5 -- never mid-walkthrough -- then walks
31
+ each of the 2 comments one at a time, rendering its full focus card (comment, code,
32
+ criticality/type/possible-user-intention, possible-follow-ups) before asking the human to
33
+ choose an action for that single comment; (6) for each, drafts the reply/fix text and shows
34
+ the mandatory confirmation titled with the comment's short title, author, and exact reply
35
+ text, persisting it as `pending-reply: drafted` once confirmed -- nothing is sent at this
36
+ point; (7) Phase 5 gathers both now-drafted comments, renders a consolidated preview table
37
+ (id, title, action, resolve-on-apply) followed by each row's exact draft text, runs the
38
+ pre-flight commit/push gate (finding the worktree and remote already clean, so it proceeds
39
+ silently), then applies both together via a single apply-all batch confirmation -- resolving
40
+ comment 1's thread as requested -- and reports the final summary by action taken and send
41
+ status.
42
+
43
+ **Simulated Human Responses**
44
+ 1. "Yes, check out the PR branch."
45
+ 2. "For comment 1: confirm criticality, action = reply. Yes, resolve the thread once
46
+ applied." (accepts the drafted text unedited)
47
+ 3. "For comment 2: confirm criticality, action = fix. Use this reply text instead:
48
+ 'Renamed per your suggestion, thanks!'" (replaces the drafted text with free text)
49
+ 4. (Phase 5) "Apply all now."
50
+
51
+ **Assertions**
52
+
53
+ - [ ] Skill activates `github-connector` because the URL host is `github.com`.
54
+ - [ ] Skill does not skip Phase 2 workspace validation before creating the tracking file.
55
+ - [ ] Skill creates `.tmp/review-pr-482.md` with a templated section per fetched open
56
+ comment only -- already-resolved comments are never recorded.
57
+ - [ ] Skill fully populates every comment's section -- including `possible-follow-ups` and
58
+ `diff-hunk-raw` -- before Phase 4 asks its first triage question.
59
+ - [ ] Skill writes the tracking file's PR summary automatically, without asking the human to
60
+ confirm or edit it first.
61
+ - [ ] Skill renders each section's `source` field as a markdown link to the matching local
62
+ file, relative to the tracking file's own location (file:line-range text for
63
+ file-scoped comments, `(PR conversation)` text with no link otherwise) -- never a link
64
+ to the provider's website.
65
+ - [ ] Skill asks about exactly one comment at a time in Phase 4, never batching multiple
66
+ comments' triage questions into a single ask.
67
+ - [ ] Skill shows the mandatory confirmation -- titled with the comment's short title,
68
+ author, and exact reply text -- before persisting each draft in Phase 4, and neither
69
+ applies nor posts anything during Phase 4.
70
+ - [ ] Skill persists both comments as `pending-reply: drafted` at the end of Phase 4, with
71
+ neither one sent to the provider yet.
72
+ - [ ] Skill's Phase 5 renders a consolidated preview table (one row per drafted comment:
73
+ id, title, action, resolve-on-apply) followed by each row's exact draft text shown
74
+ below the table.
75
+ - [ ] Skill's Phase 5 runs the pre-flight commit/push gate and, finding the worktree and
76
+ remote clean, proceeds straight to applying without asking anything about git.
77
+ - [ ] Skill applies both comments' drafts together in a single apply-all batch confirmation
78
+ in Phase 5, resolving comment 1's thread as requested.
79
+ - [ ] Skill suffixes comment 1's reply -- accepted unedited -- with
80
+ `(pr-owner-assistant skill - using defaults)` before posting it.
81
+ - [ ] Skill suffixes comment 2's reply -- replaced with human-supplied free text -- with
82
+ `(pr-owner-assistant skill - guided)` before posting it.
83
+ - [ ] Skill records the section's original comment text under `comment-raw` and any existing
84
+ thread replies under `replies-raw`, never under the old `comment`/`replies` names.
85
+ - [ ] Skill populates `source-lines` with the commented line(s) padded +-3 lines, each line
86
+ prefixed with its absolute line number.
87
+ - [ ] Skill populates `author-raw` and `comment-url` verbatim from the connector.
88
+ - [ ] Skill populates `possible-user-intention` with an under-20-word inference silently,
89
+ with no dedicated confirmation prompt of its own.
90
+
91
+ ### Scenario 2: Suggested-fix detection drives accept/evolve/decline framing
92
+
93
+ **Trigger / Input**
94
+
95
+ Phase 3 populates 3 comments, each containing a fenced `suggestion` block in `comment-raw`:
96
+ comment 1 proposes a correct rename with no side effects; comment 2 proposes a fix that is
97
+ right in direction but leaves an edge case unhandled; comment 3 proposes a change that would
98
+ silently break an unrelated caller.
99
+
100
+ **Expected Behaviour**
101
+
102
+ Phase 3 extracts each `suggested-fix` verbatim from its fenced block and records a
103
+ `suggested-fix-assessment` with rationale: `accept-as-is` for comment 1, `evolve-with-changes`
104
+ for comment 2, `not-recommended` for comment 3. In Phase 4, each focus card renders the
105
+ `suggested-fix`/`suggested-fix-assessment` pair right after `diff-hunk-raw`/`source-lines`.
106
+ For comment 1, the human applies the suggestion verbatim as a `fix` with no inline code
107
+ comment (the rename is self-evident, no rationale needed). For comment 2, the human applies
108
+ an evolved fix that also handles the edge case, and the skill adds a short inline code
109
+ comment at the changed lines explaining why the fix goes beyond the original suggestion.
110
+ For comment 3, the human declines with `wontfix`, and the skill's drafted rationale
111
+ explains why the suggestion isn't safe to apply, plus a short inline code comment at the
112
+ affected lines pointing back to that rationale.
113
+
114
+ **Simulated Human Responses**
115
+ 1. "For comment 1: action = fix, apply the suggestion as-is."
116
+ 2. "For comment 2: action = fix, but also handle the empty-input case the suggestion
117
+ missed."
118
+ 3. "For comment 3: action = wontfix -- that suggestion would break the other caller of this
119
+ function."
120
+
121
+ **Assertions**
122
+
123
+ - [ ] Skill records a `suggested-fix` verbatim from each comment's fenced `suggestion`
124
+ block, independent of which connector fetched it.
125
+ - [ ] Skill records `suggested-fix-assessment: accept-as-is` for comment 1,
126
+ `evolve-with-changes` for comment 2, and `not-recommended` for comment 3, each with a
127
+ short rationale.
128
+ - [ ] Skill renders `suggested-fix` and `suggested-fix-assessment` in the focus card right
129
+ after `diff-hunk-raw`/`source-lines`, for all 3 comments.
130
+ - [ ] Skill applies comment 1's fix verbatim with no added inline code comment, since the
131
+ rationale is self-evident.
132
+ - [ ] Skill adds a short inline code comment at comment 2's changed lines explaining why the
133
+ fix evolves beyond the original suggestion.
134
+ - [ ] Skill adds a short inline code comment at comment 3's affected lines explaining why
135
+ the suggested fix was declined, matching the `wontfix` rationale.
136
+
137
+ ### Scenario 3: Phase 5 preview table, apply-all batch, and the commit/push gate
138
+
139
+ **Trigger / Input**
140
+
141
+ Phase 5 begins with 3 comments at `pending-reply: drafted` (2 `reply`, 1 `fix`). The local
142
+ worktree has one uncommitted change and 2 local commits not yet on the remote tracking
143
+ branch. The human chooses apply-all; the connector reports a transient failure applying the
144
+ 2nd item only.
145
+
146
+ **Expected Behaviour**
147
+
148
+ Phase 5 renders the consolidated preview table (3 rows) with each row's exact draft text
149
+ shown below it, then runs the pre-flight git check. Finding both the uncommitted change and
150
+ the 2 unpushed commits, it shows a reminder that other participants won't see this code
151
+ until pushed, advises the human to add/commit/push manually -- never running any git command
152
+ itself -- and waits for explicit confirmation before applying anything. Once confirmed, it
153
+ applies item 1 successfully, hits the transient failure on item 2 (reporting it plainly
154
+ without losing item 1's already-applied state), and continues on to apply item 3
155
+ successfully.
156
+
157
+ **Simulated Human Responses**
158
+ 1. "Apply all now."
159
+ 2. "I've committed and pushed -- go ahead."
160
+
161
+ **Assertions**
162
+
163
+ - [ ] Skill renders all 3 items in the preview table with exact draft text shown below it,
164
+ before asking how to proceed.
165
+ - [ ] Skill's pre-flight git check detects both the uncommitted change and the 2 unpushed
166
+ local commits, and shows a reminder plus advice to add/commit/push manually.
167
+ - [ ] Skill never runs `git add`, `git commit`, or `git push` itself, regardless of what the
168
+ check finds.
169
+ - [ ] Skill waits for explicit human confirmation after the reminder before applying any
170
+ item.
171
+ - [ ] Skill's mid-batch failure on item 2 does not discard or reapply item 1's
172
+ already-applied result.
173
+ - [ ] Skill continues on to apply item 3 after item 2's failure, and reports a per-item
174
+ outcome (applied, applied, failed) at the end.
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Applies pr-owner-assistant tracking-file drafts to a live Azure DevOps PR, using the
6
+ * `az rest` commands documented in 251-azure-devops-connector's "Writing data" section.
7
+ * Delegates every tracking-file read/write to the sibling `update-section.js` script so
8
+ * there is exactly one place that understands the file format.
9
+ *
10
+ * Every write is verified by an independent read-back before the tracking file is ever
11
+ * marked `pending-reply: applied` -- a zero exit code from `az rest` alone is never treated
12
+ * as proof a write persisted (see 251-azure-devops-connector's Known Issues: a POST/PATCH
13
+ * can exit 0 without the change actually landing on the server).
14
+ *
15
+ * Usage:
16
+ * post-replies-azure-devops.js --pr-url <url> <tracking-file> [--dry-run] [--only <id>[,<id>...]]
17
+ *
18
+ * <url> is the PR's Azure DevOps URL (modern dev.azure.com or legacy *.visualstudio.com
19
+ * format); org/project/repo/PR number are all parsed from it.
20
+ * Processes every section whose `pending-reply:` is currently `drafted`, or exactly the
21
+ * `--only` ids (regardless of their current `pending-reply` state) when given. For each:
22
+ * skips posting if the exact reply text is already present on the live thread (verified
23
+ * idempotency, never assumed), otherwise POSTs `reply-draft` as a reply and verifies via
24
+ * GET; then, if `resolve-on-apply: true`, PATCHes the thread to "fixed" and verifies that
25
+ * too. Only after these checks pass does it mark `pending-reply: applied` (and
26
+ * `status: resolved`) via update-section.js. `AZ_BIN` (default `az`) overrides the CLI
27
+ * binary invoked, for testing with a stub.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const os = require('os');
33
+ const { execFileSync } = require('child_process');
34
+
35
+ const ADO_RESOURCE = '499b84ac-1321-427f-aa17-267ca6975798'; // Azure DevOps' well-known AAD resource id
36
+ const UPDATE_SECTION = path.join(__dirname, 'update-section.js');
37
+ const [AZ_BIN, ...AZ_PREFIX_ARGS] = (process.env.AZ_BIN || 'az').split(' ');
38
+
39
+ function parsePrUrl(url) {
40
+ let m = /^https:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/.exec(url);
41
+ if (m) return { org: m[1], project: m[2], repo: m[3], pr: m[4] };
42
+ m = /^https:\/\/([^./]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/.exec(url);
43
+ if (m) return { org: m[1], project: m[2], repo: m[3], pr: m[4] };
44
+ throw new Error(`unrecognized Azure DevOps PR URL: ${url}`);
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 getThread(base, threadId) {
108
+ const uri = `${base}/threads/${threadId}?api-version=7.1`;
109
+ const out = execFileSync(AZ_BIN, [...AZ_PREFIX_ARGS, 'rest', '--resource', ADO_RESOURCE, '--method', 'GET', '--uri', uri], {
110
+ stdio: 'pipe',
111
+ });
112
+ return JSON.parse(out.toString());
113
+ }
114
+
115
+ function applySection(base, tmpDir, file, row) {
116
+ const m = /^thread-comment\/(\d+)\.(\d+)$/.exec(row.id);
117
+ if (!m) return { id: row.id, ok: false, error: `id is not a thread-comment/<threadId>.<commentId> value: ${row.id}` };
118
+ const [, threadId, commentId] = m;
119
+ const resolveOnApply = row['resolve-on-apply'] === 'true';
120
+ const rawDraft = runUpdateSection(['get', file, row.id, 'reply-draft']).replace(/\n$/, '');
121
+ const replyText = joinReplyDraft(rawDraft);
122
+
123
+ try {
124
+ const before = getThread(base, threadId);
125
+ const alreadyPosted = (before.comments || []).some((c) => c.content === replyText);
126
+ if (!alreadyPosted) {
127
+ const bodyFile = path.join(tmpDir, `body-${threadId}.json`);
128
+ fs.writeFileSync(bodyFile, JSON.stringify({ content: replyText, parentCommentId: Number(commentId) }));
129
+ const postUri = `${base}/threads/${threadId}/comments?api-version=7.1`;
130
+ execFileSync(AZ_BIN, [...AZ_PREFIX_ARGS, 'rest', '--resource', ADO_RESOURCE, '--method', 'POST', '--uri', postUri, '--body', `@${bodyFile}`], {
131
+ stdio: 'pipe',
132
+ });
133
+
134
+ // az exiting 0 is not proof the write persisted -- always read back to confirm.
135
+ const afterPost = getThread(base, threadId);
136
+ if (!(afterPost.comments || []).some((c) => c.content === replyText)) {
137
+ return { id: row.id, ok: false, error: 'POST exited 0 but reply missing on read-back verification' };
138
+ }
139
+ }
140
+
141
+ let resolved = false;
142
+ if (resolveOnApply) {
143
+ const current = getThread(base, threadId);
144
+ if (current.status === 'fixed') {
145
+ resolved = true;
146
+ } else {
147
+ const patchUri = `${base}/threads/${threadId}?api-version=7.1`;
148
+ execFileSync(AZ_BIN, [...AZ_PREFIX_ARGS, 'rest', '--resource', ADO_RESOURCE, '--method', 'PATCH', '--uri', patchUri, '--body', '{"status":"fixed"}'], {
149
+ stdio: 'pipe',
150
+ });
151
+ const afterPatch = getThread(base, threadId);
152
+ resolved = afterPatch.status === 'fixed';
153
+ if (!resolved) {
154
+ return { id: row.id, ok: false, error: 'PATCH exited 0 but status not "fixed" on read-back verification' };
155
+ }
156
+ }
157
+ }
158
+
159
+ // Only ever mark the tracking file after independent verification above, never on a
160
+ // bare `az rest` exit code.
161
+ runUpdateSection(['set', file, row.id, 'pending-reply', 'applied']);
162
+ if (resolved) runUpdateSection(['set', file, row.id, 'status', 'resolved']);
163
+ return { id: row.id, ok: true, resolved };
164
+ } catch (e) {
165
+ return { id: row.id, ok: false, error: String(e.stderr || e.message) };
166
+ }
167
+ }
168
+
169
+ function main(argv) {
170
+ const args = parseArgs(argv);
171
+ if (!args.file || !args.prUrl) {
172
+ console.error('Usage: post-replies-azure-devops.js --pr-url <url> <tracking-file> [--dry-run] [--only <id>[,<id>...]]');
173
+ process.exit(1);
174
+ return;
175
+ }
176
+ const { org, project, repo, pr } = parsePrUrl(args.prUrl);
177
+ const base = `https://dev.azure.com/${org}/${project}/_apis/git/repositories/${repo}/pullRequests/${pr}`;
178
+
179
+ const rows = listCandidateSections(args.file, args.only);
180
+ if (rows.length === 0) {
181
+ console.log('No sections to apply (nothing pending-reply: drafted).');
182
+ return;
183
+ }
184
+
185
+ const results = [];
186
+ if (args.dryRun) {
187
+ for (const row of rows) {
188
+ const rawDraft = runUpdateSection(['get', args.file, row.id, 'reply-draft']).replace(/\n$/, '');
189
+ console.log(`\n=== ${row.id} (resolve=${row['resolve-on-apply'] === 'true'}) ===\n${joinReplyDraft(rawDraft)}`);
190
+ results.push({ id: row.id, ok: true, dryRun: true });
191
+ }
192
+ } else {
193
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-replies-'));
194
+ for (const row of rows) {
195
+ const result = applySection(base, tmpDir, args.file, row);
196
+ results.push(result);
197
+ if (result.ok) {
198
+ console.log(`OK ${result.id} replied+verified${result.resolved ? ' + resolved+verified' : ''}`);
199
+ } else {
200
+ console.error(`FAIL ${result.id}: ${result.error}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ console.log('\n--- SUMMARY ---');
206
+ console.log(JSON.stringify(results, null, 2));
207
+ if (results.some((r) => !r.ok)) process.exit(1);
208
+ }
209
+
210
+ if (require.main === module) {
211
+ try {
212
+ main(process.argv.slice(2));
213
+ } catch (err) {
214
+ console.error(`Error: ${err.message}`);
215
+ process.exit(1);
216
+ }
217
+ }
218
+
219
+ module.exports = { parsePrUrl, joinReplyDraft };
@@ -0,0 +1,253 @@
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-azure-devops.js');
11
+ const { parsePrUrl, joinReplyDraft } = require('./post-replies-azure-devops.js');
12
+
13
+ const PR_URL = 'https://dev.azure.com/nn-bank/mortgage-loan/_git/mortgage-loan/pullrequest/43556';
14
+
15
+ // Fake `az` used via AZ_BIN: reads/writes a JSON file (FAKE_ADO_STATE) to simulate thread
16
+ // state, so tests never touch the network. Supports the same `rest --method GET|POST|PATCH
17
+ // --uri <url> [--body <json|@file>]` shape the real script calls.
18
+ const FAKE_AZ_SRC = `
19
+ 'use strict';
20
+ const fs = require('fs');
21
+ const statePath = process.env.FAKE_ADO_STATE;
22
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
23
+ const argv = process.argv.slice(2);
24
+ const method = argv[argv.indexOf('--method') + 1];
25
+ const uri = argv[argv.indexOf('--uri') + 1];
26
+ let body = null;
27
+ const bodyIdx = argv.indexOf('--body');
28
+ if (bodyIdx !== -1) {
29
+ let raw = argv[bodyIdx + 1];
30
+ if (raw.startsWith('@')) raw = fs.readFileSync(raw.slice(1), 'utf8');
31
+ body = JSON.parse(raw);
32
+ }
33
+ const commentsMatch = /threads\\/(\\d+)\\/comments/.exec(uri);
34
+ const threadMatch = /threads\\/(\\d+)/.exec(uri);
35
+ const threadId = (commentsMatch || threadMatch)[1];
36
+ const thread = state.threads[threadId];
37
+ if (!thread) { console.error('unknown thread ' + threadId); process.exit(1); }
38
+ if (method === 'GET') {
39
+ process.stdout.write(JSON.stringify(thread));
40
+ } else if (method === 'POST' && commentsMatch) {
41
+ if (state.failPost && state.failPost.includes(threadId)) {
42
+ console.error('simulated hard POST failure for ' + threadId);
43
+ process.exit(1);
44
+ }
45
+ if (!(state.postBehavior && state.postBehavior[threadId] === 'false-positive')) {
46
+ thread.comments.push({ id: thread.comments.length + 1, content: body.content, parentCommentId: body.parentCommentId });
47
+ }
48
+ fs.writeFileSync(statePath, JSON.stringify(state));
49
+ process.stdout.write(JSON.stringify({ id: thread.comments.length }));
50
+ } else if (method === 'PATCH') {
51
+ if (!(state.patchBehavior && state.patchBehavior[threadId] === 'false-positive')) {
52
+ thread.status = body.status;
53
+ }
54
+ fs.writeFileSync(statePath, JSON.stringify(state));
55
+ process.stdout.write(JSON.stringify(thread));
56
+ } else {
57
+ console.error('unsupported fake az call: ' + method + ' ' + uri);
58
+ process.exit(1);
59
+ }
60
+ `;
61
+
62
+ const FIXTURE = `# PR #43556
63
+
64
+ ### Reusing atlas service principal for unrelated pipeline
65
+ id: thread-comment/100.1
66
+ status: open
67
+ source: (PR conversation)
68
+ comment-raw: |
69
+ Should we really use atlas service principal even when it is not atlas doing the action?
70
+ replies-raw:
71
+ possible-follow-ups:
72
+ - fix: create a dedicated service connection
73
+ action: fix
74
+ resolve-on-apply: false
75
+ pending-reply: drafted
76
+ reply-draft: |
77
+ Agreed -- good catch. Renamed the parameter to a dedicated codeowners-app service
78
+ connection. Leaving this thread open until it is created. (pr-owner-assistant - with user
79
+ guidance)
80
+
81
+ ### Split run() into smaller functions
82
+ id: thread-comment/200.1
83
+ status: open
84
+ source: (PR conversation)
85
+ comment-raw: |
86
+ Can we split this into smaller functions?
87
+ replies-raw:
88
+ possible-follow-ups:
89
+ action: fix
90
+ resolve-on-apply: true
91
+ pending-reply: drafted
92
+ reply-draft: |
93
+ Fixed -- split run() into named single-purpose functions. (pr-owner-assistant - with
94
+ defaults)
95
+
96
+ ### Already handled comment, should never be touched
97
+ id: thread-comment/300.1
98
+ status: open
99
+ source: (PR conversation)
100
+ comment-raw: |
101
+ Unrelated comment still awaiting triage.
102
+ replies-raw:
103
+ possible-follow-ups:
104
+ action:
105
+ resolve-on-apply: false
106
+ pending-reply: none
107
+ reply-draft: |
108
+ `;
109
+
110
+ function setup(state) {
111
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-replies-test-'));
112
+ const file = path.join(dir, 'review-pr-43556.md');
113
+ fs.writeFileSync(file, FIXTURE);
114
+ const fakeAz = path.join(dir, 'fake-az.js');
115
+ fs.writeFileSync(fakeAz, FAKE_AZ_SRC);
116
+ const statePath = path.join(dir, 'state.json');
117
+ fs.writeFileSync(statePath, JSON.stringify(state));
118
+ return { file, statePath, env: { ...process.env, AZ_BIN: `node ${fakeAz}`, FAKE_ADO_STATE: statePath } };
119
+ }
120
+
121
+ function run(args, env) {
122
+ try {
123
+ const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', env });
124
+ return { status: 0, stdout };
125
+ } catch (err) {
126
+ return { status: err.status, stdout: err.stdout, stderr: err.stderr };
127
+ }
128
+ }
129
+
130
+ function getField(file, id, field) {
131
+ return execFileSync('node', [path.join(__dirname, 'update-section.js'), 'get', file, id, field], { encoding: 'utf8' }).trim();
132
+ }
133
+
134
+ test('parsePrUrl extracts org/project/repo/pr from a modern dev.azure.com URL', () => {
135
+ assert.deepEqual(parsePrUrl(PR_URL), { org: 'nn-bank', project: 'mortgage-loan', repo: 'mortgage-loan', pr: '43556' });
136
+ });
137
+
138
+ test('parsePrUrl extracts org/project/repo/pr from a legacy *.visualstudio.com URL', () => {
139
+ assert.deepEqual(parsePrUrl('https://contoso.visualstudio.com/Widgets/_git/widgets-api/pullrequest/1029'), {
140
+ org: 'contoso',
141
+ project: 'Widgets',
142
+ repo: 'widgets-api',
143
+ pr: '1029',
144
+ });
145
+ });
146
+
147
+ test('parsePrUrl rejects an unrecognized URL rather than guessing', () => {
148
+ assert.throws(() => parsePrUrl('https://example.com/not/ado'), /unrecognized Azure DevOps PR URL/);
149
+ });
150
+
151
+ test('joinReplyDraft rejoins hand-wrapped lines within a paragraph, preserving paragraph breaks', () => {
152
+ const raw = 'Line one\nstill line one.\n\nSecond paragraph\non two lines.';
153
+ assert.equal(joinReplyDraft(raw), 'Line one still line one.\n\nSecond paragraph on two lines.');
154
+ });
155
+
156
+ test('posts a drafted reply, verifies via read-back, and marks pending-reply applied', () => {
157
+ const { file, env } = setup({
158
+ threads: {
159
+ 100: { status: 'active', comments: [{ id: 1, content: 'orig comment' }] },
160
+ },
161
+ });
162
+ const { status, stdout } = run(['--pr-url', PR_URL, '--only', 'thread-comment/100.1', file], env);
163
+ assert.equal(status, 0, stdout);
164
+ assert.match(stdout, /OK {3}thread-comment\/100\.1 replied\+verified$/m);
165
+ assert.equal(getField(file, 'thread-comment/100.1', 'pending-reply'), 'applied');
166
+ assert.equal(getField(file, 'thread-comment/100.1', 'status'), 'open'); // resolve-on-apply: false
167
+ });
168
+
169
+ test('resolves the thread and marks status resolved when resolve-on-apply is true', () => {
170
+ const { file, env } = setup({
171
+ threads: {
172
+ 200: { status: 'active', comments: [{ id: 1, content: 'orig comment' }] },
173
+ },
174
+ });
175
+ const { status, stdout } = run(['--pr-url', PR_URL, '--only', 'thread-comment/200.1', file], env);
176
+ assert.equal(status, 0, stdout);
177
+ assert.match(stdout, /replied\+verified \+ resolved\+verified/);
178
+ assert.equal(getField(file, 'thread-comment/200.1', 'pending-reply'), 'applied');
179
+ assert.equal(getField(file, 'thread-comment/200.1', 'status'), 'resolved');
180
+ });
181
+
182
+ test('REGRESSION: does not mark pending-reply applied when POST exits 0 but the reply never persists', () => {
183
+ const { file, env } = setup({
184
+ threads: {
185
+ 100: { status: 'active', comments: [{ id: 1, content: 'orig comment' }] },
186
+ },
187
+ postBehavior: { 100: 'false-positive' },
188
+ });
189
+ const { status, stderr } = run(['--pr-url', PR_URL, '--only', 'thread-comment/100.1', file], env);
190
+ assert.equal(status, 1);
191
+ assert.match(stderr, /FAIL thread-comment\/100\.1: POST exited 0 but reply missing on read-back verification/);
192
+ assert.equal(getField(file, 'thread-comment/100.1', 'pending-reply'), 'drafted');
193
+ });
194
+
195
+ test('REGRESSION: does not mark status resolved when PATCH exits 0 but status never persists', () => {
196
+ const { file, env } = setup({
197
+ threads: {
198
+ 200: { status: 'active', comments: [{ id: 1, content: 'orig comment' }] },
199
+ },
200
+ patchBehavior: { 200: 'false-positive' },
201
+ });
202
+ const { status, stderr } = run(['--pr-url', PR_URL, '--only', 'thread-comment/200.1', file], env);
203
+ assert.equal(status, 1);
204
+ assert.match(stderr, /FAIL thread-comment\/200\.1: PATCH exited 0 but status not "fixed" on read-back verification/);
205
+ assert.equal(getField(file, 'thread-comment/200.1', 'pending-reply'), 'drafted');
206
+ assert.equal(getField(file, 'thread-comment/200.1', 'status'), 'open');
207
+ });
208
+
209
+ test('skips re-posting when the exact reply is already present on the live thread, but still marks applied', () => {
210
+ const alreadyPostedText =
211
+ 'Agreed -- good catch. Renamed the parameter to a dedicated codeowners-app service connection. Leaving this thread open until it is created. (pr-owner-assistant - with user guidance)';
212
+ const { file, env } = setup({
213
+ threads: {
214
+ 100: {
215
+ status: 'active',
216
+ comments: [
217
+ { id: 1, content: 'orig comment' },
218
+ { id: 2, content: alreadyPostedText },
219
+ ],
220
+ },
221
+ },
222
+ // If the script incorrectly tries to POST again, this makes it fail loudly.
223
+ failPost: ['100'],
224
+ });
225
+ const { status, stdout } = run(['--pr-url', PR_URL, '--only', 'thread-comment/100.1', file], env);
226
+ assert.equal(status, 0, stdout);
227
+ assert.equal(getField(file, 'thread-comment/100.1', 'pending-reply'), 'applied');
228
+ });
229
+
230
+ test('never processes a section whose pending-reply is not drafted, when --only is not given', () => {
231
+ const { file, env } = setup({
232
+ threads: {
233
+ 100: { status: 'active', comments: [{ id: 1, content: 'orig comment' }] },
234
+ 200: { status: 'active', comments: [{ id: 1, content: 'orig comment' }] },
235
+ // 300 deliberately absent: pending-reply is 'none' for it, so a correct run never
236
+ // references thread 300 at all -- if it did, the fake az would fail with "unknown
237
+ // thread 300" and this test would catch it.
238
+ },
239
+ failPost: ['300'],
240
+ });
241
+ const { status, stdout } = run(['--pr-url', PR_URL, file], env);
242
+ assert.equal(status, 0, stdout);
243
+ assert.equal(getField(file, 'thread-comment/300.1', 'pending-reply'), 'none');
244
+ });
245
+
246
+ test('--dry-run prints drafts without invoking az or changing the tracking file', () => {
247
+ const { file, env } = setup({ threads: {} }); // no threads defined: any az call would throw
248
+ const { status, stdout } = run(['--pr-url', PR_URL, '--dry-run', file], env);
249
+ assert.equal(status, 0, stdout);
250
+ assert.match(stdout, /=== thread-comment\/100\.1 \(resolve=false\) ===/);
251
+ assert.match(stdout, /=== thread-comment\/200\.1 \(resolve=true\) ===/);
252
+ assert.equal(getField(file, 'thread-comment/100.1', 'pending-reply'), 'drafted');
253
+ });