Skip to content
Open
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
7 changes: 4 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1092,7 +1093,7 @@ const ReviewApp: React.FC = () => {
>
{reviewDestination === 'platform' ? (
<>
{prMetadata?.platform === 'gitlab' ? <GitLabIcon className="w-3.5 h-3.5" /> : <GitHubIcon className="w-3.5 h-3.5" />}
{prMetadata?.platform === 'gitlab' ? <GitLabIcon className="w-3.5 h-3.5" /> : prMetadata?.platform === 'azuredevops' ? <AzureDevOpsIcon className="w-3.5 h-3.5" /> : <GitHubIcon className="w-3.5 h-3.5" />}
<span>{platformLabel}</span>
</>
) : 'Agent'}
Expand Down
2 changes: 1 addition & 1 deletion packages/server/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
183 changes: 183 additions & 0 deletions packages/shared/pr-azuredevops.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading