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
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 @@ -116,6 +116,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 @@ -125,6 +126,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 @@ -134,6 +136,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 @@ -143,11 +146,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 @@ -74,6 +74,9 @@ const LOGIN_SHELL_ENV_NAMES = [
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"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",
] as const;
const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
Expand Down Expand Up @@ -389,6 +392,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"AZURE_DEVOPS_EXT_PAT",
] as const) {
if (!config.env[name] && shellEnvironment[name]) {
config.env[name] = shellEnvironment[name];
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
84 changes: 82 additions & 2 deletions apps/server/src/sourceControl/AzureDevOpsCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,86 @@ import * as SourceControlProvider from "./SourceControlProvider.ts";

const DEFAULT_TIMEOUT_MS = 30_000;

export interface AzureDevOpsRepositoryContext {
readonly organization: string;
readonly project: string;
readonly repository: string;
}

function decodeRemotePathSegment(segment: string): string | null {
try {
const decoded = decodeURIComponent(segment).trim();
return decoded.length > 0 ? decoded : null;
} catch {
return null;
}
}

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;
}

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 +286,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 +456,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 } : {}),
...(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