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:*",