Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe("DesktopShellEnvironment", () => {
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
HOMEBREW_PREFIX: "/opt/homebrew",
AZURE_DEVOPS_EXT_PAT: "pat-from-login-shell",
});
},
});
Expand All @@ -126,6 +127,7 @@ describe("DesktopShellEnvironment", () => {
assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew");
assert.equal(env.AZURE_DEVOPS_EXT_PAT, "pat-from-login-shell");
}),
);

Expand All @@ -135,6 +137,7 @@ describe("DesktopShellEnvironment", () => {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
AZURE_DEVOPS_EXT_PAT: "inherited-pat",
};

yield* runShellEnvironment({
Expand All @@ -144,11 +147,13 @@ describe("DesktopShellEnvironment", () => {
envOutput({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/login-shell.sock",
AZURE_DEVOPS_EXT_PAT: "pat-from-login-shell",
}),
});

assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock");
assert.equal(env.AZURE_DEVOPS_EXT_PAT, "inherited-pat");
}),
);

Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ const LOGIN_SHELL_ENV_NAMES = [
"XDG_CONFIG_HOME",
"XDG_CURRENT_DESKTOP",
"XDG_DATA_HOME",
// Azure DevOps CLI commonly receives its PAT from the user's login shell.
// Keep it available when the desktop app is launched outside a terminal.
"AZURE_DEVOPS_EXT_PAT",
"XDG_RUNTIME_DIR",
"XDG_SESSION_DESKTOP",
"XDG_SESSION_TYPE",
Expand Down Expand Up @@ -456,6 +459,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"AZURE_DEVOPS_EXT_PAT",
"XDG_RUNTIME_DIR",
"WAYLAND_DISPLAY",
] as const) {
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/sourceControl/AzureDevOpsCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ afterEach(() => {
mockRun.mockReset();
});

it("parses the Azure DevOps SSH clone URL used by Azure Repos", () => {
expect(
AzureDevOpsCli.parseAzureDevOpsRemoteUrl("git@ssh.dev.azure.com:v3/acme/project/repo"),
).toEqual({
organization: "acme",
project: "project",
repository: "repo",
});
});

describe("AzureDevOpsCli.layer", () => {
it.effect("parses pull request view output", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -188,6 +198,52 @@ describe("AzureDevOpsCli.layer", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("uses explicit repository arguments when Azure cannot detect an SSH remote", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]")));

const az = yield* AzureDevOpsCli.AzureDevOpsCli;
yield* az.listPullRequests({
cwd: "/repo",
headSelector: "feature/source-control",
state: "open",
limit: 10,
repositoryContext: {
organization: "acme",
project: "project",
repository: "repo",
},
});

expect(mockRun).toHaveBeenCalledWith({
operation: "AzureDevOpsCli.execute",
command: "az",
args: [
"repos",
"pr",
"list",
"--organization",
"https://dev.azure.com/acme",
"--project",
"project",
"--repository",
"repo",
"--source-branch",
"feature/source-control",
"--status",
"active",
"--top",
"10",
"--only-show-errors",
"--output",
"json",
],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

it.effect("reads repository clone URLs", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Expand Down
88 changes: 86 additions & 2 deletions apps/server/src/sourceControl/AzureDevOpsCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,90 @@ import * as SourceControlProvider from "./SourceControlProvider.ts";

const DEFAULT_TIMEOUT_MS = 30_000;

/** Identifies the Azure DevOps repository targeted by a CLI request. */
export interface AzureDevOpsRepositoryContext {
readonly organization: string;
readonly project: string;
readonly repository: string;
}

/** Decodes one URL path segment, returning null for malformed or empty values. */
function decodeRemotePathSegment(segment: string): string | null {
try {
const decoded = decodeURIComponent(segment).trim();
return decoded.length > 0 ? decoded : null;
} catch {
return null;
}
}

/** Extracts repository coordinates from an Azure DevOps SSH or HTTPS clone URL. */
export function parseAzureDevOpsRemoteUrl(
remoteUrl: string,
): AzureDevOpsRepositoryContext | undefined {
const trimmed = remoteUrl.trim();
let segments: ReadonlyArray<string>;

if (trimmed.startsWith("git@")) {
const separatorIndex = trimmed.indexOf(":");
if (separatorIndex <= "git@".length) {
return undefined;
}

const host = trimmed.slice("git@".length, separatorIndex).toLowerCase();
if (host !== "ssh.dev.azure.com") {
return undefined;
}

segments = trimmed
.slice(separatorIndex + 1)
.split("/")
.filter((segment) => segment.length > 0);
} else {
try {
const url = new URL(trimmed);
if (url.hostname.toLowerCase() !== "dev.azure.com") {
return undefined;
}
segments = url.pathname.split("/").filter((segment) => segment.length > 0);
} catch {
return undefined;
}
}

const isSshClonePath = segments[0]?.toLowerCase() === "v3";
const isHttpClonePath = segments[2]?.toLowerCase() === "_git";
const [organizationSegment, projectSegment, repositorySegment] = isSshClonePath
? [segments[1], segments[2], segments[3]]
: isHttpClonePath
? [segments[0], segments[1], segments[3]]
: [];

const organization = organizationSegment ? decodeRemotePathSegment(organizationSegment) : null;
const project = projectSegment ? decodeRemotePathSegment(projectSegment) : null;
const repository = repositorySegment
? decodeRemotePathSegment(repositorySegment.replace(/\.git$/iu, ""))
: null;

return organization && project && repository ? { organization, project, repository } : undefined;
}

/** Builds explicit Azure CLI repository arguments when remote detection is unreliable. */
function repositoryDetectionArgs(
repositoryContext: AzureDevOpsRepositoryContext | undefined,
): ReadonlyArray<string> {
return repositoryContext
? [
"--organization",
`https://dev.azure.com/${repositoryContext.organization}`,
"--project",
repositoryContext.project,
"--repository",
repositoryContext.repository,
]
: ["--detect", "true"];
}

const azureDevOpsCommandErrorFields = {
operation: Schema.Literal("execute"),
command: Schema.Literal("az"),
Expand Down Expand Up @@ -206,6 +290,7 @@ export class AzureDevOpsCli extends Context.Service<
readonly listPullRequests: (input: {
readonly cwd: string;
readonly headSelector: string;
readonly repositoryContext?: AzureDevOpsRepositoryContext;
readonly source?: SourceControlProvider.SourceControlRefSelector;
readonly state: "open" | "closed" | "merged" | "all";
readonly limit?: number;
Expand Down Expand Up @@ -375,8 +460,7 @@ export const make = Effect.gen(function* () {
"repos",
"pr",
"list",
"--detect",
"true",
...repositoryDetectionArgs(input.repositoryContext),
"--source-branch",
SourceControlProvider.sourceBranch(input),
"--status",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,41 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
}),
);

it.effect("passes the SSH remote repository context to Azure CLI PR listing", () =>
Effect.gen(function* () {
let listInput:
| Parameters<AzureDevOpsCli.AzureDevOpsCli["Service"]["listPullRequests"]>[0]
| undefined;
const provider = yield* makeProvider({
listPullRequests: (input) => {
listInput = input;
return Effect.succeed([]);
},
});

yield* provider.listChangeRequests({
cwd: "/repo",
context: {
provider: {
kind: "azure-devops",
name: "Azure DevOps",
baseUrl: "https://dev.azure.com",
},
remoteName: "origin",
remoteUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
},
headSelector: "feature/source-control",
state: "open",
});

assert.deepStrictEqual(listInput?.repositoryContext, {
organization: "acme",
project: "project",
repository: "repo",
});
}),
);

it.effect("adds change-request context while retaining Azure CLI causes", () =>
Effect.gen(function* () {
const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ export const make = Effect.gen(function* () {
kind: "azure-devops",
listChangeRequests: (input) => {
const source = SourceControlProvider.sourceControlRefFromInput(input);
const repositoryContext = input.context
? AzureDevOpsCli.parseAzureDevOpsRemoteUrl(input.context.remoteUrl)
: undefined;
return azure
.listPullRequests({
cwd: input.cwd,
headSelector: input.headSelector,
...(repositoryContext ? { repositoryContext } : {}),
Comment thread
cursor[bot] marked this conversation as resolved.
...(source !== undefined ? { source } : {}),
state: input.state,
...(input.limit !== undefined ? { limit: input.limit } : {}),
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/sourceControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
expect(
detectSourceControlProviderFromRemoteUrl("https://dev.azure.com/org/project/_git/repo")?.kind,
).toBe("azure-devops");
expect(
detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com:v3/org/project/repo"),
).toEqual({
kind: "azure-devops",
name: "Azure DevOps",
baseUrl: "https://dev.azure.com",
});
expect(
detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
).toBe("bitbucket");
Expand Down
6 changes: 4 additions & 2 deletions packages/shared/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ function isGitLabHost(host: string): boolean {
}

function isAzureDevOpsHost(host: string): boolean {
return host === "dev.azure.com" || host.endsWith(".visualstudio.com");
return (
host === "dev.azure.com" || host === "ssh.dev.azure.com" || host.endsWith(".visualstudio.com")
);
}

function isBitbucketHost(host: string): boolean {
Expand Down Expand Up @@ -212,7 +214,7 @@ export function detectSourceControlProviderFromRemoteUrl(
return {
kind: "azure-devops",
name: "Azure DevOps",
baseUrl: toBaseUrl(host),
baseUrl: toBaseUrl(hostname === "ssh.dev.azure.com" ? "dev.azure.com" : host),
};
}

Expand Down
Loading