agentme 0.35.2 → 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.
- package/.xdrs/agentme/edrs/application/skills/250-github-connector/SKILL.md +187 -0
- package/.xdrs/agentme/edrs/application/skills/250-github-connector/SKILL.test.md +118 -0
- package/.xdrs/agentme/edrs/application/skills/251-azure-devops-connector/SKILL.md +205 -0
- package/.xdrs/agentme/edrs/application/skills/251-azure-devops-connector/SKILL.test.md +114 -0
- package/.xdrs/agentme/edrs/index.md +3 -0
- package/.xdrs/agentme/edrs/principles/017-skill-testing.md +3 -0
- package/.xdrs/agentme/edrs/principles/skills/150-refine-plan-mode/SKILL.md +25 -8
- package/.xdrs/agentme/edrs/principles/skills/150-refine-plan-mode/SKILL.test.md +27 -3
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/Makefile +8 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/SKILL.md +633 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/SKILL.test.md +174 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-azure-devops.js +219 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-azure-devops.test.js +253 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-github.js +237 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/post-replies-github.test.js +272 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/update-section.js +246 -0
- package/.xdrs/agentme/edrs/principles/skills/400-pr-owner-assistant/scripts/update-section.test.js +199 -0
- 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
|
+
});
|