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.
Files changed (54) hide show
  1. package/.filedist-package.yml +1 -1
  2. package/.xdrs/agentme/bdrs/index.md +2 -1
  3. package/.xdrs/agentme/bdrs/operations/{401-epic-feature-story-planning.md → 401-plan-epic-feature-story.md} +2 -2
  4. package/.xdrs/agentme/bdrs/operations/402-digital-product-roles.md +15 -19
  5. package/.xdrs/agentme/bdrs/operations/403-product-team-composition.md +3 -0
  6. package/.xdrs/agentme/bdrs/operations/404-team-roles-and-specialists.md +61 -82
  7. package/.xdrs/agentme/bdrs/operations/405-digital-product-roles-raci.md +131 -0
  8. package/.xdrs/agentme/bdrs/principles/002-okr-framework.md +1 -1
  9. package/.xdrs/agentme/bdrs/principles/003-product-delivery-value-flow.md +3 -2
  10. package/.xdrs/agentme/edrs/application/101-javascript-project-tooling.md +1 -1
  11. package/.xdrs/agentme/edrs/application/102-golang-project-tooling.md +1 -1
  12. package/.xdrs/agentme/edrs/application/103-python-project-tooling.md +1 -1
  13. package/.xdrs/agentme/edrs/application/127-external-system-adapter-skills.md +2 -0
  14. package/.xdrs/agentme/edrs/application/skills/azure-devops-connector/SKILL.md +202 -0
  15. package/.xdrs/agentme/edrs/application/skills/azure-devops-connector/SKILL.test.md +114 -0
  16. package/.xdrs/agentme/edrs/application/skills/{051-create-golang-project → create-golang-project}/SKILL.md +1 -1
  17. package/.xdrs/agentme/edrs/application/skills/create-golang-project/SKILL.test.md +72 -0
  18. package/.xdrs/agentme/edrs/application/skills/{050-create-javascript-project → create-javascript-project}/SKILL.md +23 -24
  19. package/.xdrs/agentme/edrs/application/skills/create-javascript-project/SKILL.test.md +72 -0
  20. package/.xdrs/agentme/edrs/application/skills/{052-create-python-project → create-python-project}/SKILL.md +1 -1
  21. package/.xdrs/agentme/edrs/application/skills/create-python-project/SKILL.test.md +79 -0
  22. package/.xdrs/agentme/edrs/application/skills/github-connector/SKILL.md +184 -0
  23. package/.xdrs/agentme/edrs/application/skills/github-connector/SKILL.test.md +118 -0
  24. package/.xdrs/agentme/edrs/application/skills/{200-run-skill-tests → run-skill-tests}/SKILL.md +4 -4
  25. package/.xdrs/agentme/edrs/application/skills/{200-run-skill-tests → run-skill-tests}/SKILL.test.md +7 -7
  26. package/.xdrs/agentme/edrs/application/skills/{010-select-relevant-xdrs → select-relevant-xdrs}/SKILL.md +1 -1
  27. package/.xdrs/agentme/edrs/application/skills/select-relevant-xdrs/SKILL.test.md +73 -0
  28. package/.xdrs/agentme/edrs/governance/502-contributing-guide-requirements.md +1 -1
  29. package/.xdrs/agentme/edrs/index.md +16 -8
  30. package/.xdrs/agentme/edrs/operations/402-dora-metrics-framework.md +82 -0
  31. package/.xdrs/agentme/edrs/operations/403-dora-deployment-frequency.md +37 -0
  32. package/.xdrs/agentme/edrs/operations/404-dora-lead-time-for-changes.md +41 -0
  33. package/.xdrs/agentme/edrs/operations/405-dora-change-failure-rate.md +42 -0
  34. package/.xdrs/agentme/edrs/operations/406-dora-time-to-restore-service.md +42 -0
  35. package/.xdrs/agentme/edrs/platform/301-monorepo-structure.md +1 -1
  36. package/.xdrs/agentme/edrs/platform/skills/{053-monorepo-setup → monorepo-setup}/SKILL.md +15 -15
  37. package/.xdrs/agentme/edrs/platform/skills/monorepo-setup/SKILL.test.md +80 -0
  38. package/.xdrs/agentme/edrs/principles/012-continuous-xdr-enrichment.md +1 -1
  39. package/.xdrs/agentme/edrs/principles/017-skill-testing.md +7 -4
  40. package/.xdrs/agentme/edrs/principles/articles/001-continuous-xdr-improvement.md +1 -1
  41. package/.xdrs/agentme/edrs/principles/skills/{150-refine-plan-mode → refine-plan-mode}/SKILL.md +27 -16
  42. package/.xdrs/agentme/edrs/principles/skills/{150-refine-plan-mode → refine-plan-mode}/SKILL.test.md +28 -4
  43. package/.xdrs/agentme/edrs/principles/skills/{151-refine-user-story → refine-user-story}/SKILL.md +4 -11
  44. package/.xdrs/agentme/edrs/principles/skills/{151-refine-user-story → refine-user-story}/SKILL.test.md +17 -17
  45. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/Makefile +8 -0
  46. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/SKILL.md +662 -0
  47. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/SKILL.test.md +165 -0
  48. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-azure-devops.js +219 -0
  49. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-azure-devops.test.js +253 -0
  50. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.js +237 -0
  51. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.test.js +272 -0
  52. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/update-section.js +327 -0
  53. package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/update-section.test.js +306 -0
  54. package/package.json +2 -2
@@ -0,0 +1,165 @@
1
+ ---
2
+ skill: resolve-pr-comments
3
+ skill-version: "4.0.0"
4
+ ---
5
+
6
+ ## Test Scenarios
7
+
8
+ ### Scenario 1: End-to-end GitHub PR review, happy path with guided mode and a Phase 6 edit
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, then confirms the local repo matches
19
+ `acme/widgets` and offers to check out the PR branch; (3) Phase 2 calls `init` to write
20
+ `.tmp/review-pr-482.md`'s header before analysing anything, then computes each comment's
21
+ fields and calls `append-section` to write its section immediately, one comment at a time --
22
+ rendering each section's `source` field as a clickable markdown link to the matching file in
23
+ the local checkout (never a provider URL), `source-lines` with the commented line(s) plus
24
+ padding, `diff-hunk-raw`/`author-raw`/`comment-url` verbatim from the connector,
25
+ `possible-user-intention` (an under-20-word, silently generated read of the author's likely
26
+ concern), and `possible-follow-ups` (2-4 candidate next actions); (4) Phase 3 renders the
27
+ 2-row summary table and asks the literal numbered 4-way automation-level question -- the
28
+ human picks option 3 (guide all), so both comments use the guided flow; (5) Phase 4 walks
29
+ each comment one at a time, rendering its full focus card before asking the human to choose
30
+ an action, then drafts the reply/fix text, persists it as `pending-reply: drafted`, and shows
31
+ one FYI outcome line -- no confirmation question, nothing sent yet; (6) Phase 6 gathers both
32
+ now-drafted comments, renders a numbered consolidated preview table (`#`, `id`, title,
33
+ action, resolve-on-apply) followed by each row's exact draft text, and the human uses a
34
+ free-text instruction naming comment 2's `#` to change its reply text before applying both
35
+ together in a single apply-all batch confirmation -- resolving comment 1's thread as
36
+ requested -- then reports the final summary by action taken and send status.
37
+
38
+ **Simulated Human Responses**
39
+ 1. "Yes, check out the PR branch."
40
+ 2. (Phase 3) "3. I want to guide all comments solutions."
41
+ 3. "For comment 1: confirm criticality, action = reply. Yes, resolve the thread once
42
+ applied." (accepts the drafted text unedited)
43
+ 4. "For comment 2: confirm criticality, action = fix." (drafted text left as-is at Phase 4)
44
+ 5. (Phase 6) "For comment 2, reply with 'Renamed per your suggestion, thanks!' instead, then
45
+ apply all now."
46
+
47
+ **Assertions**
48
+
49
+ - [ ] Skill activates `github-connector` because the URL host is `github.com`.
50
+ - [ ] Skill calls `init` to write the tracking file's header before analysing any comment,
51
+ then calls `append-section` once per comment, immediately, one at a time -- never
52
+ accumulating every section in memory until the end.
53
+ - [ ] Skill renders each section's `source` field as a markdown link to the matching local
54
+ file, relative to the tracking file's own location -- never a link to the provider's
55
+ website.
56
+ - [ ] Skill's Phase 3 renders a summary table (`#`, title, author, automation-suggestion,
57
+ criticality) and asks the literal numbered 4-way automation-level question before
58
+ Phase 4 asks anything else.
59
+ - [ ] Skill asks about exactly one comment at a time in Phase 4, posting each comment's full
60
+ focus card as its own chat message before the action question.
61
+ - [ ] Skill shows no confirmation question in Phase 4 -- only a one-line FYI outcome after
62
+ each draft is persisted, for both comments.
63
+ - [ ] Skill's Phase 6 renders a numbered consolidated preview table (`#`, `id`, title,
64
+ action, resolve-on-apply) followed by each row's exact draft text below the table.
65
+ - [ ] Skill accepts a Phase 6 free-text instruction naming comment 2's preview `#` to change
66
+ its reply text before applying, persisting the change immediately.
67
+ - [ ] Skill applies both comments' drafts together in a single apply-all batch confirmation
68
+ in Phase 6, resolving comment 1's thread as requested.
69
+ - [ ] Skill suffixes both comment 1's reply (accepted as drafted) and comment 2's reply
70
+ (changed at Phase 6) with the identical `(resolve-pr-comments - guided)` literal --
71
+ no distinction between the two cases.
72
+ - [ ] Skill records the section's original comment text under `comment-raw` and any existing
73
+ thread replies under `replies-raw`.
74
+ - [ ] Skill populates `possible-user-intention` with an under-20-word inference silently,
75
+ with no dedicated confirmation prompt of its own.
76
+
77
+ ### Scenario 2: Phase 3's "something else" free text drives a mixed fully-auto/guided run
78
+
79
+ **Trigger / Input**
80
+
81
+ Phase 2 populates 3 open comments: comment A (a simple, repetitive rename request with a
82
+ clean `suggested-fix`, classified `automation-suggestion: fully-auto`), comment B (a vague
83
+ architectural question, classified `automation-suggestion: guided`), and comment C (a minor
84
+ style nitpick, classified `automation-suggestion: fully-auto`, not mentioned in the human's
85
+ free text below). At Phase 3, the human answers option 4 with "automate everything, but
86
+ guide comment B".
87
+
88
+ **Expected Behaviour**
89
+
90
+ Phase 4 reaches comment A and, matching the free text's "automate everything", skips its
91
+ focus card and action question entirely: before starting, it shows one starting banner line
92
+ (`Starting comment A of 3 (fix): "<short title>".`), then auto-chooses `fix`, applies the
93
+ `suggested-fix` verbatim, defaults `resolve-on-apply` from `can_resolve` with no ask, drafts
94
+ and persists `pending-reply: drafted` immediately, and shows one brief outcome line
95
+ (`Auto-handled comment A (fix): ...`). The drafted text carries the
96
+ `(resolve-pr-comments - fully-auto)` suffix unconditionally. Comment B, explicitly named for
97
+ guidance, still gets its full focus card and action question exactly as documented, with the
98
+ human choosing `reply` and editing the drafted text, earning the
99
+ `(resolve-pr-comments - guided)` suffix. Comment C, not explicitly addressed by the free
100
+ text, follows its own `automation-suggestion` (`fully-auto`) and is handled the same
101
+ no-question way as comment A, each with its own starting banner and outcome line. Phase 6
102
+ later gathers all three comments together and requires the same explicit apply confirmation
103
+ for all of them -- no comment's fully-auto origin grants it a bypass of that gate.
104
+
105
+ **Simulated Human Responses**
106
+ 1. (Phase 3) "4. Automate everything, but guide comment B."
107
+ 2. "For comment B: action = reply. Use this reply text instead: 'We considered that
108
+ approach; here's why we didn't take it...'" (replaces the drafted text with free text)
109
+ 3. (Phase 6) "Apply all now."
110
+
111
+ **Assertions**
112
+
113
+ - [ ] Skill's Phase 3 renders the summary table and asks the literal numbered 4-way
114
+ automation-level question before Phase 4 begins.
115
+ - [ ] Skill interprets the free-text option-4 answer against the numbered table, applying the
116
+ no-question path to comments A and C and the guided path to comment B by name.
117
+ - [ ] Skill never shows comment A's or comment C's focus card or an action question for
118
+ either, auto-choosing and applying each one's action with no human confirmation.
119
+ - [ ] Skill shows one starting banner line before each of comment A's and comment C's work
120
+ begins, and one brief outcome line after, suffixing both with
121
+ `(resolve-pr-comments - fully-auto)` unconditionally.
122
+ - [ ] Skill shows comment B's full focus card and action question, unaffected by comments A
123
+ and C's no-question handling.
124
+ - [ ] Skill suffixes comment B's reply -- replaced with human-supplied free text -- with
125
+ `(resolve-pr-comments - guided)`.
126
+ - [ ] Skill's Phase 6 requires the same explicit apply confirmation for all three comments --
127
+ being drafted via the no-question path grants no bypass of Phase 6's gate.
128
+
129
+ ### Scenario 3: Phase 2 writes the tracking file incrementally, surviving a mid-run interruption
130
+
131
+ **Trigger / Input**
132
+
133
+ A PR has 6 open comments. The session runs Phase 2 against a brand-new tracking file, then is
134
+ interrupted (e.g. context compaction or a cancelled session) immediately after the 4th
135
+ comment's section has been written, before the 5th comment's fields are computed.
136
+
137
+ **Expected Behaviour**
138
+
139
+ Phase 2 first calls `init` to durably write `.tmp/review-pr-<N>.md`'s header (PR link, raw
140
+ summary, auto-generated summary) before analysing any single comment. It then computes
141
+ comment 1's fields and calls `append-section` to write its section immediately, repeating
142
+ this one-comment-at-a-time for comments 2, 3, and 4 -- never holding more than one comment's
143
+ computed-but-unwritten section in memory at a time. When the interruption happens right
144
+ after comment 4's `append-section` call returns, comments 1-4 are already durably present on
145
+ disk. A fresh invocation re-reads the existing file, finds comments 1-4 already recorded (and
146
+ does not recompute or rewrite them), and resumes by computing and appending comments 5 and 6
147
+ the same incremental way.
148
+
149
+ **Simulated Human Responses**
150
+ (none -- Phase 2 asks nothing; the interruption and resume are session-level events, not
151
+ human decisions)
152
+
153
+ **Assertions**
154
+
155
+ - [ ] Skill calls `init` once, before computing any comment's fields, to write the tracking
156
+ file's header (PR link, raw summary, auto-generated summary).
157
+ - [ ] Skill calls `append-section` once per comment, immediately after that comment's fields
158
+ are computed, rather than batching multiple comments' sections into one write.
159
+ - [ ] Skill's on-disk tracking file already contains comments 1 through 4's full sections at
160
+ the moment of interruption, before comment 5 is ever analysed.
161
+ - [ ] Skill's fresh invocation after the interruption re-reads the existing file and does not
162
+ recompute or rewrite comments 1-4's already-written sections.
163
+ - [ ] Skill's fresh invocation computes and appends comments 5 and 6 the same
164
+ one-comment-at-a-time way, completing the file.
165
+
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Applies resolve-pr-comments tracking-file drafts to a live Azure DevOps PR, using the
6
+ * `az rest` commands documented in 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 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. (resolve-pr-comments -
79
+ guided)
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. (resolve-pr-comments -
94
+ guided)
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. (resolve-pr-comments - guided)';
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
+ });