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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";

import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitHubCli from "./GitHubCli.ts";
import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
import { parseGitHubAuthStatus, parseGitHubAuthStatusText } from "./gitHubAuthStatus.ts";
import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";

const processResult = (
Expand Down Expand Up @@ -381,3 +381,66 @@ it("reports unauthenticated when GitHub JSON has accounts but none are valid", (
},
);
});

it("parses the plain-text auth status emitted by CLIs without --json support", () => {
const auth = GitHubSourceControlProvider.discovery.parseAuth(
processResult(
[
"github.com",
" \u2713 Logged in to github.com account octocat (/home/user/.config/gh/hosts.yml)",
" - Active account: true",
" - Token: gho_************",
].join("\n"),
),
);

assert.deepStrictEqual(auth, {
status: "authenticated",
account: Option.some("octocat"),
host: Option.some("github.com"),
detail: Option.none(),
});
});

it("parses the legacy `Logged in to <host> as <login>` phrasing", () => {
const status = parseGitHubAuthStatusText(
[
"github.com",
" \u2713 Logged in to github.com as octocat (/home/user/.config/gh/hosts.yml)",
" \u2713 Token scopes: gist, read:org, repo",
].join("\n"),
);

assert.deepStrictEqual(status, {
parsed: true,
accounts: [
{
host: "github.com",
account: "octocat",
authenticated: true,
active: true,
error: null,
},
],
});
});

it("keeps failed plain-text logins unauthenticated", () => {
const status = parseGitHubAuthStatusText(
[
"github.com",
" X Failed to log in to github.com account octocat (keyring)",
" - Active account: true",
].join("\n"),
);

assert.deepStrictEqual(status.accounts, [
{
host: "github.com",
account: "octocat",
authenticated: false,
active: true,
error: null,
},
]);
});
12 changes: 10 additions & 2 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
} from "@t3tools/contracts";

import * as GitHubCli from "./GitHubCli.ts";
import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
import {
findAuthenticatedGitHubAccount,
parseGitHubAuthStatus,
parseGitHubAuthStatusText,
} from "./gitHubAuthStatus.ts";
import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts";
import * as SourceControlProvider from "./SourceControlProvider.ts";
import {
Expand Down Expand Up @@ -44,7 +48,10 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq

function parseGitHubAuth(input: SourceControlAuthProbeInput) {
const output = combinedAuthOutput(input);
const authStatus = parseGitHubAuthStatus(input.stdout);
const jsonStatus = parseGitHubAuthStatus(input.stdout);
// Older CLIs (< 2.81) lack `auth status --json`; the discovery fallback re-runs
// the plain command, whose human-readable output is parsed here.
const authStatus = jsonStatus.parsed ? jsonStatus : parseGitHubAuthStatusText(output);
const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);
const host = authenticatedAccount?.host;

Expand Down Expand Up @@ -89,6 +96,7 @@ export const discovery = {
executable: "gh",
versionArgs: ["--version"],
authArgs: ["auth", "status", "--json", "hosts"],
authFallbackArgs: ["auth", "status"],
parseAuth: parseGitHubAuth,
installHint:
"Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",
Expand Down
143 changes: 143 additions & 0 deletions apps/server/src/sourceControl/SourceControlDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,146 @@ Logged in to gitlab.com as gitlab-user
);
}).pipe(Effect.provide(testLayer));
});

it.effect("falls back to plain `gh auth status` when the CLI predates --json", () => {
const calls: string[] = [];
const processMock = {
run: (input: VcsProcess.VcsProcessInput) => {
calls.push([input.command, ...input.args].join(" "));
if (input.args[0] === "--version") {
return Effect.succeed(processOutput(`${input.command} version 2.45.0\n`));
}
if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
return Effect.succeed(
processOutput("", {
stderr: "unknown flag: --json\n",
exitCode: ChildProcessSpawner.ExitCode(1),
}),
);
}
if (input.command === "gh" && input.args.join(" ") === "auth status") {
return Effect.succeed(
processOutput(
[
"github.com",
" ✓ Logged in to github.com as octocat (/home/user/.config/gh/hosts.yml)",
" ✓ Token: gho_************",
].join("\n"),
),
);
}
return Effect.fail(
new VcsProcessSpawnError({
operation: "source-control.discovery.probe",
command: input.command,
cwd: input.cwd,
cause: new Error("not found"),
}),
);
},
} satisfies Partial<VcsProcess.VcsProcess["Service"]>;

const testLayer = SourceControlDiscovery.layer.pipe(
Layer.provide(
Layer.mergeAll(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-source-control-discovery-fallback-test-",
}).pipe(Layer.provide(NodeServices.layer)),
Layer.mock(VcsProcess.VcsProcess)(processMock),
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
sourceControlProviderRegistryTestLayer({
process: processMock,
bitbucket: {
probeAuth: Effect.succeed({
status: "unauthenticated" as const,
account: Option.none(),
host: Option.none(),
detail: Option.none(),
}),
},
}),
),
),
Layer.provideMerge(NodeServices.layer),
);

return Effect.gen(function* () {
const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
const result = yield* discovery.discover;

const github = result.sourceControlProviders.find((item) => item.kind === "github");
assert.ok(github);
assert.strictEqual(github.auth.status, "authenticated");
assert.deepStrictEqual(github.auth.account, Option.some("octocat"));
assert.ok(calls.includes("gh auth status"));
}).pipe(Effect.provide(testLayer));
});

it.effect("does NOT fall back when the primary probe reports a genuine sign-out", () => {
const calls: string[] = [];
const processMock = {
run: (input: VcsProcess.VcsProcessInput) => {
calls.push([input.command, ...input.args].join(" "));
if (input.args[0] === "--version") {
return Effect.succeed(processOutput(`${input.command} version 2.95.0\n`));
}
if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
// Modern CLI, simply not logged in: non-zero exit, specific message, no --json flag error.
return Effect.succeed(
processOutput("", {
stderr: "You are not logged into any GitHub hosts. To log in, run: gh auth login\n",
exitCode: ChildProcessSpawner.ExitCode(1),
}),
);
}
return Effect.fail(
new VcsProcessSpawnError({
operation: "source-control.discovery.probe",
command: input.command,
cwd: input.cwd,
cause: new Error("not found"),
}),
);
},
} satisfies Partial<VcsProcess.VcsProcess["Service"]>;

const testLayer = SourceControlDiscovery.layer.pipe(
Layer.provide(
Layer.mergeAll(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-source-control-discovery-no-fallback-test-",
}).pipe(Layer.provide(NodeServices.layer)),
Layer.mock(VcsProcess.VcsProcess)(processMock),
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
sourceControlProviderRegistryTestLayer({
process: processMock,
bitbucket: {
probeAuth: Effect.succeed({
status: "unauthenticated" as const,
account: Option.none(),
host: Option.none(),
detail: Option.none(),
}),
},
}),
),
),
Layer.provideMerge(NodeServices.layer),
);

return Effect.gen(function* () {
const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
const result = yield* discovery.discover;

const github = result.sourceControlProviders.find((item) => item.kind === "github");
assert.ok(github);
assert.strictEqual(github.auth.status, "unauthenticated");
// The fallback `gh auth status` must NOT have been invoked, and the specific
// sign-out detail from the primary probe must be preserved.
assert.strictEqual(calls.filter((call) => call === "gh auth status").length, 0);
assert.deepStrictEqual(
github.auth.detail,
Option.some("You are not logged into any GitHub hosts. To log in, run: gh auth login"),
);
}).pipe(Effect.provide(testLayer));
});
72 changes: 54 additions & 18 deletions apps/server/src/sourceControl/SourceControlProviderDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
readonly executable: string;
readonly versionArgs: ReadonlyArray<string>;
readonly authArgs: ReadonlyArray<string>;
/**
* Args retried when `authArgs` exits non-zero without resolving an account —
* typically an older CLI that does not support the flags in `authArgs`.
*/
readonly authFallbackArgs?: ReadonlyArray<string>;
readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
readonly refineUnknownRemote?: (
input: SourceControlUnknownRemoteRefinementInput,
Expand Down Expand Up @@ -120,6 +125,15 @@ export function combinedAuthOutput(input: SourceControlAuthProbeInput): string {
return parts.join("\n");
}

// Cobra-based CLIs (gh, glab, …) print these when handed a flag/subcommand they
// don't support — the signal that a probe failed because the CLI is too old for
// the primary args, rather than because the user is signed out.
const UNSUPPORTED_INVOCATION_PATTERN = /unknown (?:flag|shorthand flag|command)/iu;

export function looksLikeUnsupportedInvocation(input: SourceControlAuthProbeInput): boolean {
return input.exitCode !== 0 && UNSUPPORTED_INVOCATION_PATTERN.test(combinedAuthOutput(input));
}

function sanitizedAuthLines(text: string): ReadonlyArray<string> {
const lines: string[] = [];
for (const entry of text.split(/\r?\n/)) {
Expand Down Expand Up @@ -237,32 +251,54 @@ export function probeSourceControlProvider(input: {
} satisfies SourceControlProviderDiscoveryItem);
}

return input.process
.run({
const runAuth = (args: ReadonlyArray<string>) =>
input.process.run({
operation: "source-control.discovery.auth",
command: spec.executable,
args: spec.authArgs,
args,
cwd: input.cwd,
allowNonZeroExit: true,
timeoutMs: 5_000,
maxOutputBytes: 8_000,
appendTruncationMarker: true,
})
.pipe(
Effect.map(
(result) =>
({
});

return runAuth(spec.authArgs).pipe(
Effect.flatMap((result) => {
const auth = spec.parseAuth(result);
const fallbackArgs = spec.authFallbackArgs;
// Only retry with the fallback args when the CLI actually rejected the
// primary invocation (an older CLI that predates those flags). A genuine
// "not signed in" result already carries specific detail, so it must not
// trigger the fallback or have its detail replaced by a generic hint.
if (
fallbackArgs === undefined ||
auth.status === "authenticated" ||
!looksLikeUnsupportedInvocation(result)
) {
return Effect.succeed({ ...item, auth } satisfies SourceControlProviderDiscoveryItem);
}

return runAuth(fallbackArgs).pipe(
Effect.map((fallbackResult) => {
const fallbackAuth = spec.parseAuth(fallbackResult);
return {
...item,
auth: spec.parseAuth(result),
}) satisfies SourceControlProviderDiscoveryItem,
),
Effect.catch((cause) =>
Effect.succeed({
...item,
auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
} satisfies SourceControlProviderDiscoveryItem),
),
);
auth: fallbackAuth.status === "unknown" ? auth : fallbackAuth,
Comment thread
cursor[bot] marked this conversation as resolved.
} satisfies SourceControlProviderDiscoveryItem;
}),
Effect.orElseSucceed(
() => ({ ...item, auth }) satisfies SourceControlProviderDiscoveryItem,
),
);
}),
Effect.catch((cause) =>
Effect.succeed({
...item,
auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
} satisfies SourceControlProviderDiscoveryItem),
),
);
}),
);
}
Expand Down
Loading
Loading