Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlListIssues]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlGetIssue]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
cwd: input.cwd,
args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
}).pipe(Effect.asVoid),
listIssues: () => Effect.succeed([]),
getIssue: (input) =>
Effect.fail(
new GitHubCli.GitHubIssueDecodeError({
command: "gh",
cwd: input.cwd,
cause: new Error(`Unexpected issue view: ${input.reference}`),
}),
),
},
ghCalls,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const make = Effect.gen(function* () {

return SourceControlProvider.SourceControlProvider.of({
kind: "azure-devops",
...SourceControlProvider.unsupportedIssueOperations("azure-devops"),
listChangeRequests: (input) => {
const source = SourceControlProvider.sourceControlRefFromInput(input);
return azure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const make = Effect.gen(function* () {

return SourceControlProvider.SourceControlProvider.of({
kind: "bitbucket",
...SourceControlProvider.unsupportedIssueOperations("bitbucket"),
listChangeRequests: (input) => {
const source = SourceControlProvider.sourceControlRefFromInput(input);
return bitbucket
Expand Down
130 changes: 130 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,136 @@ describe("GitHubCli.layer", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("lists open issues and drops invalid rows", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
number: 0,
title: "invalid",
url: "https://github.com/pingdotgg/codething-mvp/issues/0",
},
{
number: 123,
title: " Fix login crash ",
url: " https://github.com/pingdotgg/codething-mvp/issues/123 ",
state: "OPEN",
labels: [{ name: " bug " }, { name: " " }],
updatedAt: "2026-01-02T03:04:05Z",
},
]),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.listIssues({ cwd: "/repo" });

assert.equal(result.length, 1);
assert.deepStrictEqual(
result.map(({ updatedAt: _updatedAt, ...issue }) => issue),
[
{
number: 123,
title: "Fix login crash",
url: "https://github.com/pingdotgg/codething-mvp/issues/123",
state: "open",
labels: ["bug"],
},
],
);
expect(mockRun).toHaveBeenCalledWith({
operation: "GitHubCli.execute",
command: "gh",
args: [
"issue",
"list",
"--state",
"open",
"--limit",
"50",
"--json",
"number,title,state,url,labels,updatedAt",
],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

it.effect("returns an empty issue list when gh prints nothing", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.listIssues({ cwd: "/repo" });

assert.deepStrictEqual(result, []);
}).pipe(Effect.provide(layer)),
);

it.effect("reads a single issue with its comments", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({
number: 123,
title: "Fix login crash",
url: "https://github.com/pingdotgg/codething-mvp/issues/123",
state: "OPEN",
body: "\nSteps to reproduce\n",
author: { login: "octocat" },
comments: [
{ author: { login: "hubot" }, body: " Repros here " },
{ author: null, body: null },
],
}),
),
),
);

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.getIssue({ cwd: "/repo", reference: "123" });

assert.deepStrictEqual(result, {
number: 123,
title: "Fix login crash",
url: "https://github.com/pingdotgg/codething-mvp/issues/123",
state: "open",
repository: "pingdotgg/codething-mvp",
author: "octocat",
body: "Steps to reproduce",
comments: [
{ author: "hubot", body: "Repros here" },
{ author: null, body: "" },
],
});
expect(mockRun).toHaveBeenCalledWith({
operation: "GitHubCli.execute",
command: "gh",
args: ["issue", "view", "123", "--json", "number,title,state,url,body,author,comments"],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);

it.effect("fails with a decode error when issue json is malformed", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{ not json")));

const gh = yield* GitHubCli.GitHubCli;
const error = yield* gh.getIssue({ cwd: "/repo", reference: "123" }).pipe(Effect.flip);

assert.strictEqual(error._tag, "GitHubIssueDecodeError");
}).pipe(Effect.provide(layer)),
);

it.effect("reads repository clone URLs", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Expand Down
109 changes: 109 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ import {
} from "@t3tools/contracts";

import * as VcsProcess from "../vcs/VcsProcess.ts";
import {
decodeGitHubIssueJson,
decodeGitHubIssueListJson,
type NormalizedGitHubIssue,
type NormalizedGitHubIssueSummary,
} from "./gitHubIssues.ts";
import {
decodeGitHubPullRequestJson,
decodeGitHubPullRequestListJson,
} from "./gitHubPullRequests.ts";

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_ISSUE_LIST_LIMIT = 50;

const gitHubCliFailureFields = {
command: Schema.Literal("gh"),
Expand Down Expand Up @@ -122,6 +129,32 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass<GitHub
}
}

export class GitHubIssueListDecodeError extends Schema.TaggedErrorClass<GitHubIssueListDecodeError>()(
"GitHubIssueListDecodeError",
gitHubCliDecodeFields,
) {
get detail(): string {
return "GitHub CLI returned invalid issue list JSON.";
}

override get message(): string {
return `GitHub CLI failed in listIssues: ${this.detail}`;
}
}

export class GitHubIssueDecodeError extends Schema.TaggedErrorClass<GitHubIssueDecodeError>()(
"GitHubIssueDecodeError",
gitHubCliDecodeFields,
) {
get detail(): string {
return "GitHub CLI returned invalid issue JSON.";
}

override get message(): string {
return `GitHub CLI failed in getIssue: ${this.detail}`;
}
}

export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass<GitHubRepositoryDecodeError>()(
"GitHubRepositoryDecodeError",
gitHubCliDecodeFields,
Expand All @@ -143,6 +176,8 @@ export const GitHubCliError = Schema.Union([
GitHubPullRequestListDecodeError,
GitHubChangeRequestListDecodeError,
GitHubPullRequestDecodeError,
GitHubIssueListDecodeError,
GitHubIssueDecodeError,
GitHubRepositoryDecodeError,
]);
export type GitHubCliError = typeof GitHubCliError.Type;
Expand Down Expand Up @@ -190,6 +225,9 @@ export interface GitHubPullRequestSummary {
readonly headRepositoryOwnerLogin?: string | null;
}

export type GitHubIssueSummary = NormalizedGitHubIssueSummary;
export type GitHubIssue = NormalizedGitHubIssue;

export interface GitHubRepositoryCloneUrls {
readonly nameWithOwner: string;
readonly url: string;
Expand All @@ -216,6 +254,16 @@ export class GitHubCli extends Context.Service<
readonly reference: string;
}) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;

readonly listIssues: (input: {
readonly cwd: string;
readonly limit?: number;
}) => Effect.Effect<ReadonlyArray<GitHubIssueSummary>, GitHubCliError>;

readonly getIssue: (input: {
readonly cwd: string;
readonly reference: string;
}) => Effect.Effect<GitHubIssue, GitHubCliError>;

readonly getRepositoryCloneUrls: (input: {
readonly cwd: string;
readonly repository: string;
Expand Down Expand Up @@ -390,6 +438,67 @@ export const make = Effect.gen(function* () {
),
),
),
listIssues: (input) =>
execute({
cwd: input.cwd,
args: [
"issue",
"list",
"--state",
"open",
"--limit",
String(input.limit ?? DEFAULT_ISSUE_LIST_LIMIT),
"--json",
"number,title,state,url,labels,updatedAt",
],
}).pipe(
Effect.map((result) => result.stdout.trim()),
Effect.flatMap((raw) =>
raw.length === 0
? Effect.succeed([])
: Effect.sync(() => decodeGitHubIssueListJson(raw)).pipe(
Effect.flatMap((decoded) =>
Result.isSuccess(decoded)
? Effect.succeed(decoded.success)
: Effect.fail(
new GitHubIssueListDecodeError({
command: "gh",
cwd: input.cwd,
cause: decoded.failure,
}),
),
),
),
),
),
getIssue: (input) =>
execute({
cwd: input.cwd,
args: [
"issue",
"view",
input.reference,
"--json",
"number,title,state,url,body,author,comments",
],
}).pipe(
Effect.map((result) => result.stdout.trim()),
Effect.flatMap((raw) =>
Effect.sync(() => decodeGitHubIssueJson(raw)).pipe(
Effect.flatMap((decoded) =>
Result.isSuccess(decoded)
? Effect.succeed(decoded.success)
: Effect.fail(
new GitHubIssueDecodeError({
command: "gh",
cwd: input.cwd,
cause: decoded.failure,
}),
),
),
),
),
),
getRepositoryCloneUrls: (input) =>
execute({
cwd: input.cwd,
Expand Down
Loading
Loading