agentme 0.35.2 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.filedist-package.yml +1 -1
- package/.xdrs/agentme/bdrs/index.md +2 -1
- package/.xdrs/agentme/bdrs/operations/{401-epic-feature-story-planning.md → 401-plan-epic-feature-story.md} +2 -2
- package/.xdrs/agentme/bdrs/operations/402-digital-product-roles.md +15 -19
- package/.xdrs/agentme/bdrs/operations/403-product-team-composition.md +3 -0
- package/.xdrs/agentme/bdrs/operations/404-team-roles-and-specialists.md +61 -82
- package/.xdrs/agentme/bdrs/operations/405-digital-product-roles-raci.md +131 -0
- package/.xdrs/agentme/bdrs/principles/002-okr-framework.md +1 -1
- package/.xdrs/agentme/bdrs/principles/003-product-delivery-value-flow.md +3 -2
- package/.xdrs/agentme/edrs/application/101-javascript-project-tooling.md +1 -1
- package/.xdrs/agentme/edrs/application/102-golang-project-tooling.md +1 -1
- package/.xdrs/agentme/edrs/application/103-python-project-tooling.md +1 -1
- package/.xdrs/agentme/edrs/application/127-external-system-adapter-skills.md +2 -0
- package/.xdrs/agentme/edrs/application/skills/azure-devops-connector/SKILL.md +202 -0
- package/.xdrs/agentme/edrs/application/skills/azure-devops-connector/SKILL.test.md +114 -0
- package/.xdrs/agentme/edrs/application/skills/{051-create-golang-project → create-golang-project}/SKILL.md +1 -1
- package/.xdrs/agentme/edrs/application/skills/create-golang-project/SKILL.test.md +72 -0
- package/.xdrs/agentme/edrs/application/skills/{050-create-javascript-project → create-javascript-project}/SKILL.md +23 -24
- package/.xdrs/agentme/edrs/application/skills/create-javascript-project/SKILL.test.md +72 -0
- package/.xdrs/agentme/edrs/application/skills/{052-create-python-project → create-python-project}/SKILL.md +1 -1
- package/.xdrs/agentme/edrs/application/skills/create-python-project/SKILL.test.md +79 -0
- package/.xdrs/agentme/edrs/application/skills/github-connector/SKILL.md +184 -0
- package/.xdrs/agentme/edrs/application/skills/github-connector/SKILL.test.md +118 -0
- package/.xdrs/agentme/edrs/application/skills/{200-run-skill-tests → run-skill-tests}/SKILL.md +4 -4
- package/.xdrs/agentme/edrs/application/skills/{200-run-skill-tests → run-skill-tests}/SKILL.test.md +7 -7
- package/.xdrs/agentme/edrs/application/skills/{010-select-relevant-xdrs → select-relevant-xdrs}/SKILL.md +1 -1
- package/.xdrs/agentme/edrs/application/skills/select-relevant-xdrs/SKILL.test.md +73 -0
- package/.xdrs/agentme/edrs/governance/502-contributing-guide-requirements.md +1 -1
- package/.xdrs/agentme/edrs/index.md +16 -8
- package/.xdrs/agentme/edrs/operations/402-dora-metrics-framework.md +82 -0
- package/.xdrs/agentme/edrs/operations/403-dora-deployment-frequency.md +37 -0
- package/.xdrs/agentme/edrs/operations/404-dora-lead-time-for-changes.md +41 -0
- package/.xdrs/agentme/edrs/operations/405-dora-change-failure-rate.md +42 -0
- package/.xdrs/agentme/edrs/operations/406-dora-time-to-restore-service.md +42 -0
- package/.xdrs/agentme/edrs/platform/301-monorepo-structure.md +1 -1
- package/.xdrs/agentme/edrs/platform/skills/{053-monorepo-setup → monorepo-setup}/SKILL.md +15 -15
- package/.xdrs/agentme/edrs/platform/skills/monorepo-setup/SKILL.test.md +80 -0
- package/.xdrs/agentme/edrs/principles/012-continuous-xdr-enrichment.md +1 -1
- package/.xdrs/agentme/edrs/principles/017-skill-testing.md +7 -4
- package/.xdrs/agentme/edrs/principles/articles/001-continuous-xdr-improvement.md +1 -1
- package/.xdrs/agentme/edrs/principles/skills/{150-refine-plan-mode → refine-plan-mode}/SKILL.md +27 -16
- package/.xdrs/agentme/edrs/principles/skills/{150-refine-plan-mode → refine-plan-mode}/SKILL.test.md +28 -4
- package/.xdrs/agentme/edrs/principles/skills/{151-refine-user-story → refine-user-story}/SKILL.md +4 -11
- package/.xdrs/agentme/edrs/principles/skills/{151-refine-user-story → refine-user-story}/SKILL.test.md +17 -17
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/Makefile +8 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/SKILL.md +662 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/SKILL.test.md +165 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-azure-devops.js +219 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-azure-devops.test.js +253 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.js +237 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/post-replies-github.test.js +272 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/update-section.js +327 -0
- package/.xdrs/agentme/edrs/principles/skills/resolve-pr-comments/scripts/update-section.test.js +306 -0
- package/package.json +2 -2
|
@@ -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
|
+
});
|