getownit 0.1.1 → 0.1.3
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/commands/hook.js +7 -22
- package/dist/commands/interview.js +113 -35
- package/dist/commands/settings.js +58 -0
- package/dist/debrief.js +18 -14
- package/dist/index.js +104 -17
- package/dist/ui.js +23 -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/commands/hook.js
CHANGED
|
@@ -38,22 +38,13 @@ 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';
|
|
50
|
-
}
|
|
51
|
-
async function record(api, repo, push, status) {
|
|
52
|
-
const res = await api.request('POST', '/api/pushes', { repo, base_sha: push.base, head_sha: push.head, commits: push.commits.map((c) => ({ sha: c.sha })), status }, { timeoutMs: QUICK_TIMEOUT_MS });
|
|
53
|
-
return res.status;
|
|
43
|
+
const value = answer?.trim().toLowerCase();
|
|
44
|
+
return value === '' || value === 'y' || value === 'yes' ? 'debrief' : 'later';
|
|
54
45
|
}
|
|
55
46
|
async function recordPending(api, repo, push, out) {
|
|
56
|
-
await
|
|
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 });
|
|
57
48
|
out.write(`Ownit: push recorded as pending. ${RESUME_HINT}\n`);
|
|
58
49
|
return 0;
|
|
59
50
|
}
|
|
@@ -71,20 +62,14 @@ async function knownStatus(api, repo, push) {
|
|
|
71
62
|
});
|
|
72
63
|
return known.status;
|
|
73
64
|
}
|
|
74
|
-
/** Asks whether to debrief now, then debriefs
|
|
65
|
+
/** Asks whether to debrief now, then debriefs or leaves the push pending for `ownit interview`. */
|
|
75
66
|
async function askAndDebrief(api, terminal, repo, push, out) {
|
|
76
67
|
const c = paint(out);
|
|
77
68
|
const prompter = createPrompter(terminal, out);
|
|
78
69
|
try {
|
|
79
70
|
const n = push.commits.length;
|
|
80
|
-
const answer = await prompter.ask(`${c.bold('Ownit')} ${c.dim(`(${n} ${plural(n, 'commit')} to ${repo})`)} Debrief now? [Y/n
|
|
81
|
-
|
|
82
|
-
if (choice === 'skip') {
|
|
83
|
-
await record(api, repo, push, 'skipped');
|
|
84
|
-
out.write(c.dim('Ownit: skipped. Push continues.\n'));
|
|
85
|
-
return 0;
|
|
86
|
-
}
|
|
87
|
-
if (choice === 'later')
|
|
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')
|
|
88
73
|
return await recordPending(api, repo, push, out);
|
|
89
74
|
await runDebrief(api, prompter, {
|
|
90
75
|
repo,
|
|
@@ -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
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,92 @@ 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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
/** A commit ID or ref, with a hint on where to find one when git does not know it. */
|
|
35
|
+
function commitFor(rev) {
|
|
36
|
+
try {
|
|
37
|
+
return resolveCommit(rev);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`"${rev}" is not a commit in this repo. Find commit IDs with "git log --oneline", for example --range 7a9e3f1..f3c9a12.`);
|
|
39
41
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
}
|
|
43
|
+
/** A range "A..B", or a single commit, which means just that commit. */
|
|
44
|
+
function parseRange(range) {
|
|
45
|
+
const match = range.match(/^(.+?)\.\.(.+)$/);
|
|
46
|
+
if (match)
|
|
47
|
+
return { base: commitFor(match[1]), head: commitFor(match[2]) };
|
|
48
|
+
const head = commitFor(range);
|
|
49
|
+
return { base: tryGit(['rev-parse', '--verify', '--quiet', `${head}^`])?.trim() || null, head };
|
|
50
|
+
}
|
|
51
|
+
async function debriefRange(api, prompter, repo, range) {
|
|
52
|
+
const { base, head } = parseRange(range);
|
|
53
|
+
const commits = base ? listCommits([`${base}..${head}`]) : listCommits(['-1', head]);
|
|
43
54
|
if (!commits.length) {
|
|
44
55
|
prompter.out.write(`Ownit: no commits in ${range}.\n`);
|
|
45
56
|
return null;
|
|
46
57
|
}
|
|
47
58
|
return runDebrief(api, prompter, { repo, baseSha: base, headSha: head, commits, diff: buildDiff([{ base, head }]) });
|
|
48
59
|
}
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
function toChoice(push) {
|
|
61
|
+
if (![push.head_sha, ...push.commits].every((sha) => objectExists(sha)))
|
|
62
|
+
return { push, commits: [] };
|
|
63
|
+
const range = rangeForRecordedPush(push.base_sha, push.head_sha, push.commits);
|
|
64
|
+
const known = new Set(push.commits);
|
|
65
|
+
const commits = listCommits([range.base ? `${range.base}..${range.head}` : range.head]).filter((x) => known.has(x.sha));
|
|
66
|
+
return { push, commits: commits.length ? commits : listCommits(['-1', range.head]) };
|
|
67
|
+
}
|
|
68
|
+
const shortDate = (iso) => new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
69
|
+
/** One line per push: number, status, head commit, date and what the commits say. */
|
|
70
|
+
function printChoices(out, choices) {
|
|
71
|
+
const c = paint(out);
|
|
72
|
+
const width = Math.min(process.stderr.columns || 80, 100);
|
|
73
|
+
choices.forEach(({ push, commits }, i) => {
|
|
74
|
+
const status = push.status === 'provider_error' ? 'error' : push.status;
|
|
75
|
+
const head = `${String(i + 1).padStart(3)} ${status.padEnd(7)} ${push.head_sha.slice(0, 7)} ${shortDate(push.created_at).padEnd(6)} `;
|
|
76
|
+
let title;
|
|
77
|
+
if (!commits.length)
|
|
78
|
+
title = c.yellow('not in this clone, run "git fetch"');
|
|
79
|
+
else {
|
|
80
|
+
const subject = commits.at(-1).message.split('\n')[0];
|
|
81
|
+
const more = commits.length > 1 ? ` (+${commits.length - 1} more)` : '';
|
|
82
|
+
const room = Math.max(20, width - head.length - more.length);
|
|
83
|
+
title = (subject.length > room ? `${subject.slice(0, room - 1)}…` : subject) + c.dim(more);
|
|
84
|
+
}
|
|
85
|
+
out.write(`${c.dim(head)}${title}\n`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** What the developer picked: a list index, every push, quit (also on end of input), or null when unclear. */
|
|
89
|
+
export function parsePick(answer, count) {
|
|
90
|
+
const text = answer?.trim().toLowerCase() ?? 'q';
|
|
91
|
+
if (text === '')
|
|
92
|
+
return 0;
|
|
93
|
+
if (text === 'q' || text === 'quit')
|
|
94
|
+
return 'quit';
|
|
95
|
+
if (text === 'a' || text === 'all')
|
|
96
|
+
return 'all';
|
|
97
|
+
const n = Number(text);
|
|
98
|
+
return Number.isInteger(n) && n >= 1 && n <= count ? n - 1 : null;
|
|
99
|
+
}
|
|
100
|
+
async function debriefChoice(api, prompter, repo, { push, commits }) {
|
|
101
|
+
const range = rangeForRecordedPush(push.base_sha, push.head_sha, push.commits);
|
|
102
|
+
return runDebrief(api, prompter, { repo, baseSha: range.base, headSha: range.head, commits, diff: buildDiff([range]), pushId: push.id });
|
|
103
|
+
}
|
|
104
|
+
/** The debrief was cut off or failed, so stop. "s" (later) is not a stop: it moves on to the next push. */
|
|
105
|
+
const stopped = (outcome) => outcome.reason !== LATER && (outcome.status === 'pending' || outcome.status === 'provider_error');
|
|
106
|
+
/**
|
|
107
|
+
* Lists the pushes still waiting for a debrief, newest first, and debriefs the
|
|
108
|
+
* ones the developer picks, one at a time. `all` keeps going without asking.
|
|
109
|
+
*/
|
|
110
|
+
async function debriefOpenPushes(api, prompter, repo, all) {
|
|
51
111
|
const out = prompter.out;
|
|
52
112
|
const c = paint(out);
|
|
53
113
|
const { pushes } = await api.request('GET', `/api/pushes/open?repo=${encodeURIComponent(repo)}`);
|
|
@@ -55,28 +115,46 @@ async function debriefOpenPushes(api, prompter, repo) {
|
|
|
55
115
|
out.write(`${c.green('✓')} Nothing to debrief for ${repo}.\n`);
|
|
56
116
|
return 0;
|
|
57
117
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
118
|
+
let choices = pushes.map(toChoice);
|
|
119
|
+
if (all)
|
|
120
|
+
return debriefInOrder(api, prompter, repo, choices);
|
|
121
|
+
while (choices.length) {
|
|
122
|
+
out.write(`\n${choices.length} ${plural(choices.length, 'push', 'pushes')} waiting for a debrief in ${repo}:\n\n`);
|
|
123
|
+
printChoices(out, choices);
|
|
124
|
+
const pick = parsePick(await prompter.ask(`\nDebrief which one? ${c.dim('[number, Enter for 1, a = all, q = quit]')} `), choices.length);
|
|
125
|
+
if (pick === 'quit') {
|
|
126
|
+
out.write(c.dim('Run "ownit interview" any time to come back.\n'));
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (pick === null) {
|
|
130
|
+
out.write(c.yellow(`Pick a number from 1 to ${choices.length}, a or q.\n`));
|
|
65
131
|
continue;
|
|
66
132
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
133
|
+
if (pick === 'all')
|
|
134
|
+
return debriefInOrder(api, prompter, repo, choices);
|
|
135
|
+
const choice = choices[pick];
|
|
136
|
+
if (!choice.commits.length) {
|
|
137
|
+
out.write(c.yellow(`Commit ${choice.push.head_sha.slice(0, 7)} is not in this clone. Run "git fetch" and try again.\n`));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const outcome = await debriefChoice(api, prompter, repo, choice);
|
|
141
|
+
if (stopped(outcome))
|
|
142
|
+
return 1;
|
|
143
|
+
if (outcome.reason !== LATER)
|
|
144
|
+
choices = choices.filter((x) => x !== choice);
|
|
145
|
+
}
|
|
146
|
+
out.write(`\n${c.green('✓')} Nothing left to debrief for ${repo}.\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
/** Debriefs every push in the list, newest first, until one is paused or fails. */
|
|
150
|
+
async function debriefInOrder(api, prompter, repo, choices) {
|
|
151
|
+
for (const choice of choices.filter((x) => x.commits.length)) {
|
|
152
|
+
if (stopped(await debriefChoice(api, prompter, repo, choice)))
|
|
79
153
|
return 1;
|
|
80
154
|
}
|
|
81
|
-
|
|
155
|
+
const missing = choices.filter((x) => !x.commits.length).length;
|
|
156
|
+
if (missing) {
|
|
157
|
+
prompter.out.write(paint(prompter.out).yellow(`Ownit: ${missing} ${plural(missing, 'push', 'pushes')} not in this clone. Run "git fetch" and try again.\n`));
|
|
158
|
+
}
|
|
159
|
+
return missing ? 1 : 0;
|
|
82
160
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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]\nFor example: ownit settings answers on. Run "ownit help settings" for details.\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 === 'answers' || changed === 'questions') && !on && !s.show_answers)
|
|
22
|
+
return 'Answers are hidden, and saved ones 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 === undefined) {
|
|
36
|
+
out.write(`Add on or off: "ownit settings ${changed} on" or "ownit settings ${changed} off".\n`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
if (changed !== undefined && (!Object.hasOwn(SETTING, changed) || (value !== 'on' && value !== 'off') || rest.length)) {
|
|
40
|
+
out.write(USAGE);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
const repo = githubRepo();
|
|
44
|
+
if (!repo) {
|
|
45
|
+
out.write('Ownit: no GitHub remote found. Run this in a repo set up with "ownit init".\n');
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
const api = createApi();
|
|
49
|
+
const settings = changed
|
|
50
|
+
? await api.request('PUT', '/api/repos/settings', { repo, [SETTING[changed]]: value === 'on' })
|
|
51
|
+
: await api.request('GET', `/api/repos/settings?repo=${encodeURIComponent(repo)}`);
|
|
52
|
+
const c = paint(out);
|
|
53
|
+
out.write(`${c.bold(settings.page_url)}\n${describe(settings)}\n`);
|
|
54
|
+
const line = note(settings, changed, value === 'on');
|
|
55
|
+
if (line)
|
|
56
|
+
out.write(`${c.dim(line)}\n`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
package/dist/debrief.js
CHANGED
|
@@ -3,9 +3,12 @@ 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';
|
|
9
12
|
/** Shows a spinner while `work` runs. */
|
|
10
13
|
async function withSpinner(out, text, work) {
|
|
11
14
|
const spin = spinner(out, text);
|
|
@@ -48,8 +51,8 @@ async function askUntilPassed(debrief, first) {
|
|
|
48
51
|
out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
|
|
49
52
|
return { status: 'pending', reason: 'debrief paused' };
|
|
50
53
|
}
|
|
51
|
-
if (
|
|
52
|
-
return
|
|
54
|
+
if (isLater(answer))
|
|
55
|
+
return later(out);
|
|
53
56
|
let result;
|
|
54
57
|
try {
|
|
55
58
|
result = await withSpinner(out, 'Checking your answer…', () => api.request('POST', '/api/interview/answer', {
|
|
@@ -63,6 +66,7 @@ async function askUntilPassed(debrief, first) {
|
|
|
63
66
|
return failure(out, error);
|
|
64
67
|
}
|
|
65
68
|
debrief.session = result.session;
|
|
69
|
+
debrief.earlierCredited = result.earlier_credited ?? 0;
|
|
66
70
|
if (result.pass) {
|
|
67
71
|
out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
|
|
68
72
|
return null;
|
|
@@ -100,25 +104,25 @@ export async function runDebrief(api, prompter, target) {
|
|
|
100
104
|
catch (error) {
|
|
101
105
|
return failure(out, error);
|
|
102
106
|
}
|
|
103
|
-
out.write(c.dim(`Answer in your own words, up to 5 sentences. Rough is fine. Type "s" to
|
|
104
|
-
|
|
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 };
|
|
105
111
|
for (const question of start.questions) {
|
|
106
112
|
const ended = await askUntilPassed(debrief, question);
|
|
107
113
|
if (ended)
|
|
108
114
|
return ended;
|
|
109
115
|
}
|
|
110
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`));
|
|
111
120
|
return { status: 'verified' };
|
|
112
121
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
catch (error) {
|
|
118
|
-
return failure(out, error);
|
|
119
|
-
}
|
|
120
|
-
out.write(`${paint(out).dim('Skipped. This push is recorded as skipped.')}\n`);
|
|
121
|
-
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 };
|
|
122
126
|
}
|
|
123
127
|
/** Prints a one-line reason. Provider problems stay out of the stats until retried. */
|
|
124
128
|
function failure(out, error) {
|
package/dist/index.js
CHANGED
|
@@ -6,18 +6,40 @@ import { initCommand, uninstallCommand } from './commands/init.js';
|
|
|
6
6
|
import { interviewCommand } from './commands/interview.js';
|
|
7
7
|
import { keyCommand } from './commands/key.js';
|
|
8
8
|
import { loginCommand, logoutCommand, whoamiCommand } from './commands/login.js';
|
|
9
|
-
import {
|
|
9
|
+
import { settingsCommand } from './commands/settings.js';
|
|
10
|
+
import { closestMatch, errorMessage } from './ui.js';
|
|
10
11
|
/** Every command, in the order `ownit help` lists them. */
|
|
11
12
|
const COMMANDS = {
|
|
12
|
-
login: {
|
|
13
|
+
login: {
|
|
14
|
+
help: [['ownit login [--server URL]', 'Sign in with GitHub (device flow)']],
|
|
15
|
+
details: `Shows a code and opens GitHub. Approve there, and you're signed in.
|
|
16
|
+
Use --server URL only for a self-hosted Ownit server.`,
|
|
17
|
+
run: loginCommand,
|
|
18
|
+
},
|
|
13
19
|
logout: { help: [['ownit logout', 'Sign out']], run: logoutCommand },
|
|
14
20
|
whoami: { help: [['ownit whoami', 'Show server, login, and key status']], run: whoamiCommand },
|
|
15
|
-
init: {
|
|
21
|
+
init: {
|
|
22
|
+
help: [['ownit init [--repo owner/name]', 'Install the pre-push hook in this repo']],
|
|
23
|
+
details: `Run it inside a clone of your GitHub repo. It installs the pre-push hook, registers the
|
|
24
|
+
repo with Ownit, and prints the README badge. The Ownit GitHub App must be installed on the
|
|
25
|
+
repo; if it isn't, init prints the link. Use --repo owner/name when the remote isn't GitHub.`,
|
|
26
|
+
run: initCommand,
|
|
27
|
+
},
|
|
16
28
|
interview: {
|
|
17
29
|
help: [
|
|
18
|
-
['ownit interview', '
|
|
19
|
-
['ownit interview --
|
|
30
|
+
['ownit interview', 'List waiting pushes and debrief the one you pick'],
|
|
31
|
+
['ownit interview --all', 'Debrief every waiting push in turn'],
|
|
32
|
+
['ownit interview --range A..B', 'Debrief a commit range, or one commit (SHA)'],
|
|
20
33
|
],
|
|
34
|
+
details: `With no options, it lists the pushes waiting for a debrief and asks which one to do:
|
|
35
|
+
a number that push Enter the newest
|
|
36
|
+
a all, one by one q quit
|
|
37
|
+
During a debrief, type "s" to stop and finish later.
|
|
38
|
+
|
|
39
|
+
Older commits (count once verified, if "ownit settings earlier on"):
|
|
40
|
+
ownit interview --range 8d21b07 just that commit
|
|
41
|
+
ownit interview --range 7a9e3f1..f3c9a12 every commit after 7a9e3f1, up to f3c9a12
|
|
42
|
+
Find commit IDs with "git log --oneline". Keep ranges small, about the size of a normal push.`,
|
|
21
43
|
run: interviewCommand,
|
|
22
44
|
},
|
|
23
45
|
key: {
|
|
@@ -25,30 +47,85 @@ const COMMANDS = {
|
|
|
25
47
|
['ownit key set [nebius|nvidia]', 'Save your model API key (stored locally)'],
|
|
26
48
|
['ownit key clear|status', 'Remove or show it'],
|
|
27
49
|
],
|
|
50
|
+
details: ` ownit key set asks for the provider, then the key at a hidden prompt
|
|
51
|
+
ownit key set nvidia an nvapi- key from build.nvidia.com
|
|
52
|
+
ownit key set nebius a key from tokenfactory.nebius.com
|
|
53
|
+
Never type the key after the command: it would end up in your shell history.
|
|
54
|
+
NVIDIA_API_KEY or NEBIUS_API_KEY in the environment work too.`,
|
|
28
55
|
run: keyCommand,
|
|
29
56
|
},
|
|
30
|
-
badge: {
|
|
57
|
+
badge: {
|
|
58
|
+
help: [['ownit badge', "Print this repo's badge Markdown for your README"]],
|
|
59
|
+
details: `Paste the output into your README. To copy it straight away:
|
|
60
|
+
ownit badge | clip (Windows)
|
|
61
|
+
ownit badge | pbcopy (macOS)
|
|
62
|
+
The repo page also has a Copy button.`,
|
|
63
|
+
run: badgeCommand,
|
|
64
|
+
},
|
|
65
|
+
settings: {
|
|
66
|
+
help: [
|
|
67
|
+
['ownit settings', "Show this repo's settings and page link"],
|
|
68
|
+
['ownit settings questions on|off', 'Show debrief questions on the page (admins)'],
|
|
69
|
+
['ownit settings answers on|off', 'Show passing answers on the page (admins)'],
|
|
70
|
+
['ownit settings earlier on|off', 'Count older commits once debriefed (admins)'],
|
|
71
|
+
],
|
|
72
|
+
details: `Anyone who can push sees the settings; admins and maintainers change them.
|
|
73
|
+
questions show debrief questions on the repo page (default on)
|
|
74
|
+
answers show passing answers there too (default off; off deletes saved ones)
|
|
75
|
+
earlier count commits from before Ownit once someone debriefs them (default off)
|
|
76
|
+
Examples:
|
|
77
|
+
ownit settings answers on
|
|
78
|
+
ownit settings earlier on
|
|
79
|
+
Private repos never show questions or answers.`,
|
|
80
|
+
run: settingsCommand,
|
|
81
|
+
},
|
|
31
82
|
uninstall: { help: [['ownit uninstall', 'Remove the pre-push hook']], run: uninstallCommand },
|
|
32
|
-
help: { help: [['ownit help', 'Show this help']], run: () =>
|
|
83
|
+
help: { help: [['ownit help [command]', 'Show this help, or details for one command']], run: (args) => helpCommand(args) },
|
|
33
84
|
// Not listed: the installed git hook calls `ownit hook pre-push`.
|
|
34
85
|
hook: { run: (args) => hookCommand(args) },
|
|
35
86
|
version: { run: () => print(`${version()}\n`) },
|
|
36
87
|
};
|
|
37
|
-
const ALIASES = { status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
|
|
88
|
+
const ALIASES = { interviews: 'interview', setting: 'settings', page: 'settings', status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
|
|
89
|
+
const commandName = (name) => (Object.hasOwn(ALIASES, name) ? ALIASES[name] : name);
|
|
38
90
|
function findCommand(name) {
|
|
39
|
-
const key =
|
|
91
|
+
const key = commandName(name);
|
|
40
92
|
return Object.hasOwn(COMMANDS, key) ? COMMANDS[key] : undefined;
|
|
41
93
|
}
|
|
94
|
+
const usageLines = (lines) => lines.map(([line, description]) => ` ${line.padEnd(33)}${description}`).join('\n');
|
|
95
|
+
/** One command's usage lines and details. */
|
|
96
|
+
function commandHelp(command) {
|
|
97
|
+
return `Usage:\n${usageLines(command.help ?? [])}\n${command.details ? `\n${command.details}\n` : ''}`;
|
|
98
|
+
}
|
|
99
|
+
function helpCommand(args) {
|
|
100
|
+
const [name] = args;
|
|
101
|
+
if (!name)
|
|
102
|
+
return print(helpText());
|
|
103
|
+
const command = findCommand(name);
|
|
104
|
+
if (!command?.help)
|
|
105
|
+
return unknownCommand(name);
|
|
106
|
+
return print(commandHelp(command));
|
|
107
|
+
}
|
|
108
|
+
/** Points a typo at the closest listed command, or prints the full help. */
|
|
109
|
+
function unknownCommand(name) {
|
|
110
|
+
// Suggest only listed commands, so a typo never points at the hidden hook command.
|
|
111
|
+
const listed = Object.keys(COMMANDS).filter((key) => COMMANDS[key].help);
|
|
112
|
+
const guess = closestMatch(name, listed);
|
|
113
|
+
process.stderr.write(guess
|
|
114
|
+
? `Unknown command "${name}". Did you mean "ownit ${guess}"?\nRun "ownit help" to see every command.\n`
|
|
115
|
+
: `Unknown command "${name}".\n\n${helpText()}`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
42
118
|
function helpText() {
|
|
43
|
-
const usage = Object.values(COMMANDS)
|
|
44
|
-
.flatMap((command) => command.help ?? [])
|
|
45
|
-
.map(([line, description]) => ` ${line.padEnd(33)}${description}`)
|
|
46
|
-
.join('\n');
|
|
119
|
+
const usage = usageLines(Object.values(COMMANDS).flatMap((command) => command.help ?? []));
|
|
47
120
|
return `ownit: debrief the code you just pushed.
|
|
48
121
|
|
|
122
|
+
Get started: ownit login, then ownit key set, then ownit init in your repo.
|
|
123
|
+
After that, just git push: Ownit asks "Debrief now? [Y/n]".
|
|
124
|
+
|
|
49
125
|
Usage:
|
|
50
126
|
${usage}
|
|
51
127
|
|
|
128
|
+
Run "ownit help <command>" (or "ownit <command> --help") for details and examples.
|
|
52
129
|
Set OWNIT_SKIP=1 to push once without a debrief prompt (the push is pending).
|
|
53
130
|
`;
|
|
54
131
|
}
|
|
@@ -63,11 +140,21 @@ function print(text) {
|
|
|
63
140
|
async function main(argv) {
|
|
64
141
|
const [name = 'help', ...args] = argv;
|
|
65
142
|
const command = findCommand(name);
|
|
66
|
-
if (!command)
|
|
67
|
-
|
|
68
|
-
|
|
143
|
+
if (!command)
|
|
144
|
+
return unknownCommand(name);
|
|
145
|
+
if (command.help && (args.includes('--help') || args.includes('-h')))
|
|
146
|
+
return print(commandHelp(command));
|
|
147
|
+
try {
|
|
148
|
+
return await command.run(args);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
// A mistyped option, e.g. --rnage: say where the right ones are listed.
|
|
152
|
+
if (error.code?.startsWith('ERR_PARSE_ARGS')) {
|
|
153
|
+
process.stderr.write(`Ownit: ${errorMessage(error)}\nRun "ownit help ${commandName(name)}" for the options.\n`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
69
157
|
}
|
|
70
|
-
return command.run(args);
|
|
71
158
|
}
|
|
72
159
|
main(process.argv.slice(2)).then((code) => {
|
|
73
160
|
process.exitCode = code;
|
package/dist/ui.js
CHANGED
|
@@ -95,3 +95,26 @@ export function wrap(text, indent = ' ', width = Math.min(process.stderr.column
|
|
|
95
95
|
lines.push(line);
|
|
96
96
|
return lines.map((l) => indent + l).join('\n');
|
|
97
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.3",
|
|
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
|
},
|