Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/claude-code/pr-review/.claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"name": "pr-review",
"source": "./",
"tags": ["code-quality", "azure-devops"],
"version": "0.8.0"
"version": "0.9.0"
}
]
}
2 changes: 1 addition & 1 deletion apps/claude-code/pr-review/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pr-review",
"version": "0.8.0",
"version": "0.9.0",
"description": "Review Azure DevOps pull requests with multi-agent analysis and post threaded comments back to the PR.",
"author": {
"name": "Unic AG",
Expand Down
16 changes: 16 additions & 0 deletions apps/claude-code/pr-review/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@
### Fixed
- (none)

## [0.9.0] — 2026-05-06

### Breaking
- (none)

### Added
- Doc Context enrichment: before review agents run, fetch linked ADO work items and
any Confluence pages referenced in their descriptions; inject structured,
diff-aware summaries as business context into every review agent's prompt.
Requires Confluence credentials (`CONFLUENCE_URL`, `CONFLUENCE_USER`,
`CONFLUENCE_TOKEN` or `~/.unic-confluence.json`) for Confluence page fetching;
degrades gracefully when absent or unreachable.

### Fixed
- (none)

## [0.8.0] — 2026-05-06

### Breaking
Expand Down
97 changes: 90 additions & 7 deletions apps/claude-code/pr-review/commands/review-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,74 @@ for c in data.get('changeEntries', []):

---

## Step 4a — Gather Doc Context (work items + Confluence pages)

Fetch work items linked to the PR:

```bash
az devops invoke \
--area git \
--resource pullRequestWorkItems \
--route-parameters "repositoryId={REPO_ID}" "pullRequestId={PR_ID}" \
--org {ORG_URL} \
--api-version "7.1" \
--output json
```

If the `value` array is empty, set `DOC_CONTEXT=''` and skip to step 5.

For each work item ID returned, fetch its details:

```bash
az boards work-item show --id {WI_ID} --org {ORG_URL} --output json
```

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.

Capture `fields.System.Title` and `fields.System.Description`.

Spawn one **Doc Context Sub-agent** per work item in parallel (single message).
Each sub-agent receives:

- Work item ID, title, and description (HTML — read through the markup)
- The changed files list from step 4
- The local diff from step 5 (pass it if already available; otherwise omit)

Each Doc Context Sub-agent must:

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.
2. Extract all Confluence URLs from the description.
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.
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.
5. If creds absent and Confluence URLs were found: emit this console warning (never post to the PR):
```
⚠ 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.
```
Do not spawn Confluence sub-agents.
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):
```
> Note: Confluence pages could not be fetched for this work item. The review is based on the work item description only.
```
7. Return a Doc Context block in this format:

```markdown
## Business context for this PR

### Work item: [{ID}] {Title}

{diff-aware summary of work item description}

### Confluence — {Page Title} ({URL})

{diff-aware summary of page content}
```

Collect all sub-agent outputs and concatenate into a single Doc Context block. Store as `DOC_CONTEXT`.

Steps 5–7 run **in parallel** with step 4a. Step 8 waits for all of step 4a to complete before launching review agents.

---

## Step 5 — Get the diff locally

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

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

1. The PR title and description
2. The full diff (or the most important sections if large)
3. The content of key changed files (from Step 6)
4. Project conventions from `CLAUDE.md` if present
5. File paths and language context
1. The Doc Context block from step 4a (if `DOC_CONTEXT` is non-empty)
2. The PR title and description
3. The full diff (or the most important sections if large)
4. The content of key changed files (from Step 6)
5. Project conventions from `CLAUDE.md` if present
6. File paths and language context

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.

Prompt structure when `DOC_CONTEXT` is non-empty:

```
{DOC_CONTEXT}

## Diff
{diff content}

## Changed files
{file contents}
```

**Example agent invocations (parallel):**

```txt
Agent(
subagent_type: "pr-review-toolkit:code-reviewer",
prompt: "Review PR '{title}' targeting {target-branch}. [diff content] [key file contents] [CLAUDE.md conventions]"
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]"
)

Agent(
subagent_type: "pr-review-toolkit:silent-failure-hunter",
prompt: "Review PR '{title}' for silent failures. [diff content] [key file contents]"
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]"
)
```

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 10. Doc Context Enrichment — work items + Confluence pages

**Status: pending**
**Status: done — 2026-05-06**

- Priority: P1
- Effort: M
Expand Down
2 changes: 1 addition & 1 deletion apps/claude-code/pr-review/docs/plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ Goal: when `/unic-pr-review:review-pr <url>` runs against a PR that already has
| 07 | Summary comment policy on re-review | done | 06 |
| 08 | Version bump, README, CLAUDE.md | done | 07 |
| 09 | Test harness — node:test + modules | done | 02, 05, 06 |
| 10 | Doc Context enrichment — work items + Confluence pages | pending | — |
| 10 | Doc Context enrichment — work items + Confluence pages | done | — |
4 changes: 2 additions & 2 deletions apps/claude-code/pr-review/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pr-review",
"version": "0.8.0",
"version": "0.9.0",
"private": true,
"license": "LGPL-3.0-or-later",
"type": "module",
Expand All @@ -10,7 +10,7 @@
"pnpm": ">=10"
},
"scripts": {
"test": "node --test tests/parse-signature.test.mjs tests/classify-thread.test.mjs tests/match-finding.test.mjs tests/detect-prior-review.test.mjs",
"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",
"bump": "unic-bump",
"sync-version": "unic-sync-version",
"tag": "unic-tag",
Expand Down
199 changes: 199 additions & 0 deletions apps/claude-code/pr-review/scripts/confluence-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env node
// @ts-check

import { existsSync, readFileSync } from 'node:fs'
import https from 'node:https'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'

/**
* @typedef {{ url: string, username: string, token: string }} Credentials
*/

const DEFAULT_CRED_FILE = path.join(os.homedir(), '.unic-confluence.json')

/**
* Loads Confluence credentials from env vars or a JSON credentials file.
* CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN env vars take precedence.
* Falls back to the JSON file at credPath (~/.unic-confluence.json by default).
* Throws a descriptive Error if neither source yields valid credentials.
*
* @param {string} [credPath]
* @returns {Credentials}
*/
export function loadCredentials(credPath = DEFAULT_CRED_FILE) {
const { CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN } = process.env
if (CONFLUENCE_URL && CONFLUENCE_USER && CONFLUENCE_TOKEN) {
return { url: CONFLUENCE_URL, username: CONFLUENCE_USER, token: CONFLUENCE_TOKEN }
}
if (existsSync(credPath)) {
let raw
try {
raw = JSON.parse(readFileSync(credPath, 'utf8'))
} catch (err) {
throw new Error(
`Failed to read Confluence credentials from ${credPath}: ${/** @type {Error} */ (err).message}\n` +
'Verify the file is readable and contains valid JSON.',
{ cause: err }
)
}
const typed = /** @type {Credentials} */ (raw)
if (typed.url && typed.username && typed.token) return typed
throw new Error(
`Confluence credentials file ${credPath} is missing required fields — expected { url, username, token }`
)
}
throw new Error(
'Confluence credentials not configured — set CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN' +
' or create ~/.unic-confluence.json with { url, username, token }'
)
}

/**
* Extracts the numeric page ID from a Confluence page URL.
* Handles patterns:
* - /pages/{id}/slug
* - /pages/{id} (end of string)
* - /pages/{id}?query
* - /pages/{id}#anchor
*
* @param {string} pageUrl
* @returns {string}
*/
export function extractPageId(pageUrl) {
const match = pageUrl.match(/\/pages\/(\d+)(?:\/|[?#]|$)/)
if (!match) throw new Error(`Could not extract numeric page ID from URL: ${pageUrl}`)
const id = match[1]
if (!id) throw new Error(`Could not extract numeric page ID from URL: ${pageUrl}`)
return id
}

/**
* Makes an HTTPS GET request and returns status + body.
*
* @param {string} urlStr
* @param {string} authHeader
* @returns {Promise<{ status: number, body: string }>}
* @throws {Error} On network error, request timeout, or response stream error (promise rejects).
*/
function httpsGet(urlStr, authHeader) {
return new Promise((resolve, reject) => {
const parsed = new URL(urlStr)
const options = {
method: 'GET',
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
headers: {
Authorization: authHeader,
Accept: 'application/json',
},
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }))
res.on('error', reject)
})
req.setTimeout(30_000, () => {
req.destroy(new Error('Request timed out after 30s — check VPN/network connectivity'))
})
req.on('error', reject)
req.end()
})
}

/**
* @typedef {(url: string, authHeader: string) => Promise<{ status: number, body: string }>} HttpGet
*/

/**
* Fetches the Confluence storage-format body of a page by its URL.
* Uses the Confluence v2 API with Basic auth.
* Throws on non-2xx response or network error.
*
* The optional `httpGet` parameter allows injecting an alternative transport
* (used by tests). It defaults to the internal `httpsGet` so callers do not
* need to pass anything.
*
* @param {string} pageUrl
* @param {Credentials} credentials
* @param {HttpGet} [httpGet]
* @returns {Promise<string>} The raw Confluence storage-format markup for the page body
*/
export async function fetchPageText(pageUrl, credentials, httpGet = httpsGet) {
const pageId = extractPageId(pageUrl)
const apiUrl = `${credentials.url.replace(/\/$/, '')}/wiki/api/v2/pages/${pageId}?body-format=storage`
const authHeader = `Basic ${Buffer.from(`${credentials.username}:${credentials.token}`).toString('base64')}`

let res
try {
res = await httpGet(apiUrl, authHeader)
} catch (err) {
throw new Error(`Network error fetching ${pageUrl}: ${/** @type {Error} */ (err).message}`, { cause: err })
}

if (res.status < 200 || res.status >= 300) {
throw new Error(`Confluence returned HTTP ${res.status} for ${pageUrl}`)
}

let parsed
try {
parsed = JSON.parse(res.body)
} catch {
throw new Error(`Unexpected non-JSON response from Confluence for ${pageUrl}`)
}

const content = parsed?.body?.storage?.value
if (typeof content !== 'string') {
throw new Error(`No storage body found in Confluence response for ${pageUrl}`)
}
return content
}

// ── CLI entry point ────────────────────────────────────────────────────────────

let isMain = false
try {
isMain = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href
} catch {
// not running as a CLI entry point (e.g. node -e / REPL / relative argv[1])
}

if (isMain) {
Comment thread
orioltf marked this conversation as resolved.
const args = process.argv.slice(2)

if (args.length === 0 || (args[0] !== '--check-creds' && !args[0]?.startsWith('http'))) {
console.error('Usage:')
console.error(' node scripts/confluence-client.mjs --check-creds')
console.error(' node scripts/confluence-client.mjs <confluence-page-url>')
process.exit(1)
}

if (args[0] === '--check-creds') {
try {
loadCredentials()
process.exit(0)
} catch (err) {
console.error(/** @type {Error} */ (err).message)
process.exit(1)
}
} else {
const url = args[0] ?? ''
try {
const creds = loadCredentials()
const text = await fetchPageText(url, creds)
process.stdout.write(text)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(message)
const cause = err instanceof Error ? /** @type {any} */ (err).cause : undefined
if (cause instanceof Error) {
console.error(`Caused by: ${cause.message}`)
}
process.exit(1)
}
}
}
Loading
Loading