feat: scaffold CLI and minimal issue list (task 0001)
Tracer bullet for gitea-axi: runnable npm package on axi-sdk-js with gitea-js as the sole HTTP layer, ESM on Node 20+. - issue list with --state/--limit, default fields, count line from X-Total-Count, type=issues guard, explicit empty state, and next-step suggestions - repo context detection from the git origin remote (SSH/scp/HTTPS), tea credential discovery with the three-way login-matching split, and -R/--repo and --login overrides (flag > env > auto) - token retrieval via tea login helper get: tea's login list JSON carries no token (ADR 0001 amended) - full AxiError classification table with path-based 404 split, TOON errors on stdout, exit codes 0/1/2 - test mode (GITEA_AXI_API_URL/TOKEN/REPO) suppressing subprocesses, fixture server, and vitest suites driving the CLI seam (50 tests)
This commit is contained in:
193
src/context.ts
Normal file
193
src/context.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { CliDeps } from "./deps.js";
|
||||
import { axiError } from "./errors.js";
|
||||
import { detectRemote } from "./git.js";
|
||||
import { getToken, listLogins, type TeaLogin } from "./tea.js";
|
||||
|
||||
export type ContextSource = "flag" | "env" | "auto";
|
||||
|
||||
export interface RepoContext {
|
||||
owner: string;
|
||||
name: string;
|
||||
host: string;
|
||||
/** Gitea instance base URL, without the /api/v1 suffix. */
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
repoSource: ContextSource;
|
||||
loginSource: ContextSource;
|
||||
loginName?: string;
|
||||
}
|
||||
|
||||
/** The repo/login overrides resolved from flags and environment, pre-context. */
|
||||
interface ContextOverrides {
|
||||
repoSpec?: string;
|
||||
repoSource: ContextSource;
|
||||
/** Human label for where the repo spec came from, for error messages. */
|
||||
repoOrigin: string;
|
||||
loginName?: string;
|
||||
loginSource: ContextSource;
|
||||
}
|
||||
|
||||
function resolveOverrides(deps: CliDeps): ContextOverrides {
|
||||
const repoFromFlag = deps.globals.repo;
|
||||
const repoFromEnv = deps.env.GITEA_AXI_REPO;
|
||||
const loginFromFlag = deps.globals.login;
|
||||
const loginFromEnv = deps.env.GITEA_AXI_LOGIN;
|
||||
return {
|
||||
repoSpec: repoFromFlag ?? repoFromEnv,
|
||||
repoSource: repoFromFlag ? "flag" : repoFromEnv ? "env" : "auto",
|
||||
repoOrigin: repoFromFlag ? "`-R`" : "`GITEA_AXI_REPO`",
|
||||
loginName: loginFromFlag ?? loginFromEnv,
|
||||
loginSource: loginFromFlag ? "flag" : loginFromEnv ? "env" : "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function parseRepoSpec(spec: string, origin: string): { owner: string; name: string } {
|
||||
const segments = spec.split("/");
|
||||
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
||||
throw axiError(
|
||||
`Invalid repository "${spec}" from ${origin}: expected OWNER/NAME`,
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
}
|
||||
return { owner: segments[0], name: segments[1] };
|
||||
}
|
||||
|
||||
function hostnameOf(url: string, origin: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
throw axiError(`Invalid URL "${url}" from ${origin}`, "VALIDATION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTestModeContext(
|
||||
deps: CliDeps,
|
||||
apiUrl: string,
|
||||
overrides: ContextOverrides,
|
||||
): RepoContext {
|
||||
if (!overrides.repoSpec) {
|
||||
throw axiError(
|
||||
"Repository context is required when GITEA_AXI_API_URL is set",
|
||||
"REPO_NOT_FOUND",
|
||||
["Set `GITEA_AXI_REPO=OWNER/NAME` or pass `-R OWNER/NAME`"],
|
||||
);
|
||||
}
|
||||
return {
|
||||
...parseRepoSpec(overrides.repoSpec, overrides.repoOrigin),
|
||||
host: hostnameOf(apiUrl, "`GITEA_AXI_API_URL`"),
|
||||
apiUrl: apiUrl.replace(/\/+$/, ""),
|
||||
token: deps.env.GITEA_AXI_TOKEN ?? "",
|
||||
repoSource: overrides.repoSource,
|
||||
loginSource: overrides.loginSource,
|
||||
loginName: overrides.loginName,
|
||||
};
|
||||
}
|
||||
|
||||
function matchLoginsByHost(logins: TeaLogin[], host: string): TeaLogin[] {
|
||||
return logins.filter((login) => {
|
||||
if (login.sshHost && login.sshHost === host) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return new URL(login.url).hostname === host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectLogin(
|
||||
logins: TeaLogin[],
|
||||
loginName: string | undefined,
|
||||
remoteHost: string | undefined,
|
||||
): TeaLogin {
|
||||
if (logins.length === 0) {
|
||||
throw axiError("No tea logins are configured", "AUTH_REQUIRED", [
|
||||
"Run `tea login add` to configure a login",
|
||||
]);
|
||||
}
|
||||
if (loginName) {
|
||||
const login = logins.find((entry) => entry.name === loginName);
|
||||
if (!login) {
|
||||
throw axiError(
|
||||
`Login profile "${loginName}" not found (available: ${logins.map((l) => l.name).join(", ")})`,
|
||||
"VALIDATION_ERROR",
|
||||
["Pass `--login <name>` with one of the listed profiles"],
|
||||
);
|
||||
}
|
||||
return login;
|
||||
}
|
||||
if (!remoteHost) {
|
||||
throw axiError(
|
||||
"Cannot select a tea login without a git remote hostname",
|
||||
"REPO_NOT_FOUND",
|
||||
["Pass `--login <name>` together with `-R OWNER/NAME`"],
|
||||
);
|
||||
}
|
||||
const matches = matchLoginsByHost(logins, remoteHost);
|
||||
if (matches.length === 0) {
|
||||
throw axiError(
|
||||
`No tea login matches host "${remoteHost}" — this may not be a Gitea repository`,
|
||||
"REPO_NOT_FOUND",
|
||||
[
|
||||
`Run \`tea login add --url ${remoteHost}\` if this is a Gitea instance`,
|
||||
"Or pass `-R OWNER/NAME` together with `--login <name>`",
|
||||
],
|
||||
);
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return matches[0]!;
|
||||
}
|
||||
const fallback = matches.find((login) => login.isDefault);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
throw axiError(
|
||||
`Multiple tea logins match host "${remoteHost}": ${matches.map((l) => l.name).join(", ")}`,
|
||||
"VALIDATION_ERROR",
|
||||
["Pass `--login <name>` to select one"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveRepoContext(deps: CliDeps): Promise<RepoContext> {
|
||||
const overrides = resolveOverrides(deps);
|
||||
|
||||
const testApiUrl = deps.env.GITEA_AXI_API_URL;
|
||||
if (testApiUrl) {
|
||||
return resolveTestModeContext(deps, testApiUrl, overrides);
|
||||
}
|
||||
|
||||
const remote = await detectRemote(deps);
|
||||
let owner: string;
|
||||
let name: string;
|
||||
if (overrides.repoSpec) {
|
||||
({ owner, name } = parseRepoSpec(overrides.repoSpec, overrides.repoOrigin));
|
||||
} else if (remote) {
|
||||
({ owner, name } = remote);
|
||||
} else {
|
||||
throw axiError(
|
||||
"Could not detect a Gitea repository from the git `origin` remote",
|
||||
"REPO_NOT_FOUND",
|
||||
[
|
||||
"Run inside a git repository whose `origin` remote points at a Gitea instance",
|
||||
"Or pass `-R OWNER/NAME` together with `--login <name>`",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const logins = await listLogins(deps);
|
||||
const login = selectLogin(logins, overrides.loginName, remote?.host);
|
||||
const host = hostnameOf(login.url, `tea login "${login.name}"`);
|
||||
const token = await getToken(deps, login, host);
|
||||
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
host,
|
||||
apiUrl: login.url.replace(/\/+$/, ""),
|
||||
token,
|
||||
repoSource: overrides.repoSource,
|
||||
loginSource: overrides.loginSource,
|
||||
loginName: login.name,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user