@tenyokjs/devdevian-cli 0.7.1-alpha.1 → 0.8.0-alpha.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.
- package/README.md +10 -0
- package/VERSIONS.md +14 -1
- package/bin/devdevian.mjs +27 -2
- package/lib/conversation-links.mjs +13 -0
- package/lib/github-context.mjs +113 -0
- package/lib/tui-data.mjs +6 -2
- package/lib/tui.mjs +95 -34
- package/lib/workflow.mjs +1 -0
- package/lib/workspace.mjs +50 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,16 @@ In full-text reading (**Space** on a reply or remark), **/** opens local text se
|
|
|
69
69
|
|
|
70
70
|
Active fields explain their input: Space stays a space, Enter adds a line in multiline fields, and Tab leaves the field. Project feedback accepts text and URLs; dedicated website/repository/additional links are in **People & links**. Additional links use one `Label | https://example.com` per line. Context hints stay below the editor on larger terminals and use the status/hint lines on compact ones. **Ctrl+S** checks the complete form and opens a preview; drafts autosave locally and publication still requires explicit confirmation.
|
|
71
71
|
|
|
72
|
+
Multiline fields show the current logical line and total line count. While editing, the command bar gives its space to the form; the caret stays visible as you add lines or move through the text. Previous lines remain part of the same field, including after Enter. On short terminals only a window of a long message is visible; use ↑/↓ to revisit earlier lines or Ctrl+E for the external editor.
|
|
73
|
+
|
|
74
|
+
Conversation overviews and publication previews display Demo/Repository URL fields alongside any labeled links, with duplicate destinations shown once.
|
|
75
|
+
|
|
76
|
+
## GitHub review context
|
|
77
|
+
|
|
78
|
+
Code reviews have an optional **GitHub** step after Content. Change **GitHub context** from Skip to Include, optionally add a PR URL from the same repository, and inspect the complete publication preview. The repository, branch, base commit and selected path appear in both CLI and browser. Local code may differ from that base commit; metadata is author-provided and PR content/status is not synchronized.
|
|
79
|
+
|
|
80
|
+
The command equivalent is `devdevian review create --file src/retry.ts --title "Review retries" --topic web --body-file question.md --focus "Timeout handling" --github-context --pr https://github.com/owner/repo/pull/42`. The PR is optional. Context is opt-in, reads local Git only and needs a GitHub `origin` and existing HEAD. No new OAuth setup is required. See the [full guide](https://github.com/Tenyokj/devdevian/blob/main/docs/github-context.md).
|
|
81
|
+
|
|
72
82
|
## Meet Devi
|
|
73
83
|
|
|
74
84
|
Devi is the Devdevian mascot: a small ghost with a laptop and an orbit. The terminal uses colored ASCII art, so it needs no image protocol or image downloads. A compact drawing is used when space is limited; authentication codes, addresses and controls take priority over decoration.
|
package/VERSIONS.md
CHANGED
|
@@ -6,7 +6,20 @@ Every published npm artifact has a unique immutable version. We record each vers
|
|
|
6
6
|
|
|
7
7
|
## Current milestone
|
|
8
8
|
|
|
9
|
-
**0.
|
|
9
|
+
**0.8.0 begins GitHub integration** with opt-in local source context and manually linked pull requests, shared by the CLI and browser.
|
|
10
|
+
|
|
11
|
+
## 0.8.0-alpha.1 — GitHub source context
|
|
12
|
+
|
|
13
|
+
- Optional GitHub step for code reviews: explicitly include local origin, branch/detached HEAD, base commit, repository-relative path and source type; add a matching PR URL when useful. Preview and account/project-scoped drafts preserve the choice. Command clients use `--github-context` and optional `--pr`.
|
|
14
|
+
- Terminal overviews, script output and responsive browser cards share the same context. Base-commit links distinguish local working code/diffs from committed content. Metadata is author-provided; this stage does not synchronize PR content or verify ownership/status.
|
|
15
|
+
- Local read-only Git commands only, canonical GitHub origins without embedded credentials, bounded server validation and existing private/owner permission checks. Other post details survive edits; no schema migration or new OAuth permission is required.
|
|
16
|
+
- Regression coverage exercises real Git repositories, nested project roots, detached HEAD, staged patches, rejected origins/PRs, command/API/browser sharing, private reads, draft restoration and compact/mobile layouts.
|
|
17
|
+
|
|
18
|
+
## 0.7.2-alpha.1 — Visible links and multiline writing
|
|
19
|
+
|
|
20
|
+
- Conversation overviews now include Demo and Repository URL fields alongside labeled links. Duplicate URLs appear once, preserving a supplied label; publication previews show the same destinations.
|
|
21
|
+
- Multiline editing follows the caret and displays the logical line position/count. Enter retains the preceding visible line when space permits. While editing, the inactive command bar yields its rows to the form, including at 60×18.
|
|
22
|
+
- Real terminal/API regressions cover saved Demo URLs together with labeled links, newline visibility, cursor movement and editing in compact terminals. No API/schema or credential changes.
|
|
10
23
|
|
|
11
24
|
## 0.7.1-alpha.1 — Clearer forms
|
|
12
25
|
|
package/bin/devdevian.mjs
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
loadAppearance,
|
|
24
24
|
saveAppearance,
|
|
25
25
|
} from "../lib/appearance.mjs";
|
|
26
|
+
import { readGitHubContext, workspaceRoot } from "../lib/workspace.mjs";
|
|
27
|
+
import { githubContextLines } from "../lib/github-context.mjs";
|
|
28
|
+
import { conversationLinks } from "../lib/conversation-links.mjs";
|
|
26
29
|
import { renderHelp } from "../lib/help.mjs";
|
|
27
30
|
const version = packageInfo.version;
|
|
28
31
|
const options = {
|
|
@@ -36,6 +39,8 @@ const options = {
|
|
|
36
39
|
file: { type: "string" },
|
|
37
40
|
lines: { type: "string" },
|
|
38
41
|
focus: { type: "string" },
|
|
42
|
+
"github-context": { type: "boolean" },
|
|
43
|
+
pr: { type: "string" },
|
|
39
44
|
"start-line": { type: "string" },
|
|
40
45
|
"end-line": { type: "string" },
|
|
41
46
|
status: { type: "string" },
|
|
@@ -131,6 +136,8 @@ const help = `Devdevian CLI ${version} · alpha
|
|
|
131
136
|
--welcome MODE on | off (Devi greeting)
|
|
132
137
|
--no-live Disable background reads in the live workspace
|
|
133
138
|
--project PATH Root directory for the live file/diff picker (default: current directory)
|
|
139
|
+
--github-context Include local GitHub origin, branch and base commit in a review
|
|
140
|
+
--pr URL Optional matching GitHub PR URL (requires --github-context)
|
|
134
141
|
--community ID Publish to an approved private community
|
|
135
142
|
--editor Write text in $VISUAL / $EDITOR
|
|
136
143
|
--json Machine-readable output
|
|
@@ -199,6 +206,9 @@ function show(data, json, kind = "posts") {
|
|
|
199
206
|
} else if (data.title) {
|
|
200
207
|
out(`#${data.id} · ${data.kind}\n${data.title}\n\n${data.body}`);
|
|
201
208
|
if (data.code) out("\nCode:\n" + data.code);
|
|
209
|
+
for (const line of githubContextLines(data.github_context)) out(line);
|
|
210
|
+
for (const link of conversationLinks(data))
|
|
211
|
+
out(link.label + " · " + link.url);
|
|
202
212
|
} else out(JSON.stringify(data, null, 2));
|
|
203
213
|
}
|
|
204
214
|
async function confirm(payload, flags) {
|
|
@@ -364,9 +374,15 @@ async function execute(args, flags) {
|
|
|
364
374
|
}
|
|
365
375
|
if (command === "ask" || (command === "review" && args[1] === "create")) {
|
|
366
376
|
const review = command === "review";
|
|
367
|
-
let code = ""
|
|
377
|
+
let code = "",
|
|
378
|
+
file,
|
|
379
|
+
range;
|
|
380
|
+
if ((flags["github-context"] || flags.pr) && !review)
|
|
381
|
+
throw Error("GitHub context is available for review create.");
|
|
382
|
+
if (flags.pr && !flags["github-context"])
|
|
383
|
+
throw Error("--pr requires --github-context.");
|
|
368
384
|
if (review) {
|
|
369
|
-
|
|
385
|
+
file = flags.file || (await prompt("Code file"));
|
|
370
386
|
code = await boundedFile(file, 20000);
|
|
371
387
|
if (flags.lines) {
|
|
372
388
|
const m = flags.lines.match(/^(\d+):(\d+)$/);
|
|
@@ -376,6 +392,7 @@ async function execute(args, flags) {
|
|
|
376
392
|
end = positive(m[2]);
|
|
377
393
|
if (end < start || end > lines.length)
|
|
378
394
|
throw Error("The selected range is outside the file.");
|
|
395
|
+
range = { start, end };
|
|
379
396
|
code = lines.slice(start - 1, end).join("\n");
|
|
380
397
|
}
|
|
381
398
|
}
|
|
@@ -395,6 +412,14 @@ async function execute(args, flags) {
|
|
|
395
412
|
payload.code = code;
|
|
396
413
|
payload.review_focus =
|
|
397
414
|
flags.focus || (await prompt("What should reviewers focus on?"));
|
|
415
|
+
if (flags["github-context"])
|
|
416
|
+
payload.details = {
|
|
417
|
+
github_context: await readGitHubContext(
|
|
418
|
+
await workspaceRoot(flags.project || process.cwd()),
|
|
419
|
+
{ kind: "file", path: file, ...range },
|
|
420
|
+
{ pullRequest: flags.pr || "" },
|
|
421
|
+
),
|
|
422
|
+
};
|
|
398
423
|
}
|
|
399
424
|
await confirm(payload, flags);
|
|
400
425
|
const result = await api("/posts", { method: "POST", body: payload });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Keep legacy URL fields visible alongside labeled links, without repeating a URL.
|
|
2
|
+
export function conversationLinks(post = {}) {
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
return [
|
|
5
|
+
...(post.links || []),
|
|
6
|
+
...(post.demo_url ? [{ label: "Demo", url: post.demo_url }] : []),
|
|
7
|
+
...(post.repo_url ? [{ label: "Repository", url: post.repo_url }] : []),
|
|
8
|
+
].filter((link) => {
|
|
9
|
+
if (!link.url || seen.has(link.url)) return false;
|
|
10
|
+
seen.add(link.url);
|
|
11
|
+
return true;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Pure shared contract: used by the packaged CLI, API validation and browser.
|
|
2
|
+
const repositoryPattern =
|
|
3
|
+
/^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9_.-]{1,100})$/;
|
|
4
|
+
export function githubRepository(remote) {
|
|
5
|
+
if (typeof remote !== "string") return null;
|
|
6
|
+
const match = remote
|
|
7
|
+
.trim()
|
|
8
|
+
.match(
|
|
9
|
+
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/?#:\s]+)\/([^/?#:\s]+?)\/?$/,
|
|
10
|
+
);
|
|
11
|
+
if (!match) return null;
|
|
12
|
+
const url = `https://github.com/${match[1]}/${match[2].replace(/\.git$/, "")}`;
|
|
13
|
+
return repositoryPattern.test(url) &&
|
|
14
|
+
![".", ".."].includes(url.split("/").at(-1))
|
|
15
|
+
? url
|
|
16
|
+
: null;
|
|
17
|
+
}
|
|
18
|
+
export function normalizeGitHubContext(value) {
|
|
19
|
+
if (value === null) return null;
|
|
20
|
+
const fail = () => {
|
|
21
|
+
throw Error(
|
|
22
|
+
"Check the GitHub repository, commit, relative file path and matching PR URL.",
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
|
|
26
|
+
const {
|
|
27
|
+
repository,
|
|
28
|
+
commit,
|
|
29
|
+
branch = null,
|
|
30
|
+
path,
|
|
31
|
+
mode,
|
|
32
|
+
start_line = null,
|
|
33
|
+
end_line = null,
|
|
34
|
+
pull_request = null,
|
|
35
|
+
} = value;
|
|
36
|
+
if (
|
|
37
|
+
typeof repository !== "string" ||
|
|
38
|
+
!repositoryPattern.test(repository) ||
|
|
39
|
+
githubRepository(repository) !== repository ||
|
|
40
|
+
typeof commit !== "string" ||
|
|
41
|
+
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit) ||
|
|
42
|
+
(branch !== null &&
|
|
43
|
+
(typeof branch !== "string" ||
|
|
44
|
+
!branch ||
|
|
45
|
+
branch.length > 200 ||
|
|
46
|
+
/[\s\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u.test(branch))) ||
|
|
47
|
+
typeof path !== "string" ||
|
|
48
|
+
!path ||
|
|
49
|
+
path.length > 1000 ||
|
|
50
|
+
path.startsWith("/") ||
|
|
51
|
+
/[\\\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u.test(path) ||
|
|
52
|
+
path.split("/").some((p) => !p || p === "." || p === "..") ||
|
|
53
|
+
!["file", "staged", "unstaged"].includes(mode)
|
|
54
|
+
)
|
|
55
|
+
fail();
|
|
56
|
+
if (
|
|
57
|
+
(start_line !== null || end_line !== null) &&
|
|
58
|
+
(mode !== "file" ||
|
|
59
|
+
!Number.isSafeInteger(start_line) ||
|
|
60
|
+
!Number.isSafeInteger(end_line) ||
|
|
61
|
+
start_line < 1 ||
|
|
62
|
+
end_line < start_line ||
|
|
63
|
+
end_line > 10000000)
|
|
64
|
+
)
|
|
65
|
+
fail();
|
|
66
|
+
let pr = null;
|
|
67
|
+
if (pull_request !== null && pull_request !== "") {
|
|
68
|
+
if (typeof pull_request !== "string") fail();
|
|
69
|
+
const match = pull_request.match(
|
|
70
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/([1-9]\d{0,9})$/,
|
|
71
|
+
);
|
|
72
|
+
if (
|
|
73
|
+
!match ||
|
|
74
|
+
`https://github.com/${match[1]}/${match[2]}`.toLowerCase() !==
|
|
75
|
+
repository.toLowerCase()
|
|
76
|
+
)
|
|
77
|
+
fail();
|
|
78
|
+
pr = repository + "/pull/" + match[3];
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
repository,
|
|
82
|
+
branch,
|
|
83
|
+
commit,
|
|
84
|
+
path,
|
|
85
|
+
mode,
|
|
86
|
+
start_line,
|
|
87
|
+
end_line,
|
|
88
|
+
pull_request: pr,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function githubContextLines(value) {
|
|
92
|
+
if (!value) return [];
|
|
93
|
+
let c;
|
|
94
|
+
try {
|
|
95
|
+
c = normalizeGitHubContext(value);
|
|
96
|
+
} catch {
|
|
97
|
+
return [
|
|
98
|
+
"GitHub context could not be displayed. Refresh or check it in the browser.",
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
return [
|
|
102
|
+
"GITHUB CONTEXT · provided by the author",
|
|
103
|
+
c.repository,
|
|
104
|
+
`Branch: ${c.branch || "detached HEAD"} · ${c.mode === "file" ? "Local file" : c.mode === "staged" ? "Staged patch" : "Unstaged patch"}`,
|
|
105
|
+
"File: " +
|
|
106
|
+
c.path +
|
|
107
|
+
(c.start_line ? ` · lines ${c.start_line}–${c.end_line}` : ""),
|
|
108
|
+
"Base commit: " + c.commit,
|
|
109
|
+
c.repository + "/commit/" + c.commit,
|
|
110
|
+
...(c.pull_request ? ["Pull request: " + c.pull_request] : []),
|
|
111
|
+
"Local code may differ from the base commit. GitHub content is not synchronized.",
|
|
112
|
+
];
|
|
113
|
+
}
|
package/lib/tui-data.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { loadCatalog, isCatalog } from "./catalog.mjs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { githubContextLines } from "./github-context.mjs";
|
|
4
|
+
import { conversationLinks } from "./conversation-links.mjs";
|
|
3
5
|
export function assertSnippet(code, hash) {
|
|
4
6
|
if (
|
|
5
7
|
createHash("sha256")
|
|
@@ -288,11 +290,13 @@ export function contentLines(route, data) {
|
|
|
288
290
|
.split("\n")
|
|
289
291
|
.map((line, index) => String(index + 1).padStart(4) + " " + line),
|
|
290
292
|
);
|
|
291
|
-
|
|
293
|
+
lines.push(...githubContextLines(p.github_context));
|
|
294
|
+
const links = conversationLinks(p);
|
|
295
|
+
if (links.length)
|
|
292
296
|
lines.push(
|
|
293
297
|
"",
|
|
294
298
|
"LINKS",
|
|
295
|
-
...
|
|
299
|
+
...links.map((link) => link.label + " · " + link.url),
|
|
296
300
|
);
|
|
297
301
|
if (p.archived_at || p.locked_at)
|
|
298
302
|
lines.push("", "Replies are paused for this conversation.");
|
package/lib/tui.mjs
CHANGED
|
@@ -2,7 +2,11 @@ import React, { useState, useEffect, useMemo, useRef } from "react";
|
|
|
2
2
|
import { render, Box, Text, useApp, useInput, useWindowSize } from "ink";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir, hostname } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
workspaceRoot,
|
|
7
|
+
reviewSource,
|
|
8
|
+
readGitHubContext,
|
|
9
|
+
} from "./workspace.mjs";
|
|
6
10
|
import { DeviPanel } from "./devi.mjs";
|
|
7
11
|
import { WorkspacePicker } from "./picker.mjs";
|
|
8
12
|
import { DraftStore, draftIdentity } from "./drafts.mjs";
|
|
@@ -55,6 +59,8 @@ import {
|
|
|
55
59
|
import { contextActions } from "./context-actions.mjs";
|
|
56
60
|
import { editExternal } from "./external-editor.mjs";
|
|
57
61
|
import { markdownRows } from "./rich-text.mjs";
|
|
62
|
+
import { githubContextLines } from "./github-context.mjs";
|
|
63
|
+
import { conversationLinks } from "./conversation-links.mjs";
|
|
58
64
|
import { problemMessage } from "./problems.mjs";
|
|
59
65
|
import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
|
|
60
66
|
import {
|
|
@@ -176,6 +182,25 @@ function fieldsFor(form) {
|
|
|
176
182
|
max: 15000,
|
|
177
183
|
multiline: true,
|
|
178
184
|
},
|
|
185
|
+
...(form.values.kind === "review"
|
|
186
|
+
? [
|
|
187
|
+
{
|
|
188
|
+
key: "github",
|
|
189
|
+
label: "GitHub context",
|
|
190
|
+
options: [
|
|
191
|
+
["off", "Skip"],
|
|
192
|
+
["on", "Include origin, branch and base commit"],
|
|
193
|
+
],
|
|
194
|
+
hint: "Optional. Local Git metadata is included only in your publication preview.",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
key: "pull_request",
|
|
198
|
+
label: "Pull request URL (optional)",
|
|
199
|
+
max: 1000,
|
|
200
|
+
hint: "https://github.com/owner/repo/pull/123 · same repository as origin; enable GitHub context first.",
|
|
201
|
+
},
|
|
202
|
+
]
|
|
203
|
+
: []),
|
|
179
204
|
];
|
|
180
205
|
if (form.kind === "open")
|
|
181
206
|
return [
|
|
@@ -306,9 +331,14 @@ function FormView({ form, active, cursor, width, height, navFocus }) {
|
|
|
306
331
|
navRows = step && height >= 3 ? 1 : 0;
|
|
307
332
|
const editorHeight = Math.max(1, height - helpRows - navRows);
|
|
308
333
|
let selectedLine = rows.length;
|
|
334
|
+
let caretRow = null;
|
|
309
335
|
fields.forEach((field, index) => {
|
|
310
336
|
if (step && !step.indexes.includes(index)) return;
|
|
311
337
|
if (index === active) selectedLine = rows.length;
|
|
338
|
+
const value = form.values[field.key] || "";
|
|
339
|
+
const lineNumber = field.multiline
|
|
340
|
+
? graphemes(value).slice(0, cursor).join("").split("\n").length
|
|
341
|
+
: 0;
|
|
312
342
|
rows.push({
|
|
313
343
|
text: (index === active && !navFocus ? "› " : " ") + field.label,
|
|
314
344
|
color: index === active && !navFocus ? colors.purple : colors.muted,
|
|
@@ -324,7 +354,6 @@ function FormView({ form, active, cursor, width, height, navFocus }) {
|
|
|
324
354
|
color: index === active && !navFocus ? colors.blue : colors.text,
|
|
325
355
|
});
|
|
326
356
|
} else {
|
|
327
|
-
const value = form.values[field.key] || "";
|
|
328
357
|
const text =
|
|
329
358
|
index === active && !navFocus
|
|
330
359
|
? cursorLines(value, cursor, width - 3).lines
|
|
@@ -339,12 +368,18 @@ function FormView({ form, active, cursor, width, height, navFocus }) {
|
|
|
339
368
|
const visible = field.multiline
|
|
340
369
|
? Math.min(
|
|
341
370
|
index === active
|
|
342
|
-
? Math.max(1, Math.min(12, editorHeight -
|
|
371
|
+
? Math.max(1, Math.min(12, editorHeight - 1))
|
|
343
372
|
: 3,
|
|
344
373
|
Math.max(2, text.length),
|
|
345
374
|
)
|
|
346
375
|
: 1,
|
|
347
376
|
start = index === active ? Math.max(0, caretLine - visible + 1) : 0;
|
|
377
|
+
if (index === active && !navFocus && field.multiline) {
|
|
378
|
+
rows.at(-1).text += ` · line ${lineNumber}/${value.split("\n").length}`;
|
|
379
|
+
// Follow the caret, rather than anchoring the viewport to the field label.
|
|
380
|
+
// A newline keeps its preceding row visible whenever the terminal has room.
|
|
381
|
+
caretRow = rows.length + caretLine - start;
|
|
382
|
+
}
|
|
348
383
|
rows.push(
|
|
349
384
|
...text.slice(start, start + visible).map((line) => ({
|
|
350
385
|
text: " " + line,
|
|
@@ -369,13 +404,15 @@ function FormView({ form, active, cursor, width, height, navFocus }) {
|
|
|
369
404
|
color: colors.blue,
|
|
370
405
|
bold: true,
|
|
371
406
|
});
|
|
372
|
-
|
|
407
|
+
let offset = Math.max(
|
|
373
408
|
0,
|
|
374
409
|
Math.min(
|
|
375
410
|
selectedLine - (editorHeight >= 4 ? 1 : 0),
|
|
376
411
|
rows.length - editorHeight,
|
|
377
412
|
),
|
|
378
413
|
);
|
|
414
|
+
if (caretRow !== null && caretRow >= offset + editorHeight)
|
|
415
|
+
offset = caretRow - editorHeight + 1;
|
|
379
416
|
return h(
|
|
380
417
|
Box,
|
|
381
418
|
{ flexDirection: "column", height, overflow: "hidden" },
|
|
@@ -554,12 +591,16 @@ function documentRows(route, data, width, colors) {
|
|
|
554
591
|
...markdownRows(fence + "\n" + post.code + "\n" + fence, width, colors),
|
|
555
592
|
);
|
|
556
593
|
}
|
|
557
|
-
|
|
594
|
+
rows.push(
|
|
595
|
+
...githubContextLines(post.github_context).flatMap((line) =>
|
|
596
|
+
wrap(line, width),
|
|
597
|
+
),
|
|
598
|
+
);
|
|
599
|
+
const links = conversationLinks(post);
|
|
600
|
+
if (links.length)
|
|
558
601
|
rows.push(
|
|
559
602
|
{ text: "LINKS", bold: true },
|
|
560
|
-
...
|
|
561
|
-
wrap(link.label + " · " + link.url, width),
|
|
562
|
-
),
|
|
603
|
+
...links.flatMap((link) => wrap(link.label + " · " + link.url, width)),
|
|
563
604
|
);
|
|
564
605
|
if (post.archived_at || post.locked_at)
|
|
565
606
|
rows.push({
|
|
@@ -708,7 +749,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
708
749
|
height = Math.max(1, rows - 1),
|
|
709
750
|
compact = width < 90 || effectiveAppearance.sidebar === "hidden",
|
|
710
751
|
sidebar = compact ? 0 : 25,
|
|
711
|
-
|
|
752
|
+
editingForm = !!form && !preview && !picker,
|
|
753
|
+
bodyHeight = Math.max(1, height - (editingForm ? 6 : 9)),
|
|
712
754
|
contentWidth = Math.max(1, width - sidebar - 6),
|
|
713
755
|
contentHeight = Math.max(1, bodyHeight - 6);
|
|
714
756
|
const key = JSON.stringify(route),
|
|
@@ -1499,6 +1541,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1499
1541
|
lines: "",
|
|
1500
1542
|
review_focus: "",
|
|
1501
1543
|
demo_url: "",
|
|
1544
|
+
github: "off",
|
|
1545
|
+
pull_request: "",
|
|
1502
1546
|
},
|
|
1503
1547
|
});
|
|
1504
1548
|
});
|
|
@@ -1733,6 +1777,16 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
1733
1777
|
"Shorten the context to leave room for the Git diff source label.",
|
|
1734
1778
|
);
|
|
1735
1779
|
}
|
|
1780
|
+
if (form.values.github === "on")
|
|
1781
|
+
payload.details = {
|
|
1782
|
+
github_context: await readGitHubContext(root, source, {
|
|
1783
|
+
pullRequest: form.values.pull_request || "",
|
|
1784
|
+
}),
|
|
1785
|
+
};
|
|
1786
|
+
else if (form.values.pull_request?.trim())
|
|
1787
|
+
throw Error(
|
|
1788
|
+
"Enable GitHub context to attach a matching pull request URL, or clear the URL.",
|
|
1789
|
+
);
|
|
1736
1790
|
payload.review_focus = form.values.review_focus;
|
|
1737
1791
|
}
|
|
1738
1792
|
if (payload.kind === "showcase")
|
|
@@ -3150,6 +3204,11 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3150
3204
|
? [payload.title || payload.name, ""]
|
|
3151
3205
|
: []),
|
|
3152
3206
|
payload.body || payload.reason || payload.description || "",
|
|
3207
|
+
...githubContextLines(payload.details?.github_context),
|
|
3208
|
+
...conversationLinks(payload).flatMap((link) => [
|
|
3209
|
+
"",
|
|
3210
|
+
link.label + " · " + link.url,
|
|
3211
|
+
]),
|
|
3153
3212
|
...(preview.catalog
|
|
3154
3213
|
? [
|
|
3155
3214
|
"",
|
|
@@ -3614,31 +3673,33 @@ function App({ initialConfig, flags, root, initialAppearance }) {
|
|
|
3614
3673
|
width,
|
|
3615
3674
|
color: error ? colors.red : notice ? colors.green : colors.muted,
|
|
3616
3675
|
}),
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3676
|
+
editingForm
|
|
3677
|
+
? null
|
|
3678
|
+
: h(
|
|
3679
|
+
Box,
|
|
3680
|
+
{
|
|
3681
|
+
height: 3,
|
|
3682
|
+
borderStyle: colors.frame,
|
|
3683
|
+
borderColor: focus === "prompt" ? colors.blue : colors.line,
|
|
3684
|
+
paddingX: 1,
|
|
3685
|
+
overflow: "hidden",
|
|
3686
|
+
},
|
|
3687
|
+
h(Text, { color: colors.purple, bold: true }, "› "),
|
|
3688
|
+
focus === "prompt"
|
|
3689
|
+
? h(InputText, {
|
|
3690
|
+
value: command,
|
|
3691
|
+
cursor: commandCursor,
|
|
3692
|
+
width: Math.max(1, width - 6),
|
|
3693
|
+
})
|
|
3694
|
+
: h(
|
|
3695
|
+
Text,
|
|
3696
|
+
{ color: colors.muted },
|
|
3697
|
+
clip(
|
|
3698
|
+
"Search or /command · Press / to type · X actions · , appearance",
|
|
3699
|
+
width - 6,
|
|
3700
|
+
),
|
|
3701
|
+
),
|
|
3702
|
+
),
|
|
3642
3703
|
h(TextLine, { text: hint, width, color: colors.muted }),
|
|
3643
3704
|
),
|
|
3644
3705
|
);
|
package/lib/workflow.mjs
CHANGED
|
@@ -6,6 +6,7 @@ export function formSteps(form, fields) {
|
|
|
6
6
|
groups = [
|
|
7
7
|
["About", ["kind", "title", "topic", "community_id"]],
|
|
8
8
|
["Content", ["file", "lines", "review_focus", "demo_url", "body"]],
|
|
9
|
+
["GitHub", ["github", "pull_request"]],
|
|
9
10
|
];
|
|
10
11
|
else if (form.kind === "catalog")
|
|
11
12
|
groups =
|
package/lib/workspace.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir, realpath, lstat, open } from "node:fs/promises";
|
|
|
3
3
|
import { resolve, relative, join, isAbsolute, sep } from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
import { githubRepository, normalizeGitHubContext } from "./github-context.mjs";
|
|
6
7
|
const skipped = new Set([
|
|
7
8
|
".git",
|
|
8
9
|
"node_modules",
|
|
@@ -21,7 +22,9 @@ export const sensitivePath = (path) =>
|
|
|
21
22
|
.split(/[\\/]/)
|
|
22
23
|
.some(
|
|
23
24
|
(name) =>
|
|
24
|
-
[".git", ".ssh", ".aws", ".gnupg", ".envrc"].includes(
|
|
25
|
+
[".git", ".ssh", ".aws", ".gnupg", ".envrc"].includes(
|
|
26
|
+
name.toLowerCase(),
|
|
27
|
+
) ||
|
|
25
28
|
/^\.env(?:\.|$)/i.test(name) ||
|
|
26
29
|
/\.(?:pem|key|p12|pfx|keystore)$/i.test(name) ||
|
|
27
30
|
/^(?:id_(?:rsa|ed25519|ecdsa|dsa)(?:\.pub)?|credentials(?:\.json)?|\.npmrc|\.netrc|\.git-credentials)$/i.test(
|
|
@@ -384,3 +387,49 @@ export async function reviewSource(root, source, { signal } = {}) {
|
|
|
384
387
|
kind: "file",
|
|
385
388
|
};
|
|
386
389
|
}
|
|
390
|
+
|
|
391
|
+
// Explicit opt-in only: local Git metadata, no network calls or raw remote URLs in output.
|
|
392
|
+
export async function readGitHubContext(
|
|
393
|
+
root,
|
|
394
|
+
source,
|
|
395
|
+
{ pullRequest = "", signal } = {},
|
|
396
|
+
) {
|
|
397
|
+
const file = await workspacePath(root, source.path);
|
|
398
|
+
const [remote, commit, branch, prefix] = await Promise.all([
|
|
399
|
+
git(root, ["config", "--get", "remote.origin.url"], {
|
|
400
|
+
signal,
|
|
401
|
+
max: 4096,
|
|
402
|
+
}).catch((e) => {
|
|
403
|
+
if (e.gitCode === 1)
|
|
404
|
+
throw Error(
|
|
405
|
+
"No origin remote. Skip GitHub context or configure a GitHub origin.",
|
|
406
|
+
);
|
|
407
|
+
throw e;
|
|
408
|
+
}),
|
|
409
|
+
git(root, ["rev-parse", "--verify", "HEAD"], { signal, max: 4096 }),
|
|
410
|
+
git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
411
|
+
signal,
|
|
412
|
+
max: 4096,
|
|
413
|
+
}).catch((e) => {
|
|
414
|
+
if (e.gitCode === 1) return "";
|
|
415
|
+
throw e;
|
|
416
|
+
}),
|
|
417
|
+
git(root, ["rev-parse", "--show-prefix"], { signal, max: 4096 }),
|
|
418
|
+
]);
|
|
419
|
+
const repository = githubRepository(remote.trim());
|
|
420
|
+
if (!repository)
|
|
421
|
+
throw Error(
|
|
422
|
+
"Origin must be a standard GitHub HTTPS or SSH URL without credentials. You can skip GitHub context.",
|
|
423
|
+
);
|
|
424
|
+
return normalizeGitHubContext({
|
|
425
|
+
repository,
|
|
426
|
+
commit: commit.trim(),
|
|
427
|
+
branch: branch.trim() || null,
|
|
428
|
+
path: prefix.replace(/\n$/, "") + relative(root, file).split(sep).join("/"),
|
|
429
|
+
mode:
|
|
430
|
+
source.kind === "diff" ? (source.staged ? "staged" : "unstaged") : "file",
|
|
431
|
+
start_line: source.start || null,
|
|
432
|
+
end_line: source.end || null,
|
|
433
|
+
pull_request: pullRequest.trim() || null,
|
|
434
|
+
});
|
|
435
|
+
}
|