@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 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.7.0 focuses on everyday terminal use**: discoverable quick actions, staged forms and resumable reading.
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
- const file = flags.file || (await prompt("Code file"));
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
- if (p.links?.length)
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
- ...p.links.map((link) => link.label + " · " + link.url),
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 { workspaceRoot, reviewSource } from "./workspace.mjs";
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 - 2))
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
- const offset = Math.max(
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
- if (post.links?.length)
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
- ...post.links.flatMap((link) =>
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
- bodyHeight = Math.max(1, height - 9),
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
- h(
3618
- Box,
3619
- {
3620
- height: 3,
3621
- borderStyle: colors.frame,
3622
- borderColor: focus === "prompt" ? colors.blue : colors.line,
3623
- paddingX: 1,
3624
- overflow: "hidden",
3625
- },
3626
- h(Text, { color: colors.purple, bold: true }, "› "),
3627
- focus === "prompt"
3628
- ? h(InputText, {
3629
- value: command,
3630
- cursor: commandCursor,
3631
- width: Math.max(1, width - 6),
3632
- })
3633
- : h(
3634
- Text,
3635
- { color: colors.muted },
3636
- clip(
3637
- "Search or /command · Press / to type · X actions · , appearance",
3638
- width - 6,
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(name.toLowerCase()) ||
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenyokjs/devdevian-cli",
3
- "version": "0.7.1-alpha.1",
3
+ "version": "0.8.0-alpha.1",
4
4
  "description": "Devdevian in your terminal: discussions, questions and human code reviews.",
5
5
  "type": "module",
6
6
  "bin": {