Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
227 changes: 227 additions & 0 deletions SOURCE_CONTROL.md

Large diffs are not rendered by default.

17 changes: 15 additions & 2 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,23 @@ export const layer = Layer.effect(
): Electron.MenuItemConstructorOptions[] => {
const template: Electron.MenuItemConstructorOptions[] = [];
let hasInsertedDestructiveSeparator = false;
let lastWasSeparator = false;

for (const item of entries) {
if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
template.push({ type: "separator" });
if (item.separator === true) {
if (template.length > 0 && !lastWasSeparator) {
template.push({ type: "separator" });
lastWasSeparator = true;
}
continue;
}

if (item.destructive && !hasInsertedDestructiveSeparator) {
hasInsertedDestructiveSeparator = true;
if (template.length > 0 && !lastWasSeparator) {
template.push({ type: "separator" });
lastWasSeparator = true;
}
}

const itemOption: Electron.MenuItemConstructorOptions = {
Expand All @@ -138,6 +150,7 @@ export const layer = Layer.effect(
}

template.push(itemOption);
lastWasSeparator = false;
}

return template;
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/sourceControl/AzureDevOpsCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface AzureDevOpsCliShape {
readonly cwd: string;
readonly headSelector: string;
readonly source?: SourceControlProvider.SourceControlRefSelector;
readonly repository?: string;
readonly project?: string;
readonly state: "open" | "closed" | "merged" | "all";
readonly limit?: number;
}) => Effect.Effect<
Expand Down Expand Up @@ -270,6 +272,8 @@ export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
"list",
"--detect",
"true",
...(input.repository ? ["--repository", input.repository] : []),
...(input.project ? ["--project", input.project] : []),
"--source-branch",
SourceControlProvider.sourceBranch(input),
"--status",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,44 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
}),
);

it.effect("lists Azure DevOps PRs against the requested remote repository context", () =>
Effect.gen(function* () {
let listInput: Parameters<AzureDevOpsCli.AzureDevOpsCliShape["listPullRequests"]>[0] | null =
null;
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: "upstream",
remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
},
headSelector: "feature/provider",
state: "open",
limit: 10,
});

assert.deepStrictEqual(listInput, {
cwd: "/repo",
headSelector: "feature/provider",
repository: "repo",
project: "project",
state: "open",
limit: 10,
});
}),
);

it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
Effect.gen(function* () {
let createInput: Parameters<AzureDevOpsCli.AzureDevOpsCliShape["createPullRequest"]>[0] | null =
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,45 @@ function toChangeRequest(summary: {
};
}

function azureRepositoryFromContext(
context: SourceControlProvider.SourceControlProviderContext | undefined,
): { readonly repository: string; readonly project?: string } | undefined {
if (!context) return undefined;
const path = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl);
if (!path) return undefined;
const parts = path
.split("/")
.map((part) => part.trim())
.filter(Boolean);
const gitIndex = parts.findIndex((part) => part.toLowerCase() === "_git");
const gitProject = parts[gitIndex - 1];
const gitRepository = parts[gitIndex + 1];
if (gitIndex >= 1 && gitProject && gitRepository) {
return { project: gitProject, repository: gitRepository };
}
const sshProject = parts[2];
const sshRepository = parts[3];
if (parts[0]?.toLowerCase() === "v3" && sshProject && sshRepository) {
return { project: sshProject, repository: sshRepository };
}
const repository = parts.at(-1);
return repository ? { repository } : undefined;
}

export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () {
const azure = yield* AzureDevOpsCli.AzureDevOpsCli;

return SourceControlProvider.SourceControlProvider.of({
kind: "azure-devops",
listChangeRequests: (input) => {
const source = SourceControlProvider.sourceControlRefFromInput(input);
const repository = azureRepositoryFromContext(input.context);
return azure
.listPullRequests({
cwd: input.cwd,
headSelector: input.headSelector,
...(source !== undefined ? { source } : {}),
...(repository !== undefined ? repository : {}),
state: input.state,
...(input.limit !== undefined ? { limit: input.limit } : {}),
})
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface GitHubCliShape {
readonly listOpenPullRequests: (input: {
readonly cwd: string;
readonly headSelector: string;
readonly repository?: string;
readonly limit?: number;
}) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;

Expand Down Expand Up @@ -248,6 +249,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
args: [
"pr",
"list",
...(input.repository ? ["--repo", input.repository] : []),
"--head",
input.headSelector,
"--state",
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,41 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () =
}),
);

it.effect("lists GitHub PRs against the requested remote repository context", () =>
Effect.gen(function* () {
let listInput: Parameters<GitHubCli.GitHubCliShape["listOpenPullRequests"]>[0] | null = null;
const provider = yield* makeProvider({
listOpenPullRequests: (input) => {
listInput = input;
return Effect.succeed([]);
},
});

yield* provider.listChangeRequests({
cwd: "/repo",
context: {
provider: {
kind: "github",
name: "GitHub Enterprise",
baseUrl: "https://github.example.test",
},
remoteName: "upstream",
remoteUrl: "git@github.example.test:acme/repo.git",
},
headSelector: "feature/provider",
state: "open",
limit: 10,
});

assert.deepStrictEqual(listInput, {
cwd: "/repo",
headSelector: "feature/provider",
repository: "github.example.test/acme/repo",
limit: 10,
});
}),
);

it.effect("uses gh json listing for non-open change request state queries", () =>
Effect.gen(function* () {
let executeArgs: ReadonlyArray<string> = [];
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
};
}

function repositoryFromContext(
context: SourceControlProvider.SourceControlProviderContext | undefined,
): string | undefined {
if (!context) return undefined;
const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl);
if (!repository) return undefined;
try {
const host = new URL(context.provider.baseUrl).host;
return host && host !== "github.com" ? `${host}/${repository}` : repository;
} catch {
return repository;
}
}

function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
const authStatus = parseGitHubAuthStatus(input.stdout);
Expand Down Expand Up @@ -111,11 +125,13 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {

const listChangeRequests: SourceControlProvider.SourceControlProviderShape["listChangeRequests"] =
(input) => {
const repository = repositoryFromContext(input.context);
if (input.state === "open") {
return github
.listOpenPullRequests({
cwd: input.cwd,
headSelector: input.headSelector,
...(repository ? { repository } : {}),
...(input.limit !== undefined ? { limit: input.limit } : {}),
})
.pipe(
Expand All @@ -131,6 +147,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
args: [
"pr",
"list",
...(repository ? ["--repo", repository] : []),
"--head",
input.headSelector,
"--state",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/sourceControl/GitLabCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface GitLabCliShape {
readonly cwd: string;
readonly headSelector: string;
readonly source?: SourceControlProvider.SourceControlRefSelector;
readonly repository?: string;
readonly state: "open" | "closed" | "merged" | "all";
readonly limit?: number;
}) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>;
Expand Down Expand Up @@ -281,6 +282,7 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
args: [
"mr",
"list",
...(input.repository ? ["--repo", input.repository] : []),
"--source-branch",
sourceRefName(input),
...stateArgs(input.state),
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ it.effect("lists GitLab MRs through provider-neutral input names", () =>
}),
);

it.effect("lists GitLab MRs against the requested remote repository context", () =>
Effect.gen(function* () {
let listInput: Parameters<GitLabCli.GitLabCliShape["listMergeRequests"]>[0] | null = null;
const provider = yield* makeProvider({
listMergeRequests: (input) => {
listInput = input;
return Effect.succeed([]);
},
});

yield* provider.listChangeRequests({
cwd: "/repo",
context: {
provider: {
kind: "gitlab",
name: "GitLab Self-Hosted",
baseUrl: "https://gitlab.example.test",
},
remoteName: "upstream",
remoteUrl: "https://gitlab.example.test/group/subgroup/repo.git",
},
headSelector: "feature/provider",
state: "all",
limit: 10,
});

assert.deepStrictEqual(listInput, {
cwd: "/repo",
headSelector: "feature/provider",
repository: "gitlab.example.test/group/subgroup/repo",
state: "all",
limit: 10,
});
}),
);

it.effect("creates GitLab MRs through provider-neutral input names", () =>
Effect.gen(function* () {
let createInput: Parameters<GitLabCli.GitLabCliShape["createMergeRequest"]>[0] | null = null;
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/sourceControl/GitLabSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ function refineUnknownGitLabRemote(
} as const;
}

function repositoryFromContext(
context: SourceControlProvider.SourceControlProviderContext | undefined,
): string | undefined {
if (!context) return undefined;
const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl);
if (!repository) return undefined;
try {
const host = new URL(context.provider.baseUrl).host;
return host && host !== "gitlab.com" ? `${host}/${repository}` : repository;
} catch {
return repository;
}
}

export const discovery = {
type: "cli",
kind: "gitlab",
Expand All @@ -116,11 +130,13 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
kind: "gitlab",
listChangeRequests: (input) => {
const source = SourceControlProvider.sourceControlRefFromInput(input);
const repository = repositoryFromContext(input.context);
return gitlab
.listMergeRequests({
cwd: input.cwd,
headSelector: input.headSelector,
...(source ? { source } : {}),
...(repository ? { repository } : {}),
state: input.state,
...(input.limit !== undefined ? { limit: input.limit } : {}),
})
Expand Down
Loading
Loading