nugit-cli 0.0.1 → 0.1.1

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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  58. package/src/stack-propagate.js +0 -422
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nugit-cli",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "description": "CLI for stacked GitHub PRs (.nugit/stack.json)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api-client.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  githubListUserRepos,
8
8
  githubRestJson,
9
9
  githubSearchIssues,
10
+ githubSearchRepositories,
10
11
  githubListOpenPulls
11
12
  } from "./github-rest.js";
12
13
  import {
@@ -14,6 +15,7 @@ import {
14
15
  githubDeviceFlowRequestCode
15
16
  } from "./github-device-flow.js";
16
17
  import { resolveGithubToken } from "./auth-token.js";
18
+ import { resolveGithubOAuthClientId } from "./github-oauth-client-id.js";
17
19
 
18
20
  /** @returns {string} */
19
21
  export function getToken() {
@@ -29,15 +31,10 @@ export function withAuthHeaders(headers = {}) {
29
31
  }
30
32
 
31
33
  /**
32
- * Start GitHub OAuth device flow (requires GITHUB_OAUTH_CLIENT_ID).
34
+ * Start GitHub OAuth device flow (bundled OAuth App client id; override with GITHUB_OAUTH_CLIENT_ID).
33
35
  */
34
36
  export async function startDeviceFlow() {
35
- const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
36
- if (!clientId) {
37
- throw new Error(
38
- "Set GITHUB_OAUTH_CLIENT_ID to your GitHub OAuth App client ID (Settings → Developer settings → OAuth Apps), or use a PAT with NUGIT_USER_TOKEN."
39
- );
40
- }
37
+ const clientId = resolveGithubOAuthClientId();
41
38
  return githubDeviceFlowRequestCode(clientId, "repo read:user user:email");
42
39
  }
43
40
 
@@ -47,10 +44,7 @@ export async function startDeviceFlow() {
47
44
  * @param {number} [intervalSeconds] minimum wait before next poll (from GitHub or prior slow_down)
48
45
  */
49
46
  export async function pollDeviceFlow(deviceCode, intervalSeconds = 5) {
50
- const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
51
- if (!clientId) {
52
- throw new Error("Set GITHUB_OAUTH_CLIENT_ID");
53
- }
47
+ const clientId = resolveGithubOAuthClientId();
54
48
  const payload = await githubDeviceFlowPollAccessToken(clientId, deviceCode);
55
49
  if (payload.access_token) {
56
50
  return { access_token: payload.access_token, token_type: payload.token_type, scope: payload.scope };
@@ -145,6 +139,11 @@ export async function getRepoMetadata(owner, repo) {
145
139
  return githubGetRepoMetadata(owner, repo);
146
140
  }
147
141
 
142
+ /** @param {string} query GitHub `q` syntax for /search/repositories */
143
+ export async function searchRepositories(query, perPage = 20, page = 1) {
144
+ return githubSearchRepositories(query, perPage, page);
145
+ }
146
+
148
147
  export async function createPullRequest(owner, repo, fields) {
149
148
  return githubCreatePullRequest(owner, repo, fields);
150
149
  }
@@ -160,18 +159,6 @@ export function decodeGithubFileContent(item) {
160
159
  return Buffer.from(item.content.replace(/\s/g, ""), "base64").toString("utf8");
161
160
  }
162
161
 
163
- export async function fetchRemoteStackJson(repoFullName, ref) {
164
- const [owner, repo] = repoFullName.split("/");
165
- if (!owner || !repo) {
166
- throw new Error("repoFullName must be owner/repo");
167
- }
168
- const item = await getGithubContents(owner, repo, ".nugit/stack.json", ref);
169
- const text = decodeGithubFileContent(item);
170
- if (!text) {
171
- throw new Error("Could not read .nugit/stack.json from GitHub");
172
- }
173
- return JSON.parse(text);
174
- }
175
162
 
176
163
  export async function getPull(owner, repo, number) {
177
164
  return githubGetPull(owner, repo, number);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GitHub OAuth device flow (no backend). Requires GITHUB_OAUTH_CLIENT_ID.
2
+ * GitHub OAuth device flow (no backend). Client id from resolveGithubOAuthClientId() (bundled default or GITHUB_OAUTH_CLIENT_ID).
3
3
  * @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
4
4
  */
5
5
 
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Default GitHub OAuth App for `nugit auth login` (device flow).
3
+ * Client IDs are public (not secrets). Override with env for a custom app or fork.
4
+ */
5
+ export const NUGIT_BUNDLED_GITHUB_OAUTH_CLIENT_ID = "Ov23liMBQqJJvRNiO0qm";
6
+
7
+ /** @returns {string} */
8
+ export function resolveGithubOAuthClientId() {
9
+ const fromEnv = process.env.GITHUB_OAUTH_CLIENT_ID?.trim();
10
+ return fromEnv || NUGIT_BUNDLED_GITHUB_OAUTH_CLIENT_ID;
11
+ }
@@ -124,3 +124,45 @@ export async function githubListAssignableUsers(owner, repo) {
124
124
  const r = encodeURIComponent(repo);
125
125
  return githubGetAllPages(`/repos/${o}/${r}/assignees`);
126
126
  }
127
+
128
+ /**
129
+ * All pull reviews (paginated).
130
+ * @param {string} owner
131
+ * @param {string} repo
132
+ * @param {number} pullNumber
133
+ */
134
+ export async function githubListPullReviewsAll(owner, repo, pullNumber) {
135
+ const o = encodeURIComponent(owner);
136
+ const r = encodeURIComponent(repo);
137
+ const n = encodeURIComponent(String(pullNumber));
138
+ return githubGetAllPages(`/repos/${o}/${r}/pulls/${n}/reviews`);
139
+ }
140
+
141
+ /**
142
+ * Post a single line-linked review comment on a PR (creates a new review thread).
143
+ *
144
+ * @param {string} owner
145
+ * @param {string} repo
146
+ * @param {number} pullNumber
147
+ * @param {{ body: string, commit_id: string, path: string, line: number, side?: "LEFT" | "RIGHT" }} payload
148
+ */
149
+ export async function githubPostPullReviewLineComment(owner, repo, pullNumber, payload) {
150
+ const o = encodeURIComponent(owner);
151
+ const r = encodeURIComponent(repo);
152
+ const n = encodeURIComponent(String(pullNumber));
153
+ return githubRestJson("POST", `/repos/${o}/${r}/pulls/${n}/comments`, payload);
154
+ }
155
+
156
+ /**
157
+ * Submit a PR review (approve, comment, request changes).
158
+ * @param {string} owner
159
+ * @param {string} repo
160
+ * @param {number} pullNumber
161
+ * @param {{ event: string, body?: string, commit_id?: string }} payload
162
+ */
163
+ export async function githubPostPullReview(owner, repo, pullNumber, payload) {
164
+ const o = encodeURIComponent(owner);
165
+ const r = encodeURIComponent(repo);
166
+ const n = encodeURIComponent(String(pullNumber));
167
+ return githubRestJson("POST", `/repos/${o}/${r}/pulls/${n}/reviews`, payload);
168
+ }
@@ -26,16 +26,30 @@ export function useDirectGithub() {
26
26
  */
27
27
  export async function githubRestJson(method, path, jsonBody, tokenOverride) {
28
28
  const token = tokenOverride ?? getGithubPat();
29
- if (!token) {
29
+ const methodUpper = String(method).toUpperCase();
30
+ const unauthDisabled =
31
+ process.env.NUGIT_GITHUB_UNAUTHENTICATED === "0" ||
32
+ process.env.NUGIT_GITHUB_UNAUTHENTICATED === "false";
33
+ const allowUnauthenticatedGet =
34
+ !unauthDisabled &&
35
+ !token &&
36
+ (methodUpper === "GET" || methodUpper === "HEAD");
37
+
38
+ if (!token && !allowUnauthenticatedGet) {
30
39
  throw new Error(
31
- "Set NUGIT_USER_TOKEN or STACKPR_USER_TOKEN (GitHub PAT or OAuth token with repo scope)"
40
+ "GitHub authentication required for this request. Run `nugit auth login` or `nugit auth pat --token …`, " +
41
+ "or set NUGIT_USER_TOKEN / STACKPR_USER_TOKEN. " +
42
+ "Some read-only public GETs work without a token (low rate limits); set NUGIT_GITHUB_UNAUTHENTICATED=0 to disable that."
32
43
  );
33
44
  }
34
45
  const headers = {
35
- Authorization: `Bearer ${token}`,
36
46
  Accept: "application/vnd.github+json",
37
- "X-GitHub-Api-Version": "2022-11-28"
47
+ "X-GitHub-Api-Version": "2022-11-28",
48
+ "User-Agent": "nugit-cli"
38
49
  };
50
+ if (token) {
51
+ headers.Authorization = `Bearer ${token}`;
52
+ }
39
53
  if (jsonBody !== undefined) {
40
54
  headers["Content-Type"] = "application/json";
41
55
  }
@@ -147,19 +161,125 @@ export async function githubCreatePullRequest(owner, repo, fields) {
147
161
  /**
148
162
  * @param {number} page
149
163
  */
150
- export async function githubListUserRepos(page = 1) {
164
+ /**
165
+ * @param {number} [page]
166
+ * @param {number} [perPage] max 100
167
+ */
168
+ export async function githubListUserRepos(page = 1, perPage = 100) {
169
+ const pp = Math.min(100, Math.max(1, perPage));
170
+ const p = Math.max(1, page);
151
171
  return githubRestJson(
152
172
  "GET",
153
- `/user/repos?page=${encodeURIComponent(String(page))}&per_page=30`
173
+ `/user/repos?page=${encodeURIComponent(String(p))}&per_page=${encodeURIComponent(String(pp))}&sort=full_name`
154
174
  );
155
175
  }
156
176
 
177
+ /**
178
+ * @returns {Promise<unknown[]>}
179
+ */
180
+ export async function githubListAllUserRepos() {
181
+ /** @type {unknown[]} */
182
+ const all = [];
183
+ let page = 1;
184
+ for (;;) {
185
+ const chunk = await githubListUserRepos(page, 100);
186
+ if (!Array.isArray(chunk) || chunk.length === 0) {
187
+ break;
188
+ }
189
+ all.push(...chunk);
190
+ if (chunk.length < 100) {
191
+ break;
192
+ }
193
+ page += 1;
194
+ }
195
+ return all;
196
+ }
197
+
198
+ /**
199
+ * @param {string} owner
200
+ * @param {string} repo
201
+ * @param {string} base
202
+ * @param {string} head
203
+ */
204
+ export async function githubCompareRefs(owner, repo, base, head) {
205
+ const o = encodeURIComponent(owner);
206
+ const r = encodeURIComponent(repo);
207
+ const bh = `${base}...${head}`;
208
+ return githubRestJson("GET", `/repos/${o}/${r}/compare/${encodeURIComponent(bh)}`);
209
+ }
210
+
211
+ /**
212
+ * @param {string} owner
213
+ * @param {string} repo
214
+ * @param {string} treeSha commit or tree sha
215
+ * @param {boolean} [recursive]
216
+ */
217
+ export async function githubGetGitTree(owner, repo, treeSha, recursive = false) {
218
+ const o = encodeURIComponent(owner);
219
+ const r = encodeURIComponent(repo);
220
+ const sha = encodeURIComponent(treeSha);
221
+ const q = recursive ? "?recursive=1" : "";
222
+ return githubRestJson("GET", `/repos/${o}/${r}/git/trees/${sha}${q}`);
223
+ }
224
+
157
225
  /**
158
226
  * Search issues (includes PRs). `q` is GitHub search query syntax.
159
227
  * @param {string} q
160
228
  * @param {number} [perPage] max 100
161
229
  * @param {number} [page] 1-based
162
230
  */
231
+ /**
232
+ * GitHub repository search (`q` uses GitHub search syntax, e.g. `todo language:python` or `user:orgname`).
233
+ * @param {string} q
234
+ * @param {number} [perPage]
235
+ * @param {number} [page]
236
+ */
237
+ export async function githubSearchRepositories(q, perPage = 20, page = 1) {
238
+ const pp = Math.min(100, Math.max(1, perPage));
239
+ const p = Math.max(1, page);
240
+ const query = new URLSearchParams({
241
+ q,
242
+ per_page: String(pp),
243
+ page: String(p)
244
+ });
245
+ return githubRestJson("GET", `/search/repositories?${query.toString()}`);
246
+ }
247
+
248
+ /**
249
+ * List branches in a repository (one page).
250
+ * @param {string} owner
251
+ * @param {string} repo
252
+ * @param {number} [page] 1-based
253
+ * @param {number} [perPage] max 100
254
+ */
255
+ export async function githubListBranches(owner, repo, page = 1, perPage = 100) {
256
+ const pp = Math.min(100, Math.max(1, perPage));
257
+ const p = Math.max(1, page);
258
+ return githubRestJson(
259
+ "GET",
260
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches?page=${p}&per_page=${pp}`
261
+ );
262
+ }
263
+
264
+ /**
265
+ * @param {string} owner
266
+ * @param {string} repo
267
+ * @returns {Promise<unknown[]>}
268
+ */
269
+ export async function githubListAllBranches(owner, repo) {
270
+ /** @type {unknown[]} */
271
+ const all = [];
272
+ let page = 1;
273
+ for (;;) {
274
+ const chunk = await githubListBranches(owner, repo, page, 100);
275
+ if (!Array.isArray(chunk) || chunk.length === 0) break;
276
+ all.push(...chunk);
277
+ if (chunk.length < 100) break;
278
+ page += 1;
279
+ }
280
+ return all;
281
+ }
282
+
163
283
  export async function githubSearchIssues(q, perPage = 30, page = 1) {
164
284
  const pp = Math.min(100, Math.max(1, perPage));
165
285
  const p = Math.max(1, page);
@@ -189,6 +309,29 @@ export async function githubListOpenPulls(owner, repo, page = 1, perPage = 30) {
189
309
  );
190
310
  }
191
311
 
312
+ /**
313
+ * @param {string} owner
314
+ * @param {string} repo
315
+ * @returns {Promise<unknown[]>}
316
+ */
317
+ export async function githubListAllOpenPulls(owner, repo) {
318
+ /** @type {unknown[]} */
319
+ const all = [];
320
+ let page = 1;
321
+ for (;;) {
322
+ const chunk = await githubListOpenPulls(owner, repo, page, 100);
323
+ if (!Array.isArray(chunk) || chunk.length === 0) {
324
+ break;
325
+ }
326
+ all.push(...chunk);
327
+ if (chunk.length < 100) {
328
+ break;
329
+ }
330
+ page += 1;
331
+ }
332
+ return all;
333
+ }
334
+
192
335
  /**
193
336
  * @param {string} owner
194
337
  * @param {string} repo
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Config-related actions for the `nugit config` and `nugit env` commands.
3
+ * Extracted from the former nugit-start.js.
4
+ */
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import {
8
+ expandUserPath,
9
+ getConfigPath,
10
+ inferMonorepoRootFromCli,
11
+ readUserConfig,
12
+ writeUserConfig,
13
+ buildStartEnv
14
+ } from "./user-config.js";
15
+
16
+ /**
17
+ * @param {object} opts
18
+ * @param {string} [opts.installRoot]
19
+ * @param {string} [opts.envFile]
20
+ * @param {string} [opts.workingDirectory]
21
+ */
22
+ export function runConfigInit(opts) {
23
+ const root = path.resolve(expandUserPath(opts.installRoot || inferMonorepoRootFromCli()));
24
+ const defaultEnv = path.join(root, ".env");
25
+ const envFile = expandUserPath(opts.envFile || defaultEnv);
26
+ const cfg = { installRoot: root, envFile };
27
+ if (opts.workingDirectory) cfg.workingDirectory = expandUserPath(opts.workingDirectory);
28
+ writeUserConfig(cfg);
29
+ console.error(`Wrote ${getConfigPath()}`);
30
+ console.error(` installRoot: ${root}`);
31
+ console.error(` envFile: ${envFile}`);
32
+ if (!fs.existsSync(envFile)) {
33
+ console.error(" (env file does not exist yet — create it or run: nugit config set env-file <path>)");
34
+ }
35
+ if (cfg.workingDirectory) console.error(` workingDirectory: ${cfg.workingDirectory}`);
36
+ }
37
+
38
+ export function runConfigShow() {
39
+ console.log(JSON.stringify(readUserConfig(), null, 2));
40
+ }
41
+
42
+ /**
43
+ * @param {string} key
44
+ * @param {string} value
45
+ */
46
+ export function runConfigSet(key, value) {
47
+ const c = readUserConfig();
48
+ const k = key.toLowerCase().replace(/_/g, "-");
49
+ if (k === "install-root") {
50
+ c.installRoot = path.resolve(expandUserPath(value));
51
+ } else if (k === "env-file") {
52
+ c.envFile = expandUserPath(value);
53
+ } else if (k === "working-directory" || k === "cwd") {
54
+ c.workingDirectory = expandUserPath(value);
55
+ } else {
56
+ throw new Error(`Unknown key "${key}". Use: install-root | env-file | working-directory`);
57
+ }
58
+ writeUserConfig(c);
59
+ console.error(`Updated ${getConfigPath()}`);
60
+ }
61
+
62
+ /**
63
+ * @param {"bash" | "fish"} shell
64
+ */
65
+ export function runEnvExport(shell) {
66
+ const cfg = readUserConfig();
67
+ let env;
68
+ try {
69
+ env = buildStartEnv(cfg);
70
+ } catch (e) {
71
+ console.error(String(e?.message || e));
72
+ process.exit(1);
73
+ }
74
+ const entries = Object.entries(env);
75
+ if (shell === "fish") {
76
+ for (const [k, v] of entries) {
77
+ if (v !== undefined) console.log(`set -gx ${k} ${JSON.stringify(v)};`);
78
+ }
79
+ } else {
80
+ for (const [k, v] of entries) {
81
+ if (v !== undefined) console.log(`export ${k}=${JSON.stringify(v)}`);
82
+ }
83
+ }
84
+ }