getownit 0.1.0 → 0.1.2
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 +3 -3
- package/dist/api.js +2 -1
- package/dist/commands/hook.js +59 -59
- package/dist/commands/init.js +28 -19
- package/dist/commands/interview.js +105 -36
- package/dist/commands/key.js +43 -32
- package/dist/commands/login.js +51 -37
- package/dist/commands/settings.js +54 -0
- package/dist/config.js +0 -36
- package/dist/debrief.js +96 -66
- package/dist/index.js +69 -48
- package/dist/providers.js +36 -0
- package/dist/ui.js +27 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@ ownit key set # pick Nebius or NVIDIA, then paste your key; stored only in ~
|
|
|
11
11
|
ownit init # in a repo: installs the pre-push hook
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
On each push: `Debrief now? [Y/n
|
|
15
|
-
finished any time with `ownit interview
|
|
14
|
+
On each push: `Debrief now? [Y/n]`. Ownit never blocks a push. Pending debriefs can be
|
|
15
|
+
finished any time with `ownit interview`, which lets you pick the push.
|
|
16
16
|
|
|
17
|
-
Your code
|
|
17
|
+
Your code is never stored, your answers only if the repo's admins choose to show them on its page, and your key is never stored or logged by the server.
|
|
18
18
|
|
|
19
19
|
Full documentation: [github.com/Yoosseph/ownit](https://github.com/Yoosseph/ownit).
|
package/dist/api.js
CHANGED
package/dist/commands/hook.js
CHANGED
|
@@ -6,7 +6,7 @@ import { RESUME_HINT, runDebrief } from '../debrief.js';
|
|
|
6
6
|
import { buildDiff } from '../diff.js';
|
|
7
7
|
import { githubRepo } from '../git.js';
|
|
8
8
|
import { computePush, parseRefs } from '../pushRange.js';
|
|
9
|
-
import { createPrompter, paint, sentence } from '../ui.js';
|
|
9
|
+
import { createPrompter, errorMessage, paint, plural, sentence } from '../ui.js';
|
|
10
10
|
const PROMPT_TIMEOUT_MS = 120_000;
|
|
11
11
|
const QUICK_TIMEOUT_MS = 8_000;
|
|
12
12
|
// Coding agents and CI run git without a person at the keyboard.
|
|
@@ -38,19 +38,58 @@ async function readStdin() {
|
|
|
38
38
|
text += chunk;
|
|
39
39
|
return text;
|
|
40
40
|
}
|
|
41
|
+
/** Yes (or Enter) debriefs now; anything else, including the old "s", leaves it for later. */
|
|
41
42
|
export function interpretChoice(answer) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const value = answer.trim().toLowerCase();
|
|
45
|
-
if (value === '' || value === 'y' || value === 'yes')
|
|
46
|
-
return 'debrief';
|
|
47
|
-
if (value === 's' || value === 'skip')
|
|
48
|
-
return 'skip';
|
|
49
|
-
return 'later';
|
|
43
|
+
const value = answer?.trim().toLowerCase();
|
|
44
|
+
return value === '' || value === 'y' || value === 'yes' ? 'debrief' : 'later';
|
|
50
45
|
}
|
|
51
|
-
async function
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
async function recordPending(api, repo, push, out) {
|
|
47
|
+
await api.request('POST', '/api/pushes', { repo, base_sha: push.base, head_sha: push.head, commits: push.commits.map((c) => ({ sha: c.sha })), status: 'pending' }, { timeoutMs: QUICK_TIMEOUT_MS });
|
|
48
|
+
out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What the server already recorded for these commits, so a verified push is not
|
|
53
|
+
* asked again. Only an unreachable server is an error; anything else means unknown.
|
|
54
|
+
*/
|
|
55
|
+
async function knownStatus(api, repo, push) {
|
|
56
|
+
const known = await api
|
|
57
|
+
.request('POST', '/api/pushes/lookup', { repo, head_sha: push.head, shas: push.commits.map((x) => x.sha) }, { timeoutMs: QUICK_TIMEOUT_MS })
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
if (error instanceof ApiError && error.code === 'network')
|
|
60
|
+
throw error;
|
|
61
|
+
return { status: null };
|
|
62
|
+
});
|
|
63
|
+
return known.status;
|
|
64
|
+
}
|
|
65
|
+
/** Asks whether to debrief now, then debriefs or leaves the push pending for `ownit interview`. */
|
|
66
|
+
async function askAndDebrief(api, terminal, repo, push, out) {
|
|
67
|
+
const c = paint(out);
|
|
68
|
+
const prompter = createPrompter(terminal, out);
|
|
69
|
+
try {
|
|
70
|
+
const n = push.commits.length;
|
|
71
|
+
const answer = await prompter.ask(`${c.bold('Ownit')} ${c.dim(`(${n} ${plural(n, 'commit')} to ${repo})`)} Debrief now? [Y/n] `, PROMPT_TIMEOUT_MS);
|
|
72
|
+
if (interpretChoice(answer) === 'later')
|
|
73
|
+
return await recordPending(api, repo, push, out);
|
|
74
|
+
await runDebrief(api, prompter, {
|
|
75
|
+
repo,
|
|
76
|
+
baseSha: push.base,
|
|
77
|
+
headSha: push.head,
|
|
78
|
+
commits: push.commits,
|
|
79
|
+
diff: buildDiff(push.ranges),
|
|
80
|
+
});
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
prompter.close();
|
|
85
|
+
if (terminal !== process.stdin)
|
|
86
|
+
terminal.destroy();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function continueReason(error) {
|
|
90
|
+
return error instanceof ApiError && error.code === 'network'
|
|
91
|
+
? `${sentence(error.message)} The push continues and will show as pending.`
|
|
92
|
+
: `${sentence(errorMessage(error))} The push continues.`;
|
|
54
93
|
}
|
|
55
94
|
/** `ownit hook pre-push <remote> <url>`. Always exits 0: the debrief never blocks a push. */
|
|
56
95
|
export async function hookCommand(argv, env = process.env) {
|
|
@@ -73,58 +112,19 @@ export async function hookCommand(argv, env = process.env) {
|
|
|
73
112
|
return 0;
|
|
74
113
|
}
|
|
75
114
|
const api = createApi(config, env);
|
|
76
|
-
const known = await api
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
throw error;
|
|
81
|
-
return { status: null };
|
|
82
|
-
});
|
|
83
|
-
if (known.status === 'verified' || known.status === 'skipped') {
|
|
84
|
-
out.write(c.dim(`Ownit: this push is already ${known.status}.\n`));
|
|
115
|
+
const known = await knownStatus(api, repo, push);
|
|
116
|
+
// Skipped pushes are asked again: they stay unverified until debriefed.
|
|
117
|
+
if (known === 'verified') {
|
|
118
|
+
out.write(c.dim(`Ownit: this push is already ${known}.\n`));
|
|
85
119
|
return 0;
|
|
86
120
|
}
|
|
87
121
|
const terminal = openTerminal(env);
|
|
88
|
-
if (!terminal)
|
|
89
|
-
await
|
|
90
|
-
|
|
91
|
-
return 0;
|
|
92
|
-
}
|
|
93
|
-
const prompter = createPrompter(terminal, out);
|
|
94
|
-
try {
|
|
95
|
-
const n = push.commits.length;
|
|
96
|
-
const answer = await prompter.ask(`${c.bold('Ownit')} ${c.dim(`(${n} ${n === 1 ? 'commit' : 'commits'} to ${repo})`)} Debrief now? [Y/n/s=skip] `, PROMPT_TIMEOUT_MS);
|
|
97
|
-
const choice = interpretChoice(answer);
|
|
98
|
-
if (choice === 'skip') {
|
|
99
|
-
await record(api, repo, push, 'skipped');
|
|
100
|
-
out.write(c.dim('Ownit: skipped. Push continues.\n'));
|
|
101
|
-
return 0;
|
|
102
|
-
}
|
|
103
|
-
if (choice === 'later') {
|
|
104
|
-
await record(api, repo, push, 'pending');
|
|
105
|
-
out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
|
|
106
|
-
return 0;
|
|
107
|
-
}
|
|
108
|
-
await runDebrief(api, prompter, {
|
|
109
|
-
repo,
|
|
110
|
-
baseSha: push.base,
|
|
111
|
-
headSha: push.head,
|
|
112
|
-
commits: push.commits,
|
|
113
|
-
diff: buildDiff(push.ranges),
|
|
114
|
-
});
|
|
115
|
-
return 0;
|
|
116
|
-
}
|
|
117
|
-
finally {
|
|
118
|
-
prompter.close();
|
|
119
|
-
if (terminal !== process.stdin)
|
|
120
|
-
terminal.destroy();
|
|
121
|
-
}
|
|
122
|
+
if (!terminal)
|
|
123
|
+
return await recordPending(api, repo, push, out);
|
|
124
|
+
return await askAndDebrief(api, terminal, repo, push, out);
|
|
122
125
|
}
|
|
123
126
|
catch (error) {
|
|
124
|
-
|
|
125
|
-
? `${sentence(error.message)} The push continues and will show as pending.`
|
|
126
|
-
: `${sentence(error instanceof Error ? error.message : String(error))} The push continues.`;
|
|
127
|
-
out.write(`${c.yellow('Ownit:')} ${reason} ${RESUME_HINT}\n`);
|
|
127
|
+
out.write(`${c.yellow('Ownit:')} ${continueReason(error)} ${RESUME_HINT}\n`);
|
|
128
128
|
return 0;
|
|
129
129
|
}
|
|
130
130
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -2,31 +2,26 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { createApi } from '../api.js';
|
|
3
3
|
import { git, githubRepo } from '../git.js';
|
|
4
4
|
import { hookLocation, installHook, MANAGED_SNIPPET, uninstallHook } from '../hooks.js';
|
|
5
|
-
import { paint, sentence } from '../ui.js';
|
|
5
|
+
import { errorMessage, paint, sentence } from '../ui.js';
|
|
6
6
|
import { badgeMarkdown } from './badge.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const out = process.stderr;
|
|
7
|
+
/** Installs the pre-push hook where git looks for it, or explains how to add it to a hook manager. */
|
|
8
|
+
function installHookHere(out) {
|
|
10
9
|
const c = paint(out);
|
|
11
|
-
if (values.repo)
|
|
12
|
-
git(['config', 'ownit.repo', values.repo]);
|
|
13
|
-
const repo = githubRepo();
|
|
14
|
-
if (!repo) {
|
|
15
|
-
out.write('Ownit: no GitHub remote found. Add one, or run "ownit init --repo owner/name".\n');
|
|
16
|
-
return 1;
|
|
17
|
-
}
|
|
18
10
|
const location = hookLocation();
|
|
19
11
|
if (location.kind === 'manual') {
|
|
20
12
|
out.write(`Ownit: ${location.reason}, so add these lines to the end of .husky/pre-push (create the file if needed):\n\n`);
|
|
21
13
|
out.write(`${MANAGED_SNIPPET}\n\n`);
|
|
14
|
+
return;
|
|
22
15
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
const result = installHook(location.dir);
|
|
17
|
+
out.write(`${c.green('✓')} ${result.updated ? 'Updated' : 'Installed'} the pre-push hook in ${location.dir}`);
|
|
18
|
+
out.write(location.via === 'core.hooksPath' ? ' (from core.hooksPath).\n' : '.\n');
|
|
19
|
+
if (result.chained)
|
|
20
|
+
out.write(` Your existing hook is now pre-push.local and still runs first.\n`);
|
|
21
|
+
}
|
|
22
|
+
/** Registers the repo with the Ownit server and shows what to do next. */
|
|
23
|
+
async function registerRepo(repo, out) {
|
|
24
|
+
const c = paint(out);
|
|
30
25
|
try {
|
|
31
26
|
const api = createApi();
|
|
32
27
|
const registered = await api.request('POST', '/api/repos', { repo }, { timeoutMs: 15_000 });
|
|
@@ -42,12 +37,26 @@ export async function initCommand(argv) {
|
|
|
42
37
|
out.write(`\nAdd the badge to your README (run "ownit badge" to see this again):\n ${badgeMarkdown(origin, registered.repo)}\n`);
|
|
43
38
|
}
|
|
44
39
|
catch (error) {
|
|
45
|
-
out.write(`${c.yellow('Ownit:')} the hook is installed, but registering ${repo} failed: ${sentence(
|
|
40
|
+
out.write(`${c.yellow('Ownit:')} the hook is installed, but registering ${repo} failed: ${sentence(errorMessage(error))}\n`);
|
|
46
41
|
out.write('Run "ownit init" again once that is fixed.\n');
|
|
47
42
|
return 1;
|
|
48
43
|
}
|
|
49
44
|
return 0;
|
|
50
45
|
}
|
|
46
|
+
/** `ownit init [--repo owner/name]` */
|
|
47
|
+
export async function initCommand(argv) {
|
|
48
|
+
const { values } = parseArgs({ args: argv, options: { repo: { type: 'string' } } });
|
|
49
|
+
const out = process.stderr;
|
|
50
|
+
if (values.repo)
|
|
51
|
+
git(['config', 'ownit.repo', values.repo]);
|
|
52
|
+
const repo = githubRepo();
|
|
53
|
+
if (!repo) {
|
|
54
|
+
out.write('Ownit: no GitHub remote found. Add one, or run "ownit init --repo owner/name".\n');
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
installHookHere(out);
|
|
58
|
+
return registerRepo(repo, out);
|
|
59
|
+
}
|
|
51
60
|
export async function uninstallCommand() {
|
|
52
61
|
const out = process.stderr;
|
|
53
62
|
const location = hookLocation();
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { createApi } from '../api.js';
|
|
3
3
|
import { buildDiff } from '../diff.js';
|
|
4
|
-
import { runDebrief } from '../debrief.js';
|
|
5
|
-
import { githubRepo, listCommits, objectExists, resolveCommit } from '../git.js';
|
|
4
|
+
import { LATER, runDebrief } from '../debrief.js';
|
|
5
|
+
import { githubRepo, listCommits, objectExists, resolveCommit, tryGit } from '../git.js';
|
|
6
6
|
import { rangeForRecordedPush } from '../pushRange.js';
|
|
7
|
-
import { createPrompter, paint } from '../ui.js';
|
|
7
|
+
import { createPrompter, paint, plural } from '../ui.js';
|
|
8
8
|
export async function interviewCommand(argv) {
|
|
9
9
|
const { values } = parseArgs({
|
|
10
10
|
args: argv,
|
|
11
|
-
options: { range: { type: 'string' }, repo: { type: 'string' } },
|
|
11
|
+
options: { range: { type: 'string' }, repo: { type: 'string' }, all: { type: 'boolean' } },
|
|
12
12
|
allowPositionals: false,
|
|
13
13
|
});
|
|
14
14
|
const out = process.stderr;
|
|
@@ -22,32 +22,83 @@ export async function interviewCommand(argv) {
|
|
|
22
22
|
try {
|
|
23
23
|
if (values.range)
|
|
24
24
|
return exitCode(await debriefRange(api, prompter, repo, values.range));
|
|
25
|
-
return await debriefOpenPushes(api, prompter, repo);
|
|
25
|
+
return await debriefOpenPushes(api, prompter, repo, Boolean(values.all));
|
|
26
26
|
}
|
|
27
27
|
finally {
|
|
28
28
|
prompter.close();
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
function exitCode(outcome) {
|
|
32
|
-
return outcome && (outcome.status === 'verified' || outcome.
|
|
32
|
+
return outcome && (outcome.status === 'verified' || outcome.reason === LATER) ? 0 : 1;
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** A range "A..B", or a single commit, which means just that commit. */
|
|
35
|
+
function parseRange(range) {
|
|
35
36
|
const match = range.match(/^(.+?)\.\.(.+)$/);
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
37
|
+
if (match)
|
|
38
|
+
return { base: resolveCommit(match[1]), head: resolveCommit(match[2]) };
|
|
39
|
+
const head = resolveCommit(range);
|
|
40
|
+
return { base: tryGit(['rev-parse', '--verify', '--quiet', `${head}^`])?.trim() || null, head };
|
|
41
|
+
}
|
|
42
|
+
async function debriefRange(api, prompter, repo, range) {
|
|
43
|
+
const { base, head } = parseRange(range);
|
|
44
|
+
const commits = base ? listCommits([`${base}..${head}`]) : listCommits(['-1', head]);
|
|
43
45
|
if (!commits.length) {
|
|
44
46
|
prompter.out.write(`Ownit: no commits in ${range}.\n`);
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
47
49
|
return runDebrief(api, prompter, { repo, baseSha: base, headSha: head, commits, diff: buildDiff([{ base, head }]) });
|
|
48
50
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
function toChoice(push) {
|
|
52
|
+
if (![push.head_sha, ...push.commits].every((sha) => objectExists(sha)))
|
|
53
|
+
return { push, commits: [] };
|
|
54
|
+
const range = rangeForRecordedPush(push.base_sha, push.head_sha, push.commits);
|
|
55
|
+
const known = new Set(push.commits);
|
|
56
|
+
const commits = listCommits([range.base ? `${range.base}..${range.head}` : range.head]).filter((x) => known.has(x.sha));
|
|
57
|
+
return { push, commits: commits.length ? commits : listCommits(['-1', range.head]) };
|
|
58
|
+
}
|
|
59
|
+
const shortDate = (iso) => new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
60
|
+
/** One line per push: number, status, head commit, date and what the commits say. */
|
|
61
|
+
function printChoices(out, choices) {
|
|
62
|
+
const c = paint(out);
|
|
63
|
+
const width = Math.min(process.stderr.columns || 80, 100);
|
|
64
|
+
choices.forEach(({ push, commits }, i) => {
|
|
65
|
+
const status = push.status === 'provider_error' ? 'error' : push.status;
|
|
66
|
+
const head = `${String(i + 1).padStart(3)} ${status.padEnd(7)} ${push.head_sha.slice(0, 7)} ${shortDate(push.created_at).padEnd(6)} `;
|
|
67
|
+
let title;
|
|
68
|
+
if (!commits.length)
|
|
69
|
+
title = c.yellow('not in this clone, run "git fetch"');
|
|
70
|
+
else {
|
|
71
|
+
const subject = commits.at(-1).message.split('\n')[0];
|
|
72
|
+
const more = commits.length > 1 ? ` (+${commits.length - 1} more)` : '';
|
|
73
|
+
const room = Math.max(20, width - head.length - more.length);
|
|
74
|
+
title = (subject.length > room ? `${subject.slice(0, room - 1)}…` : subject) + c.dim(more);
|
|
75
|
+
}
|
|
76
|
+
out.write(`${c.dim(head)}${title}\n`);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** What the developer picked: a list index, every push, quit (also on end of input), or null when unclear. */
|
|
80
|
+
export function parsePick(answer, count) {
|
|
81
|
+
const text = answer?.trim().toLowerCase() ?? 'q';
|
|
82
|
+
if (text === '')
|
|
83
|
+
return 0;
|
|
84
|
+
if (text === 'q' || text === 'quit')
|
|
85
|
+
return 'quit';
|
|
86
|
+
if (text === 'a' || text === 'all')
|
|
87
|
+
return 'all';
|
|
88
|
+
const n = Number(text);
|
|
89
|
+
return Number.isInteger(n) && n >= 1 && n <= count ? n - 1 : null;
|
|
90
|
+
}
|
|
91
|
+
async function debriefChoice(api, prompter, repo, { push, commits }) {
|
|
92
|
+
const range = rangeForRecordedPush(push.base_sha, push.head_sha, push.commits);
|
|
93
|
+
return runDebrief(api, prompter, { repo, baseSha: range.base, headSha: range.head, commits, diff: buildDiff([range]), pushId: push.id });
|
|
94
|
+
}
|
|
95
|
+
/** The debrief was cut off or failed, so stop. "s" (later) is not a stop: it moves on to the next push. */
|
|
96
|
+
const stopped = (outcome) => outcome.reason !== LATER && (outcome.status === 'pending' || outcome.status === 'provider_error');
|
|
97
|
+
/**
|
|
98
|
+
* Lists the pushes still waiting for a debrief, newest first, and debriefs the
|
|
99
|
+
* ones the developer picks, one at a time. `all` keeps going without asking.
|
|
100
|
+
*/
|
|
101
|
+
async function debriefOpenPushes(api, prompter, repo, all) {
|
|
51
102
|
const out = prompter.out;
|
|
52
103
|
const c = paint(out);
|
|
53
104
|
const { pushes } = await api.request('GET', `/api/pushes/open?repo=${encodeURIComponent(repo)}`);
|
|
@@ -55,28 +106,46 @@ async function debriefOpenPushes(api, prompter, repo) {
|
|
|
55
106
|
out.write(`${c.green('✓')} Nothing to debrief for ${repo}.\n`);
|
|
56
107
|
return 0;
|
|
57
108
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
109
|
+
let choices = pushes.map(toChoice);
|
|
110
|
+
if (all)
|
|
111
|
+
return debriefInOrder(api, prompter, repo, choices);
|
|
112
|
+
while (choices.length) {
|
|
113
|
+
out.write(`\n${choices.length} ${plural(choices.length, 'push', 'pushes')} waiting for a debrief in ${repo}:\n\n`);
|
|
114
|
+
printChoices(out, choices);
|
|
115
|
+
const pick = parsePick(await prompter.ask(`\nDebrief which one? ${c.dim('[number, Enter for 1, a = all, q = quit]')} `), choices.length);
|
|
116
|
+
if (pick === 'quit') {
|
|
117
|
+
out.write(c.dim('Run "ownit interview" any time to come back.\n'));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
if (pick === null) {
|
|
121
|
+
out.write(c.yellow(`Pick a number from 1 to ${choices.length}, a or q.\n`));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (pick === 'all')
|
|
125
|
+
return debriefInOrder(api, prompter, repo, choices);
|
|
126
|
+
const choice = choices[pick];
|
|
127
|
+
if (!choice.commits.length) {
|
|
128
|
+
out.write(c.yellow(`Commit ${choice.push.head_sha.slice(0, 7)} is not in this clone. Run "git fetch" and try again.\n`));
|
|
65
129
|
continue;
|
|
66
130
|
}
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const commits = listCommits([range.base ? `${range.base}..${range.head}` : range.head]).filter((x) => known.has(x.sha));
|
|
70
|
-
const outcome = await runDebrief(api, prompter, {
|
|
71
|
-
repo,
|
|
72
|
-
baseSha: range.base,
|
|
73
|
-
headSha: range.head,
|
|
74
|
-
commits: commits.length ? commits : listCommits(['-1', range.head]),
|
|
75
|
-
diff: buildDiff([range]),
|
|
76
|
-
pushId: push.id,
|
|
77
|
-
});
|
|
78
|
-
if (outcome.status === 'pending' || outcome.status === 'provider_error')
|
|
131
|
+
const outcome = await debriefChoice(api, prompter, repo, choice);
|
|
132
|
+
if (stopped(outcome))
|
|
79
133
|
return 1;
|
|
134
|
+
if (outcome.reason !== LATER)
|
|
135
|
+
choices = choices.filter((x) => x !== choice);
|
|
136
|
+
}
|
|
137
|
+
out.write(`\n${c.green('✓')} Nothing left to debrief for ${repo}.\n`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
/** Debriefs every push in the list, newest first, until one is paused or fails. */
|
|
141
|
+
async function debriefInOrder(api, prompter, repo, choices) {
|
|
142
|
+
for (const choice of choices.filter((x) => x.commits.length)) {
|
|
143
|
+
if (stopped(await debriefChoice(api, prompter, repo, choice)))
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
const missing = choices.filter((x) => !x.commits.length).length;
|
|
147
|
+
if (missing) {
|
|
148
|
+
prompter.out.write(paint(prompter.out).yellow(`Ownit: ${missing} ${plural(missing, 'push', 'pushes')} not in this clone. Run "git fetch" and try again.\n`));
|
|
80
149
|
}
|
|
81
|
-
return
|
|
150
|
+
return missing ? 1 : 0;
|
|
82
151
|
}
|
package/dist/commands/key.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readConfig, updateConfig } from '../config.js';
|
|
2
|
+
import { describeKey, isProvider, modelKey, PROVIDER_IDS, PROVIDERS, providerByPrefix } from '../providers.js';
|
|
2
3
|
import { readSecret } from '../secret.js';
|
|
3
4
|
import { createPrompter } from '../ui.js';
|
|
4
5
|
const CHOICES = PROVIDER_IDS.join('|');
|
|
@@ -30,39 +31,49 @@ async function chooseProvider(arg) {
|
|
|
30
31
|
out.write(`Ownit: "${answer}" is not one of the choices; nothing changed.\n`);
|
|
31
32
|
return picked;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
async function setKey(providerArg) {
|
|
34
35
|
const out = process.stderr;
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
out.write('Ownit: no key entered; nothing changed.\n');
|
|
45
|
-
return 1;
|
|
46
|
-
}
|
|
47
|
-
const looksLike = providerByPrefix(key);
|
|
48
|
-
if (looksLike && looksLike !== provider) {
|
|
49
|
-
out.write(`Ownit: that key looks like it is for ${PROVIDERS[looksLike].name}. Run "ownit key set ${looksLike}" instead; nothing changed.\n`);
|
|
50
|
-
return 1;
|
|
51
|
-
}
|
|
52
|
-
updateConfig({ provider, apiKey: key, nebiusKey: undefined });
|
|
53
|
-
out.write(`Saved your ${name} key to ~/.ownit/config.json. It is sent only with debrief requests and never stored by Ownit.\n`);
|
|
54
|
-
return 0;
|
|
36
|
+
const provider = await chooseProvider(providerArg);
|
|
37
|
+
if (!provider)
|
|
38
|
+
return 1;
|
|
39
|
+
const { name } = PROVIDERS[provider];
|
|
40
|
+
// Read from a prompt or stdin, never argv, so the key stays out of shell history.
|
|
41
|
+
const key = (await readSecret(`${name} API key (input hidden): `)).trim();
|
|
42
|
+
if (!key) {
|
|
43
|
+
out.write('Ownit: no key entered; nothing changed.\n');
|
|
44
|
+
return 1;
|
|
55
45
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
out.write(
|
|
59
|
-
return
|
|
46
|
+
const looksLike = providerByPrefix(key);
|
|
47
|
+
if (looksLike && looksLike !== provider) {
|
|
48
|
+
out.write(`Ownit: that key looks like it is for ${PROVIDERS[looksLike].name}. Run "ownit key set ${looksLike}" instead; nothing changed.\n`);
|
|
49
|
+
return 1;
|
|
60
50
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
51
|
+
updateConfig({ provider, apiKey: key, nebiusKey: undefined });
|
|
52
|
+
out.write(`Saved your ${name} key to ~/.ownit/config.json. It is sent only with debrief requests and never stored by Ownit.\n`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
function clearKey() {
|
|
56
|
+
updateConfig({ provider: undefined, apiKey: undefined, nebiusKey: undefined });
|
|
57
|
+
process.stderr.write('Removed your API key.\n');
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
function showKey() {
|
|
61
|
+
const model = modelKey(readConfig());
|
|
62
|
+
process.stderr.write(model ? `API key set (${describeKey(model)}).\n` : 'No API key set. Run "ownit key set" with a Nebius or NVIDIA key.\n');
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
/** `ownit key set [provider] | clear | status` */
|
|
66
|
+
export async function keyCommand(argv) {
|
|
67
|
+
const [action = 'status', providerArg] = argv;
|
|
68
|
+
switch (action) {
|
|
69
|
+
case 'set':
|
|
70
|
+
return setKey(providerArg);
|
|
71
|
+
case 'clear':
|
|
72
|
+
return clearKey();
|
|
73
|
+
case 'status':
|
|
74
|
+
return showKey();
|
|
75
|
+
default:
|
|
76
|
+
process.stderr.write(`Usage: ownit key set [${CHOICES}] | clear | status\n`);
|
|
77
|
+
return 1;
|
|
65
78
|
}
|
|
66
|
-
out.write(`Usage: ownit key set [${CHOICES}] | clear | status\n`);
|
|
67
|
-
return 1;
|
|
68
79
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { ApiError, createApi } from '../api.js';
|
|
3
|
-
import {
|
|
3
|
+
import { readConfig, serverUrl, updateConfig } from '../config.js';
|
|
4
|
+
import { describeKey, modelKey } from '../providers.js';
|
|
4
5
|
import { readSecret } from '../secret.js';
|
|
5
6
|
import { paint } from '../ui.js';
|
|
6
7
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -24,33 +25,10 @@ async function githubOAuth(path, form) {
|
|
|
24
25
|
return (await response.json());
|
|
25
26
|
}
|
|
26
27
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* hit). The server then verifies the identity, drops the GitHub token, and
|
|
30
|
-
* issues an Ownit session token.
|
|
28
|
+
* Polls GitHub until the user authorizes the device code. Returns the GitHub
|
|
29
|
+
* token, or the reason the login ended without one.
|
|
31
30
|
*/
|
|
32
|
-
|
|
33
|
-
const { values } = parseArgs({ args: argv, options: { server: { type: 'string' }, 'with-token': { type: 'boolean' } } });
|
|
34
|
-
const out = process.stderr;
|
|
35
|
-
const c = paint(out);
|
|
36
|
-
if (values.server)
|
|
37
|
-
updateConfig({ server: values.server.replace(/\/+$/, '') });
|
|
38
|
-
if (values['with-token']) {
|
|
39
|
-
// For self-hosted or local servers: read a session token from stdin.
|
|
40
|
-
const token = (await readSecret('Ownit session token: ')).trim();
|
|
41
|
-
if (!/^ownit_[\w-]{20,}$/.test(token)) {
|
|
42
|
-
out.write('Ownit: that does not look like an Ownit session token.\n');
|
|
43
|
-
return 1;
|
|
44
|
-
}
|
|
45
|
-
updateConfig({ token, login: undefined });
|
|
46
|
-
out.write(`${c.green('✓')} Saved the session token.\n`);
|
|
47
|
-
return 0;
|
|
48
|
-
}
|
|
49
|
-
const api = createApi({ ...readConfig(), token: undefined });
|
|
50
|
-
const { github_client_id: clientId } = await api.request('GET', '/auth/config');
|
|
51
|
-
const start = await githubOAuth('/login/device/code', { client_id: clientId });
|
|
52
|
-
out.write(`\nOpen ${c.bold(start.verification_uri)} and enter the code ${c.bold(c.cyan(start.user_code))}\n`);
|
|
53
|
-
out.write(c.dim('Waiting for you to authorize Ownit on GitHub…\n'));
|
|
31
|
+
async function pollForAccessToken(clientId, start) {
|
|
54
32
|
let interval = Math.max(start.interval, 0) * 1000;
|
|
55
33
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
56
34
|
while (Date.now() < deadline) {
|
|
@@ -60,24 +38,60 @@ export async function loginCommand(argv) {
|
|
|
60
38
|
device_code: start.device_code,
|
|
61
39
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
62
40
|
});
|
|
63
|
-
if (reply.access_token)
|
|
64
|
-
|
|
65
|
-
const session = await api.request('POST', '/auth/github', { access_token: reply.access_token });
|
|
66
|
-
updateConfig({ token: session.token, login: session.login });
|
|
67
|
-
out.write(`${c.green('✓')} Logged in as ${c.bold(session.login)} on ${serverUrl(readConfig())}.\n`);
|
|
68
|
-
return 0;
|
|
69
|
-
}
|
|
41
|
+
if (reply.access_token)
|
|
42
|
+
return { token: reply.access_token };
|
|
70
43
|
if (reply.error === 'authorization_pending')
|
|
71
44
|
continue;
|
|
72
45
|
if (reply.error === 'slow_down') {
|
|
73
46
|
interval = Math.max(interval + 5000, (reply.interval ?? 0) * 1000);
|
|
74
47
|
continue;
|
|
75
48
|
}
|
|
76
|
-
|
|
49
|
+
return { failure: `login ${reply.error === 'access_denied' ? 'was cancelled' : `failed (${reply.error ?? 'unknown error'})`}.` };
|
|
50
|
+
}
|
|
51
|
+
return { failure: 'the login code expired.' };
|
|
52
|
+
}
|
|
53
|
+
/** For self-hosted or local servers: reads a session token from stdin. */
|
|
54
|
+
async function loginWithToken() {
|
|
55
|
+
const out = process.stderr;
|
|
56
|
+
const token = (await readSecret('Ownit session token: ')).trim();
|
|
57
|
+
if (!/^ownit_[\w-]{20,}$/.test(token)) {
|
|
58
|
+
out.write('Ownit: that does not look like an Ownit session token.\n');
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
updateConfig({ token, login: undefined });
|
|
62
|
+
out.write(`${paint(out).green('✓')} Saved the session token.\n`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The GitHub device flow runs here, from the user's own IP (GitHub rate-limits
|
|
67
|
+
* its login endpoints per IP, which shared server IPs hit). The server then
|
|
68
|
+
* verifies the identity, drops the GitHub token, and issues an Ownit session token.
|
|
69
|
+
*/
|
|
70
|
+
async function deviceFlowLogin() {
|
|
71
|
+
const out = process.stderr;
|
|
72
|
+
const c = paint(out);
|
|
73
|
+
const api = createApi({ ...readConfig(), token: undefined });
|
|
74
|
+
const { github_client_id: clientId } = await api.request('GET', '/auth/config');
|
|
75
|
+
const start = await githubOAuth('/login/device/code', { client_id: clientId });
|
|
76
|
+
out.write(`\nOpen ${c.bold(start.verification_uri)} and enter the code ${c.bold(c.cyan(start.user_code))}\n`);
|
|
77
|
+
out.write(c.dim('Waiting for you to authorize Ownit on GitHub…\n'));
|
|
78
|
+
const result = await pollForAccessToken(clientId, start);
|
|
79
|
+
if ('failure' in result) {
|
|
80
|
+
out.write(`Ownit: ${result.failure} Run "ownit login" to try again.\n`);
|
|
77
81
|
return 1;
|
|
78
82
|
}
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
// The GitHub token goes to the Ownit server once, to prove who you are, and is not kept anywhere.
|
|
84
|
+
const session = await api.request('POST', '/auth/github', { access_token: result.token });
|
|
85
|
+
updateConfig({ token: session.token, login: session.login });
|
|
86
|
+
out.write(`${c.green('✓')} Logged in as ${c.bold(session.login)} on ${serverUrl(readConfig())}.\n`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
/** `ownit login [--server URL] [--with-token]` */
|
|
90
|
+
export async function loginCommand(argv) {
|
|
91
|
+
const { values } = parseArgs({ args: argv, options: { server: { type: 'string' }, 'with-token': { type: 'boolean' } } });
|
|
92
|
+
if (values.server)
|
|
93
|
+
updateConfig({ server: values.server.replace(/\/+$/, '') });
|
|
94
|
+
return values['with-token'] ? loginWithToken() : deviceFlowLogin();
|
|
81
95
|
}
|
|
82
96
|
export async function logoutCommand() {
|
|
83
97
|
const config = readConfig();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createApi } from '../api.js';
|
|
2
|
+
import { githubRepo } from '../git.js';
|
|
3
|
+
import { paint } from '../ui.js';
|
|
4
|
+
const USAGE = 'Usage: ownit settings [questions|answers|earlier] [on|off]\n';
|
|
5
|
+
/** The setting each name changes on the server. */
|
|
6
|
+
const SETTING = { questions: 'show_questions', answers: 'show_answers', earlier: 'allow_backfill' };
|
|
7
|
+
function describe(s) {
|
|
8
|
+
const page = s.private
|
|
9
|
+
? 'Questions and answers: hidden (private repo)'
|
|
10
|
+
: `Questions: ${s.show_questions ? 'shown' : 'hidden'}\nAnswers: ${s.show_answers ? 'shown' : 'hidden'}`;
|
|
11
|
+
return `${page}\nEarlier commits: ${s.allow_backfill ? 'count once debriefed' : 'not counted'}`;
|
|
12
|
+
}
|
|
13
|
+
/** A line about what just changed, or how to change things. */
|
|
14
|
+
function note(s, changed, on) {
|
|
15
|
+
if (changed === 'answers' && on)
|
|
16
|
+
return 'From now on, passing answers are saved and shown. Developers see a note before they answer.';
|
|
17
|
+
if (changed === 'earlier' && on)
|
|
18
|
+
return 'Debrief older commits with "ownit interview --range A..B" (or a single commit SHA). They count once verified, never as unverified.';
|
|
19
|
+
if (changed === 'earlier')
|
|
20
|
+
return 'New debriefs of older commits no longer count. Ones already verified stay.';
|
|
21
|
+
if (changed && !s.show_answers)
|
|
22
|
+
return 'Saved answers, if any, are deleted.';
|
|
23
|
+
if (changed)
|
|
24
|
+
return '';
|
|
25
|
+
return 'Change with "ownit settings questions|answers|earlier on|off" (repo admins and maintainers).';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `ownit settings`: shows this repo's Ownit settings and page link.
|
|
29
|
+
* `ownit settings questions|answers|earlier on|off` changes one (admins and maintainers).
|
|
30
|
+
*/
|
|
31
|
+
export async function settingsCommand(args) {
|
|
32
|
+
const out = process.stderr;
|
|
33
|
+
const [what, value, ...rest] = args;
|
|
34
|
+
const changed = what;
|
|
35
|
+
if (changed !== undefined && (!Object.hasOwn(SETTING, changed) || (value !== 'on' && value !== 'off') || rest.length)) {
|
|
36
|
+
out.write(USAGE);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const repo = githubRepo();
|
|
40
|
+
if (!repo) {
|
|
41
|
+
out.write('Ownit: no GitHub remote found. Run this in a repo set up with "ownit init".\n');
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
const api = createApi();
|
|
45
|
+
const settings = changed
|
|
46
|
+
? await api.request('PUT', '/api/repos/settings', { repo, [SETTING[changed]]: value === 'on' })
|
|
47
|
+
: await api.request('GET', `/api/repos/settings?repo=${encodeURIComponent(repo)}`);
|
|
48
|
+
const c = paint(out);
|
|
49
|
+
out.write(`${c.bold(settings.page_url)}\n${describe(settings)}\n`);
|
|
50
|
+
const line = note(settings, changed, value === 'on');
|
|
51
|
+
if (line)
|
|
52
|
+
out.write(`${c.dim(line)}\n`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
/**
|
|
5
|
-
* Model providers the worker accepts, in the order `ownit key set` lists them.
|
|
6
|
-
* Add a provider here and in the worker's PROVIDERS.
|
|
7
|
-
*/
|
|
8
|
-
export const PROVIDERS = {
|
|
9
|
-
nebius: { name: 'Nebius', envVar: 'NEBIUS_API_KEY', keyUrl: 'https://tokenfactory.nebius.com/', keyPrefix: undefined },
|
|
10
|
-
nvidia: { name: 'NVIDIA', envVar: 'NVIDIA_API_KEY', keyUrl: 'https://build.nvidia.com/', keyPrefix: 'nvapi-' },
|
|
11
|
-
};
|
|
12
|
-
export const PROVIDER_IDS = Object.keys(PROVIDERS);
|
|
13
|
-
export function isProvider(value) {
|
|
14
|
-
return Object.hasOwn(PROVIDERS, value);
|
|
15
|
-
}
|
|
16
4
|
// The hosted Ownit server. Self-hosters point the CLI elsewhere with `ownit login --server`.
|
|
17
5
|
export const DEFAULT_SERVER = 'https://ownit.ownit-worker.workers.dev';
|
|
18
6
|
export function configDir(env = process.env) {
|
|
@@ -53,30 +41,6 @@ export function updateConfig(patch, env = process.env) {
|
|
|
53
41
|
writeConfig(next, env);
|
|
54
42
|
return next;
|
|
55
43
|
}
|
|
56
|
-
/** The key to send with debriefs and its provider. A provider's env var wins over the saved key. */
|
|
57
|
-
export function modelKey(config, env = process.env) {
|
|
58
|
-
for (const provider of PROVIDER_IDS) {
|
|
59
|
-
const key = env[PROVIDERS[provider].envVar];
|
|
60
|
-
if (key)
|
|
61
|
-
return { provider, key };
|
|
62
|
-
}
|
|
63
|
-
if (config.apiKey)
|
|
64
|
-
return { provider: config.provider ?? 'nebius', key: config.apiKey };
|
|
65
|
-
if (config.nebiusKey)
|
|
66
|
-
return { provider: 'nebius', key: config.nebiusKey };
|
|
67
|
-
return undefined;
|
|
68
|
-
}
|
|
69
|
-
/** The provider whose keys have this key's prefix, if any. */
|
|
70
|
-
export function providerByPrefix(key) {
|
|
71
|
-
return PROVIDER_IDS.find((id) => {
|
|
72
|
-
const prefix = PROVIDERS[id].keyPrefix;
|
|
73
|
-
return prefix !== undefined && key.startsWith(prefix);
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
/** e.g. "NVIDIA, …1a2b" */
|
|
77
|
-
export function describeKey({ provider, key }) {
|
|
78
|
-
return `${PROVIDERS[provider]?.name ?? provider}, …${key.slice(-4)}`;
|
|
79
|
-
}
|
|
80
44
|
export function serverUrl(config, env = process.env) {
|
|
81
45
|
return (env.OWNIT_SERVER || config.server || DEFAULT_SERVER).replace(/\/+$/, '');
|
|
82
46
|
}
|
package/dist/debrief.js
CHANGED
|
@@ -1,105 +1,135 @@
|
|
|
1
1
|
import { ApiError } from './api.js';
|
|
2
|
-
import { paint, sentence, spinner, wrap } from './ui.js';
|
|
2
|
+
import { errorMessage, paint, plural, sentence, spinner, wrap } from './ui.js';
|
|
3
3
|
const MAX_ANSWER_CHARS = 2000;
|
|
4
4
|
export const RESUME_HINT = 'Run "ownit interview" when you\'re ready.';
|
|
5
5
|
const KIND_LABEL = { why: 'why', how: 'how', next: "what's next" };
|
|
6
|
-
|
|
6
|
+
/** "s" (or the older "skip") stops the debrief for now. */
|
|
7
|
+
function isLater(answer) {
|
|
7
8
|
return /^(s|skip|:skip)$/i.test(answer.trim());
|
|
8
9
|
}
|
|
10
|
+
/** The reason on a debrief the developer stopped with "s", as opposed to one that was cut off. */
|
|
11
|
+
export const LATER = 'later';
|
|
12
|
+
/** Shows a spinner while `work` runs. */
|
|
13
|
+
async function withSpinner(out, text, work) {
|
|
14
|
+
const spin = spinner(out, text);
|
|
15
|
+
try {
|
|
16
|
+
return await work();
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
spin.stop();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function printQuestion(out, question, total, followUp) {
|
|
23
|
+
const c = paint(out);
|
|
24
|
+
const label = `Question ${question.idx + 1}/${total}`;
|
|
25
|
+
out.write(`\n${c.bold(c.cyan(label))} ${c.dim(`(${KIND_LABEL[question.kind]}${followUp ? ', follow-up' : ''})`)}\n`);
|
|
26
|
+
out.write(`${wrap(question.text)}\n`);
|
|
27
|
+
}
|
|
28
|
+
/** The next non-blank answer, or null when the input ends. */
|
|
29
|
+
async function readAnswer(prompter) {
|
|
30
|
+
const c = paint(prompter.out);
|
|
31
|
+
let answer;
|
|
32
|
+
do
|
|
33
|
+
answer = await prompter.ask(c.bold('> '));
|
|
34
|
+
while (answer !== null && !answer.trim());
|
|
35
|
+
return answer;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Asks a question, and follow-ups after a miss, until an answer passes (null)
|
|
39
|
+
* or the debrief ends early (its outcome).
|
|
40
|
+
*/
|
|
41
|
+
async function askUntilPassed(debrief, first) {
|
|
42
|
+
const { api, prompter } = debrief;
|
|
43
|
+
const out = prompter.out;
|
|
44
|
+
const c = paint(out);
|
|
45
|
+
let question = first;
|
|
46
|
+
let followUp = false;
|
|
47
|
+
for (;;) {
|
|
48
|
+
printQuestion(out, question, debrief.total, followUp);
|
|
49
|
+
const answer = await readAnswer(prompter);
|
|
50
|
+
if (answer === null) {
|
|
51
|
+
out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
|
|
52
|
+
return { status: 'pending', reason: 'debrief paused' };
|
|
53
|
+
}
|
|
54
|
+
if (isLater(answer))
|
|
55
|
+
return later(out);
|
|
56
|
+
let result;
|
|
57
|
+
try {
|
|
58
|
+
result = await withSpinner(out, 'Checking your answer…', () => api.request('POST', '/api/interview/answer', {
|
|
59
|
+
session: debrief.session,
|
|
60
|
+
idx: question.idx,
|
|
61
|
+
answer: answer.slice(0, MAX_ANSWER_CHARS),
|
|
62
|
+
...debrief.payload,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return failure(out, error);
|
|
67
|
+
}
|
|
68
|
+
debrief.session = result.session;
|
|
69
|
+
debrief.earlierCredited = result.earlier_credited ?? 0;
|
|
70
|
+
if (result.pass) {
|
|
71
|
+
out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
out.write(`${c.yellow('Not quite yet.')}\n${wrap(result.feedback)}\n`);
|
|
75
|
+
if (!result.followup)
|
|
76
|
+
return { status: 'pending', reason: 'no follow-up question' };
|
|
77
|
+
out.write(c.dim("Here's another angle on the same topic:\n"));
|
|
78
|
+
question = result.followup;
|
|
79
|
+
followUp = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
9
82
|
/** Runs one debrief: asks each question, grades answers, and offers follow-ups until every question passes. */
|
|
10
83
|
export async function runDebrief(api, prompter, target) {
|
|
11
84
|
const out = prompter.out;
|
|
12
85
|
const c = paint(out);
|
|
13
|
-
const commitWord = target.commits.length
|
|
14
|
-
out.write(`\n${c.bold('Ownit debrief')} ${c.dim(`· ${target.repo} · ${target.commits.length} ${commitWord} · ${target.diff.changedLines} changed ${target.diff.changedLines
|
|
86
|
+
const commitWord = plural(target.commits.length, 'commit');
|
|
87
|
+
out.write(`\n${c.bold('Ownit debrief')} ${c.dim(`· ${target.repo} · ${target.commits.length} ${commitWord} · ${target.diff.changedLines} changed ${plural(target.diff.changedLines, 'line')}`)}\n`);
|
|
15
88
|
const payload = {
|
|
16
89
|
commits: target.commits,
|
|
17
90
|
diff: target.diff.diff,
|
|
18
91
|
diff_truncated: target.diff.truncated,
|
|
19
92
|
};
|
|
20
93
|
let start;
|
|
21
|
-
const spin = spinner(out, 'Nemotron is reading your push…');
|
|
22
94
|
try {
|
|
23
|
-
start = await api.request('POST', '/api/interview/start', {
|
|
95
|
+
start = await withSpinner(out, 'Nemotron is reading your push…', () => api.request('POST', '/api/interview/start', {
|
|
24
96
|
repo: target.repo,
|
|
25
97
|
base_sha: target.baseSha,
|
|
26
98
|
head_sha: target.headSha,
|
|
27
99
|
changed_lines: target.diff.changedLines,
|
|
28
100
|
push_id: target.pushId,
|
|
29
101
|
...payload,
|
|
30
|
-
});
|
|
102
|
+
}));
|
|
31
103
|
}
|
|
32
104
|
catch (error) {
|
|
33
|
-
spin.stop();
|
|
34
105
|
return failure(out, error);
|
|
35
106
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
out.write(`\n${c.bold(c.cyan(label))} ${c.dim(`(${KIND_LABEL[question.kind]}${followUp ? ', follow-up' : ''})`)}\n`);
|
|
45
|
-
out.write(`${wrap(question.text)}\n`);
|
|
46
|
-
let answer;
|
|
47
|
-
do
|
|
48
|
-
answer = await prompter.ask(c.bold('> '));
|
|
49
|
-
while (answer !== null && !answer.trim());
|
|
50
|
-
if (answer === null) {
|
|
51
|
-
out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
|
|
52
|
-
return { status: 'pending', reason: 'debrief paused' };
|
|
53
|
-
}
|
|
54
|
-
if (isSkip(answer))
|
|
55
|
-
return skip(api, out, session);
|
|
56
|
-
const grading = spinner(out, 'Checking your answer…');
|
|
57
|
-
let result;
|
|
58
|
-
try {
|
|
59
|
-
result = await api.request('POST', '/api/interview/answer', {
|
|
60
|
-
session,
|
|
61
|
-
idx: question.idx,
|
|
62
|
-
answer: answer.slice(0, MAX_ANSWER_CHARS),
|
|
63
|
-
...payload,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
grading.stop();
|
|
68
|
-
return failure(out, error);
|
|
69
|
-
}
|
|
70
|
-
grading.stop();
|
|
71
|
-
session = result.session;
|
|
72
|
-
if (result.pass) {
|
|
73
|
-
out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
out.write(`${c.yellow('Not quite yet.')}\n${wrap(result.feedback)}\n`);
|
|
77
|
-
if (!result.followup)
|
|
78
|
-
return { status: 'pending', reason: 'no follow-up question' };
|
|
79
|
-
out.write(c.dim("Here's another angle on the same topic:\n"));
|
|
80
|
-
question = result.followup;
|
|
81
|
-
followUp = true;
|
|
82
|
-
}
|
|
107
|
+
out.write(c.dim(`Answer in your own words, up to 5 sentences. Rough is fine. Type "s" to stop and finish later.\n`));
|
|
108
|
+
if (start.answers_public)
|
|
109
|
+
out.write(c.yellow('Note: this repo shows passing answers on its public Ownit page.\n'));
|
|
110
|
+
const debrief = { api, prompter, total: start.total, payload, session: start.session, earlierCredited: 0 };
|
|
111
|
+
for (const question of start.questions) {
|
|
112
|
+
const ended = await askUntilPassed(debrief, question);
|
|
113
|
+
if (ended)
|
|
114
|
+
return ended;
|
|
83
115
|
}
|
|
84
116
|
out.write(`\n${c.green(c.bold('✓ Push verified.'))} ${c.dim(`${target.commits.length} ${commitWord} owned. Nice work.`)}\n`);
|
|
117
|
+
const earlier = debrief.earlierCredited;
|
|
118
|
+
if (earlier)
|
|
119
|
+
out.write(c.dim(`${earlier} earlier ${plural(earlier, 'commit')} now ${earlier === 1 ? 'counts' : 'count'} as verified.\n`));
|
|
85
120
|
return { status: 'verified' };
|
|
86
121
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
catch (error) {
|
|
92
|
-
return failure(out, error);
|
|
93
|
-
}
|
|
94
|
-
out.write(`${paint(out).dim('Skipped. This push is recorded as skipped.')}\n`);
|
|
95
|
-
return { status: 'skipped' };
|
|
122
|
+
/** "s" stops for now. Nothing is recorded: the push stays pending, so it waits in `ownit interview`. */
|
|
123
|
+
function later(out) {
|
|
124
|
+
out.write(`${paint(out).dim(`Stopped. This push stays unverified until you debrief it. ${RESUME_HINT}`)}\n`);
|
|
125
|
+
return { status: 'pending', reason: LATER };
|
|
96
126
|
}
|
|
97
127
|
/** Prints a one-line reason. Provider problems stay out of the stats until retried. */
|
|
98
128
|
function failure(out, error) {
|
|
99
129
|
const c = paint(out);
|
|
100
130
|
if (error instanceof ApiError) {
|
|
101
131
|
if (error.isProviderError) {
|
|
102
|
-
out.write(`${c.yellow('Ownit:')} ${error.message}
|
|
132
|
+
out.write(`${c.yellow('Ownit:')} ${error.message} These commits stay unverified until you retry. ${RESUME_HINT}\n`);
|
|
103
133
|
return { status: 'provider_error', reason: error.message };
|
|
104
134
|
}
|
|
105
135
|
if (error.code === 'already_resolved') {
|
|
@@ -110,7 +140,7 @@ function failure(out, error) {
|
|
|
110
140
|
out.write(`${c.yellow('Ownit:')} ${sentence(error.message)} ${RESUME_HINT}\n`);
|
|
111
141
|
return { status: 'pending', reason: error.message };
|
|
112
142
|
}
|
|
113
|
-
const message =
|
|
143
|
+
const message = errorMessage(error);
|
|
114
144
|
out.write(`${c.yellow('Ownit:')} ${sentence(message)} ${RESUME_HINT}\n`);
|
|
115
145
|
return { status: 'pending', reason: message };
|
|
116
146
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,72 +1,93 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { badgeCommand } from './commands/badge.js';
|
|
3
4
|
import { hookCommand } from './commands/hook.js';
|
|
4
5
|
import { initCommand, uninstallCommand } from './commands/init.js';
|
|
5
6
|
import { interviewCommand } from './commands/interview.js';
|
|
6
|
-
import { badgeCommand } from './commands/badge.js';
|
|
7
7
|
import { keyCommand } from './commands/key.js';
|
|
8
8
|
import { loginCommand, logoutCommand, whoamiCommand } from './commands/login.js';
|
|
9
|
-
|
|
9
|
+
import { settingsCommand } from './commands/settings.js';
|
|
10
|
+
import { closestMatch, errorMessage } from './ui.js';
|
|
11
|
+
/** Every command, in the order `ownit help` lists them. */
|
|
12
|
+
const COMMANDS = {
|
|
13
|
+
login: { help: [['ownit login [--server URL]', 'Sign in with GitHub (device flow)']], run: loginCommand },
|
|
14
|
+
logout: { help: [['ownit logout', 'Sign out']], run: logoutCommand },
|
|
15
|
+
whoami: { help: [['ownit whoami', 'Show server, login, and key status']], run: whoamiCommand },
|
|
16
|
+
init: { help: [['ownit init [--repo owner/name]', 'Install the pre-push hook in this repo']], run: initCommand },
|
|
17
|
+
interview: {
|
|
18
|
+
help: [
|
|
19
|
+
['ownit interview', 'List waiting pushes and debrief the one you pick'],
|
|
20
|
+
['ownit interview --all', 'Debrief every waiting push in turn'],
|
|
21
|
+
['ownit interview --range A..B', 'Debrief a commit range, or one commit (SHA)'],
|
|
22
|
+
],
|
|
23
|
+
run: interviewCommand,
|
|
24
|
+
},
|
|
25
|
+
key: {
|
|
26
|
+
help: [
|
|
27
|
+
['ownit key set [nebius|nvidia]', 'Save your model API key (stored locally)'],
|
|
28
|
+
['ownit key clear|status', 'Remove or show it'],
|
|
29
|
+
],
|
|
30
|
+
run: keyCommand,
|
|
31
|
+
},
|
|
32
|
+
badge: { help: [['ownit badge', "Print this repo's badge Markdown for your README"]], run: badgeCommand },
|
|
33
|
+
settings: {
|
|
34
|
+
help: [
|
|
35
|
+
['ownit settings', "Show this repo's settings and page link"],
|
|
36
|
+
['ownit settings questions on|off', 'Show debrief questions on the page (admins)'],
|
|
37
|
+
['ownit settings answers on|off', 'Show passing answers on the page (admins)'],
|
|
38
|
+
['ownit settings earlier on|off', 'Count older commits once debriefed (admins)'],
|
|
39
|
+
],
|
|
40
|
+
run: settingsCommand,
|
|
41
|
+
},
|
|
42
|
+
uninstall: { help: [['ownit uninstall', 'Remove the pre-push hook']], run: uninstallCommand },
|
|
43
|
+
help: { help: [['ownit help', 'Show this help']], run: () => print(helpText()) },
|
|
44
|
+
// Not listed: the installed git hook calls `ownit hook pre-push`.
|
|
45
|
+
hook: { run: (args) => hookCommand(args) },
|
|
46
|
+
version: { run: () => print(`${version()}\n`) },
|
|
47
|
+
};
|
|
48
|
+
const ALIASES = { page: 'settings', status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
|
|
49
|
+
function findCommand(name) {
|
|
50
|
+
const key = Object.hasOwn(ALIASES, name) ? ALIASES[name] : name;
|
|
51
|
+
return Object.hasOwn(COMMANDS, key) ? COMMANDS[key] : undefined;
|
|
52
|
+
}
|
|
53
|
+
function helpText() {
|
|
54
|
+
const usage = Object.values(COMMANDS)
|
|
55
|
+
.flatMap((command) => command.help ?? [])
|
|
56
|
+
.map(([line, description]) => ` ${line.padEnd(33)}${description}`)
|
|
57
|
+
.join('\n');
|
|
58
|
+
return `ownit: debrief the code you just pushed.
|
|
10
59
|
|
|
11
60
|
Usage:
|
|
12
|
-
|
|
13
|
-
ownit logout Sign out
|
|
14
|
-
ownit whoami Show server, login, and key status
|
|
15
|
-
ownit init [--repo owner/name] Install the pre-push hook in this repo
|
|
16
|
-
ownit interview Debrief pushes that are still pending
|
|
17
|
-
ownit interview --range A..B Debrief a commit range
|
|
18
|
-
ownit key set [nebius|nvidia] Save your model API key (stored locally)
|
|
19
|
-
ownit key clear|status Remove or show it
|
|
20
|
-
ownit badge Print this repo's badge Markdown for your README
|
|
21
|
-
ownit uninstall Remove the pre-push hook
|
|
22
|
-
ownit help Show this help
|
|
61
|
+
${usage}
|
|
23
62
|
|
|
24
63
|
Set OWNIT_SKIP=1 to push once without a debrief prompt (the push is pending).
|
|
25
64
|
`;
|
|
65
|
+
}
|
|
26
66
|
function version() {
|
|
27
67
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
28
68
|
return pkg.version;
|
|
29
69
|
}
|
|
70
|
+
function print(text) {
|
|
71
|
+
process.stdout.write(text);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
30
74
|
async function main(argv) {
|
|
31
|
-
const [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return initCommand(rest);
|
|
42
|
-
case 'uninstall':
|
|
43
|
-
return uninstallCommand();
|
|
44
|
-
case 'hook':
|
|
45
|
-
return hookCommand(rest);
|
|
46
|
-
case 'interview':
|
|
47
|
-
return interviewCommand(rest);
|
|
48
|
-
case 'key':
|
|
49
|
-
return keyCommand(rest);
|
|
50
|
-
case 'badge':
|
|
51
|
-
return badgeCommand();
|
|
52
|
-
case '--version':
|
|
53
|
-
case '-v':
|
|
54
|
-
process.stdout.write(`${version()}\n`);
|
|
55
|
-
return 0;
|
|
56
|
-
case undefined:
|
|
57
|
-
case 'help':
|
|
58
|
-
case '--help':
|
|
59
|
-
case '-h':
|
|
60
|
-
process.stdout.write(HELP);
|
|
61
|
-
return 0;
|
|
62
|
-
default:
|
|
63
|
-
process.stderr.write(`Unknown command "${command}".\n\n${HELP}`);
|
|
64
|
-
return 1;
|
|
75
|
+
const [name = 'help', ...args] = argv;
|
|
76
|
+
const command = findCommand(name);
|
|
77
|
+
if (!command) {
|
|
78
|
+
// Suggest only listed commands, so a typo never points at the hidden hook command.
|
|
79
|
+
const listed = Object.keys(COMMANDS).filter((key) => COMMANDS[key].help);
|
|
80
|
+
const guess = closestMatch(name, listed);
|
|
81
|
+
process.stderr.write(guess
|
|
82
|
+
? `Unknown command "${name}". Did you mean "ownit ${guess}"?\nRun "ownit help" to see every command.\n`
|
|
83
|
+
: `Unknown command "${name}".\n\n${helpText()}`);
|
|
84
|
+
return 1;
|
|
65
85
|
}
|
|
86
|
+
return command.run(args);
|
|
66
87
|
}
|
|
67
88
|
main(process.argv.slice(2)).then((code) => {
|
|
68
89
|
process.exitCode = code;
|
|
69
90
|
}, (error) => {
|
|
70
|
-
process.stderr.write(`Ownit: ${
|
|
91
|
+
process.stderr.write(`Ownit: ${errorMessage(error)}\n`);
|
|
71
92
|
process.exitCode = 1;
|
|
72
93
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model providers the worker accepts, in the order `ownit key set` lists them.
|
|
3
|
+
* Add a provider here and in the worker's PROVIDERS (worker/src/model.ts).
|
|
4
|
+
*/
|
|
5
|
+
export const PROVIDERS = {
|
|
6
|
+
nebius: { name: 'Nebius', envVar: 'NEBIUS_API_KEY', keyUrl: 'https://tokenfactory.nebius.com/', keyPrefix: undefined },
|
|
7
|
+
nvidia: { name: 'NVIDIA', envVar: 'NVIDIA_API_KEY', keyUrl: 'https://build.nvidia.com/', keyPrefix: 'nvapi-' },
|
|
8
|
+
};
|
|
9
|
+
export const PROVIDER_IDS = Object.keys(PROVIDERS);
|
|
10
|
+
export function isProvider(value) {
|
|
11
|
+
return Object.hasOwn(PROVIDERS, value);
|
|
12
|
+
}
|
|
13
|
+
/** The key to send with debriefs and its provider. A provider's env var wins over the saved key. */
|
|
14
|
+
export function modelKey(config, env = process.env) {
|
|
15
|
+
for (const provider of PROVIDER_IDS) {
|
|
16
|
+
const key = env[PROVIDERS[provider].envVar];
|
|
17
|
+
if (key)
|
|
18
|
+
return { provider, key };
|
|
19
|
+
}
|
|
20
|
+
if (config.apiKey)
|
|
21
|
+
return { provider: config.provider ?? 'nebius', key: config.apiKey };
|
|
22
|
+
if (config.nebiusKey)
|
|
23
|
+
return { provider: 'nebius', key: config.nebiusKey };
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/** The provider whose keys have this key's prefix, if any. */
|
|
27
|
+
export function providerByPrefix(key) {
|
|
28
|
+
return PROVIDER_IDS.find((id) => {
|
|
29
|
+
const prefix = PROVIDERS[id].keyPrefix;
|
|
30
|
+
return prefix !== undefined && key.startsWith(prefix);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** e.g. "NVIDIA, …1a2b" */
|
|
34
|
+
export function describeKey({ provider, key }) {
|
|
35
|
+
return `${PROVIDERS[provider]?.name ?? provider}, …${key.slice(-4)}`;
|
|
36
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -5,6 +5,10 @@ export function paint(out) {
|
|
|
5
5
|
const wrap = (code) => (text) => (on ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
6
6
|
return { bold: wrap('1'), dim: wrap('2'), green: wrap('32'), red: wrap('31'), yellow: wrap('33'), cyan: wrap('36') };
|
|
7
7
|
}
|
|
8
|
+
/** "1 commit", "2 commits". Pass the plural form for irregular words. */
|
|
9
|
+
export const plural = (n, singular, pluralForm = `${singular}s`) => (n === 1 ? singular : pluralForm);
|
|
10
|
+
/** The message of whatever was thrown. */
|
|
11
|
+
export const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
|
|
8
12
|
/** Ends a message with a period unless it already ends a sentence (server messages usually do). */
|
|
9
13
|
export function sentence(text) {
|
|
10
14
|
return /[.!?]$/.test(text.trimEnd()) ? text.trimEnd() : `${text.trimEnd()}.`;
|
|
@@ -91,3 +95,26 @@ export function wrap(text, indent = ' ', width = Math.min(process.stderr.column
|
|
|
91
95
|
lines.push(line);
|
|
92
96
|
return lines.map((l) => indent + l).join('\n');
|
|
93
97
|
}
|
|
98
|
+
/** Edit distance between two short strings (insert, delete, or replace one character). */
|
|
99
|
+
function editDistance(a, b) {
|
|
100
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
101
|
+
for (let i = 1; i <= a.length; i++) {
|
|
102
|
+
const current = [i];
|
|
103
|
+
for (let j = 1; j <= b.length; j++) {
|
|
104
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
105
|
+
}
|
|
106
|
+
previous = current;
|
|
107
|
+
}
|
|
108
|
+
return previous[b.length];
|
|
109
|
+
}
|
|
110
|
+
/** The candidate a typo most likely meant, or null when none is close. */
|
|
111
|
+
export function closestMatch(typed, candidates) {
|
|
112
|
+
const word = typed.toLowerCase();
|
|
113
|
+
let best = null;
|
|
114
|
+
for (const name of candidates) {
|
|
115
|
+
const distance = editDistance(word, name);
|
|
116
|
+
if (distance <= 2 && distance < (best?.distance ?? Infinity))
|
|
117
|
+
best = { name, distance };
|
|
118
|
+
}
|
|
119
|
+
return best?.name ?? null;
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getownit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Debrief the code you just pushed, in your terminal, with NVIDIA Nemotron.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"prepublishOnly": "npm run build && npm test",
|
|
34
35
|
"test": "vitest run",
|
|
35
36
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
36
37
|
},
|