250 lines
11 KiB
JavaScript
250 lines
11 KiB
JavaScript
/**
|
||
* @file OpenAPI fragment for the Changes panel's history reads
|
||
* (server/routes/lane-git-history.js over server/lib/git-history.js): the
|
||
* paged, searchable commit graph (`/git/log`, which replaces the shorter
|
||
* entry in openapi-extra/lanes.js), commit details, a commit's patch, a
|
||
* file's history across renames, blame at a revision, a tree at a revision,
|
||
* comparing two refs, a cherry-pick/revert plan and a paused one's state.
|
||
* Exports `{ tags, schemas, paths }` for server/openapi-extra.js. Every
|
||
* /api/lanes path is also served under /api/workspaces.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||
const q = (name, description, schema = { type: "string" }, required = false) => ({
|
||
name,
|
||
in: "query",
|
||
...(required ? { required } : {}),
|
||
description,
|
||
schema,
|
||
});
|
||
/** The repo selectors every /git read takes. */
|
||
const target = [
|
||
q("session", "A session's own worktree (`claude --worktree`) instead of the workspace folder."),
|
||
q("source", "An extra folder of this workspace (`lane_sources` id)."),
|
||
q("repo", "A repo inside the workspace folder, by its relative path."),
|
||
];
|
||
const READ =
|
||
" Read-only and unguarded like every /git read; the repo is resolved from the lane row (or ?session / ?source / ?repo), never from a path in the request. A folder that is not a repo answers 200 {available:false}.";
|
||
const errors = (bad, forbidden = false) => ({
|
||
400: { description: bad },
|
||
...(forbidden
|
||
? {
|
||
403: {
|
||
description:
|
||
"ESECRET (`.claude/settings.local.json`, at any rev and depth) or EFOREIGN (S3: the folder resolves into another account's files).",
|
||
},
|
||
}
|
||
: {}),
|
||
404: { description: "Lane not found, or ENOSOURCE / ENOREPO / ENOWORKTREE." },
|
||
});
|
||
|
||
const paths = {
|
||
"/api/lanes/{id}/git/log": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "The lane's commit graph, paged and searchable",
|
||
description:
|
||
"Newest first in --date-order (no parent before its children), each commit with its parents and ref badges ({kind: local|remote|tag|head, name, current?}) for drawing the graph. limit (default 30, max 500) and skip page it; hasMore says a next page exists. all=1 reads every branch, remote branch and tag (the stash excluded); ref= another existing branch (else 400 ENOBRANCH). Search: q= the message (case-insensitive, literal — never a regex) and, when it is hex, the commit with that SHA prefix first; author= name or e-mail (literal); path= (alias file=) only commits touching that lane-relative path (no `..`, else 400 EBADPATH). Text over 200 characters is 400 EBADQUERY. A walk is killed after 20 s." +
|
||
READ,
|
||
operationId: "listLaneCommits",
|
||
parameters: [
|
||
idParam,
|
||
q("limit", "Page size.", { type: "integer", default: 30, maximum: 500 }),
|
||
q("skip", "Commits to skip (the page's start).", { type: "integer", default: 0 }),
|
||
q("all", "1 = every branch, remote branch and tag.", { type: "string", enum: ["1"] }),
|
||
q("ref", "Another branch's history."),
|
||
q("q", "Message text, or a SHA prefix."),
|
||
q("author", "Author name or e-mail."),
|
||
q("path", "Only commits touching this path."),
|
||
q("file", "Alias of path."),
|
||
...target,
|
||
],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, commits:[{sha, short, subject, author, email, date, refs, parents, badges}], hasMore, filtered}.",
|
||
},
|
||
...errors("EBADPATH, ENOBRANCH or EBADQUERY."),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/commit": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "One commit in full (details pane)",
|
||
description:
|
||
"The whole message, author and committer with dates, parents, the commit's own ref badges, isHead, and the branches and tags that contain it (50 each, containsTruncated past that). Its files and diffs are GET /git/changes?range=commit&sha=." +
|
||
READ,
|
||
operationId: "readLaneCommit",
|
||
parameters: [idParam, q("sha", "A commit (sha or ref name).", undefined, true), ...target],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, sha, short, subject, message, author, email, date, committer, committerEmail, committedAt, parents, badges, isHead, branches, tags, containsTruncated}.",
|
||
},
|
||
...errors("EBADREV."),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/patch": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "A commit as a patch (Copy patch)",
|
||
description:
|
||
"`git format-patch -1 --stdout --relative` over the commit's files inside the lane. A secret file's diff is never included: it is named in omitted instead. Capped at 2 MB (truncated). A merge commit has no single patch (400 EMERGECOMMIT)." +
|
||
READ,
|
||
operationId: "readLaneCommitPatch",
|
||
parameters: [idParam, q("sha", "A commit.", undefined, true), ...target],
|
||
responses: {
|
||
200: { description: "{available, sha, patch, omitted:[paths], truncated}." },
|
||
...errors("EBADREV or EMERGECOMMIT."),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/file-history": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "One file's history across renames",
|
||
description:
|
||
"`git log --follow` for one lane-relative path, newest first: each commit with the path the file had there, its status letter (A/M/R/D…) and, for a rename, from. Paged like /git/log (limit ≤ 500, skip, hasMore). A secret file is 403 ESECRET." +
|
||
READ,
|
||
operationId: "listLaneFileHistory",
|
||
parameters: [
|
||
idParam,
|
||
q("path", "The file.", undefined, true),
|
||
q("limit", "Page size.", { type: "integer", default: 100, maximum: 500 }),
|
||
q("skip", "Commits to skip.", { type: "integer", default: 0 }),
|
||
...target,
|
||
],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, path, commits:[{sha, short, subject, author, date, parents, status, path, from?}], hasMore}.",
|
||
},
|
||
...errors("EBADPATH.", true),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/blame-at": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "Blame at a revision, with each line's text",
|
||
description:
|
||
"`git blame --porcelain` of the file at rev (a commit) or rev=work (the file on disk). Each line carries its text and the sha that last changed it (null = not committed yet); commits maps a sha to {short, author, date, summary, previous} — previous is where 'blame before this change' goes. Same guards as /git/file: lexical lane path, 403 ESECRET, 1 MB cap (400 ETOOBIG)." +
|
||
READ,
|
||
operationId: "blameLaneFileAt",
|
||
parameters: [
|
||
idParam,
|
||
q("path", "The file.", undefined, true),
|
||
q("rev", "A commit, or work.", { type: "string", default: "work" }),
|
||
...target,
|
||
],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, path, rev, commits: {<sha>: {short, author, date, summary, previous?}}, lines: [{sha|null, text}]}.",
|
||
},
|
||
...errors("EBADPATH, EBADREV, ENOFILE or ETOOBIG.", true),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/tree-at": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "One folder of the tree at a commit (Browse files)",
|
||
description:
|
||
"`git ls-tree` of path (a lane-relative folder; empty = the lane's own folder) at rev, folders first. A secret file is left out of the list; a file's content is then GET /git/file?rev=<sha> (or /git/raw for an image) under that read's own guards. Capped at 5000 entries (truncated)." +
|
||
READ,
|
||
operationId: "listLaneTreeAt",
|
||
parameters: [
|
||
idParam,
|
||
q("rev", "A commit.", undefined, true),
|
||
q("path", "A folder inside the lane."),
|
||
...target,
|
||
],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, rev, path, entries:[{path, name, dir, submodule, size}], truncated}.",
|
||
},
|
||
...errors("EBADPATH or EBADREV."),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/compare": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "Compare two refs",
|
||
description:
|
||
"a and b are branches, tags or commits (each resolved to a commit, else 400 EBADREV). Without path: onlyA / onlyB — the commits only in a and only in b (`--left-right a...b`, 300 each, commitsTruncated) — and files, the files that differ (1000, filesTruncated): mode=three (default) is `a...b`, what b adds since the two diverged; mode=two is `a b`, the two trees as they stand. With path (and a rename's from): that one file with its hunks (200 KB cap); a secret file comes back as {secret:true, hunks:[]}." +
|
||
READ,
|
||
operationId: "compareLaneRefs",
|
||
parameters: [
|
||
idParam,
|
||
q("a", "Left ref.", undefined, true),
|
||
q("b", "Right ref.", undefined, true),
|
||
q("mode", "three (a...b) or two (a b).", { type: "string", enum: ["three", "two"] }),
|
||
q("path", "One file's diff."),
|
||
q("from", "That file's old name, for a rename."),
|
||
...target,
|
||
],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, a, b, mode, onlyA, onlyB, commitsTruncated, files, filesTruncated} — or {available, a, b, mode, file} with path.",
|
||
},
|
||
...errors("EBADREV or EBADPATH."),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/pick-plan": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "What a cherry-pick / revert of several commits would do",
|
||
description:
|
||
"The confirmation dialog's preview, computed by the same code the action runs: shas (comma-separated, 1–50) resolved and ordered — a cherry-pick oldest → newest, a revert newest → oldest (ancestry first, then date) — plus the current branch, whether the tree is clean, whether the lane is the repo root, and busy (an operation is already open). A merge commit is 400 EMERGECOMMIT, an unknown one 400 EBADSHA." +
|
||
READ,
|
||
operationId: "planLanePick",
|
||
parameters: [
|
||
idParam,
|
||
q(
|
||
"op",
|
||
"cherry-pick or revert.",
|
||
{ type: "string", enum: ["cherry-pick", "revert"] },
|
||
true
|
||
),
|
||
q("shas", "Comma-separated commit ids.", undefined, true),
|
||
...target,
|
||
],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, op, commits:[{sha, short, subject, author, date}], branch, clean, repoRoot, busy}.",
|
||
},
|
||
...errors("EBADACTION, EBADSHA or EMERGECOMMIT."),
|
||
},
|
||
},
|
||
},
|
||
"/api/lanes/{id}/git/sequence": {
|
||
get: {
|
||
tags: ["Lanes"],
|
||
summary: "The cherry-pick / revert that is paused, if any",
|
||
description:
|
||
"For the 'paused at commit X (2 of 5)' banner: operation, current (the commit it stopped at), remaining (still to apply), done (applied since it started), startedFrom (the HEAD it started at) and conflicts (unmerged paths). {active:false} when none is open (a merge is not reported here)." +
|
||
READ,
|
||
operationId: "readLanePickSequence",
|
||
parameters: [idParam, ...target],
|
||
responses: {
|
||
200: {
|
||
description:
|
||
"{available, active, operation, current, remaining, done, startedFrom, conflicts}.",
|
||
},
|
||
...errors("—"),
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
module.exports = { tags: [], schemas: {}, paths };
|