@tenyokjs/devdevian-cli 0.7.2-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
@@ -73,6 +73,12 @@ Multiline fields show the current logical line and total line count. While editi
73
73
 
74
74
  Conversation overviews and publication previews display Demo/Repository URL fields alongside any labeled links, with duplicate destinations shown once.
75
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
+
76
82
  ## Meet Devi
77
83
 
78
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,14 @@ 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.
10
17
 
11
18
  ## 0.7.2-alpha.1 — Visible links and multiline writing
12
19
 
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,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,6 @@
1
1
  import { loadCatalog, isCatalog } from "./catalog.mjs";
2
2
  import { createHash } from "node:crypto";
3
+ import { githubContextLines } from "./github-context.mjs";
3
4
  import { conversationLinks } from "./conversation-links.mjs";
4
5
  export function assertSnippet(code, hash) {
5
6
  if (
@@ -289,6 +290,7 @@ export function contentLines(route, data) {
289
290
  .split("\n")
290
291
  .map((line, index) => String(index + 1).padStart(4) + " " + line),
291
292
  );
293
+ lines.push(...githubContextLines(p.github_context));
292
294
  const links = conversationLinks(p);
293
295
  if (links.length)
294
296
  lines.push(
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,7 @@ 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";
58
63
  import { conversationLinks } from "./conversation-links.mjs";
59
64
  import { problemMessage } from "./problems.mjs";
60
65
  import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
@@ -177,6 +182,25 @@ function fieldsFor(form) {
177
182
  max: 15000,
178
183
  multiline: true,
179
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
+ : []),
180
204
  ];
181
205
  if (form.kind === "open")
182
206
  return [
@@ -567,6 +591,11 @@ function documentRows(route, data, width, colors) {
567
591
  ...markdownRows(fence + "\n" + post.code + "\n" + fence, width, colors),
568
592
  );
569
593
  }
594
+ rows.push(
595
+ ...githubContextLines(post.github_context).flatMap((line) =>
596
+ wrap(line, width),
597
+ ),
598
+ );
570
599
  const links = conversationLinks(post);
571
600
  if (links.length)
572
601
  rows.push(
@@ -1512,6 +1541,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1512
1541
  lines: "",
1513
1542
  review_focus: "",
1514
1543
  demo_url: "",
1544
+ github: "off",
1545
+ pull_request: "",
1515
1546
  },
1516
1547
  });
1517
1548
  });
@@ -1746,6 +1777,16 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1746
1777
  "Shorten the context to leave room for the Git diff source label.",
1747
1778
  );
1748
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
+ );
1749
1790
  payload.review_focus = form.values.review_focus;
1750
1791
  }
1751
1792
  if (payload.kind === "showcase")
@@ -3163,6 +3204,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
3163
3204
  ? [payload.title || payload.name, ""]
3164
3205
  : []),
3165
3206
  payload.body || payload.reason || payload.description || "",
3207
+ ...githubContextLines(payload.details?.github_context),
3166
3208
  ...conversationLinks(payload).flatMap((link) => [
3167
3209
  "",
3168
3210
  link.label + " · " + link.url,
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.2-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": {