diff --git a/bun.lock b/bun.lock index 6a441dab..43704e26 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -61,7 +62,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.15.2", + "version": "0.15.5", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -83,7 +84,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.15.4", + "version": "0.15.5", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -168,7 +169,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.15.2", + "version": "0.15.5", "dependencies": { "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 43e6bdf1..abf7a990 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -8,6 +8,7 @@ import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; import { GitHubIcon } from '@plannotator/ui/components/GitHubIcon'; import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon'; +import { AzureDevOpsIcon } from '@plannotator/ui/components/AzureDevOpsIcon'; import { RepoIcon } from '@plannotator/ui/components/RepoIcon'; import { PullRequestIcon } from '@plannotator/ui/components/PullRequestIcon'; import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-provider'; @@ -1092,7 +1093,7 @@ const ReviewApp: React.FC = () => { > {reviewDestination === 'platform' ? ( <> - {prMetadata?.platform === 'gitlab' ? : } + {prMetadata?.platform === 'gitlab' ? : prMetadata?.platform === 'azuredevops' ? : } {platformLabel} ) : 'Agent'} diff --git a/packages/server/pr.ts b/packages/server/pr.ts index e9371053..9d8d8fa3 100644 --- a/packages/server/pr.ts +++ b/packages/server/pr.ts @@ -29,7 +29,7 @@ import { getCliInstallUrl, } from "@plannotator/shared/pr-provider"; -export type { PRRef, PRMetadata, PRContext, PRReviewFileComment } from "@plannotator/shared/pr-provider"; +export type { PRRef, PRMetadata, PRContext, PRReviewFileComment, AzureDevOpsPRRef, AzureDevOpsPRMetadata } from "@plannotator/shared/pr-provider"; export { prRefFromMetadata, getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo, getCliName, getCliInstallUrl } from "@plannotator/shared/pr-provider"; export type { GithubPRMetadata } from "@plannotator/shared/pr-provider"; diff --git a/packages/shared/pr-azuredevops.test.ts b/packages/shared/pr-azuredevops.test.ts new file mode 100644 index 00000000..e953a63e --- /dev/null +++ b/packages/shared/pr-azuredevops.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test"; +import { parsePRUrl } from "./pr-provider"; +import { buildFilePatch_TEST, computeHunks_TEST } from "./pr-azuredevops"; + +// ─── URL Parsing ───────────────────────────────────────────────────────────── + +describe("parsePRUrl – Azure DevOps", () => { + test("parses dev.azure.com URL", () => { + const ref = parsePRUrl("https://dev.azure.com/myorg/MyProject/_git/MyRepo/pullrequest/42"); + expect(ref).toMatchObject({ + platform: "azuredevops", + orgUrl: "https://dev.azure.com/myorg", + organization: "myorg", + project: "MyProject", + repo: "MyRepo", + id: 42, + }); + }); + + test("parses legacy visualstudio.com URL", () => { + const ref = parsePRUrl("https://myorg.visualstudio.com/MyProject/_git/MyRepo/pullrequest/99"); + expect(ref).toMatchObject({ + platform: "azuredevops", + orgUrl: "https://myorg.visualstudio.com", + organization: "myorg", + project: "MyProject", + repo: "MyRepo", + id: 99, + }); + }); + + test("decodes URL-encoded spaces in project/repo names", () => { + const ref = parsePRUrl("https://dev.azure.com/myorg/My%20Project/_git/My%20Repo/pullrequest/7"); + expect(ref).toMatchObject({ + platform: "azuredevops", + project: "My Project", + repo: "My Repo", + }); + }); + + test("is case-insensitive for pullrequest segment", () => { + const ref = parsePRUrl("https://dev.azure.com/myorg/Proj/_git/Repo/PullRequest/1"); + expect(ref).toMatchObject({ platform: "azuredevops", id: 1 }); + }); + + test("parses large PR ID", () => { + const ref = parsePRUrl("https://dev.azure.com/org/proj/_git/repo/pullrequest/155857"); + expect(ref).toMatchObject({ platform: "azuredevops", id: 155857 }); + }); + + test("returns null for non-ADO URLs", () => { + expect(parsePRUrl("https://example.com/foo")).toBeNull(); + expect(parsePRUrl("")).toBeNull(); + }); + + // Regression: GitHub and GitLab still parse correctly + test("does not break GitHub URL parsing", () => { + const ref = parsePRUrl("https://github.com/owner/repo/pull/1"); + expect(ref).toMatchObject({ platform: "github", owner: "owner", repo: "repo", number: 1 }); + }); + + test("does not break GitLab URL parsing", () => { + const ref = parsePRUrl("https://gitlab.com/group/project/-/merge_requests/5"); + expect(ref).toMatchObject({ platform: "gitlab", host: "gitlab.com", iid: 5 }); + }); + + test("does not break self-hosted GitLab parsing", () => { + const ref = parsePRUrl("https://gitlab.myco.com/grp/sub/proj/-/merge_requests/10"); + expect(ref).toMatchObject({ platform: "gitlab", host: "gitlab.myco.com", iid: 10 }); + }); +}); + +// ─── Diff Engine ───────────────────────────────────────────────────────────── + +describe("buildFilePatch – unified diff generation", () => { + test("produces git-style header for edited file", () => { + const patch = buildFilePatch_TEST( + "line1\nline2\nline3\n", + "line1\nchanged\nline3\n", + "src/foo.ts", + ); + expect(patch).toContain("diff --git a/src/foo.ts b/src/foo.ts"); + expect(patch).toContain("--- a/src/foo.ts"); + expect(patch).toContain("+++ b/src/foo.ts"); + expect(patch).toContain("-line2"); + expect(patch).toContain("+changed"); + }); + + test("uses /dev/null for added files", () => { + const patch = buildFilePatch_TEST("", "new content\n", "src/new.ts", undefined, "add"); + expect(patch).toContain("--- /dev/null"); + expect(patch).toContain("+++ b/src/new.ts"); + expect(patch).toContain("+new content"); + }); + + test("uses /dev/null for deleted files", () => { + const patch = buildFilePatch_TEST("old content\n", "", "src/old.ts", undefined, "delete"); + expect(patch).toContain("--- a/src/old.ts"); + expect(patch).toContain("+++ /dev/null"); + expect(patch).toContain("-old content"); + }); + + test("returns empty string for identical files", () => { + const patch = buildFilePatch_TEST("same\n", "same\n", "src/same.ts"); + expect(patch).toBe(""); + }); + + test("handles files without trailing newline", () => { + const patch = buildFilePatch_TEST("a\nb", "a\nc", "src/no-newline.ts"); + expect(patch).toContain("-b"); + expect(patch).toContain("+c"); + }); + + test("includes rename path in header", () => { + const patch = buildFilePatch_TEST( + "content\n", + "content changed\n", + "src/new-name.ts", + "src/old-name.ts", + "rename", + ); + expect(patch).toContain("a/src/old-name.ts"); + expect(patch).toContain("b/src/new-name.ts"); + }); + + test("produces correct hunk header line numbers", () => { + const old = Array.from({ length: 10 }, (_, i) => `line${i + 1}`).join("\n") + "\n"; + const changed = old.replace("line5", "CHANGED"); + const patch = buildFilePatch_TEST(old, changed, "src/long.ts"); + expect(patch).toContain("@@"); + expect(patch).toContain("-line5"); + expect(patch).toContain("+CHANGED"); + }); + + test("groups nearby changes into a single hunk", () => { + const old = "a\nb\nc\nd\ne\nf\ng\n"; + const changed = "a\nB\nc\nd\ne\nF\ng\n"; + const patch = buildFilePatch_TEST(old, changed, "src/multi.ts"); + // Two changes 3 lines apart should be merged into one hunk + const hunkCount = (patch.match(/^@@/gm) ?? []).length; + expect(hunkCount).toBe(1); + }); + + test("splits distant changes into separate hunks", () => { + const lines = Array.from({ length: 30 }, (_, i) => `line${i + 1}`); + const changed = [...lines]; + changed[0] = "CHANGED_TOP"; + changed[29] = "CHANGED_BOTTOM"; + const patch = buildFilePatch_TEST( + lines.join("\n") + "\n", + changed.join("\n") + "\n", + "src/split.ts", + ); + const hunkCount = (patch.match(/^@@/gm) ?? []).length; + expect(hunkCount).toBe(2); + }); +}); + +// ─── Hunk computation ──────────────────────────────────────────────────────── + +describe("computeHunks", () => { + test("returns empty for identical content", () => { + const hunks = computeHunks_TEST(["a", "b", "c"], ["a", "b", "c"]); + expect(hunks).toHaveLength(0); + }); + + test("detects addition at end", () => { + const hunks = computeHunks_TEST(["a", "b"], ["a", "b", "c"]); + expect(hunks.join("\n")).toContain("+c"); + }); + + test("detects deletion", () => { + const hunks = computeHunks_TEST(["a", "b", "c"], ["a", "c"]); + expect(hunks.join("\n")).toContain("-b"); + }); + + test("detects replacement", () => { + const hunks = computeHunks_TEST(["a", "b", "c"], ["a", "X", "c"]); + const joined = hunks.join("\n"); + expect(joined).toContain("-b"); + expect(joined).toContain("+X"); + }); +}); diff --git a/packages/shared/pr-azuredevops.ts b/packages/shared/pr-azuredevops.ts new file mode 100644 index 00000000..adcd0725 --- /dev/null +++ b/packages/shared/pr-azuredevops.ts @@ -0,0 +1,651 @@ +/** + * Azure DevOps-specific PR provider implementation. + * + * Uses the `az` CLI (Azure CLI with azure-devops extension) via the PRRuntime + * abstraction. Supports both dev.azure.com and legacy visualstudio.com URLs. + * + * Auth: `az login` (interactive) or `az devops login` (PAT-based). + * Diff: fetched via git using the source/target commit SHAs from the PR metadata. + * File content: fetched via the ADO REST API using `az rest`. + */ + +import type { PRRuntime, PRMetadata, PRContext, PRReviewFileComment } from "./pr-provider"; + +// Azure DevOps-specific PRRef shape (used internally) +interface AdoPRRef { + platform: "azuredevops"; + orgUrl: string; // e.g., "https://dev.azure.com/myorg" + organization: string; // e.g., "myorg" + project: string; // e.g., "MyProject" + repo: string; // e.g., "MyRepo" + id: number; // PR number +} + +// --- Helpers --- + +/** Build the base ADO REST API URL for a git repository */ +function repoApiBase(ref: AdoPRRef): string { + return `${ref.orgUrl}/${encodeURIComponent(ref.project)}/_apis/git/repositories/${encodeURIComponent(ref.repo)}`; +} + +/** + * Azure DevOps OAuth resource ID. + * `az rest` defaults to the ARM token — specifying this resource tells it to + * acquire a token scoped for Azure DevOps instead. + */ +const ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"; + +/** Run `az rest` to call the ADO REST API and return parsed JSON */ +async function azRest( + runtime: PRRuntime, + method: string, + uri: string, + body?: string, +): Promise { + const args = ["rest", "--method", method, "--uri", uri, "--resource", ADO_RESOURCE_ID]; + if (body) { + args.push("--body", body); + args.push("--headers", "Content-Type=application/json"); + } + + const result = await runtime.runCommand("az", args); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || `az rest failed (exit ${result.exitCode})`); + } + + return JSON.parse(result.stdout) as T; +} + +// --- Auth --- + +export async function checkAdoAuth(runtime: PRRuntime, orgUrl: string): Promise { + // Try az account show first (covers az login) + const accountResult = await runtime.runCommand("az", ["account", "show", "--output", "none"]); + if (accountResult.exitCode === 0) return; + + // Fall back: try az devops project list as a connectivity check + const projectResult = await runtime.runCommand("az", [ + "devops", "project", "list", + "--org", orgUrl, + "--output", "none", + ]); + if (projectResult.exitCode !== 0) { + throw new Error( + `Azure DevOps CLI not authenticated. Run \`az login\` or \`az devops login --org ${orgUrl}\`.\n${projectResult.stderr.trim()}`, + ); + } +} + +export async function getAdoUser(runtime: PRRuntime, orgUrl: string): Promise { + try { + // Try az ad signed-in-user show (works with Entra ID login) + const result = await runtime.runCommand("az", ["ad", "signed-in-user", "show", "--query", "userPrincipalName", "--output", "tsv"]); + if (result.exitCode === 0 && result.stdout.trim()) { + return result.stdout.trim(); + } + // Fallback: az devops user show (PAT-based) + const devopsResult = await runtime.runCommand("az", [ + "devops", "user", "show", + "--org", orgUrl, + "--output", "json", + ]); + if (devopsResult.exitCode === 0 && devopsResult.stdout.trim()) { + const data = JSON.parse(devopsResult.stdout) as { user?: { mailAddress?: string; principalName?: string } }; + return data.user?.mailAddress ?? data.user?.principalName ?? null; + } + return null; + } catch { + return null; + } +} + +// --- Fetch PR --- + +/** Shape of `az repos pr show` JSON output we care about */ +interface AdoPRShowResult { + pullRequestId: number; + title: string; + createdBy: { uniqueName?: string; displayName?: string }; + sourceRefName: string; // "refs/heads/feature" + targetRefName: string; // "refs/heads/main" + lastMergeSourceCommit?: { commitId: string }; + lastMergeTargetCommit?: { commitId: string }; + repository?: { remoteUrl?: string; id?: string }; + remoteUrl?: string; + status: string; + isDraft: boolean; + url: string; +} + +/** Strip "refs/heads/" prefix from an ADO ref name */ +function stripRefsHeads(ref: string): string { + return ref.replace(/^refs\/heads\//, ""); +} + +/** Build the browser-facing ADO PR URL from the remote URL and PR id */ +function buildPRWebUrl(remoteUrl: string | undefined, orgUrl: string, project: string, repo: string, id: number): string { + const base = remoteUrl ?? `${orgUrl}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repo)}`; + return `${base}/pullrequest/${id}`; +} + +// --- Diff via ADO REST API --- + +interface AdoChangeEntry { + changeType: string; // "add" | "edit" | "delete" | "rename" | "copy" | ... + item: { path: string; isFolder?: boolean; objectId?: string }; + originalItem?: { path: string; objectId?: string }; +} + +/** Fetch raw file content from ADO items API at a specific commit SHA */ +async function fetchItemContentRaw( + runtime: PRRuntime, + base: string, + filePath: string, + sha: string, +): Promise { + const encodedPath = encodeURIComponent(filePath); + const uri = `${base}/items?path=${encodedPath}&versionDescriptor.version=${sha}&versionDescriptor.versionType=commit&api-version=7.1&$format=text`; + const result = await runtime.runCommand("az", ["rest", "--method", "get", "--uri", uri, "--resource", ADO_RESOURCE_ID]); + if (result.exitCode !== 0) return ""; + return result.stdout; +} + +/** Exported for testing only */ +export const buildFilePatch_TEST = (...args: Parameters) => buildFilePatch(...args); +/** Exported for testing only */ +export const computeHunks_TEST = (...args: Parameters) => computeHunks(...args); + +/** + * Build a git-style unified diff for a single file from old/new content strings. + * Uses a simple line-level diff algorithm (no external packages required). + */ +function buildFilePatch( + oldContent: string, + newContent: string, + filePath: string, + originalPath?: string, + changeType?: string, +): string { + const aPath = changeType === "add" ? "/dev/null" : `a/${originalPath ?? filePath}`; + const bPath = changeType === "delete" ? "/dev/null" : `b/${filePath}`; + const displayOld = originalPath ?? filePath; + + const oldLines = oldContent ? oldContent.split("\n") : []; + const newLines = newContent ? newContent.split("\n") : []; + + // Remove trailing empty line created by split on trailing newline + if (oldLines[oldLines.length - 1] === "") oldLines.pop(); + if (newLines[newLines.length - 1] === "") newLines.pop(); + + // Myers diff — compute edit script + const hunks = computeHunks(oldLines, newLines); + if (hunks.length === 0) return ""; + + const header = [ + `diff --git a/${displayOld} b/${filePath}`, + `--- ${aPath}`, + `+++ ${bPath}`, + ]; + + return [...header, ...hunks].join("\n") + "\n"; +} + +/** + * Minimal Myers diff → unified diff hunks. + * Returns lines in unified diff format (@@ ... @@ + context/add/remove lines). + */ +function computeHunks(oldLines: string[], newLines: string[]): string[] { + const CONTEXT = 3; + // Build edit script using simple LCS + const n = oldLines.length; + const m = newLines.length; + + // dp[i][j] = LCS length of oldLines[0..i) and newLines[0..j) + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + dp[i][j] = oldLines[i - 1] === newLines[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + // Backtrack to get edit operations: 'k'=keep, 'd'=delete, 'i'=insert + type Op = { type: "k" | "d" | "i"; oldIdx?: number; newIdx?: number; line: string }; + const ops: Op[] = []; + let i = n, j = m; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + ops.unshift({ type: "k", oldIdx: i - 1, newIdx: j - 1, line: oldLines[i - 1] }); + i--; j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + ops.unshift({ type: "i", newIdx: j - 1, line: newLines[j - 1] }); + j--; + } else { + ops.unshift({ type: "d", oldIdx: i - 1, line: oldLines[i - 1] }); + i--; + } + } + + if (ops.every(o => o.type === "k")) return []; // No changes + + // Group ops into hunks with context + const result: string[] = []; + const ranges: Array<{ start: number; end: number }> = []; + + // Find changed op indices + const changedIndices = ops.reduce((acc, op, idx) => { + if (op.type !== "k") acc.push(idx); + return acc; + }, []); + + if (changedIndices.length === 0) return []; + + // Merge nearby changes into hunks + let hunkStart = Math.max(0, changedIndices[0] - CONTEXT); + let hunkEnd = Math.min(ops.length - 1, changedIndices[0] + CONTEXT); + for (let k = 1; k < changedIndices.length; k++) { + const next = changedIndices[k]; + if (next - CONTEXT <= hunkEnd + 1) { + hunkEnd = Math.min(ops.length - 1, next + CONTEXT); + } else { + ranges.push({ start: hunkStart, end: hunkEnd }); + hunkStart = Math.max(0, next - CONTEXT); + hunkEnd = Math.min(ops.length - 1, next + CONTEXT); + } + } + ranges.push({ start: hunkStart, end: hunkEnd }); + + for (const range of ranges) { + const slice = ops.slice(range.start, range.end + 1); + + // Compute old/new line numbers for @@ header + const oldStart = slice.find(o => o.oldIdx !== undefined)?.oldIdx ?? 0; + const newStart = slice.find(o => o.newIdx !== undefined)?.newIdx ?? 0; + const oldCount = slice.filter(o => o.type !== "i").length; + const newCount = slice.filter(o => o.type !== "d").length; + + result.push(`@@ -${oldStart + 1},${oldCount} +${newStart + 1},${newCount} @@`); + for (const op of slice) { + if (op.type === "k") result.push(` ${op.line}`); + else if (op.type === "d") result.push(`-${op.line}`); + else result.push(`+${op.line}`); + } + } + + return result; +} + +/** + * Fetch the PR diff via ADO REST API. + * Gets the list of changed files from the PR iteration changes endpoint, + * then fetches old/new content for each file and builds a git-style unified diff. + */ +async function fetchAdoDiffViaApi( + runtime: PRRuntime, + ref: AdoPRRef, + baseSha: string, + headSha: string, +): Promise { + const base = repoApiBase(ref); + + // Get latest iteration ID + const iterResult = await azRest<{ value: Array<{ id: number }> }>( + runtime, "get", + `${base}/pullRequests/${ref.id}/iterations?api-version=7.1`, + ); + const iterations = iterResult.value ?? []; + if (iterations.length === 0) throw new Error("No PR iterations found"); + const latestIterId = iterations[iterations.length - 1].id; + + // Get changed files for this iteration + const changesResult = await azRest<{ changeEntries: AdoChangeEntry[] }>( + runtime, "get", + `${base}/pullRequests/${ref.id}/iterations/${latestIterId}/changes?api-version=7.1&$top=2000`, + ); + + const entries = (changesResult.changeEntries ?? []).filter(e => !e.item.isFolder); + if (entries.length === 0) return ""; + + // Fetch old+new content in parallel and build unified diff + const patches = await Promise.all(entries.map(async (entry) => { + const ct = entry.changeType?.toLowerCase() ?? "edit"; + const filePath = entry.item.path.replace(/^\//, ""); + const originalPath = entry.originalItem?.path?.replace(/^\//, ""); + + const [oldContent, newContent] = await Promise.all([ + ct === "add" ? Promise.resolve("") : fetchItemContentRaw(runtime, base, entry.originalItem?.path ?? entry.item.path, baseSha), + ct === "delete" ? Promise.resolve("") : fetchItemContentRaw(runtime, base, entry.item.path, headSha), + ]); + + return buildFilePatch(oldContent, newContent, filePath, originalPath, ct); + })); + + return patches.filter(Boolean).join(""); +} + +/** + * Attempt to get a unified diff for the PR. + * + * Strategy: + * 1. Try `git diff ..` (works if the repo is cloned locally). + * 2. If that fails, fall back to fetching the diff via the ADO REST API. + */ +async function fetchAdoDiff( + runtime: PRRuntime, + ref: AdoPRRef, + baseSha: string, + headSha: string, +): Promise { + const tryDiff = () => runtime.runCommand("git", ["diff", `${baseSha}..${headSha}`]); + + let diffResult = await tryDiff(); + + if (diffResult.exitCode !== 0) { + // Commits might not be local — try fetching from origin first + await runtime.runCommand("git", ["fetch", "origin"]); + diffResult = await tryDiff(); + } + + if (diffResult.exitCode === 0) { + return diffResult.stdout; + } + + // Not in the repo — fall back to ADO REST API + return fetchAdoDiffViaApi(runtime, ref, baseSha, headSha); +} + +export async function fetchAdoPR( + runtime: PRRuntime, + ref: AdoPRRef, +): Promise<{ metadata: PRMetadata; rawPatch: string }> { + const result = await runtime.runCommand("az", [ + "repos", "pr", "show", + "--id", String(ref.id), + "--org", ref.orgUrl, + "--output", "json", + ]); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to fetch PR metadata: ${result.stderr.trim() || `exit code ${result.exitCode}`}\n` + + `Make sure the azure-devops extension is installed: \`az extension add --name azure-devops\``, + ); + } + + const raw = JSON.parse(result.stdout) as AdoPRShowResult; + + const baseSha = raw.lastMergeTargetCommit?.commitId; + const headSha = raw.lastMergeSourceCommit?.commitId; + + if (!baseSha || !headSha) { + throw new Error("PR has no merge commits — it may be in a pending state or the source branch was deleted."); + } + + const remoteUrl = raw.repository?.remoteUrl ?? raw.remoteUrl; + const webUrl = buildPRWebUrl(remoteUrl, ref.orgUrl, ref.project, ref.repo, ref.id); + + const author = raw.createdBy.uniqueName ?? raw.createdBy.displayName ?? "unknown"; + + const metadata: PRMetadata = { + platform: "azuredevops", + orgUrl: ref.orgUrl, + organization: ref.organization, + project: ref.project, + repo: ref.repo, + id: ref.id, + title: raw.title, + author, + baseBranch: stripRefsHeads(raw.targetRefName), + headBranch: stripRefsHeads(raw.sourceRefName), + baseSha, + headSha, + url: webUrl, + }; + + const rawPatch = await fetchAdoDiff(runtime, ref, baseSha, headSha); + + return { metadata, rawPatch }; +} + +// --- PR Context --- + +interface AdoThread { + id: number; + isDeleted?: boolean; + comments?: Array<{ + id: number; + author: { uniqueName?: string; displayName?: string }; + content?: string; + publishedDate: string; + commentType: string; // "text" | "system" | "codeChange" + }>; + status?: string; // "active" | "fixed" | "wontFix" | "closed" | "byDesign" | "pending" + threadContext?: object | null; +} + +interface AdoPolicyEvaluation { + configuration?: { + type?: { displayName?: string }; + isEnabled?: boolean; + isBlocking?: boolean; + }; + status?: string; // "approved" | "running" | "queued" | "rejected" | "notApplicable" | "broken" + context?: { buildId?: number; pipelineRef?: { name?: string } }; +} + +export async function fetchAdoPRContext( + runtime: PRRuntime, + ref: AdoPRRef, +): Promise { + const apiVersion = "api-version=7.1"; + const base = repoApiBase(ref); + const prBase = `${base}/pullRequests/${ref.id}`; + + // Fetch threads (comments + reviews) and policy evaluations in parallel + const [threadsResult, policiesResult, prResult] = await Promise.allSettled([ + azRest<{ value: AdoThread[] }>(runtime, "get", `${prBase}/threads?${apiVersion}`), + azRest<{ value: AdoPolicyEvaluation[] }>( + runtime, "get", + `${ref.orgUrl}/${encodeURIComponent(ref.project)}/_apis/policy/evaluations?artifactId=vstfs:///CodeReview/CodeReviewId/${encodeURIComponent(ref.project)}/${ref.id}&${apiVersion}`, + ), + azRest(runtime, "get", `${prBase}?${apiVersion}`), + ]); + + const str = (v: unknown): string => (typeof v === "string" ? v : ""); + + // --- PR details --- + let prRaw: AdoPRShowResult | null = null; + if (prResult.status === "fulfilled") prRaw = prResult.value; + + const status = prRaw?.status ?? "active"; + const normalizedState = status === "active" ? "OPEN" : status.toUpperCase(); + const isDraft = prRaw?.isDraft ?? false; + + // --- Threads → comments + reviews --- + const comments: PRContext["comments"] = []; + const reviews: PRContext["reviews"] = []; + + if (threadsResult.status === "fulfilled") { + for (const thread of threadsResult.value.value ?? []) { + if (thread.isDeleted) continue; + const firstComment = thread.comments?.[0]; + if (!firstComment) continue; + + if (firstComment.commentType === "system") continue; + + const author = firstComment.author.uniqueName ?? firstComment.author.displayName ?? ""; + const body = str(firstComment.content); + const createdAt = str(firstComment.publishedDate); + const webUrl = `${ref.orgUrl}/${encodeURIComponent(ref.project)}/_git/${encodeURIComponent(ref.repo)}/pullrequest/${ref.id}?_a=overview&discussionId=${thread.id}`; + + // Threads with threadContext are inline code comments; others are general + comments.push({ + id: String(thread.id), + author, + body, + createdAt, + url: webUrl, + }); + + // Identify approval votes embedded in threads (ADO uses vote threads for reviews) + if (thread.status === "fixed" || thread.status === "byDesign") { + reviews.push({ + id: String(thread.id), + author, + state: "APPROVED", + body, + submittedAt: createdAt, + }); + } + } + } + + // --- Policy evaluations → checks --- + const checks: PRContext["checks"] = []; + if (policiesResult.status === "fulfilled") { + for (const policy of policiesResult.value.value ?? []) { + if (!policy.configuration?.isEnabled) continue; + const name = policy.configuration?.type?.displayName ?? "Policy"; + const polStatus = policy.status ?? ""; + const isComplete = ["approved", "rejected", "notApplicable", "broken"].includes(polStatus); + const conclusionMap: Record = { + approved: "SUCCESS", + rejected: "FAILURE", + notApplicable: "SKIPPED", + broken: "FAILURE", + }; + checks.push({ + name, + status: isComplete ? "COMPLETED" : "IN_PROGRESS", + conclusion: isComplete ? (conclusionMap[polStatus] ?? polStatus.toUpperCase()) : null, + workflowName: name, + detailsUrl: "", + }); + } + } + + // --- Merge status --- + // ADO uses mergeStatus: "succeeded" | "conflicts" | "rejected" | "queued" | "notSet" + const mergeStatus = (prRaw as any)?.mergeStatus ?? ""; + const mergeable = mergeStatus === "succeeded" ? "MERGEABLE" + : mergeStatus === "conflicts" ? "CONFLICTING" + : "UNKNOWN"; + + return { + body: str((prRaw as any)?.description), + state: normalizedState, + isDraft, + labels: [], + reviewDecision: "", + mergeable, + mergeStateStatus: mergeable, + comments, + reviews, + checks, + linkedIssues: [], + }; +} + +// --- File Content --- + +export async function fetchAdoFileContent( + runtime: PRRuntime, + ref: AdoPRRef, + sha: string, + filePath: string, +): Promise { + const base = repoApiBase(ref); + const encodedPath = encodeURIComponent(filePath); + const uri = `${base}/items?path=${encodedPath}&versionDescriptor.version=${sha}&versionDescriptor.versionType=commit&api-version=7.1&$format=text`; + + try { + const result = await runtime.runCommand("az", ["rest", "--method", "get", "--uri", uri, "--resource", ADO_RESOURCE_ID]); + if (result.exitCode !== 0) return null; + return result.stdout; + } catch { + return null; + } +} + +// --- Submit PR Review --- + +export async function submitAdoPRReview( + runtime: PRRuntime, + ref: AdoPRRef, + _headSha: string, + action: "approve" | "comment", + body: string, + fileComments: PRReviewFileComment[], +): Promise { + const apiVersion = "api-version=7.1"; + const base = repoApiBase(ref); + const prBase = `${base}/pullRequests/${ref.id}`; + + const threadsUri = `${prBase}/threads?${apiVersion}`; + + // 1. Post general comment as a thread (if non-empty) + if (body && body.trim()) { + await azRest(runtime, "post", threadsUri, JSON.stringify({ + comments: [{ parentCommentId: 0, content: body.trim(), commentType: 1 }], + status: 1, + })); + } + + // 2. Post inline file comments as threads with file context + if (fileComments.length > 0) { + const errors: string[] = []; + + const results = await Promise.allSettled( + fileComments.map(async (comment) => { + const isOldSide = comment.side === "LEFT"; + await azRest(runtime, "post", threadsUri, JSON.stringify({ + comments: [{ parentCommentId: 0, content: comment.body, commentType: 1 }], + status: 1, + threadContext: { + filePath: `/${comment.path}`, + rightFileStart: isOldSide ? null : { line: comment.start_line ?? comment.line, offset: 1 }, + rightFileEnd: isOldSide ? null : { line: comment.line, offset: 1 }, + leftFileStart: isOldSide ? { line: comment.start_line ?? comment.line, offset: 1 } : null, + leftFileEnd: isOldSide ? { line: comment.line, offset: 1 } : null, + }, + })); + }), + ); + + for (const r of results) { + if (r.status === "rejected") { + errors.push(r.reason instanceof Error ? r.reason.message : String(r.reason)); + } + } + + if (errors.length > 0 && errors.length === fileComments.length) { + throw new Error(`Failed to post inline comments:\n${errors.join("\n")}`); + } + if (errors.length > 0) { + console.error(`Warning: ${errors.length}/${fileComments.length} inline comments failed:\n${errors.join("\n")}`); + } + } + + // 3. Submit vote if approving via ADO REST API (votes: 10=approve, 5=approve-with-suggestions, 0=reset, -5=wait, -10=reject) + if (action === "approve") { + await azRest(runtime, "put", + `${ref.orgUrl}/${encodeURIComponent(ref.project)}/_apis/git/repositories/${encodeURIComponent(ref.repo)}/pullRequests/${ref.id}/reviewers/${encodeURIComponent(await getCurrentUserId(runtime, ref))}?api-version=7.1`, + JSON.stringify({ vote: 10, isRequired: false }), + ); + } +} + +/** Get the current user's ADO identity ID for the vote API */ +async function getCurrentUserId(runtime: PRRuntime, ref: AdoPRRef): Promise { + try { + // Try getting the connection data which includes the authenticated user + const data = await azRest<{ authenticatedUser?: { id?: string; subjectDescriptor?: string } }>( + runtime, "get", + `${ref.orgUrl}/_apis/connectionData?api-version=7.1`, + ); + return data.authenticatedUser?.id ?? "me"; + } catch { + return "me"; + } +} diff --git a/packages/shared/pr-provider.ts b/packages/shared/pr-provider.ts index 94c62865..66347dfc 100644 --- a/packages/shared/pr-provider.ts +++ b/packages/shared/pr-provider.ts @@ -10,6 +10,7 @@ import { checkGhAuth, getGhUser, fetchGhPR, fetchGhPRContext, fetchGhPRFileContent, submitGhPRReview, fetchGhPRViewedFiles, markGhFilesViewed } from "./pr-github"; import { checkGlAuth, getGlUser, fetchGlMR, fetchGlMRContext, fetchGlFileContent, submitGlMRReview } from "./pr-gitlab"; +import { checkAdoAuth, getAdoUser, fetchAdoPR, fetchAdoPRContext, fetchAdoFileContent, submitAdoPRReview } from "./pr-azuredevops"; // --- Runtime Types --- @@ -33,7 +34,7 @@ export interface PRRuntime { // --- Platform Types --- -export type Platform = "github" | "gitlab"; +export type Platform = "github" | "gitlab" | "azuredevops"; /** GitHub PR reference */ export interface GithubPRRef { @@ -51,8 +52,18 @@ export interface GitlabMRRef { iid: number; } +/** Azure DevOps PR reference */ +export interface AzureDevOpsPRRef { + platform: "azuredevops"; + orgUrl: string; // e.g., "https://dev.azure.com/myorg" + organization: string; // e.g., "myorg" + project: string; // e.g., "MyProject" + repo: string; // e.g., "MyRepo" + id: number; // PR number +} + /** Discriminated union — auto-detected from URL */ -export type PRRef = GithubPRRef | GitlabMRRef; +export type PRRef = GithubPRRef | GitlabMRRef | AzureDevOpsPRRef; /** GitHub PR metadata */ export interface GithubPRMetadata { @@ -86,8 +97,25 @@ export interface GitlabMRMetadata { url: string; } +/** Azure DevOps PR metadata */ +export interface AzureDevOpsPRMetadata { + platform: "azuredevops"; + orgUrl: string; + organization: string; + project: string; + repo: string; + id: number; + title: string; + author: string; + baseBranch: string; + headBranch: string; + baseSha: string; + headSha: string; + url: string; +} + /** Discriminated union — downstream gets type narrowing for free */ -export type PRMetadata = GithubPRMetadata | GitlabMRMetadata; +export type PRMetadata = GithubPRMetadata | GitlabMRMetadata | AzureDevOpsPRMetadata; // --- PR Context Types (platform-agnostic) --- @@ -149,26 +177,30 @@ export interface PRReviewFileComment { type HasPlatform = PRRef | PRMetadata; -/** "GitHub" or "GitLab" */ +/** "GitHub", "GitLab", or "Azure DevOps" */ export function getPlatformLabel(m: HasPlatform): string { - return m.platform === "github" ? "GitHub" : "GitLab"; + if (m.platform === "github") return "GitHub"; + if (m.platform === "gitlab") return "GitLab"; + return "Azure DevOps"; } /** "PR" or "MR" */ export function getMRLabel(m: HasPlatform): string { - return m.platform === "github" ? "PR" : "MR"; + return m.platform === "github" ? "PR" : m.platform === "gitlab" ? "MR" : "PR"; } -/** "#123" or "!42" */ +/** "#123", "!42", or "!123" */ export function getMRNumberLabel(m: HasPlatform): string { if (m.platform === "github") return `#${m.number}`; - return `!${m.iid}`; + if (m.platform === "gitlab") return `!${m.iid}`; + return `!${m.id}`; } -/** "owner/repo" or "group/project" */ +/** "owner/repo", "group/project", or "org/project/repo" */ export function getDisplayRepo(m: HasPlatform): string { if (m.platform === "github") return `${m.owner}/${m.repo}`; - return m.projectPath; + if (m.platform === "gitlab") return m.projectPath; + return `${m.organization}/${m.project}/${m.repo}`; } /** Reconstruct a PRRef from metadata */ @@ -176,19 +208,24 @@ export function prRefFromMetadata(m: PRMetadata): PRRef { if (m.platform === "github") { return { platform: "github", owner: m.owner, repo: m.repo, number: m.number }; } - return { platform: "gitlab", host: m.host, projectPath: m.projectPath, iid: m.iid }; + if (m.platform === "gitlab") { + return { platform: "gitlab", host: m.host, projectPath: m.projectPath, iid: m.iid }; + } + return { platform: "azuredevops", orgUrl: m.orgUrl, organization: m.organization, project: m.project, repo: m.repo, id: m.id }; } /** CLI tool name for the platform */ export function getCliName(ref: PRRef): string { - return ref.platform === "github" ? "gh" : "glab"; + if (ref.platform === "github") return "gh"; + if (ref.platform === "gitlab") return "glab"; + return "az"; } /** Install URL for the platform CLI */ export function getCliInstallUrl(ref: PRRef): string { - return ref.platform === "github" - ? "https://cli.github.com" - : "https://gitlab.com/gitlab-org/cli"; + if (ref.platform === "github") return "https://cli.github.com"; + if (ref.platform === "gitlab") return "https://gitlab.com/gitlab-org/cli"; + return "https://learn.microsoft.com/en-us/cli/azure/install-azure-cli"; } /** Encode a file path for use in platform API URLs */ @@ -205,6 +242,8 @@ export function encodeApiFilePath(filePath: string): string { * - GitHub: https://github.com/owner/repo/pull/123[/files|/commits] * - GitLab: https://gitlab.com/group/subgroup/project/-/merge_requests/42[/diffs] * - Self-hosted GitLab: https://gitlab.mycompany.com/group/project/-/merge_requests/42 + * - Azure DevOps: https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id} + * - Azure DevOps (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id} */ export function parsePRUrl(url: string): PRRef | null { if (!url) return null; @@ -222,8 +261,39 @@ export function parsePRUrl(url: string): PRRef | null { }; } + // Azure DevOps: https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id} + const adoMatch = url.match( + /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i, + ); + if (adoMatch) { + return { + platform: "azuredevops", + orgUrl: `https://dev.azure.com/${adoMatch[1]}`, + organization: adoMatch[1], + project: decodeURIComponent(adoMatch[2]), + repo: decodeURIComponent(adoMatch[3]), + id: parseInt(adoMatch[4], 10), + }; + } + + // Azure DevOps (legacy visualstudio.com): https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id} + const vsMatch = url.match( + /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/i, + ); + if (vsMatch) { + return { + platform: "azuredevops", + orgUrl: `https://${vsMatch[1]}.visualstudio.com`, + organization: vsMatch[1], + project: decodeURIComponent(vsMatch[2]), + repo: decodeURIComponent(vsMatch[3]), + id: parseInt(vsMatch[4], 10), + }; + } + // GitLab: https://{host}/{projectPath}/-/merge_requests/{iid}[/...] - // Handles any hostname, nested groups, self-hosted instances + // Handles any hostname, nested groups, self-hosted instances. + // Must come after ADO checks to avoid false-matching dev.azure.com paths. const glMatch = url.match( /^https?:\/\/([^/]+)\/(.+?)\/-\/merge_requests\/(\d+)/, ); @@ -243,12 +313,14 @@ export function parsePRUrl(url: string): PRRef | null { export async function checkAuth(runtime: PRRuntime, ref: PRRef): Promise { if (ref.platform === "github") return checkGhAuth(runtime); - return checkGlAuth(runtime, ref.host); + if (ref.platform === "gitlab") return checkGlAuth(runtime, ref.host); + return checkAdoAuth(runtime, ref.orgUrl); } export async function getUser(runtime: PRRuntime, ref: PRRef): Promise { if (ref.platform === "github") return getGhUser(runtime); - return getGlUser(runtime, ref.host); + if (ref.platform === "gitlab") return getGlUser(runtime, ref.host); + return getAdoUser(runtime, ref.orgUrl); } export async function fetchPR( @@ -256,7 +328,8 @@ export async function fetchPR( ref: PRRef, ): Promise<{ metadata: PRMetadata; rawPatch: string }> { if (ref.platform === "github") return fetchGhPR(runtime, ref); - return fetchGlMR(runtime, ref); + if (ref.platform === "gitlab") return fetchGlMR(runtime, ref); + return fetchAdoPR(runtime, ref); } export async function fetchPRContext( @@ -264,7 +337,8 @@ export async function fetchPRContext( ref: PRRef, ): Promise { if (ref.platform === "github") return fetchGhPRContext(runtime, ref); - return fetchGlMRContext(runtime, ref); + if (ref.platform === "gitlab") return fetchGlMRContext(runtime, ref); + return fetchAdoPRContext(runtime, ref); } export async function fetchPRFileContent( @@ -274,7 +348,8 @@ export async function fetchPRFileContent( filePath: string, ): Promise { if (ref.platform === "github") return fetchGhPRFileContent(runtime, ref, sha, filePath); - return fetchGlFileContent(runtime, ref, sha, filePath); + if (ref.platform === "gitlab") return fetchGlFileContent(runtime, ref, sha, filePath); + return fetchAdoFileContent(runtime, ref, sha, filePath); } export async function submitPRReview( @@ -286,26 +361,27 @@ export async function submitPRReview( fileComments: PRReviewFileComment[], ): Promise { if (ref.platform === "github") return submitGhPRReview(runtime, ref, headSha, action, body, fileComments); - return submitGlMRReview(runtime, ref, headSha, action, body, fileComments); + if (ref.platform === "gitlab") return submitGlMRReview(runtime, ref, headSha, action, body, fileComments); + return submitAdoPRReview(runtime, ref, headSha, action, body, fileComments); } /** * Fetch per-file "viewed" state for a PR. * GitHub: returns { filePath: isViewed } map. - * GitLab: always returns {} (no server-side viewed state API). + * GitLab/Azure DevOps: always returns {} (no server-side viewed state API). */ export async function fetchPRViewedFiles( runtime: PRRuntime, ref: PRRef, ): Promise> { if (ref.platform === "github") return fetchGhPRViewedFiles(runtime, ref); - return {}; // GitLab has no server-side viewed state + return {}; // GitLab and Azure DevOps have no server-side viewed state } /** * Mark or unmark files as viewed in a PR. * GitHub: fires markFileAsViewed / unmarkFileAsViewed GraphQL mutations. - * GitLab: no-op (no server-side viewed state API). + * GitLab/Azure DevOps: no-op (no server-side viewed state API). */ export async function markPRFilesViewed( runtime: PRRuntime, @@ -315,5 +391,5 @@ export async function markPRFilesViewed( viewed: boolean, ): Promise { if (ref.platform === "github") return markGhFilesViewed(runtime, ref, prNodeId, filePaths, viewed); - // GitLab: no-op + // GitLab and Azure DevOps: no-op } diff --git a/packages/ui/components/AzureDevOpsIcon.tsx b/packages/ui/components/AzureDevOpsIcon.tsx new file mode 100644 index 00000000..683dd3ec --- /dev/null +++ b/packages/ui/components/AzureDevOpsIcon.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +/** + * Azure DevOps icon using the official Azure DevOps SVG logo paths. + * Uses currentColor for theme-adaptive rendering. + */ +export const AzureDevOpsIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); diff --git a/packages/ui/package.json b/packages/ui/package.json index eb3442c4..70341da5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,8 @@ "./utils/*": "./utils/*.ts", "./hooks/*": "./hooks/*.ts", "./types": "./types.ts", - "./theme": "./theme.css" + "./theme": "./theme.css", + "./config": "./config/index.ts" }, "dependencies": { "@plannotator/shared": "workspace:*",