All checks were successful
CI / test (22) (pull_request) Successful in 50s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 47s
CI / test (true, 24) (push) Successful in 1m4s
CI / flake (push) Successful in 3s
Both halves of `setup` assumed the files they manage are writable. A declaratively managed target — read-only because a configuration manager owns it, because a file is flagged immutable, or because the path is root-owned — made the skill install raise a raw filesystem exception and the hook install surface the underlying message with no guidance. Both now fail with `TARGET_NOT_WRITABLE`, naming the file and pointing at the general remedy: it appears to be managed by another tool, so declare the skill or hook through that configuration instead. The error names no particular manager, because read-only is not diagnostic of one. A target already byte-identical to the bundled copy still succeeds — nothing needs writing, so its being read-only is beside the point.
192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
import { AxiError } from "axi-sdk-js";
|
|
|
|
export type AxiErrorCode =
|
|
| "REPO_NOT_FOUND"
|
|
| "ISSUE_NOT_FOUND"
|
|
| "PR_NOT_FOUND"
|
|
| "AUTH_REQUIRED"
|
|
| "FORBIDDEN"
|
|
| "RATE_LIMITED"
|
|
| "TEA_NOT_INSTALLED"
|
|
| "VALIDATION_ERROR"
|
|
| "GIT_ERROR"
|
|
| "TARGET_NOT_WRITABLE"
|
|
| "UNKNOWN";
|
|
|
|
export function axiError(
|
|
message: string,
|
|
code: AxiErrorCode,
|
|
suggestions: string[] = [],
|
|
): AxiError {
|
|
return new AxiError(message, code, suggestions);
|
|
}
|
|
|
|
// The three ways a filesystem refuses a write for reasons the user must settle
|
|
// outside this tool: the permission bits deny it, the file is flagged immutable
|
|
// or otherwise protected, or the filesystem itself is mounted read-only.
|
|
const NOT_WRITABLE_ERRNOS = ["EACCES", "EPERM", "EROFS"];
|
|
|
|
/** Whether a caught filesystem error means the target cannot be written. */
|
|
export function isNotWritableError(error: unknown): boolean {
|
|
const errno = (error as { code?: unknown } | null)?.code;
|
|
return typeof errno === "string" && NOT_WRITABLE_ERRNOS.includes(errno);
|
|
}
|
|
|
|
/**
|
|
* The same judgement made from an error's *message*, for a caller handed the
|
|
* formatted text rather than the error object.
|
|
*
|
|
* This takes the message alone, never a string the target's path has been
|
|
* spliced into: a path is the user's to name, and one that happened to contain
|
|
* `EACCES` would otherwise misreport an unrelated failure as a read-only target.
|
|
*/
|
|
export function isNotWritableMessage(message: string): boolean {
|
|
return NOT_WRITABLE_ERRNOS.some((errno) => message.includes(errno));
|
|
}
|
|
|
|
/**
|
|
* A read-only target reported as something the user can act on.
|
|
*
|
|
* The remedy is deliberately general. Read-only is not diagnostic of any
|
|
* particular configuration manager, and every plausible cause — a declarative
|
|
* home manager, an immutable flag, a root-owned path — has the same answer:
|
|
* whatever renders the file read-only is where this belongs, not here.
|
|
*
|
|
* `subject` names what the caller was installing, for the remedy line.
|
|
*/
|
|
export function unwritableTargetError(path: string, subject: string): AxiError {
|
|
return axiError(
|
|
`Cannot write ${path}: it is not writable, so it appears to be managed by another tool`,
|
|
"TARGET_NOT_WRITABLE",
|
|
[
|
|
`Declare ${subject} through that tool's configuration rather than installing it with this command`,
|
|
`Or make ${path} writable and re-run`,
|
|
],
|
|
);
|
|
}
|
|
|
|
interface HttpResponseLike {
|
|
status: number;
|
|
url: string;
|
|
error: unknown;
|
|
}
|
|
|
|
function isHttpResponseLike(value: unknown): value is HttpResponseLike {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
typeof (value as HttpResponseLike).status === "number" &&
|
|
typeof (value as HttpResponseLike).url === "string"
|
|
);
|
|
}
|
|
|
|
function bodyMessage(response: HttpResponseLike): string | undefined {
|
|
const error = response.error;
|
|
if (typeof error === "object" && error !== null) {
|
|
const message = (error as { message?: unknown }).message;
|
|
if (typeof message === "string" && message.length > 0) {
|
|
return message;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function pathname(url: string): string {
|
|
try {
|
|
return new URL(url).pathname;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
const ISSUE_PATH = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(?:\/|$)/;
|
|
// The trailing `.` case covers the `.diff`/`.patch` download paths, whose PR
|
|
// number is followed by a suffix rather than a `/` or the end of the path.
|
|
const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:[./]|$)/;
|
|
const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/;
|
|
|
|
function classify404(response: HttpResponseLike): AxiError {
|
|
const path = pathname(response.url);
|
|
const issue = ISSUE_PATH.exec(path);
|
|
if (issue) {
|
|
return axiError(`Issue #${issue[1]} not found`, "ISSUE_NOT_FOUND", [
|
|
"Run `gitea-axi issue list` to see existing issues",
|
|
]);
|
|
}
|
|
const pull = PULL_PATH.exec(path);
|
|
if (pull) {
|
|
return axiError(`Pull request #${pull[1]} not found`, "PR_NOT_FOUND");
|
|
}
|
|
// Gitea returns 404 for every path under a nonexistent repository, so any
|
|
// repo-subtree 404 that is not an indexed issue/pull lookup means the
|
|
// repository itself was not found.
|
|
const repo = REPO_PATH.exec(path);
|
|
if (repo) {
|
|
return axiError(
|
|
`Repository ${repo[1]}/${repo[2]} not found`,
|
|
"REPO_NOT_FOUND",
|
|
[
|
|
"Check the repository owner and name",
|
|
"Pass `-R OWNER/NAME` to target a different repository",
|
|
],
|
|
);
|
|
}
|
|
return axiError(`Not found: ${path}`, "UNKNOWN");
|
|
}
|
|
|
|
/**
|
|
* The HTTP status of a failed API call, or undefined if the failure was not an
|
|
* HTTP response at all. For the callers that give one status a meaning of their
|
|
* own before falling back to {@link classifyHttpError} — a 404 from Gitea's
|
|
* by-base-head pull lookup, for instance, means "no such pull request exists",
|
|
* which is an ordinary answer rather than an error.
|
|
*/
|
|
export function httpStatus(error: unknown): number | undefined {
|
|
return isHttpResponseLike(error) ? error.status : undefined;
|
|
}
|
|
|
|
export function classifyHttpError(error: unknown): AxiError {
|
|
if (error instanceof AxiError) {
|
|
return error;
|
|
}
|
|
if (!isHttpResponseLike(error)) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const cause =
|
|
error instanceof Error && error.cause instanceof Error
|
|
? ` (${error.cause.message})`
|
|
: "";
|
|
return axiError(`Request failed: ${message}${cause}`, "UNKNOWN");
|
|
}
|
|
const detail = bodyMessage(error);
|
|
switch (error.status) {
|
|
case 401:
|
|
return axiError(detail ?? "Authentication required", "AUTH_REQUIRED", [
|
|
"Run `tea login add` to configure credentials, or verify the token is still valid",
|
|
]);
|
|
case 403:
|
|
return axiError(detail ?? "Access forbidden", "FORBIDDEN", [
|
|
"Verify the token has permission to access this repository",
|
|
]);
|
|
case 404:
|
|
return classify404(error);
|
|
case 405:
|
|
case 409:
|
|
case 422:
|
|
return axiError(
|
|
detail ?? `Validation failed (HTTP ${error.status})`,
|
|
"VALIDATION_ERROR",
|
|
);
|
|
case 429:
|
|
return axiError(detail ?? "Rate limited", "RATE_LIMITED", [
|
|
"Wait and retry, or reduce `--limit` to make smaller requests",
|
|
]);
|
|
default:
|
|
return axiError(
|
|
detail
|
|
? `Gitea API error (HTTP ${error.status}): ${detail}`
|
|
: `Gitea API error (HTTP ${error.status})`,
|
|
"UNKNOWN",
|
|
);
|
|
}
|
|
}
|