Skip to content

Commit 79a53f2

Browse files
authored
Merge pull request #21 from unic/feature/pr-review/doc-context-enrichment
feat(pr-review): doc context enrichment from work items and Confluence pages (v0.9.0)
2 parents 67fca1b + 6ac4000 commit 79a53f2

13 files changed

Lines changed: 503 additions & 17 deletions

File tree

apps/claude-code/pr-review/.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"name": "pr-review",
2222
"source": "./",
2323
"tags": ["code-quality", "azure-devops"],
24-
"version": "0.8.0"
24+
"version": "0.9.0"
2525
}
2626
]
2727
}

apps/claude-code/pr-review/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pr-review",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"description": "Review Azure DevOps pull requests with multi-agent analysis and post threaded comments back to the PR.",
55
"author": {
66
"name": "Unic AG",

apps/claude-code/pr-review/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@
1111
### Fixed
1212
- (none)
1313

14+
## [0.9.0] — 2026-05-06
15+
16+
### Breaking
17+
- (none)
18+
19+
### Added
20+
- Doc Context enrichment: before review agents run, fetch linked ADO work items and
21+
any Confluence pages referenced in their descriptions; inject structured,
22+
diff-aware summaries as business context into every review agent's prompt.
23+
Requires Confluence credentials (`CONFLUENCE_URL`, `CONFLUENCE_USER`,
24+
`CONFLUENCE_TOKEN` or `~/.unic-confluence.json`) for Confluence page fetching;
25+
degrades gracefully when absent or unreachable.
26+
27+
### Fixed
28+
- (none)
29+
1430
## [0.8.0] — 2026-05-06
1531

1632
### Breaking

apps/claude-code/pr-review/commands/review-pr.md

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,74 @@ for c in data.get('changeEntries', []):
294294

295295
---
296296

297+
## Step 4a — Gather Doc Context (work items + Confluence pages)
298+
299+
Fetch work items linked to the PR:
300+
301+
```bash
302+
az devops invoke \
303+
--area git \
304+
--resource pullRequestWorkItems \
305+
--route-parameters "repositoryId={REPO_ID}" "pullRequestId={PR_ID}" \
306+
--org {ORG_URL} \
307+
--api-version "7.1" \
308+
--output json
309+
```
310+
311+
If the `value` array is empty, set `DOC_CONTEXT=''` and skip to step 5.
312+
313+
For each work item ID returned, fetch its details:
314+
315+
```bash
316+
az boards work-item show --id {WI_ID} --org {ORG_URL} --output json
317+
```
318+
319+
If this command fails (network error, auth expiry, deleted work item), emit `⚠ Could not fetch work item {WI_ID} — {error}` to the console and skip that work item. Do not abort the step.
320+
321+
Capture `fields.System.Title` and `fields.System.Description`.
322+
323+
Spawn one **Doc Context Sub-agent** per work item in parallel (single message).
324+
Each sub-agent receives:
325+
326+
- Work item ID, title, and description (HTML — read through the markup)
327+
- The changed files list from step 4
328+
- The local diff from step 5 (pass it if already available; otherwise omit)
329+
330+
Each Doc Context Sub-agent must:
331+
332+
1. Summarise the work item description, focusing only on what is relevant to the changed files. Ignore sections that have no bearing on the diff.
333+
2. Extract all Confluence URLs from the description.
334+
3. Check Confluence credentials: `node scripts/confluence-client.mjs --check-creds` (exit 0 = creds available). If the command does not return within 10 seconds, treat as creds absent and follow instruction 5.
335+
4. If creds available: spawn one nested Doc Context Sub-agent per Confluence URL in parallel. Each runs `node scripts/confluence-client.mjs <url>` and returns a diff-aware plain-text summary of the page.
336+
5. If creds absent and Confluence URLs were found: emit this console warning (never post to the PR):
337+
```
338+
⚠ Confluence pages not fetched — set CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN (or create ~/.unic-confluence.json with { url, username, token }) to enable doc-aware review.
339+
```
340+
Do not spawn Confluence sub-agents.
341+
6. If a Confluence page fetch fails (network error, 401, 403, etc.): skip that page, emit `⚠ Could not fetch Confluence page <url> — <reason>`, continue with remaining context. If every Confluence page for a work item fails to fetch, include the following note in that work item's Doc Context section (in addition to the console warnings):
342+
```
343+
> Note: Confluence pages could not be fetched for this work item. The review is based on the work item description only.
344+
```
345+
7. Return a Doc Context block in this format:
346+
347+
```markdown
348+
## Business context for this PR
349+
350+
### Work item: [{ID}] {Title}
351+
352+
{diff-aware summary of work item description}
353+
354+
### Confluence — {Page Title} ({URL})
355+
356+
{diff-aware summary of page content}
357+
```
358+
359+
Collect all sub-agent outputs and concatenate into a single Doc Context block. Store as `DOC_CONTEXT`.
360+
361+
Steps 5–7 run **in parallel** with step 4a. Step 8 waits for all of step 4a to complete before launching review agents.
362+
363+
---
364+
297365
## Step 5 — Get the diff locally
298366

299367
Check if the local branch matches the PR source branch:
@@ -479,23 +547,38 @@ Map aspects to agents:
479547

480548
Launch at least `code-reviewer` and `silent-failure-hunter` in a **single message** (parallel). For each agent, provide a self-contained prompt including:
481549

482-
1. The PR title and description
483-
2. The full diff (or the most important sections if large)
484-
3. The content of key changed files (from Step 6)
485-
4. Project conventions from `CLAUDE.md` if present
486-
5. File paths and language context
550+
1. The Doc Context block from step 4a (if `DOC_CONTEXT` is non-empty)
551+
2. The PR title and description
552+
3. The full diff (or the most important sections if large)
553+
4. The content of key changed files (from Step 6)
554+
5. Project conventions from `CLAUDE.md` if present
555+
6. File paths and language context
556+
557+
Inject `DOC_CONTEXT` as a preamble before the diff content. If `DOC_CONTEXT` is empty, omit the preamble and agents receive the same prompt as today.
558+
559+
Prompt structure when `DOC_CONTEXT` is non-empty:
560+
561+
```
562+
{DOC_CONTEXT}
563+
564+
## Diff
565+
{diff content}
566+
567+
## Changed files
568+
{file contents}
569+
```
487570

488571
**Example agent invocations (parallel):**
489572

490573
```txt
491574
Agent(
492575
subagent_type: "pr-review-toolkit:code-reviewer",
493-
prompt: "Review PR '{title}' targeting {target-branch}. [diff content] [key file contents] [CLAUDE.md conventions]"
576+
prompt: "Review PR '{title}' targeting {target-branch}. {DOC_CONTEXT if non-empty}\n\n## Diff\n[diff content]\n\n## Changed files\n[key file contents]\n\n[CLAUDE.md conventions]"
494577
)
495578
496579
Agent(
497580
subagent_type: "pr-review-toolkit:silent-failure-hunter",
498-
prompt: "Review PR '{title}' for silent failures. [diff content] [key file contents]"
581+
prompt: "Review PR '{title}' for silent failures. {DOC_CONTEXT if non-empty}\n\n## Diff\n[diff content]\n\n## Changed files\n[key file contents]"
499582
)
500583
```
501584

apps/claude-code/pr-review/docs/plans/10-doc-context-enrichment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 10. Doc Context Enrichment — work items + Confluence pages
22

3-
**Status: pending**
3+
**Status: done — 2026-05-06**
44

55
- Priority: P1
66
- Effort: M

apps/claude-code/pr-review/docs/plans/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ Goal: when `/unic-pr-review:review-pr <url>` runs against a PR that already has
1616
| 07 | Summary comment policy on re-review | done | 06 |
1717
| 08 | Version bump, README, CLAUDE.md | done | 07 |
1818
| 09 | Test harness — node:test + modules | done | 02, 05, 06 |
19-
| 10 | Doc Context enrichment — work items + Confluence pages | pending ||
19+
| 10 | Doc Context enrichment — work items + Confluence pages | done ||

apps/claude-code/pr-review/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pr-review",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"private": true,
55
"license": "LGPL-3.0-or-later",
66
"type": "module",
@@ -10,7 +10,7 @@
1010
"pnpm": ">=10"
1111
},
1212
"scripts": {
13-
"test": "node --test tests/parse-signature.test.mjs tests/classify-thread.test.mjs tests/match-finding.test.mjs tests/detect-prior-review.test.mjs",
13+
"test": "node --test tests/parse-signature.test.mjs tests/classify-thread.test.mjs tests/match-finding.test.mjs tests/detect-prior-review.test.mjs tests/confluence-client.test.mjs",
1414
"bump": "unic-bump",
1515
"sync-version": "unic-sync-version",
1616
"tag": "unic-tag",
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env node
2+
// @ts-check
3+
4+
import { existsSync, readFileSync } from 'node:fs'
5+
import https from 'node:https'
6+
import os from 'node:os'
7+
import path from 'node:path'
8+
import { pathToFileURL } from 'node:url'
9+
10+
/**
11+
* @typedef {{ url: string, username: string, token: string }} Credentials
12+
*/
13+
14+
const DEFAULT_CRED_FILE = path.join(os.homedir(), '.unic-confluence.json')
15+
16+
/**
17+
* Loads Confluence credentials from env vars or a JSON credentials file.
18+
* CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN env vars take precedence.
19+
* Falls back to the JSON file at credPath (~/.unic-confluence.json by default).
20+
* Throws a descriptive Error if neither source yields valid credentials.
21+
*
22+
* @param {string} [credPath]
23+
* @returns {Credentials}
24+
*/
25+
export function loadCredentials(credPath = DEFAULT_CRED_FILE) {
26+
const { CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN } = process.env
27+
if (CONFLUENCE_URL && CONFLUENCE_USER && CONFLUENCE_TOKEN) {
28+
return { url: CONFLUENCE_URL, username: CONFLUENCE_USER, token: CONFLUENCE_TOKEN }
29+
}
30+
if (existsSync(credPath)) {
31+
let raw
32+
try {
33+
raw = JSON.parse(readFileSync(credPath, 'utf8'))
34+
} catch (err) {
35+
throw new Error(
36+
`Failed to read Confluence credentials from ${credPath}: ${/** @type {Error} */ (err).message}\n` +
37+
'Verify the file is readable and contains valid JSON.',
38+
{ cause: err }
39+
)
40+
}
41+
const typed = /** @type {Credentials} */ (raw)
42+
if (typed.url && typed.username && typed.token) return typed
43+
throw new Error(
44+
`Confluence credentials file ${credPath} is missing required fields — expected { url, username, token }`
45+
)
46+
}
47+
throw new Error(
48+
'Confluence credentials not configured — set CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN' +
49+
' or create ~/.unic-confluence.json with { url, username, token }'
50+
)
51+
}
52+
53+
/**
54+
* Extracts the numeric page ID from a Confluence page URL.
55+
* Handles patterns:
56+
* - /pages/{id}/slug
57+
* - /pages/{id} (end of string)
58+
* - /pages/{id}?query
59+
* - /pages/{id}#anchor
60+
*
61+
* @param {string} pageUrl
62+
* @returns {string}
63+
*/
64+
export function extractPageId(pageUrl) {
65+
const match = pageUrl.match(/\/pages\/(\d+)(?:\/|[?#]|$)/)
66+
if (!match) throw new Error(`Could not extract numeric page ID from URL: ${pageUrl}`)
67+
const id = match[1]
68+
if (!id) throw new Error(`Could not extract numeric page ID from URL: ${pageUrl}`)
69+
return id
70+
}
71+
72+
/**
73+
* Makes an HTTPS GET request and returns status + body.
74+
*
75+
* @param {string} urlStr
76+
* @param {string} authHeader
77+
* @returns {Promise<{ status: number, body: string }>}
78+
* @throws {Error} On network error, request timeout, or response stream error (promise rejects).
79+
*/
80+
function httpsGet(urlStr, authHeader) {
81+
return new Promise((resolve, reject) => {
82+
const parsed = new URL(urlStr)
83+
const options = {
84+
method: 'GET',
85+
hostname: parsed.hostname,
86+
path: parsed.pathname + parsed.search,
87+
headers: {
88+
Authorization: authHeader,
89+
Accept: 'application/json',
90+
},
91+
}
92+
const req = https.request(options, (res) => {
93+
let data = ''
94+
res.on('data', (chunk) => {
95+
data += chunk
96+
})
97+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }))
98+
res.on('error', reject)
99+
})
100+
req.setTimeout(30_000, () => {
101+
req.destroy(new Error('Request timed out after 30s — check VPN/network connectivity'))
102+
})
103+
req.on('error', reject)
104+
req.end()
105+
})
106+
}
107+
108+
/**
109+
* @typedef {(url: string, authHeader: string) => Promise<{ status: number, body: string }>} HttpGet
110+
*/
111+
112+
/**
113+
* Fetches the Confluence storage-format body of a page by its URL.
114+
* Uses the Confluence v2 API with Basic auth.
115+
* Throws on non-2xx response or network error.
116+
*
117+
* The optional `httpGet` parameter allows injecting an alternative transport
118+
* (used by tests). It defaults to the internal `httpsGet` so callers do not
119+
* need to pass anything.
120+
*
121+
* @param {string} pageUrl
122+
* @param {Credentials} credentials
123+
* @param {HttpGet} [httpGet]
124+
* @returns {Promise<string>} The raw Confluence storage-format markup for the page body
125+
*/
126+
export async function fetchPageText(pageUrl, credentials, httpGet = httpsGet) {
127+
const pageId = extractPageId(pageUrl)
128+
const apiUrl = `${credentials.url.replace(/\/$/, '')}/wiki/api/v2/pages/${pageId}?body-format=storage`
129+
const authHeader = `Basic ${Buffer.from(`${credentials.username}:${credentials.token}`).toString('base64')}`
130+
131+
let res
132+
try {
133+
res = await httpGet(apiUrl, authHeader)
134+
} catch (err) {
135+
throw new Error(`Network error fetching ${pageUrl}: ${/** @type {Error} */ (err).message}`, { cause: err })
136+
}
137+
138+
if (res.status < 200 || res.status >= 300) {
139+
throw new Error(`Confluence returned HTTP ${res.status} for ${pageUrl}`)
140+
}
141+
142+
let parsed
143+
try {
144+
parsed = JSON.parse(res.body)
145+
} catch {
146+
throw new Error(`Unexpected non-JSON response from Confluence for ${pageUrl}`)
147+
}
148+
149+
const content = parsed?.body?.storage?.value
150+
if (typeof content !== 'string') {
151+
throw new Error(`No storage body found in Confluence response for ${pageUrl}`)
152+
}
153+
return content
154+
}
155+
156+
// ── CLI entry point ────────────────────────────────────────────────────────────
157+
158+
let isMain = false
159+
try {
160+
isMain = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href
161+
} catch {
162+
// not running as a CLI entry point (e.g. node -e / REPL / relative argv[1])
163+
}
164+
165+
if (isMain) {
166+
const args = process.argv.slice(2)
167+
168+
if (args.length === 0 || (args[0] !== '--check-creds' && !args[0]?.startsWith('http'))) {
169+
console.error('Usage:')
170+
console.error(' node scripts/confluence-client.mjs --check-creds')
171+
console.error(' node scripts/confluence-client.mjs <confluence-page-url>')
172+
process.exit(1)
173+
}
174+
175+
if (args[0] === '--check-creds') {
176+
try {
177+
loadCredentials()
178+
process.exit(0)
179+
} catch (err) {
180+
console.error(/** @type {Error} */ (err).message)
181+
process.exit(1)
182+
}
183+
} else {
184+
const url = args[0] ?? ''
185+
try {
186+
const creds = loadCredentials()
187+
const text = await fetchPageText(url, creds)
188+
process.stdout.write(text)
189+
} catch (err) {
190+
const message = err instanceof Error ? err.message : String(err)
191+
console.error(message)
192+
const cause = err instanceof Error ? /** @type {any} */ (err).cause : undefined
193+
if (cause instanceof Error) {
194+
console.error(`Caused by: ${cause.message}`)
195+
}
196+
process.exit(1)
197+
}
198+
}
199+
}

0 commit comments

Comments
 (0)