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
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlSetDefaultRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlListIssues]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlGetIssue]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlResolveReferences]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
}).pipe(Effect.asVoid),
listIssues: () => Effect.succeed([]),
resolveReferences: () => Effect.succeed([]),
getIssue: (input) =>
Effect.fail(
new GitHubCli.GitHubIssueDecodeError({
Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,4 +577,86 @@ describe("GitHubCli.layer", () => {
assert.notInclude(error.message, "user ID");
}).pipe(Effect.provide(layer)),
);

/**
* `gh api graphql` exits non-zero whenever any part of an answer failed, while still printing
* the parts that resolved. Reading the body rather than the exit code is what lets one missing
* reference travel beside a dozen good ones — and telling that apart from a body that never
* arrived is what keeps a logged-out host from reading as a page full of dead references.
*/
it.effect("keeps a partial answer that the host exited non-zero over", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed({
exitCode: ChildProcessSpawner.ExitCode(1),
// @effect-diagnostics-next-line preferSchemaOverJson:off
stdout: JSON.stringify({
data: { r0: { i0: { __typename: "Issue", title: "A bug", url: "u", state: "OPEN" } } },
errors: [{ type: "NOT_FOUND", path: ["r0", "i1"] }],
}),
stderr: "gh: Could not resolve to an issue or pull request with the number of 2.",
stdoutTruncated: false,
stderrTruncated: false,
}),
);
const github = yield* GitHubCli.GitHubCli;

const resolved = yield* github.resolveReferences({
cwd: "/repo",
host: "github.com",
references: [
{ repository: "owner/repo", number: 1 },
{ repository: "owner/repo", number: 2 },
],
});

assert.deepStrictEqual(
resolved.map((reference) => [reference.number, reference.kind]),
[
[1, "issue"],
[2, null],
],
);
}).pipe(Effect.provide(layer)),
);

it.effect("says why an answer never arrived rather than reporting no references", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed({
exitCode: ChildProcessSpawner.ExitCode(4),
stdout: "",
stderr: "gh: To get started with GitHub CLI, please run: gh auth login",
stdoutTruncated: false,
stderrTruncated: false,
}),
);
const github = yield* GitHubCli.GitHubCli;

const failure = yield* github
.resolveReferences({
cwd: "/repo",
host: "github.com",
references: [{ repository: "owner/repo", number: 1 }],
})
.pipe(Effect.flip);

assert.equal(failure._tag, "GitHubCliAuthenticationError");
}).pipe(Effect.provide(layer)),
);

it.effect("asks nothing at all when no reference names a repository", () =>
Effect.gen(function* () {
const github = yield* GitHubCli.GitHubCli;

const resolved = yield* github.resolveReferences({
cwd: "/repo",
host: "github.com",
references: [{ repository: "notarepository", number: 1 }],
});

assert.deepStrictEqual(resolved, []);
assert.equal(mockRun.mock.calls.length, 0);
}).pipe(Effect.provide(layer)),
);
});
69 changes: 69 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import {
type VcsError,
} from "@t3tools/contracts";

import { encodeGraphQlRequestJson } from "../pullRequest/gitHubPullRequestJson.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import {
buildGitHubReferenceQuery,
decodeGitHubReferenceResponseJson,
type GitHubReferenceRequest,
type GitHubResolvedReference,
} from "./gitHubReferences.ts";
import {
decodeGitHubIssueJson,
decodeGitHubIssueListJson,
Expand Down Expand Up @@ -168,6 +175,19 @@ export class GitHubIssueDecodeError extends Schema.TaggedErrorClass<GitHubIssueD
}
}

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

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

export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass<GitHubRepositoryDecodeError>()(
"GitHubRepositoryDecodeError",
gitHubCliDecodeFields,
Expand All @@ -192,6 +212,7 @@ export const GitHubCliError = Schema.Union([
GitHubPullRequestDecodeError,
GitHubIssueListDecodeError,
GitHubIssueDecodeError,
GitHubReferenceDecodeError,
GitHubRepositoryDecodeError,
]);
export type GitHubCliError = typeof GitHubCliError.Type;
Expand Down Expand Up @@ -262,6 +283,8 @@ export class GitHubCli extends Context.Service<
/** Piped to the child's stdin, for payloads that must never appear in argv. */
readonly stdin?: string;
readonly maxOutputBytes?: number;
/** Keeps the output of a command whose non-zero exit the caller reads for itself. */
readonly allowNonZeroExit?: boolean;
}) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;

readonly listOpenPullRequests: (input: {
Expand All @@ -285,6 +308,13 @@ export class GitHubCli extends Context.Service<
readonly reference: string;
}) => Effect.Effect<GitHubIssue, GitHubCliError>;

/** What each `owner/repo#number` turns out to be, asked at once. */
readonly resolveReferences: (input: {
readonly cwd: string;
readonly host: string;
readonly references: ReadonlyArray<GitHubReferenceRequest>;
}) => Effect.Effect<ReadonlyArray<GitHubResolvedReference>, GitHubCliError>;

readonly getRepositoryCloneUrls: (input: {
readonly cwd: string;
readonly repository: string;
Expand Down Expand Up @@ -393,6 +423,7 @@ export const make = Effect.gen(function* () {
timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
...(input.allowNonZeroExit === true ? { allowNonZeroExit: true } : {}),
})
.pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error)));

Expand Down Expand Up @@ -530,6 +561,44 @@ export const make = Effect.gen(function* () {
),
),
),
resolveReferences: (input) => {
const built = buildGitHubReferenceQuery(input.references);
if (built === null) return Effect.succeed([]);
return execute({
cwd: input.cwd,
args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
// Over stdin: a variable carries a repository path a body wrote, and argv is visible in
// process listings and echoed back in process-runner failures.
stdin: encodeGraphQlRequestJson({ query: built.query, variables: built.variables }),
// `gh` exits non-zero when any part failed — a reference nobody can see is exactly that —
// while still printing what did resolve. The exit code is read below, against the body.
allowNonZeroExit: true,
}).pipe(
Effect.flatMap(
(result): Effect.Effect<ReadonlyArray<GitHubResolvedReference>, GitHubCliError> => {
const decoded = decodeGitHubReferenceResponseJson(result.stdout.trim(), built.aliases);
if (Result.isSuccess(decoded)) return Effect.succeed(decoded.success);
const context = { command: "gh", cwd: input.cwd } as const;
if (result.exitCode === 0) {
return Effect.fail(
new GitHubReferenceDecodeError({ ...context, cause: decoded.failure }),
);
}
// No answer at all: saying why keeps a rate-limited or logged-out host from reading
// as a body full of references that do not exist.
const cause = result.stderr;
switch (VcsProcess.classifyNonZeroExit("gh", result.stderr)) {
case "authentication":
return Effect.fail(new GitHubCliAuthenticationError({ ...context, cause }));
case "rate-limited":
return Effect.fail(new GitHubCliRateLimitError({ ...context, cause }));
default:
return Effect.fail(new GitHubCliCommandError({ ...context, cause }));
}
},
),
);
},
getRepositoryCloneUrls: (input) =>
execute({
cwd: input.cwd,
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,26 @@ export const make = Effect.gen(function* () {
}),
),
),
resolveReferences: (input) =>
github
.resolveReferences({
cwd: input.cwd,
host: input.host,
references: input.references,
})
.pipe(
Effect.mapError(
(error) =>
new SourceControlProviderError({
provider: "github",
operation: "resolveReferences",
command: error.command,
cwd: input.cwd,
detail: error.detail,
cause: error,
}),
),
),
createChangeRequest: (input) =>
github
.createPullRequest({
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/sourceControl/SourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
SourceControlProviderKind,
SourceControlIssue,
SourceControlIssueSummary,
SourceControlReference,
SourceControlResolvedReference,
SourceControlRepositoryCloneUrls,
SourceControlRepositoryVisibility,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -108,6 +110,12 @@ export class SourceControlProvider extends Context.Service<
readonly context?: SourceControlProviderContext;
readonly number: number;
}) => Effect.Effect<SourceControlIssue, SourceControlProviderError>;
readonly resolveReferences: (input: {
readonly cwd: string;
readonly context?: SourceControlProviderContext;
readonly host: string;
readonly references: ReadonlyArray<SourceControlReference>;
}) => Effect.Effect<ReadonlyArray<SourceControlResolvedReference>, SourceControlProviderError>;
readonly createChangeRequest: (input: {
readonly cwd: string;
readonly context?: SourceControlProviderContext;
Expand Down Expand Up @@ -145,12 +153,16 @@ export class SourceControlProvider extends Context.Service<
* Issue browsing only ships for GitHub today. Every other provider reuses this
* so the capability gap is a typed, explainable failure instead of a missing
* method.
*
* Resolving references belongs here too, answering empty rather than failing: no reference
* resolved is exactly right for a host whose bodies do not write GitHub's shorthand.
*/
export function unsupportedIssueOperations(
kind: SourceControlProviderKind,
detail = `Browsing issues is not supported for ${kind} yet.`,
): Pick<SourceControlProvider["Service"], "listIssues" | "getIssue"> {
): Pick<SourceControlProvider["Service"], "listIssues" | "getIssue" | "resolveReferences"> {
return {
resolveReferences: () => Effect.succeed([]),
listIssues: (input) =>
new SourceControlProviderError({
provider: kind,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ function bindProviderContext(
...input,
context: input.context ?? context,
}),
resolveReferences: (input) =>
provider.resolveReferences({
...input,
context: input.context ?? context,
}),
getChangeRequest: (input) =>
provider.getChangeRequest({
...input,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function makeProvider(
listChangeRequests: () => unsupported("listChangeRequests"),
listIssues: () => unsupported("listIssues"),
getIssue: () => unsupported("getIssue"),
resolveReferences: () => unsupported("resolveReferences"),
getChangeRequest: () => unsupported("getChangeRequest"),
createChangeRequest: () => unsupported("createChangeRequest"),
getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS),
Expand Down
Loading
Loading