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.
@@ -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
+ }