nugit-cli 0.0.1-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +30 -0
- package/src/api-client.js +182 -0
- package/src/auth-token.js +14 -0
- package/src/cli-output.js +228 -0
- package/src/git-info.js +60 -0
- package/src/github-device-flow.js +64 -0
- package/src/github-pr-social.js +126 -0
- package/src/github-rest.js +212 -0
- package/src/nugit-stack.js +289 -0
- package/src/nugit-start.js +211 -0
- package/src/nugit.js +829 -0
- package/src/open-browser.js +21 -0
- package/src/split-view/run-split.js +181 -0
- package/src/split-view/split-git.js +88 -0
- package/src/split-view/split-ink.js +104 -0
- package/src/stack-discover.js +284 -0
- package/src/stack-discovery-config.js +91 -0
- package/src/stack-extra-commands.js +353 -0
- package/src/stack-graph.js +214 -0
- package/src/stack-helpers.js +58 -0
- package/src/stack-propagate.js +422 -0
- package/src/stack-view/fetch-pr-data.js +126 -0
- package/src/stack-view/ink-app.js +421 -0
- package/src/stack-view/loader.js +101 -0
- package/src/stack-view/open-url.js +18 -0
- package/src/stack-view/prompt-line.js +47 -0
- package/src/stack-view/run-stack-view.js +366 -0
- package/src/stack-view/static-render.js +98 -0
- package/src/token-store.js +45 -0
- package/src/user-config.js +169 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub PR/issue comments and reviewers (REST). Uses direct API only.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { githubRestJson } from "./github-rest.js";
|
|
6
|
+
|
|
7
|
+
const PER_PAGE = 100;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} pathNoQuery path starting with /repos/... without ?
|
|
11
|
+
*/
|
|
12
|
+
async function githubGetAllPages(pathNoQuery) {
|
|
13
|
+
let page = 1;
|
|
14
|
+
/** @type {unknown[]} */
|
|
15
|
+
const out = [];
|
|
16
|
+
while (true) {
|
|
17
|
+
const q = `?per_page=${PER_PAGE}&page=${page}`;
|
|
18
|
+
const chunk = await githubRestJson("GET", `${pathNoQuery}${q}`);
|
|
19
|
+
if (!Array.isArray(chunk) || chunk.length === 0) {
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
out.push(...chunk);
|
|
23
|
+
if (chunk.length < PER_PAGE) {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
page += 1;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} owner
|
|
33
|
+
* @param {string} repo
|
|
34
|
+
* @param {number} issueNumber same as PR number on GitHub
|
|
35
|
+
*/
|
|
36
|
+
export async function githubListIssueComments(owner, repo, issueNumber) {
|
|
37
|
+
const o = encodeURIComponent(owner);
|
|
38
|
+
const r = encodeURIComponent(repo);
|
|
39
|
+
const n = encodeURIComponent(String(issueNumber));
|
|
40
|
+
return githubGetAllPages(`/repos/${o}/${r}/issues/${n}/comments`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Pull review comments (line-linked).
|
|
45
|
+
* @param {string} owner
|
|
46
|
+
* @param {string} repo
|
|
47
|
+
* @param {number} pullNumber
|
|
48
|
+
*/
|
|
49
|
+
export async function githubListPullReviewComments(owner, repo, pullNumber) {
|
|
50
|
+
const o = encodeURIComponent(owner);
|
|
51
|
+
const r = encodeURIComponent(repo);
|
|
52
|
+
const n = encodeURIComponent(String(pullNumber));
|
|
53
|
+
return githubGetAllPages(`/repos/${o}/${r}/pulls/${n}/comments`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Single pull review comment by id (REST).
|
|
58
|
+
* @param {string} owner
|
|
59
|
+
* @param {string} repo
|
|
60
|
+
* @param {number} commentId
|
|
61
|
+
*/
|
|
62
|
+
export async function githubGetPullReviewComment(owner, repo, commentId) {
|
|
63
|
+
const o = encodeURIComponent(owner);
|
|
64
|
+
const r = encodeURIComponent(repo);
|
|
65
|
+
const id = encodeURIComponent(String(commentId));
|
|
66
|
+
return githubRestJson("GET", `/repos/${o}/${r}/pulls/comments/${id}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} owner
|
|
71
|
+
* @param {string} repo
|
|
72
|
+
* @param {number} issueNumber
|
|
73
|
+
* @param {string} body
|
|
74
|
+
*/
|
|
75
|
+
export async function githubPostIssueComment(owner, repo, issueNumber, body) {
|
|
76
|
+
const o = encodeURIComponent(owner);
|
|
77
|
+
const r = encodeURIComponent(repo);
|
|
78
|
+
const n = encodeURIComponent(String(issueNumber));
|
|
79
|
+
return githubRestJson("POST", `/repos/${o}/${r}/issues/${n}/comments`, { body });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reply in a review thread.
|
|
84
|
+
* @param {string} owner
|
|
85
|
+
* @param {string} repo
|
|
86
|
+
* @param {number} commentId root review comment id
|
|
87
|
+
* @param {string} body
|
|
88
|
+
*/
|
|
89
|
+
export async function githubPostPullReviewCommentReply(owner, repo, commentId, body) {
|
|
90
|
+
const o = encodeURIComponent(owner);
|
|
91
|
+
const r = encodeURIComponent(repo);
|
|
92
|
+
const id = encodeURIComponent(String(commentId));
|
|
93
|
+
return githubRestJson("POST", `/repos/${o}/${r}/pulls/comments/${id}/replies`, { body });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} owner
|
|
98
|
+
* @param {string} repo
|
|
99
|
+
* @param {number} pullNumber
|
|
100
|
+
* @param {{ reviewers?: string[], team_reviewers?: string[] }} reviewers
|
|
101
|
+
*/
|
|
102
|
+
export async function githubPostRequestedReviewers(owner, repo, pullNumber, reviewers) {
|
|
103
|
+
const o = encodeURIComponent(owner);
|
|
104
|
+
const r = encodeURIComponent(repo);
|
|
105
|
+
const n = encodeURIComponent(String(pullNumber));
|
|
106
|
+
const payload = {
|
|
107
|
+
reviewers: reviewers.reviewers || [],
|
|
108
|
+
team_reviewers: reviewers.team_reviewers || []
|
|
109
|
+
};
|
|
110
|
+
return githubRestJson(
|
|
111
|
+
"POST",
|
|
112
|
+
`/repos/${o}/${r}/pulls/${n}/requested_reviewers`,
|
|
113
|
+
payload
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* List users assignable in this repo (good reviewer candidates).
|
|
119
|
+
* @param {string} owner
|
|
120
|
+
* @param {string} repo
|
|
121
|
+
*/
|
|
122
|
+
export async function githubListAssignableUsers(owner, repo) {
|
|
123
|
+
const o = encodeURIComponent(owner);
|
|
124
|
+
const r = encodeURIComponent(repo);
|
|
125
|
+
return githubGetAllPages(`/repos/${o}/${r}/assignees`);
|
|
126
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call GitHub's REST API directly (no StackPR/nugit FastAPI proxy).
|
|
3
|
+
* Direct GitHub REST for the nugit CLI (PAT / OAuth token).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveGithubToken } from "./auth-token.js";
|
|
7
|
+
|
|
8
|
+
const GITHUB_API_BASE = (
|
|
9
|
+
process.env.GITHUB_API_URL || "https://api.github.com"
|
|
10
|
+
).replace(/\/$/, "");
|
|
11
|
+
|
|
12
|
+
export function getGithubPat() {
|
|
13
|
+
return resolveGithubToken();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @deprecated All GitHub calls are direct; proxy env vars are ignored. */
|
|
17
|
+
export function useDirectGithub() {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} method
|
|
23
|
+
* @param {string} path API path starting with /
|
|
24
|
+
* @param {Record<string, unknown> | undefined} jsonBody
|
|
25
|
+
* @param {string} [tokenOverride] PAT for this call only (e.g. `nugit auth pat --token`)
|
|
26
|
+
*/
|
|
27
|
+
export async function githubRestJson(method, path, jsonBody, tokenOverride) {
|
|
28
|
+
const token = tokenOverride ?? getGithubPat();
|
|
29
|
+
if (!token) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Set NUGIT_USER_TOKEN or STACKPR_USER_TOKEN (GitHub PAT or OAuth token with repo scope)"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const headers = {
|
|
35
|
+
Authorization: `Bearer ${token}`,
|
|
36
|
+
Accept: "application/vnd.github+json",
|
|
37
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
38
|
+
};
|
|
39
|
+
if (jsonBody !== undefined) {
|
|
40
|
+
headers["Content-Type"] = "application/json";
|
|
41
|
+
}
|
|
42
|
+
const response = await fetch(`${GITHUB_API_BASE}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
headers,
|
|
45
|
+
body: jsonBody !== undefined ? JSON.stringify(jsonBody) : undefined
|
|
46
|
+
});
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
let payload = {};
|
|
49
|
+
try {
|
|
50
|
+
payload = text ? JSON.parse(text) : {};
|
|
51
|
+
} catch {
|
|
52
|
+
payload = { message: text };
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const msg =
|
|
56
|
+
payload.message ||
|
|
57
|
+
payload.detail ||
|
|
58
|
+
`GitHub API ${response.status}: ${response.statusText}`;
|
|
59
|
+
throw new Error(typeof msg === "string" ? msg : JSON.stringify(payload));
|
|
60
|
+
}
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function githubGetViewer() {
|
|
65
|
+
return githubRestJson("GET", "/user");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} owner
|
|
70
|
+
* @param {string} repo
|
|
71
|
+
*/
|
|
72
|
+
export async function githubGetRepoMetadata(owner, repo) {
|
|
73
|
+
return githubRestJson(
|
|
74
|
+
"GET",
|
|
75
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} owner
|
|
81
|
+
* @param {string} repo
|
|
82
|
+
* @param {string} filePath e.g. ".nugit/stack.json"
|
|
83
|
+
* @param {string} [ref]
|
|
84
|
+
*/
|
|
85
|
+
export async function githubGetContents(owner, repo, filePath, ref) {
|
|
86
|
+
const pathSeg = filePath
|
|
87
|
+
.split("/")
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.map(encodeURIComponent)
|
|
90
|
+
.join("/");
|
|
91
|
+
const q = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
|
92
|
+
return githubRestJson(
|
|
93
|
+
"GET",
|
|
94
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${pathSeg}${q}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} owner
|
|
100
|
+
* @param {string} repo
|
|
101
|
+
* @param {number} number
|
|
102
|
+
*/
|
|
103
|
+
export async function githubGetPull(owner, repo, number) {
|
|
104
|
+
return githubRestJson(
|
|
105
|
+
"GET",
|
|
106
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List changed files for a pull request (single page up to 100 files).
|
|
112
|
+
* @param {string} owner
|
|
113
|
+
* @param {string} repo
|
|
114
|
+
* @param {number} number
|
|
115
|
+
*/
|
|
116
|
+
export async function githubListPullFiles(owner, repo, number) {
|
|
117
|
+
return githubRestJson(
|
|
118
|
+
"GET",
|
|
119
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${encodeURIComponent(
|
|
120
|
+
String(number)
|
|
121
|
+
)}/files?per_page=100&page=1`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} owner
|
|
127
|
+
* @param {string} repo
|
|
128
|
+
* @param {{ title: string, head: string, base: string, body?: string, draft?: boolean }} fields
|
|
129
|
+
*/
|
|
130
|
+
export async function githubCreatePullRequest(owner, repo, fields) {
|
|
131
|
+
const payload = {
|
|
132
|
+
title: fields.title,
|
|
133
|
+
head: fields.head,
|
|
134
|
+
base: fields.base,
|
|
135
|
+
draft: !!fields.draft
|
|
136
|
+
};
|
|
137
|
+
if (fields.body) {
|
|
138
|
+
payload.body = fields.body;
|
|
139
|
+
}
|
|
140
|
+
return githubRestJson(
|
|
141
|
+
"POST",
|
|
142
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
|
|
143
|
+
payload
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {number} page
|
|
149
|
+
*/
|
|
150
|
+
export async function githubListUserRepos(page = 1) {
|
|
151
|
+
return githubRestJson(
|
|
152
|
+
"GET",
|
|
153
|
+
`/user/repos?page=${encodeURIComponent(String(page))}&per_page=30`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Search issues (includes PRs). `q` is GitHub search query syntax.
|
|
159
|
+
* @param {string} q
|
|
160
|
+
* @param {number} [perPage] max 100
|
|
161
|
+
* @param {number} [page] 1-based
|
|
162
|
+
*/
|
|
163
|
+
export async function githubSearchIssues(q, perPage = 30, page = 1) {
|
|
164
|
+
const pp = Math.min(100, Math.max(1, perPage));
|
|
165
|
+
const p = Math.max(1, page);
|
|
166
|
+
const query = new URLSearchParams({
|
|
167
|
+
q,
|
|
168
|
+
per_page: String(pp),
|
|
169
|
+
page: String(p),
|
|
170
|
+
sort: "updated",
|
|
171
|
+
order: "desc"
|
|
172
|
+
});
|
|
173
|
+
return githubRestJson("GET", `/search/issues?${query.toString()}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Open pull requests in a repository (paginated). Prefer this over search for “all open PRs”.
|
|
178
|
+
* @param {string} owner
|
|
179
|
+
* @param {string} repo
|
|
180
|
+
* @param {number} [page] 1-based
|
|
181
|
+
* @param {number} [perPage] max 100
|
|
182
|
+
*/
|
|
183
|
+
export async function githubListOpenPulls(owner, repo, page = 1, perPage = 30) {
|
|
184
|
+
const pp = Math.min(100, Math.max(1, perPage));
|
|
185
|
+
const p = Math.max(1, page);
|
|
186
|
+
return githubRestJson(
|
|
187
|
+
"GET",
|
|
188
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=open&page=${p}&per_page=${pp}`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string} owner
|
|
194
|
+
* @param {string} repo
|
|
195
|
+
* @param {string} path file path
|
|
196
|
+
* @param {string} ref branch or sha
|
|
197
|
+
*/
|
|
198
|
+
export async function githubGetBlobText(owner, repo, path, ref) {
|
|
199
|
+
const pathSeg = path
|
|
200
|
+
.split("/")
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.map(encodeURIComponent)
|
|
203
|
+
.join("/");
|
|
204
|
+
const item = await githubRestJson(
|
|
205
|
+
"GET",
|
|
206
|
+
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${pathSeg}?ref=${encodeURIComponent(ref)}`
|
|
207
|
+
);
|
|
208
|
+
if (!item || item.type !== "file" || item.encoding !== "base64" || !item.content) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return Buffer.from(String(item.content).replace(/\s/g, ""), "base64").toString("utf8");
|
|
212
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
|
|
5
|
+
const STACK_REL = path.join(".nugit", "stack.json");
|
|
6
|
+
|
|
7
|
+
export function findGitRoot(cwd = process.cwd()) {
|
|
8
|
+
try {
|
|
9
|
+
const out = execSync("git rev-parse --show-toplevel", {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
13
|
+
}).trim();
|
|
14
|
+
return out || null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stackJsonPath(root) {
|
|
21
|
+
return path.join(root, STACK_REL);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readStackFile(root) {
|
|
25
|
+
const p = stackJsonPath(root);
|
|
26
|
+
if (!fs.existsSync(p)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateStackDoc(doc) {
|
|
34
|
+
if (!doc || typeof doc !== "object") {
|
|
35
|
+
throw new Error("Invalid stack document");
|
|
36
|
+
}
|
|
37
|
+
if (doc.version !== 1) {
|
|
38
|
+
throw new Error("version must be 1");
|
|
39
|
+
}
|
|
40
|
+
if (!doc.repo_full_name || typeof doc.repo_full_name !== "string") {
|
|
41
|
+
throw new Error("repo_full_name is required");
|
|
42
|
+
}
|
|
43
|
+
if (!doc.created_by || typeof doc.created_by !== "string") {
|
|
44
|
+
throw new Error("created_by is required");
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(doc.prs)) {
|
|
47
|
+
throw new Error("prs must be an array");
|
|
48
|
+
}
|
|
49
|
+
const seenN = new Set();
|
|
50
|
+
const seenP = new Set();
|
|
51
|
+
for (let i = 0; i < doc.prs.length; i++) {
|
|
52
|
+
const pr = doc.prs[i];
|
|
53
|
+
if (!pr || typeof pr !== "object") {
|
|
54
|
+
throw new Error(`prs[${i}] invalid`);
|
|
55
|
+
}
|
|
56
|
+
const n = pr.pr_number;
|
|
57
|
+
const pos = pr.position;
|
|
58
|
+
if (typeof n !== "number" || typeof pos !== "number") {
|
|
59
|
+
throw new Error(`prs[${i}]: pr_number and position required`);
|
|
60
|
+
}
|
|
61
|
+
if (seenN.has(n)) {
|
|
62
|
+
throw new Error(`duplicate pr_number ${n}`);
|
|
63
|
+
}
|
|
64
|
+
if (seenP.has(pos)) {
|
|
65
|
+
throw new Error(`duplicate position ${pos}`);
|
|
66
|
+
}
|
|
67
|
+
seenN.add(n);
|
|
68
|
+
seenP.add(pos);
|
|
69
|
+
}
|
|
70
|
+
validateOptionalLayer(doc);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {unknown} p
|
|
76
|
+
* @param {string} label
|
|
77
|
+
* @param {boolean} allowBranch
|
|
78
|
+
*/
|
|
79
|
+
function validateLayerPointer(p, label, allowBranch) {
|
|
80
|
+
if (p == null || typeof p !== "object") {
|
|
81
|
+
throw new Error(`${label} must be an object`);
|
|
82
|
+
}
|
|
83
|
+
const o = /** @type {Record<string, unknown>} */ (p);
|
|
84
|
+
const t = o.type;
|
|
85
|
+
if (t === "branch") {
|
|
86
|
+
if (!allowBranch) {
|
|
87
|
+
throw new Error(`${label}: type "branch" not allowed here`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof o.ref !== "string") {
|
|
90
|
+
throw new Error(`${label}: branch ref must be a string`);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (t === "stack_pr") {
|
|
95
|
+
if (typeof o.pr_number !== "number" || !Number.isInteger(o.pr_number) || o.pr_number < 1) {
|
|
96
|
+
throw new Error(`${label}: stack_pr pr_number invalid`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof o.head_branch !== "string") {
|
|
99
|
+
throw new Error(`${label}: stack_pr head_branch must be a string`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`${label}: type must be "branch" or "stack_pr"`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {unknown} tip
|
|
108
|
+
*/
|
|
109
|
+
function validateLayerTip(tip) {
|
|
110
|
+
if (tip == null || typeof tip !== "object") {
|
|
111
|
+
throw new Error("layer.tip must be an object when present");
|
|
112
|
+
}
|
|
113
|
+
const t = /** @type {Record<string, unknown>} */ (tip);
|
|
114
|
+
if (typeof t.pr_number !== "number" || !Number.isInteger(t.pr_number) || t.pr_number < 1) {
|
|
115
|
+
throw new Error("layer.tip.pr_number must be a positive integer");
|
|
116
|
+
}
|
|
117
|
+
if (typeof t.head_branch !== "string") {
|
|
118
|
+
throw new Error("layer.tip.head_branch must be a string");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {unknown} layer
|
|
124
|
+
* @param {number} prsLength
|
|
125
|
+
* @param {unknown[]} [prs] entries for prefix / position checks
|
|
126
|
+
*/
|
|
127
|
+
export function validateLayerShape(layer, prsLength, prs) {
|
|
128
|
+
if (layer == null || typeof layer !== "object") {
|
|
129
|
+
throw new Error("layer must be an object");
|
|
130
|
+
}
|
|
131
|
+
const L = /** @type {Record<string, unknown>} */ (layer);
|
|
132
|
+
if (typeof L.position !== "number" || !Number.isInteger(L.position) || L.position < 0) {
|
|
133
|
+
throw new Error("layer.position must be a non-negative integer");
|
|
134
|
+
}
|
|
135
|
+
if (typeof L.stack_size !== "number" || !Number.isInteger(L.stack_size) || L.stack_size < 1) {
|
|
136
|
+
throw new Error("layer.stack_size must be a positive integer");
|
|
137
|
+
}
|
|
138
|
+
const hasTip = "tip" in L && L.tip !== undefined && L.tip !== null;
|
|
139
|
+
if (hasTip) {
|
|
140
|
+
validateLayerTip(L.tip);
|
|
141
|
+
if (L.stack_size < prsLength) {
|
|
142
|
+
throw new Error(`layer.stack_size (${L.stack_size}) must be >= prs.length (${prsLength})`);
|
|
143
|
+
}
|
|
144
|
+
if (prsLength !== L.position + 1) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`with layer.tip, prs must be a bottom-up prefix: prs.length (${prsLength}) === layer.position + 1 (${L.position + 1})`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (prs && Array.isArray(prs) && prs.length > 0) {
|
|
150
|
+
const sorted = [...prs].sort(
|
|
151
|
+
(a, b) =>
|
|
152
|
+
(/** @type {{ position?: number }} */ (a).position ?? 0) -
|
|
153
|
+
(/** @type {{ position?: number }} */ (b).position ?? 0)
|
|
154
|
+
);
|
|
155
|
+
const last = sorted[sorted.length - 1];
|
|
156
|
+
const lp = last && typeof last === "object" ? /** @type {{ position?: number }} */ (last).position : undefined;
|
|
157
|
+
if (lp !== L.position) {
|
|
158
|
+
throw new Error("last pr in prs must have position === layer.position");
|
|
159
|
+
}
|
|
160
|
+
const want = new Set();
|
|
161
|
+
for (let i = 0; i <= L.position; i++) {
|
|
162
|
+
want.add(i);
|
|
163
|
+
}
|
|
164
|
+
const have = new Set(sorted.map((p) => (p && typeof p === "object" ? p.position : -1)));
|
|
165
|
+
if (want.size !== have.size || [...want].some((x) => !have.has(x))) {
|
|
166
|
+
throw new Error("prs positions must be contiguous 0..layer.position");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} else if (L.stack_size !== prsLength) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`without layer.tip, layer.stack_size (${L.stack_size}) must equal prs.length (${prsLength})`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
validateLayerPointer(L.below, "layer.below", true);
|
|
175
|
+
if (L.above !== null) {
|
|
176
|
+
validateLayerPointer(L.above, "layer.above", false);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {Record<string, unknown>} doc
|
|
182
|
+
*/
|
|
183
|
+
export function validateOptionalLayer(doc) {
|
|
184
|
+
if (!("layer" in doc) || doc.layer === undefined) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!Array.isArray(doc.prs)) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
validateLayerShape(doc.layer, doc.prs.length, doc.prs);
|
|
191
|
+
const L = /** @type {Record<string, unknown>} */ (doc.layer);
|
|
192
|
+
const positions = new Set(
|
|
193
|
+
doc.prs.map((p) => (p && typeof p === "object" ? /** @type {{ position?: number }} */ (p).position : undefined))
|
|
194
|
+
);
|
|
195
|
+
if (!positions.has(L.position)) {
|
|
196
|
+
throw new Error("layer.position must match one of prs[].position values");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function writeStackFile(root, doc) {
|
|
201
|
+
validateStackDoc(doc);
|
|
202
|
+
const dir = path.join(root, ".nugit");
|
|
203
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
204
|
+
fs.writeFileSync(stackJsonPath(root), JSON.stringify(doc, null, 2) + "\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function createInitialStackDoc(repoFullName, createdBy) {
|
|
208
|
+
return {
|
|
209
|
+
version: 1,
|
|
210
|
+
repo_full_name: repoFullName,
|
|
211
|
+
created_by: createdBy,
|
|
212
|
+
prs: [],
|
|
213
|
+
resolution_contexts: []
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function parseRepoFullName(s) {
|
|
218
|
+
const parts = s.split("/").filter(Boolean);
|
|
219
|
+
if (parts.length !== 2) {
|
|
220
|
+
throw new Error("repo must be owner/repo");
|
|
221
|
+
}
|
|
222
|
+
return { owner: parts[0], repo: parts[1] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Next position after current max, or 0 if empty. */
|
|
226
|
+
export function nextStackPosition(prs) {
|
|
227
|
+
if (!prs || !prs.length) {
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
return Math.max(...prs.map((p) => p.position)) + 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Normalize `nugit stack add --pr` values (variadic and/or comma-separated).
|
|
235
|
+
* @param {string | string[] | undefined} optsPr
|
|
236
|
+
* @returns {number[]}
|
|
237
|
+
*/
|
|
238
|
+
export function parseStackAddPrNumbers(optsPr) {
|
|
239
|
+
const raw = optsPr == null ? [] : Array.isArray(optsPr) ? optsPr : [optsPr];
|
|
240
|
+
const tokens = [];
|
|
241
|
+
for (const r of raw) {
|
|
242
|
+
for (const part of String(r).split(/[\s,]+/)) {
|
|
243
|
+
const t = part.trim();
|
|
244
|
+
if (t) {
|
|
245
|
+
tokens.push(t);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (tokens.length === 0) {
|
|
250
|
+
throw new Error("Pass at least one PR number: --pr <n> [n...]");
|
|
251
|
+
}
|
|
252
|
+
const nums = tokens.map((t) => Number.parseInt(t, 10));
|
|
253
|
+
for (let i = 0; i < nums.length; i++) {
|
|
254
|
+
if (!Number.isFinite(nums[i]) || nums[i] < 1) {
|
|
255
|
+
throw new Error(`Invalid PR number: ${tokens[i]}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
for (const n of nums) {
|
|
260
|
+
if (seen.has(n)) {
|
|
261
|
+
throw new Error(`Duplicate PR #${n} in --pr list`);
|
|
262
|
+
}
|
|
263
|
+
seen.add(n);
|
|
264
|
+
}
|
|
265
|
+
return nums;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build a stack PR entry from GitHub GET /pulls/{n} JSON.
|
|
270
|
+
* @param {Record<string, unknown>} pull
|
|
271
|
+
* @param {number} position
|
|
272
|
+
*/
|
|
273
|
+
export function stackEntryFromGithubPull(pull, position) {
|
|
274
|
+
const head = pull.head && typeof pull.head === "object" ? pull.head : {};
|
|
275
|
+
const base = pull.base && typeof pull.base === "object" ? pull.base : {};
|
|
276
|
+
let status = "open";
|
|
277
|
+
if (pull.state === "closed") {
|
|
278
|
+
status = pull.merged ? "merged" : "closed";
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
pr_number: pull.number,
|
|
282
|
+
position,
|
|
283
|
+
head_branch: head.ref || "",
|
|
284
|
+
base_branch: base.ref || "",
|
|
285
|
+
head_sha: head.sha || "",
|
|
286
|
+
base_sha: base.sha || "",
|
|
287
|
+
status
|
|
288
|
+
};
|
|
289
|
+
}
|