scribeclock 0.2.0 → 0.3.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 +21 -3
- package/dist/auth/lock.js +55 -0
- package/dist/auth/store.js +21 -5
- package/dist/auth/token.js +67 -29
- package/dist/commands/agent.js +25 -7
- package/dist/commands/gitflow.js +16 -4
- package/dist/commands/index.js +2 -0
- package/dist/commands/ingest.js +21 -2
- package/dist/commands/pulls.js +279 -0
- package/dist/commands/team.js +33 -1
- package/dist/mcp/client.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Requires Node 20 or newer. No runtime dependencies.
|
|
|
30
30
|
|
|
31
31
|
Credentials live in the macOS Keychain or libsecret (`secret-tool`) on Linux; without a usable
|
|
32
32
|
keychain they go to `~/.config/scribeclock/credentials.json` with mode `0600` and a one-time warning.
|
|
33
|
+
Concurrent commands share session refresh safely. Temporary refresh failures preserve the saved login; retry the command when the service is available.
|
|
33
34
|
Tokens are never printed; anything that looks like one is scrubbed from output.
|
|
34
35
|
|
|
35
36
|
`SCRIBECLOCK_URL` (or `--url`) points the CLI at another server (staging). Logins are kept per server.
|
|
@@ -49,7 +50,8 @@ Run `scribeclock --help` for the full list and `scribeclock <command> --help` fo
|
|
|
49
50
|
| Vault & env | `vault ls\|get\|set\|credential\|rm` · `env pull\|push` · `run -- <command>` |
|
|
50
51
|
| Cloudflare | `cf accounts\|zones\|dns\|dns set\|dns rm\|dns diff\|pages\|deployments\|deploy-status\|workers\|purge\|request` |
|
|
51
52
|
| Team | `team` · `overlap [files…]` (exit 4 on overlap) · `team report` · `handoff [record]` |
|
|
52
|
-
| Git | `branch KEY [--create]` · `commit -m "…"` · `pr KEY` · `changelog --since <tag>` |
|
|
53
|
+
| Git | `branch KEY [--create]` · `commit -m "…"` · `pr KEY [--reviewer …]` · `changelog --since <tag>` |
|
|
54
|
+
| Pull requests | `prs [--mine]` · `pr gate [--all]` (exit 4 while a PR needs you) · `pr wait [N] [--deploy]` · `pr report [N\|URL] [--reviewer …]` |
|
|
53
55
|
| Bug reports | `connect [--env] [--vault] [--create]` · `issues [show\|resolve --release X\|ignore\|reopen\|task\|urgency]` · `analytics [--days N] [--report X] [--csv]` · `ingest settings [--immediate critical --per-hour 2 --every 120]` · `bug "title" [-d …] [--log file] [--stack file]` · `ingest keys\|revoke\|test\|snippets\|forward [provider]` · `health [--days N]` |
|
|
54
56
|
| Ops & agents | `doctor` · `watch tasks [--on-change cmd]` · `serve --stdio` · `mcp-config <client> [--install]` · `ask "…"` |
|
|
55
57
|
| Raw tools | `tools` · `call <tool> [--arg v \| --args '{json}']` · `<tool_name> …` · `refresh` |
|
|
@@ -92,6 +94,7 @@ scribeclock start #731 # claim + clock in against the task +
|
|
|
92
94
|
scribeclock progress auto "DNS pointed at Pages; waiting for SSL"
|
|
93
95
|
scribeclock commit -m "Point apex at Pages" # appends (#731) and posts the commit as progress
|
|
94
96
|
scribeclock overlap src/dns.ts # exit 4 if a teammate is on the same file
|
|
97
|
+
scribeclock delivery # client project: push here, or is this the client's repo? (exit 4 = do not push)
|
|
95
98
|
|
|
96
99
|
# Finishing
|
|
97
100
|
scribeclock done #731 -m "Site live with SSL; checkout tested." # runs .scribeclock.json checks first
|
|
@@ -154,8 +157,23 @@ scribeclock hooks status
|
|
|
154
157
|
With the hooks installed, the agent's session starts with one server call (`scribeclock session`, which
|
|
155
158
|
runs `start_repo_session`: clock, your tasks, who is active, the handoff to resume from, overlaps, inbox),
|
|
156
159
|
every file edit keeps your presence fresh (`scribeclock heartbeat`, debounced, no tokens), every `git push`
|
|
157
|
-
tells teammates' agents to pull, every `git pull` clears the notice,
|
|
158
|
-
|
|
160
|
+
tells teammates' agents to pull, every `git pull` clears the notice, every `gh pr create` records the PR and
|
|
161
|
+
notifies its reviewers, and the session end leaves a handoff. Only warnings (a teammate pushed, a pull request
|
|
162
|
+
needs you, someone is on the same file, a message) are put in front of the agent.
|
|
163
|
+
|
|
164
|
+
### Pull request gate
|
|
165
|
+
|
|
166
|
+
Like the pull notice, a pull request that needs you stays at the top of your agent's replies
|
|
167
|
+
(`pr_action_required`) until it is handled: a review someone asked of you, changes requested on yours, red CI,
|
|
168
|
+
an approved PR waiting to be merged, or a failed deploy after merge. Reviewers are notified as soon as the PR
|
|
169
|
+
is opened (through the GitHub webhook, or the hook above when `gh pr create` runs).
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
scribeclock pr '#731' --reviewer octocat # open, notify, attach to the task
|
|
173
|
+
scribeclock prs # this repository's PRs and what each needs from you
|
|
174
|
+
scribeclock pr gate || echo "handle your PRs first" # exit 4 while something blocks
|
|
175
|
+
scribeclock pr wait --deploy # block until this branch's PR is merged and deployed
|
|
176
|
+
```
|
|
159
177
|
Commit `.claude/settings.json` / `.cursor/hooks.json` so the whole team gets it.
|
|
160
178
|
|
|
161
179
|
`scribeclock session` and `scribeclock heartbeat` also work by hand, and `scribeclock serve --stdio` runs
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CliError } from "../cli/errors.js";
|
|
5
|
+
/** Keep a rotating refresh token exclusive until its replacement is safely persisted. */
|
|
6
|
+
export async function withRefreshLock(dir, profile, run) {
|
|
7
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
8
|
+
const path = join(dir, `refresh-${createHash("sha256").update(profile).digest("hex")}.lock`);
|
|
9
|
+
const started = Date.now();
|
|
10
|
+
for (;;) {
|
|
11
|
+
try {
|
|
12
|
+
const fd = openSync(path, "wx", 0o600);
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(fd, String(process.pid));
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
closeSync(fd);
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code !== "EEXIST")
|
|
23
|
+
throw err;
|
|
24
|
+
try {
|
|
25
|
+
const pid = Number(readFileSync(path, "utf8"));
|
|
26
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err.code === "ESRCH")
|
|
32
|
+
unlinkSync(path);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (Date.now() - statSync(path).mtimeMs > 30_000) {
|
|
36
|
+
unlinkSync(path);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err.code !== "ENOENT")
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
if (Date.now() - started > 30_000) {
|
|
44
|
+
throw new CliError("Another ScribeClock command is refreshing your session. Try again shortly.", { id: "refresh_busy" });
|
|
45
|
+
}
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return await run();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
unlinkSync(path);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/auth/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, renameSync, unlinkSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { writeJsonPrivate } from "../config.js";
|
|
@@ -109,13 +110,24 @@ export class FileStore {
|
|
|
109
110
|
return {};
|
|
110
111
|
}
|
|
111
112
|
}
|
|
113
|
+
writeAll(profiles) {
|
|
114
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
115
|
+
try {
|
|
116
|
+
writeJsonPrivate(temporary, { version: 1, profiles });
|
|
117
|
+
renameSync(temporary, this.path);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
if (existsSync(temporary))
|
|
121
|
+
unlinkSync(temporary);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
112
124
|
async load(profile) {
|
|
113
125
|
return this.readAll()[profile] ?? null;
|
|
114
126
|
}
|
|
115
127
|
async save(profile, creds) {
|
|
116
128
|
const all = this.readAll();
|
|
117
129
|
all[profile] = creds;
|
|
118
|
-
|
|
130
|
+
this.writeAll(all);
|
|
119
131
|
}
|
|
120
132
|
async clear(profile) {
|
|
121
133
|
const all = this.readAll();
|
|
@@ -131,7 +143,7 @@ export class FileStore {
|
|
|
131
143
|
}
|
|
132
144
|
return;
|
|
133
145
|
}
|
|
134
|
-
|
|
146
|
+
this.writeAll(all);
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
/**
|
|
@@ -177,8 +189,11 @@ export class ResilientStore {
|
|
|
177
189
|
if (!this.usingFallback) {
|
|
178
190
|
try {
|
|
179
191
|
const fromKeychain = await this.primary.load(profile);
|
|
180
|
-
|
|
181
|
-
|
|
192
|
+
const fromFile = await this.fallbackStore.load(profile);
|
|
193
|
+
// A failed keychain write can leave the rotated pair only in the fallback file.
|
|
194
|
+
if (fromFile && (!fromKeychain || fromFile.saved_at > fromKeychain.saved_at))
|
|
195
|
+
return fromFile;
|
|
196
|
+
return fromKeychain ?? fromFile;
|
|
182
197
|
}
|
|
183
198
|
catch (err) {
|
|
184
199
|
this.degrade(err);
|
|
@@ -190,6 +205,7 @@ export class ResilientStore {
|
|
|
190
205
|
if (!this.usingFallback && this.primary !== this.fallbackStore) {
|
|
191
206
|
try {
|
|
192
207
|
await this.primary.save(profile, creds);
|
|
208
|
+
await this.fallbackStore.clear(profile);
|
|
193
209
|
return;
|
|
194
210
|
}
|
|
195
211
|
catch (err) {
|
package/dist/auth/token.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { configDir } from "../config.js";
|
|
2
|
+
import { withRefreshLock } from "./lock.js";
|
|
3
|
+
import { CliError, authError } from "../cli/errors.js";
|
|
2
4
|
import { refreshTokens, expiresAt } from "./oauth.js";
|
|
3
5
|
/** Access tokens expire after 8 h; refresh this long before that so a call never lands on a dead token. */
|
|
4
6
|
const REFRESH_SKEW_MS = 60 * 1000;
|
|
5
7
|
export class TokenProvider {
|
|
6
8
|
opts;
|
|
9
|
+
refreshing;
|
|
7
10
|
cached;
|
|
8
11
|
constructor(opts) {
|
|
9
12
|
this.opts = opts;
|
|
@@ -35,40 +38,75 @@ export class TokenProvider {
|
|
|
35
38
|
if (!creds)
|
|
36
39
|
return null;
|
|
37
40
|
if (creds.kind === "oauth" && creds.refresh_token && this.isExpiring(creds)) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
try {
|
|
42
|
+
const refreshed = await this.refresh(creds.access_token);
|
|
43
|
+
if (refreshed)
|
|
44
|
+
return refreshed;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
// A transient failure (network, 5xx, busy lock) must not break a token that still works.
|
|
48
|
+
const stillValid = !creds.expires_at || new Date(creds.expires_at).getTime() > this.now().getTime();
|
|
49
|
+
if (!stillValid || err.id === "auth")
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
41
52
|
}
|
|
42
53
|
return creds.access_token;
|
|
43
54
|
}
|
|
44
|
-
/**
|
|
45
|
-
async refresh() {
|
|
55
|
+
/** Serialize rotation across providers and CLI processes, then reload the winning credentials. */
|
|
56
|
+
async refresh(rejectedToken) {
|
|
46
57
|
if (this.opts.env.SCRIBECLOCK_TOKEN)
|
|
47
58
|
return null;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
+
if (this.refreshing)
|
|
60
|
+
return this.refreshing;
|
|
61
|
+
const pending = this.refreshLocked(rejectedToken);
|
|
62
|
+
this.refreshing = pending;
|
|
63
|
+
try {
|
|
64
|
+
return await pending;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
this.refreshing = undefined;
|
|
59
68
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
}
|
|
70
|
+
async refreshLocked(rejectedToken) {
|
|
71
|
+
const observed = await this.credentials();
|
|
72
|
+
const previous = rejectedToken ?? observed?.access_token;
|
|
73
|
+
return withRefreshLock(configDir(this.opts.env), this.opts.profile, async () => {
|
|
74
|
+
const creds = await this.opts.store.load(this.opts.profile);
|
|
75
|
+
this.cached = creds;
|
|
76
|
+
if (!creds)
|
|
77
|
+
return null;
|
|
78
|
+
if (previous && creds.access_token !== previous)
|
|
79
|
+
return creds.access_token;
|
|
80
|
+
if (creds.kind !== "oauth" || !creds.refresh_token || !creds.token_endpoint)
|
|
81
|
+
return null;
|
|
82
|
+
const result = await refreshTokens(this.opts.fetch, creds.token_endpoint, creds.refresh_token, creds.client_id);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
if (result.error === "invalid_grant" || result.error === "invalid_client") {
|
|
85
|
+
// Another process may have rotated the pair while we were waiting; use theirs if so.
|
|
86
|
+
const latest = await this.opts.store.load(this.opts.profile);
|
|
87
|
+
if (latest && latest.refresh_token !== creds.refresh_token) {
|
|
88
|
+
this.cached = latest;
|
|
89
|
+
return latest.access_token;
|
|
90
|
+
}
|
|
91
|
+
await this.opts.store.clear(this.opts.profile);
|
|
92
|
+
this.cached = null;
|
|
93
|
+
throw authError("Your ScribeClock session was revoked or expired.", "Run `scribeclock login` to sign in again.");
|
|
94
|
+
}
|
|
95
|
+
throw new CliError("Could not refresh your ScribeClock session. Try again in a moment.", { id: "refresh_failed" });
|
|
96
|
+
}
|
|
97
|
+
const next = {
|
|
98
|
+
...creds,
|
|
99
|
+
access_token: result.tokens.access_token,
|
|
100
|
+
refresh_token: result.tokens.refresh_token ?? creds.refresh_token,
|
|
101
|
+
expires_at: expiresAt(result.tokens.expires_in, this.now()),
|
|
102
|
+
scopes: result.tokens.scope ? result.tokens.scope.split(/\s+/).filter(Boolean) : creds.scopes,
|
|
103
|
+
saved_at: this.now().toISOString(),
|
|
104
|
+
};
|
|
105
|
+
await this.opts.store.save(this.opts.profile, next);
|
|
106
|
+
this.cached = next;
|
|
107
|
+
this.opts.onRefreshed?.(next);
|
|
108
|
+
return next.access_token;
|
|
109
|
+
});
|
|
72
110
|
}
|
|
73
111
|
/** Forget the in-memory copy (after login/logout). */
|
|
74
112
|
invalidate() {
|
package/dist/commands/agent.js
CHANGED
|
@@ -17,6 +17,7 @@ import { changedFiles, currentBranch, detectTaskKey, git, gitState, lastCommit,
|
|
|
17
17
|
import { EXIT } from "../types.js";
|
|
18
18
|
import { fmtDate } from "../util/format.js";
|
|
19
19
|
import { cmd, emit, list, num, str, taskKey } from "./_shared.js";
|
|
20
|
+
import { isGhPrCreate, prUrlFrom, reportNotice, reportPullRequest, reviewersFromCommand } from "./pulls.js";
|
|
20
21
|
export const HOOK_CLIENTS = ["claude-code", "cursor"];
|
|
21
22
|
/** Seconds between two presence reports for the same file set (new files always report). */
|
|
22
23
|
export const HEARTBEAT_MIN_INTERVAL_S = 300;
|
|
@@ -44,8 +45,11 @@ export function parseHookEvent(input, client) {
|
|
|
44
45
|
const path = str(ti.file_path) ?? str(ti.notebook_path);
|
|
45
46
|
return { ...base, kind: "edit", files: path ? [path] : [] };
|
|
46
47
|
}
|
|
47
|
-
if (name === "PostToolUse" && tool === "Bash")
|
|
48
|
-
|
|
48
|
+
if (name === "PostToolUse" && tool === "Bash") {
|
|
49
|
+
const resp = input.tool_response;
|
|
50
|
+
const output = typeof resp === "string" ? resp : resp && typeof resp === "object" ? str(resp.stdout) : undefined;
|
|
51
|
+
return { ...base, kind: "shell", files: [], command: str(ti.command) ?? "", output };
|
|
52
|
+
}
|
|
49
53
|
return { ...base, kind: "other", files: [] };
|
|
50
54
|
}
|
|
51
55
|
// Cursor
|
|
@@ -58,7 +62,7 @@ export function parseHookEvent(input, client) {
|
|
|
58
62
|
return { ...base, kind: "edit", files: path ? [path] : [] };
|
|
59
63
|
}
|
|
60
64
|
if (name === "afterShellExecution")
|
|
61
|
-
return { ...base, kind: "shell", files: [], command: str(input.command) ?? "" };
|
|
65
|
+
return { ...base, kind: "shell", files: [], command: str(input.command) ?? "", output: str(input.output) };
|
|
62
66
|
return { ...base, kind: "other", files: [] };
|
|
63
67
|
}
|
|
64
68
|
export function isGitPush(command) {
|
|
@@ -181,6 +185,8 @@ export function presenceNotices(data) {
|
|
|
181
185
|
const lines = [];
|
|
182
186
|
if (typeof data.git_pull_required === "string")
|
|
183
187
|
lines.push(data.git_pull_required.split(". In your local")[0] + ". Run `git pull --rebase --autostash` before touching files.");
|
|
188
|
+
if (typeof data.pr_action_required === "string")
|
|
189
|
+
lines.push(data.pr_action_required);
|
|
184
190
|
const overlaps = Array.isArray(data.overlaps) ? data.overlaps : [];
|
|
185
191
|
for (const o of overlaps.slice(0, 3)) {
|
|
186
192
|
const who = o.is_my_other_session ? "Your other agent session" : String(o.person?.name ?? o.person?.email ?? "A teammate");
|
|
@@ -258,7 +264,7 @@ export const sessionCommand = cmd({
|
|
|
258
264
|
const lines = [
|
|
259
265
|
`ScribeClock session started (${repo}). Hooks are installed: presence heartbeats, push notices and pull checks are automatic — do not call report_repo_activity, announce_push or check_repo_overlap yourself; still use report_task_progress on the person's task and record_handoff when stopping.`,
|
|
260
266
|
String(data.brief ?? ""),
|
|
261
|
-
...presenceNotices({ git_pull_required: data.git_pull_required, inbox: data.inbox }),
|
|
267
|
+
...presenceNotices({ git_pull_required: data.git_pull_required, pr_action_required: data.pr_action_required, inbox: data.inbox }),
|
|
262
268
|
].filter(Boolean);
|
|
263
269
|
ctx.out.stdout.write(JSON.stringify(hookContext(client, event?.eventName || "SessionStart", lines.join("\n"))) + "\n");
|
|
264
270
|
return EXIT.OK;
|
|
@@ -281,6 +287,8 @@ function renderSession(ctx, data, repo) {
|
|
|
281
287
|
ctx.out.warn(String(w));
|
|
282
288
|
if (typeof data.git_pull_required === "string")
|
|
283
289
|
ctx.out.warn(String(data.git_pull_required).split(". In your local")[0] + ".");
|
|
290
|
+
if (typeof data.pr_action_required === "string")
|
|
291
|
+
ctx.out.warn(String(data.pr_action_required));
|
|
284
292
|
}
|
|
285
293
|
// ---------------------------------------------------------------------------
|
|
286
294
|
// scribeclock heartbeat
|
|
@@ -289,8 +297,8 @@ export const heartbeatCommand = cmd({
|
|
|
289
297
|
path: "heartbeat",
|
|
290
298
|
group: "Automation & agents",
|
|
291
299
|
summary: "Report presence from the local git state, debounced; as a hook it also announces pushes and records pulls.",
|
|
292
|
-
description: "Sends report_repo_activity with the branch, the files touched recently and the git state, but only when new files appear or --min-interval seconds passed (so an editor hook can call it on every edit for free). With --hook claude-code|cursor it reads the hook event from stdin: file edits become presence, a `git push` becomes announce_push, a `git pull` clears the pull notice, and the session end leaves a handoff. Only warnings (a teammate's push, an overlap, a message) are printed back into the agent's context; nothing else costs tokens.",
|
|
293
|
-
tool: ["report_repo_activity", "announce_push", "record_handoff"],
|
|
300
|
+
description: "Sends report_repo_activity with the branch, the files touched recently and the git state, but only when new files appear or --min-interval seconds passed (so an editor hook can call it on every edit for free). With --hook claude-code|cursor it reads the hook event from stdin: file edits become presence, a `git push` becomes announce_push, a `git pull` clears the pull notice, `gh pr create` / `gh pr ready` becomes report_pull_request (reviewers notified), and the session end leaves a handoff. Only warnings (a teammate's push, a pull request that needs you, an overlap, a message) are printed back into the agent's context; nothing else costs tokens.",
|
|
301
|
+
tool: ["report_repo_activity", "announce_push", "record_handoff", "report_pull_request"],
|
|
294
302
|
positionals: [{ name: "files", variadic: true, description: "Files touched (default: from the hook event, else uncommitted changes)." }],
|
|
295
303
|
flags: [
|
|
296
304
|
REPO_FLAG,
|
|
@@ -307,7 +315,7 @@ export const heartbeatCommand = cmd({
|
|
|
307
315
|
const event = client ? parseHookEvent(await readStdinJson(ctx), client) : null;
|
|
308
316
|
if (event && event.kind === "other")
|
|
309
317
|
return EXIT.OK;
|
|
310
|
-
if (event && event.kind === "shell" && !isGitPush(event.command) && !isGitPull(event.command))
|
|
318
|
+
if (event && event.kind === "shell" && !isGitPush(event.command) && !isGitPull(event.command) && !isGhPrCreate(event.command))
|
|
311
319
|
return EXIT.OK;
|
|
312
320
|
const cwd = event?.cwd ?? ctx.cwd;
|
|
313
321
|
const repo = str(inv.flags.repo) ?? repoRef(cwd);
|
|
@@ -363,6 +371,16 @@ export const heartbeatCommand = cmd({
|
|
|
363
371
|
return EXIT.OK;
|
|
364
372
|
});
|
|
365
373
|
}
|
|
374
|
+
if (event?.kind === "shell" && isGhPrCreate(event.command)) {
|
|
375
|
+
return guard(async () => {
|
|
376
|
+
const data = await reportPullRequest(ctx, { cwd, repo, url: prUrlFrom(event.output), reviewers: reviewersFromCommand(event.command) });
|
|
377
|
+
state.last_error = null;
|
|
378
|
+
writeState(path, state);
|
|
379
|
+
if (client && data)
|
|
380
|
+
ctx.out.stdout.write(JSON.stringify(hookContext(client, event.eventName, `ScribeClock: ${reportNotice(data)}`)) + "\n");
|
|
381
|
+
return EXIT.OK;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
366
384
|
if (event?.kind === "shell" && isGitPull(event.command)) {
|
|
367
385
|
return guard(async () => {
|
|
368
386
|
await ctx.call("report_repo_activity", { repo, branch, pulled: true, git: gitState(cwd), session_key: sessionKey, client_name: clientLabel(client) });
|
package/dist/commands/gitflow.js
CHANGED
|
@@ -4,6 +4,7 @@ import { changedFiles, currentBranch, detectTaskKey, git, gitState, githubSlug,
|
|
|
4
4
|
import { EXIT } from "../types.js";
|
|
5
5
|
import { fmtDate, parseWhen, slugify } from "../util/format.js";
|
|
6
6
|
import { PROJECT_FLAG, bool, cmd, emit, list, personName, projectIdArg, str, taskKey } from "./_shared.js";
|
|
7
|
+
import { reportNotice, reportPullRequest } from "./pulls.js";
|
|
7
8
|
/** feat/123-short-title — the prefix comes from .scribeclock.json → branch_prefix (default feat). */
|
|
8
9
|
export function branchNameFor(ctx, task, prefix) {
|
|
9
10
|
const p = prefix ?? ctx.repo.config.branch_prefix ?? "feat";
|
|
@@ -105,12 +106,13 @@ export const commitCommand = cmd({
|
|
|
105
106
|
export const prCommand = cmd({
|
|
106
107
|
path: "pr",
|
|
107
108
|
group: "Git",
|
|
108
|
-
summary: "Open a GitHub pull request for a task
|
|
109
|
+
summary: "Open a GitHub pull request for a task, notify its reviewers through ScribeClock, and attach it to the task as evidence.",
|
|
109
110
|
description: "Needs the GitHub CLI (`gh`) and a pushed branch. The PR title is \"#123 Task title\", the body links the ScribeClock task; the PR url is then reported on the task (outcome=progress, pull_request evidence).",
|
|
110
|
-
tool: ["get_task", "report_task_progress"],
|
|
111
|
+
tool: ["get_task", "report_task_progress", "report_pull_request"],
|
|
111
112
|
positionals: [{ name: "task", description: "Task key (default: detected from git)." }],
|
|
112
113
|
flags: [
|
|
113
114
|
{ name: "base", alias: "B", type: "string", description: "Base branch (default: the repository default)." },
|
|
115
|
+
{ name: "reviewer", type: "array", description: "Reviewers (GitHub logins; emails or member names are notified on ScribeClock only). Each is told to review before new work." },
|
|
114
116
|
{ name: "draft", type: "boolean", description: "Open as a draft." },
|
|
115
117
|
{ name: "body", type: "string", description: "Extra body text (@file reads a file)." },
|
|
116
118
|
{ name: "no-report", type: "boolean", description: "Do not post the PR on the task." },
|
|
@@ -134,6 +136,10 @@ export const prCommand = cmd({
|
|
|
134
136
|
args.push("--base", String(inv.flags.base));
|
|
135
137
|
if (inv.flags.draft)
|
|
136
138
|
args.push("--draft");
|
|
139
|
+
const reviewers = list(inv.flags.reviewer) ?? [];
|
|
140
|
+
const ghReviewers = reviewers.filter((r) => /^@?[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(r)).map((r) => r.replace(/^@/, ""));
|
|
141
|
+
if (ghReviewers.length)
|
|
142
|
+
args.push("--reviewer", ghReviewers.join(","));
|
|
137
143
|
if (ctx.flags.dryRun) {
|
|
138
144
|
ctx.out.log(`${ctx.out.dim("dry run →")} gh ${args.map((a) => (a.includes(" ") || a.includes("\n") ? JSON.stringify(a.slice(0, 60)) : a)).join(" ")}`);
|
|
139
145
|
return EXIT.OK;
|
|
@@ -143,10 +149,16 @@ export const prCommand = cmd({
|
|
|
143
149
|
throw new CliError(`gh pr create failed: ${(r.stderr || r.stdout).trim()}`, { id: "gh_failed", hint: "Push the branch first (git push -u origin HEAD) and make sure gh is logged in." });
|
|
144
150
|
const url = (r.stdout.trim().split("\n").pop() ?? "").trim();
|
|
145
151
|
ctx.out.info(`${ctx.out.green("✓")} ${url}`);
|
|
152
|
+
const repo = repoRef(ctx.cwd);
|
|
153
|
+
const gate = repo ? await reportPullRequest(ctx, { cwd: ctx.cwd, repo, url, reviewers }).catch((err) => ({ ok: false, message: err instanceof Error ? err.message : String(err) })) : null;
|
|
154
|
+
if (gate && gate.ok !== false)
|
|
155
|
+
ctx.out.info(reportNotice(gate));
|
|
156
|
+
else if (gate)
|
|
157
|
+
ctx.out.warn(`Reviewers not notified on ScribeClock: ${String(gate.message ?? "error")} (scribeclock pr report)`);
|
|
146
158
|
if (inv.flags["no-report"])
|
|
147
|
-
return emit(ctx, { ok: true, url, reported: false }, () => undefined);
|
|
159
|
+
return emit(ctx, { ok: true, url, reported: false, pull_request: gate }, () => undefined);
|
|
148
160
|
const report = await ctx.call("report_task_progress", { task_id: key, outcome: "progress", summary: `Opened pull request ${url}`, evidence: [{ kind: "pull_request", title, url }], repo: repoRef(ctx.cwd) ?? undefined, branch, git: gitState(ctx.cwd) });
|
|
149
|
-
return emit(ctx, { ok: true, url, reported: true, report }, () => ctx.out.log(`${ctx.out.green("✓")} Pull request attached to ${String(t.key ?? key)}.`));
|
|
161
|
+
return emit(ctx, { ok: true, url, reported: true, report, pull_request: gate }, () => ctx.out.log(`${ctx.out.green("✓")} Pull request attached to ${String(t.key ?? key)}.`));
|
|
150
162
|
},
|
|
151
163
|
});
|
|
152
164
|
export const changelogCommand = cmd({
|
package/dist/commands/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { CF_COMMANDS } from "./cf.js";
|
|
|
12
12
|
import { TEAM_COMMANDS } from "./team.js";
|
|
13
13
|
import { WORKFLOW_COMMANDS } from "./workflow.js";
|
|
14
14
|
import { GITFLOW_COMMANDS } from "./gitflow.js";
|
|
15
|
+
import { PULL_COMMANDS } from "./pulls.js";
|
|
15
16
|
import { OPS_COMMANDS } from "./ops.js";
|
|
16
17
|
import { AUTOMATION_COMMANDS } from "./automation.js";
|
|
17
18
|
import { INGEST_COMMANDS } from "./ingest.js";
|
|
@@ -24,6 +25,7 @@ export function buildRegistry() {
|
|
|
24
25
|
registry.add(...CLOCK_COMMANDS);
|
|
25
26
|
registry.add(...TASK_COMMANDS);
|
|
26
27
|
registry.add(...GITFLOW_COMMANDS);
|
|
28
|
+
registry.add(...PULL_COMMANDS);
|
|
27
29
|
registry.add(...INGEST_COMMANDS);
|
|
28
30
|
registry.add(...PROJECT_COMMANDS);
|
|
29
31
|
registry.add(...MEETING_COMMANDS);
|
package/dist/commands/ingest.js
CHANGED
|
@@ -121,12 +121,27 @@ sys.excepthook = lambda t, v, tb: report(v, level="fatal")`,
|
|
|
121
121
|
},
|
|
122
122
|
];
|
|
123
123
|
}
|
|
124
|
+
/** The PostHog webhook body: the event, the person, and the link back to the PostHog issue. */
|
|
125
|
+
export const POSTHOG_WEBHOOK_BODY = `{
|
|
126
|
+
"event": "{event}",
|
|
127
|
+
"person": "{person}",
|
|
128
|
+
"issue_url": "{project.url}/error_tracking/{event.properties.$exception_issue_id}"
|
|
129
|
+
}`;
|
|
124
130
|
/** Forward webhooks from an existing bug tracker (mirrors supabase/functions/_shared/ingestProviders.ts). */
|
|
125
131
|
export function providerSetups(key) {
|
|
126
132
|
const u = (p) => `${INGEST_URL}/${p}?key=${key}`;
|
|
127
133
|
return [
|
|
128
134
|
{ id: "sentry", label: "Sentry", url: u("sentry"), steps: ["Alerts → Create Alert Rule → action “Send a notification via a webhook”, or Settings → Developer Settings → Internal Integrations → Webhook URL (issue + error webhooks on).", "Paste the URL. For an internal integration, store its Client Secret on the key: scribeclock update_ingest_key --key-id <id> --webhook-secret <secret>", "Each Sentry issue becomes one ScribeClock issue; the task links back to Sentry."] },
|
|
129
|
-
{
|
|
135
|
+
{
|
|
136
|
+
id: "posthog", label: "PostHog", url: `${INGEST_URL}/posthog`, headers: { "X-ScribeClock-Key": key }, body: POSTHOG_WEBHOOK_BODY,
|
|
137
|
+
steps: [
|
|
138
|
+
"Data pipelines → Destinations → New destination → HTTP Webhook. Paste the URL (no key in it).",
|
|
139
|
+
"Under secret headers add X-ScribeClock-Key with the ingest key — keys in URLs end up in logs and screenshots.",
|
|
140
|
+
"Replace the body with the one below: it adds issue_url, so each task links back to the PostHog issue (or: scribeclock update_ingest_key --key-id <id> --posthog-project-url https://us.posthog.com/project/<id>).",
|
|
141
|
+
"Filter on the $exception event for error tracking; any other event is counted as analytics.",
|
|
142
|
+
"Optional: --webhook-secret <signing secret>, --environment-property build_channel, --posthog-api-key phx_… (error_tracking:write: resolving here resolves in PostHog), --link-only true when the app also reports its own crashes.",
|
|
143
|
+
],
|
|
144
|
+
},
|
|
130
145
|
{ id: "bugsnag", label: "Bugsnag", url: u("bugsnag"), steps: ["Project Settings → Integrations → Data forwarding → Webhook.", "Paste the URL and pick the triggers to forward (new, reopened, spikes).", "Optional shared secret: add ?secret=… to the URL and set the same value on the key."] },
|
|
131
146
|
{ id: "rollbar", label: "Rollbar", url: u("rollbar"), steps: ["Project → Settings → Notifications → Webhook.", "Paste the URL; enable new item / reactivated item / high occurrence rules.", "Optional shared secret: ?secret=… on the URL."] },
|
|
132
147
|
{ id: "crashlytics", label: "Firebase Crashlytics", url: u("crashlytics"), steps: ["Crashlytics has no outgoing webhook: add a Cloud Function on the Firebase Alerts trigger (onNewFatalIssuePublished, onRegressionAlertPublished, onNewNonfatalIssuePublished).", "POST { alertType, appId, payload } from the alert to this URL — snippet: https://scribeclock.com/help/bug-reports#forward", "Each Crashlytics issue becomes one ScribeClock issue with a link to the Firebase console."] },
|
|
@@ -137,7 +152,7 @@ export const ingestForward = cmd({
|
|
|
137
152
|
path: "ingest forward",
|
|
138
153
|
group: "Bug reports",
|
|
139
154
|
summary: "Webhook URLs and steps to forward Sentry, PostHog, Bugsnag, Rollbar, Crashlytics or any tool's reports into this project.",
|
|
140
|
-
description: "Prints the per-provider URL
|
|
155
|
+
description: "Prints the per-provider URL and where to paste it; the ingest key rides in the URL, except for PostHog, which takes it as the X-ScribeClock-Key secret header (plus the body to paste). Each issue in the other tool becomes one ScribeClock issue and task that links back. Set a webhook secret on the key (update_ingest_key --webhook-secret) to verify Sentry signatures or a shared secret the tool echoes.",
|
|
141
156
|
tool: ["list_ingest_keys"],
|
|
142
157
|
positionals: [{ name: "provider", description: "sentry, posthog, bugsnag, rollbar, crashlytics or webhook (default: all)." }],
|
|
143
158
|
flags: [{ name: "key", alias: "k", type: "string", description: `Ingest key to embed (default: ${KEY_ENV} from the environment / .env).` }],
|
|
@@ -155,7 +170,11 @@ export const ingestForward = cmd({
|
|
|
155
170
|
for (const p of providers) {
|
|
156
171
|
ctx.out.log(ctx.out.bold(`── ${p.label}`));
|
|
157
172
|
ctx.out.log(` ${p.url}`);
|
|
173
|
+
for (const [h, v] of Object.entries(p.headers ?? {}))
|
|
174
|
+
ctx.out.log(` header ${h}: ${v}`);
|
|
158
175
|
p.steps.forEach((s, i) => ctx.out.log(` ${i + 1}. ${s}`));
|
|
176
|
+
if (p.body)
|
|
177
|
+
ctx.out.log(` body:\n${p.body.replace(/^/gm, " ")}`);
|
|
159
178
|
ctx.out.log("");
|
|
160
179
|
}
|
|
161
180
|
if (key.startsWith("sc_ing_<"))
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull request gate: `prs`, `pr gate`, `pr wait`, `pr report`.
|
|
3
|
+
*
|
|
4
|
+
* Like the push notice (git_pull_required), the server keeps every pull request that needs a person — a review
|
|
5
|
+
* asked of them, changes requested on theirs, red CI, approved and ready to merge, a failed deploy — and puts it
|
|
6
|
+
* at the top of their agent's replies (pr_action_required) until it is resolved. These commands read and feed
|
|
7
|
+
* that gate from a terminal or a script: `pr gate` exits 4 while something blocks, `pr wait` blocks until a PR
|
|
8
|
+
* is merged (and deployed with --deploy), the way `git pull` blocks until the tree is current.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { CliError, usageError } from "../cli/errors.js";
|
|
12
|
+
import { currentBranch, lastCommit, repoRef } from "../git.js";
|
|
13
|
+
import { EXIT } from "../types.js";
|
|
14
|
+
import { cmd, emit, list, num, str } from "./_shared.js";
|
|
15
|
+
/** A PR number from a flag, positional ("42", "#42") or gh JSON. */
|
|
16
|
+
export function prNumber(v) {
|
|
17
|
+
const n = typeof v === "number" ? v : typeof v === "string" && /^#?\d+$/.test(v.trim()) ? Number(v.trim().replace(/^#/, "")) : undefined;
|
|
18
|
+
return n && n > 0 ? n : undefined;
|
|
19
|
+
}
|
|
20
|
+
const PR_URL = /https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/;
|
|
21
|
+
/** `gh pr create` / `gh pr ready` in a shell command (also after `&&`, `;` or a pipe). */
|
|
22
|
+
export function isGhPrCreate(command) {
|
|
23
|
+
return Boolean(command && /(^|[;&|\s])gh\s+pr\s+(create|ready)\b/.test(command));
|
|
24
|
+
}
|
|
25
|
+
/** The PR link `gh pr create` printed, if any. */
|
|
26
|
+
export function prUrlFrom(text) {
|
|
27
|
+
const m = text ? PR_URL.exec(text) : null;
|
|
28
|
+
return m ? m[0] : null;
|
|
29
|
+
}
|
|
30
|
+
/** Reviewers named on a `gh pr create` command line (`--reviewer a,b`, `-r a`). */
|
|
31
|
+
export function reviewersFromCommand(command) {
|
|
32
|
+
if (!command)
|
|
33
|
+
return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
const re = /(?:--reviewer|-r)(?:=|\s+)("[^"]*"|'[^']*'|[^\s;&|]+)/g;
|
|
36
|
+
for (let m = re.exec(command); m; m = re.exec(command))
|
|
37
|
+
out.push(...m[1].replace(/^["']|["']$/g, "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
38
|
+
return [...new Set(out)];
|
|
39
|
+
}
|
|
40
|
+
/** `gh pr view` for the current branch (or a number): what report_pull_request needs. Null without gh or a PR. */
|
|
41
|
+
export function ghPrView(cwd, ref, env = process.env) {
|
|
42
|
+
if (env.SCRIBECLOCK_GH === "0")
|
|
43
|
+
return null;
|
|
44
|
+
const args = ["pr", "view", ...(ref ? [ref] : []), "--json", "number,url,title,headRefName,baseRefName,isDraft,headRefOid,reviewRequests"];
|
|
45
|
+
const r = spawnSync("gh", args, { cwd, encoding: "utf8", timeout: 15_000 });
|
|
46
|
+
if (r.status !== 0 || !r.stdout)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(r.stdout);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** report_pull_request arguments from `gh pr view` output plus any reviewers named elsewhere. */
|
|
56
|
+
export function reportArgsFromGh(repo, view, extraReviewers = []) {
|
|
57
|
+
const requested = Array.isArray(view.reviewRequests) ? view.reviewRequests.map((r) => str(r.login)).filter((x) => Boolean(x)) : [];
|
|
58
|
+
return {
|
|
59
|
+
repo,
|
|
60
|
+
url: str(view.url),
|
|
61
|
+
number: prNumber(view.number),
|
|
62
|
+
title: str(view.title),
|
|
63
|
+
head_branch: str(view.headRefName),
|
|
64
|
+
base_branch: str(view.baseRefName),
|
|
65
|
+
draft: view.isDraft === true,
|
|
66
|
+
reviewers: [...new Set([...requested, ...extraReviewers])],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Reports the PR a hook or `scribeclock pr` just created; returns the server reply (or null when nothing is known). */
|
|
70
|
+
export async function reportPullRequest(ctx, opts) {
|
|
71
|
+
const view = ghPrView(opts.cwd, opts.url ?? undefined, ctx.env);
|
|
72
|
+
const args = view
|
|
73
|
+
? reportArgsFromGh(opts.repo, view, opts.reviewers)
|
|
74
|
+
: { repo: opts.repo, url: opts.url ?? undefined, head_branch: currentBranch(opts.cwd) ?? undefined, reviewers: opts.reviewers ?? [] };
|
|
75
|
+
if (!args.url && !args.number)
|
|
76
|
+
return null;
|
|
77
|
+
return ctx.call("report_pull_request", args);
|
|
78
|
+
}
|
|
79
|
+
/** One line for a reviewer's / author's agent after report_pull_request. */
|
|
80
|
+
export function reportNotice(data) {
|
|
81
|
+
const pr = (data.pull_request ?? {});
|
|
82
|
+
const notified = Array.isArray(data.notified) ? data.notified.map((p) => String(p.name ?? p.email ?? "a teammate")) : [];
|
|
83
|
+
const unresolved = Array.isArray(data.unresolved_reviewers) ? data.unresolved_reviewers : [];
|
|
84
|
+
return `PR #${String(pr.number ?? "?")} recorded on ScribeClock${notified.length ? `; reviewers notified: ${notified.join(", ")}` : data.already_notified ? "; reviewers were already notified" : ""}.` +
|
|
85
|
+
(unresolved.length ? ` Not found in the organization: ${unresolved.join(", ")}.` : "") +
|
|
86
|
+
(data.hint ? ` ${String(data.hint)}` : "");
|
|
87
|
+
}
|
|
88
|
+
function gateLine(ctx, g) {
|
|
89
|
+
const mark = g.blocking ? ctx.out.red("●") : ctx.out.yellow("○");
|
|
90
|
+
return `${mark} ${ctx.out.bold(`#${String(g.number ?? "?")}`)} ${String(g.status ?? "").replace(/_/g, " ")} ${ctx.out.dim(`(${String(g.role ?? "")}, ${String(g.repo_name ?? g.repo ?? "")})`)}\n ${String(g.action ?? "")}`;
|
|
91
|
+
}
|
|
92
|
+
function prStatus(pr) {
|
|
93
|
+
if (pr.state === "merged")
|
|
94
|
+
return pr.deploy?.state ? `merged · deploy ${pr.deploy.state}` : "merged";
|
|
95
|
+
if (pr.state === "closed")
|
|
96
|
+
return "closed";
|
|
97
|
+
if (pr.draft)
|
|
98
|
+
return "draft";
|
|
99
|
+
const parts = [`${pr.approvals ?? 0} approval${pr.approvals === 1 ? "" : "s"}`];
|
|
100
|
+
if ((pr.reviewers ?? []).some((r) => r.review === "changes_requested"))
|
|
101
|
+
parts.push("changes requested");
|
|
102
|
+
if (pr.checks)
|
|
103
|
+
parts.push(`CI ${pr.checks}`);
|
|
104
|
+
return parts.join(" · ");
|
|
105
|
+
}
|
|
106
|
+
const REPO_FLAG = { name: "repo", alias: "r", type: "string", placeholder: "<remote|owner/repo>", description: "Repository (default: this folder's git origin)." };
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// scribeclock prs
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
export const prsCommand = cmd({
|
|
111
|
+
path: "prs",
|
|
112
|
+
group: "Git",
|
|
113
|
+
summary: "Pull requests of this repository (or yours everywhere with --mine) and what each needs from you.",
|
|
114
|
+
tool: "list_pull_requests",
|
|
115
|
+
flags: [
|
|
116
|
+
REPO_FLAG,
|
|
117
|
+
{ name: "state", alias: "s", type: "string", choices: ["open", "merged", "closed", "all"], description: "Default open." },
|
|
118
|
+
{ name: "mine", type: "boolean", description: "Only PRs you authored or review, in every repository." },
|
|
119
|
+
],
|
|
120
|
+
examples: ["scribeclock prs", "scribeclock prs --state merged", "scribeclock prs --mine --json"],
|
|
121
|
+
async run(ctx, inv) {
|
|
122
|
+
const mine = inv.flags.mine === true;
|
|
123
|
+
const repo = mine ? str(inv.flags.repo) : str(inv.flags.repo) ?? repoRef(ctx.cwd) ?? undefined;
|
|
124
|
+
const data = await ctx.call("list_pull_requests", { repo, state: str(inv.flags.state), mine: mine || undefined });
|
|
125
|
+
return emit(ctx, data, () => {
|
|
126
|
+
const prs = (data.pull_requests ?? []);
|
|
127
|
+
if (!prs.length)
|
|
128
|
+
ctx.out.log(ctx.out.dim("No pull requests recorded. They appear when the GitHub webhook delivers or after report_pull_request."));
|
|
129
|
+
else
|
|
130
|
+
ctx.out.table(["#", "Title", "Branch", "Status", "Needs you"], prs.map((p) => [p.number, p.title ?? "", `${p.head_branch ?? "?"} → ${p.base_branch ?? "?"}`, prStatus(p), p.needs_you ? String(p.needs_you.status ?? "").replace(/_/g, " ") : ""]));
|
|
131
|
+
const gate = (data.gate ?? []);
|
|
132
|
+
if (gate.length) {
|
|
133
|
+
ctx.out.log("");
|
|
134
|
+
for (const g of gate)
|
|
135
|
+
ctx.out.log(gateLine(ctx, g));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// scribeclock pr gate
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
export const prGateCommand = cmd({
|
|
144
|
+
path: "pr gate",
|
|
145
|
+
group: "Git",
|
|
146
|
+
summary: "What pull requests need from you before new work; exits 4 while something blocks (for scripts and pre-work checks).",
|
|
147
|
+
description: "Blocking: a review asked of you, changes requested on your PR, red CI, an approved PR waiting to be merged, a failed deploy after merge. Waiting on others (review, CI, deploy) is listed but does not block. --all covers every repository you work in.",
|
|
148
|
+
tool: "list_pull_requests",
|
|
149
|
+
flags: [REPO_FLAG, { name: "all", type: "boolean", description: "Every repository, not just this one." }],
|
|
150
|
+
examples: ["scribeclock pr gate", "scribeclock pr gate --all || echo 'resolve your PRs first'"],
|
|
151
|
+
async run(ctx, inv) {
|
|
152
|
+
const repo = inv.flags.all ? undefined : str(inv.flags.repo) ?? repoRef(ctx.cwd) ?? undefined;
|
|
153
|
+
const data = await ctx.call("list_pull_requests", { repo, mine: true });
|
|
154
|
+
const gate = (data.gate ?? []);
|
|
155
|
+
const blocking = gate.filter((g) => g.blocking);
|
|
156
|
+
emit(ctx, { ok: true, repo: repo ?? null, blocking: blocking.length, gate }, () => {
|
|
157
|
+
if (!gate.length)
|
|
158
|
+
return ctx.out.log(`${ctx.out.green("✓")} No pull request needs you${repo ? ` in ${repo}` : ""}.`);
|
|
159
|
+
for (const g of gate)
|
|
160
|
+
ctx.out.log(gateLine(ctx, g));
|
|
161
|
+
});
|
|
162
|
+
return blocking.length ? EXIT.CONFLICT : EXIT.OK;
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
/** Where a PR stands for `pr wait`: done (merged, or deployed with --deploy), failed, or still waiting. */
|
|
166
|
+
export function waitOutcome(pr, wantDeploy) {
|
|
167
|
+
if (!pr)
|
|
168
|
+
return "waiting";
|
|
169
|
+
if (pr.state === "closed")
|
|
170
|
+
return "closed";
|
|
171
|
+
if (pr.state !== "merged")
|
|
172
|
+
return "waiting";
|
|
173
|
+
if (!wantDeploy)
|
|
174
|
+
return "merged";
|
|
175
|
+
if (pr.deploy?.state === "failure")
|
|
176
|
+
return "deploy_failed";
|
|
177
|
+
return pr.deploy?.state === "success" ? "deployed" : "waiting";
|
|
178
|
+
}
|
|
179
|
+
export const prWaitCommand = cmd({
|
|
180
|
+
path: "pr wait",
|
|
181
|
+
group: "Git",
|
|
182
|
+
summary: "Block until a pull request is merged (--deploy: and deployed), printing reviews, CI and deploy as they change.",
|
|
183
|
+
description: "Default PR: the open one for the current branch. Exit 0 when merged (or deployed with --deploy), 1 when it was closed without merging, the deploy failed, or --timeout passed. While waiting, the reviewers have already been told through ScribeClock.",
|
|
184
|
+
tool: "list_pull_requests",
|
|
185
|
+
positionals: [{ name: "number", description: "PR number (default: the PR of the current branch)." }],
|
|
186
|
+
flags: [
|
|
187
|
+
REPO_FLAG,
|
|
188
|
+
{ name: "deploy", type: "boolean", description: "Also wait for a successful production deploy of the merge." },
|
|
189
|
+
{ name: "interval", type: "number", placeholder: "<seconds>", description: "Seconds between checks (default 30).", default: 30 },
|
|
190
|
+
{ name: "timeout", type: "number", placeholder: "<minutes>", description: "Give up after this many minutes (default 120).", default: 120 },
|
|
191
|
+
{ name: "once", type: "boolean", description: "Check once and exit (scripts, tests)." },
|
|
192
|
+
],
|
|
193
|
+
examples: ["scribeclock pr wait", "scribeclock pr wait 42 --deploy --timeout 60"],
|
|
194
|
+
async run(ctx, inv) {
|
|
195
|
+
const repo = str(inv.flags.repo) ?? repoRef(ctx.cwd);
|
|
196
|
+
if (!repo)
|
|
197
|
+
throw usageError("Not in a git repository.", "Pass --repo owner/repo.");
|
|
198
|
+
let number = prNumber(inv.positionals[0]);
|
|
199
|
+
if (!number) {
|
|
200
|
+
const branch = currentBranch(ctx.cwd);
|
|
201
|
+
const data = await ctx.call("list_pull_requests", { repo, state: "open" });
|
|
202
|
+
const mine = (data.pull_requests ?? []).find((p) => p.head_branch && p.head_branch === branch);
|
|
203
|
+
number = mine?.number ?? prNumber(ghPrView(ctx.cwd, undefined, ctx.env)?.number);
|
|
204
|
+
if (!number)
|
|
205
|
+
throw usageError(`No open pull request for ${branch ?? "this branch"}.`, "Pass the PR number: scribeclock pr wait 42");
|
|
206
|
+
}
|
|
207
|
+
const wantDeploy = inv.flags.deploy === true;
|
|
208
|
+
const interval = Math.max(5, num(inv.flags.interval) ?? 30) * 1000;
|
|
209
|
+
const deadline = ctx.now().getTime() + Math.max(1, num(inv.flags.timeout) ?? 120) * 60_000;
|
|
210
|
+
let last = "";
|
|
211
|
+
let stop = false;
|
|
212
|
+
const onSignal = () => {
|
|
213
|
+
stop = true;
|
|
214
|
+
};
|
|
215
|
+
process.once("SIGINT", onSignal);
|
|
216
|
+
try {
|
|
217
|
+
for (;;) {
|
|
218
|
+
const data = await ctx.call("list_pull_requests", { repo, number, state: "all" });
|
|
219
|
+
const pr = (data.pull_requests ?? [])[0];
|
|
220
|
+
const outcome = waitOutcome(pr, wantDeploy);
|
|
221
|
+
const line = pr ? `#${number} ${prStatus(pr)}${pr.needs_you?.action ? ` — ${pr.needs_you.action}` : ""}` : `#${number} not recorded yet`;
|
|
222
|
+
if (line !== last) {
|
|
223
|
+
if (ctx.flags.json)
|
|
224
|
+
ctx.out.ndjson({ event: "pr_status", at: ctx.now().toISOString(), number, outcome, pull_request: pr ?? null });
|
|
225
|
+
else
|
|
226
|
+
ctx.out.log(`${ctx.out.dim(ctx.now().toISOString().slice(11, 19))} ${line}`);
|
|
227
|
+
last = line;
|
|
228
|
+
}
|
|
229
|
+
if (outcome === "merged" || outcome === "deployed") {
|
|
230
|
+
if (!ctx.flags.json)
|
|
231
|
+
ctx.out.log(`${ctx.out.green("✓")} #${number} ${outcome}. Pull the base branch: git checkout ${pr?.base_branch ?? "main"} && git pull --rebase --autostash`);
|
|
232
|
+
return EXIT.OK;
|
|
233
|
+
}
|
|
234
|
+
if (outcome === "closed" || outcome === "deploy_failed")
|
|
235
|
+
throw new CliError(`#${number} ${outcome === "closed" ? "was closed without merging" : "was merged but its deploy failed"}.`, { id: outcome });
|
|
236
|
+
if (inv.flags.once || stop)
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
if (ctx.now().getTime() >= deadline)
|
|
239
|
+
throw new CliError(`#${number} is still not ${wantDeploy ? "deployed" : "merged"} after ${num(inv.flags.timeout) ?? 120} minutes.`, { id: "timeout", hint: "Ask the reviewers (scribeclock prs) or wait longer with --timeout." });
|
|
240
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
process.off("SIGINT", onSignal);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// scribeclock pr report
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
export const prReportCommand = cmd({
|
|
252
|
+
path: "pr report",
|
|
253
|
+
group: "Git",
|
|
254
|
+
summary: "Record a pull request on ScribeClock and notify its reviewers (what the hook does after `gh pr create`).",
|
|
255
|
+
tool: "report_pull_request",
|
|
256
|
+
positionals: [{ name: "pr", description: "PR number or URL (default: the PR of the current branch, via gh)." }],
|
|
257
|
+
flags: [REPO_FLAG, { name: "reviewer", type: "array", description: "Reviewers: GitHub logins, emails or member names (comma-separated or repeated)." }],
|
|
258
|
+
examples: ["scribeclock pr report", "scribeclock pr report 42 --reviewer octocat,sam@example.com"],
|
|
259
|
+
async run(ctx, inv) {
|
|
260
|
+
const repo = str(inv.flags.repo) ?? repoRef(ctx.cwd);
|
|
261
|
+
if (!repo)
|
|
262
|
+
throw usageError("Not in a git repository.", "Pass --repo owner/repo.");
|
|
263
|
+
const ref = inv.positionals[0];
|
|
264
|
+
const reviewers = list(inv.flags.reviewer) ?? [];
|
|
265
|
+
const view = ghPrView(ctx.cwd, ref, ctx.env);
|
|
266
|
+
const args = view
|
|
267
|
+
? reportArgsFromGh(repo, view, reviewers)
|
|
268
|
+
: { repo, url: prUrlFrom(ref) ?? undefined, number: prNumber(ref), head_branch: currentBranch(ctx.cwd) ?? undefined, reviewers };
|
|
269
|
+
if (!args.url && !args.number)
|
|
270
|
+
throw usageError("Which pull request?", "Pass its number or URL, or install gh (https://cli.github.com) so the current branch's PR is found.");
|
|
271
|
+
if (!view && !args.head_branch)
|
|
272
|
+
args.head_branch = currentBranch(ctx.cwd) ?? undefined;
|
|
273
|
+
if (!args.title)
|
|
274
|
+
args.title = lastCommit(ctx.cwd)?.message.split("\n")[0];
|
|
275
|
+
const data = await ctx.call("report_pull_request", args);
|
|
276
|
+
return emit(ctx, data, () => ctx.out.log(`${ctx.out.green("✓")} ${reportNotice(data)}`));
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
export const PULL_COMMANDS = [prsCommand, prGateCommand, prWaitCommand, prReportCommand];
|
package/dist/commands/team.js
CHANGED
|
@@ -187,4 +187,36 @@ export const handoffRecord = cmd({
|
|
|
187
187
|
});
|
|
188
188
|
},
|
|
189
189
|
});
|
|
190
|
-
export const
|
|
190
|
+
export const deliveryCommand = cmd({
|
|
191
|
+
path: "delivery",
|
|
192
|
+
group: "Team",
|
|
193
|
+
summary: "Client hand-over: is this the team repository (push here, the client sees only your organization) or the client's (do not push)? Exits 4 in the client's repository, so it works as a pre-push hook.",
|
|
194
|
+
tool: "get_client_delivery",
|
|
195
|
+
flags: [REPO_FLAG],
|
|
196
|
+
async run(ctx, inv) {
|
|
197
|
+
const repo = repoOf(ctx, inv);
|
|
198
|
+
const data = await ctx.call("get_client_delivery", { repo });
|
|
199
|
+
const rows = (data.deliveries ?? []);
|
|
200
|
+
const d = rows[0];
|
|
201
|
+
const blocked = Boolean(d) && d.push_here !== true;
|
|
202
|
+
emit(ctx, data, () => {
|
|
203
|
+
if (!d) {
|
|
204
|
+
ctx.out.log(`${repo} is not handed over to a client: nothing republishes your commits under the organization's name.`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (blocked)
|
|
208
|
+
ctx.out.warn(String(d.guidance ?? "Do not push here."));
|
|
209
|
+
else
|
|
210
|
+
ctx.out.log(ctx.out.bold(String(d.guidance ?? "")));
|
|
211
|
+
ctx.out.log("");
|
|
212
|
+
ctx.out.table(["team repository", "client's repository", "branch", "client sees", "status", "published"], [[d.team_repo, d.client_repo, d.published_branch, d.published_as, d.status, d.commits_published]].map((r) => r.map((v) => String(v ?? ""))));
|
|
213
|
+
for (const p of (d.problems ?? []))
|
|
214
|
+
ctx.out.warn(String(p));
|
|
215
|
+
if (!blocked)
|
|
216
|
+
for (const r of (d.rules ?? []))
|
|
217
|
+
ctx.out.log(ctx.out.dim(` • ${String(r)}`));
|
|
218
|
+
});
|
|
219
|
+
return blocked ? EXIT.CONFLICT : EXIT.OK;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
export const TEAM_COMMANDS = [teamCommand, overlapCommand, teamReport, handoffCommand, handoffRecord, deliveryCommand];
|
package/dist/mcp/client.js
CHANGED
|
@@ -50,7 +50,7 @@ export class McpClient {
|
|
|
50
50
|
const body = { jsonrpc: "2.0", id: this.nextId++, method, params };
|
|
51
51
|
let res = await this.post(token, body);
|
|
52
52
|
if (res.status === 401) {
|
|
53
|
-
const refreshed = await this.tokens.refresh();
|
|
53
|
+
const refreshed = await this.tokens.refresh(token);
|
|
54
54
|
if (!refreshed) {
|
|
55
55
|
throw authError("Your ScribeClock session is no longer valid (the token was rejected).", "Run `scribeclock login` again. If you use SCRIBECLOCK_TOKEN, create a new personal access token.");
|
|
56
56
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scribeclock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "ScribeClock from the terminal: tasks, time clock, projects, PRDs, meetings, files, vault and Cloudflare — a thin client over the ScribeClock MCP server.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Haydar Al-Karar LLC, dba Karar Tech",
|