|
#!/usr/bin/env node |
|
// Weekly work log. Pulls GitHub PR activity for one or more authors over a date |
|
// window with `gh`, hands each author's data to `claude -p` for summarizing, and |
|
// saves the markdown under ~/Documents/work-log: one file for your own log, or |
|
// one file per login in a dated folder for a team. |
|
// Requires Node 22+, a logged-in `gh`, and a logged-in `claude` CLI. `work-log --help` for flags. |
|
// MIT License. Copyright (c) 2026 Taylor Beseda. |
|
import {execFileSync, spawn} from 'node:child_process' |
|
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs' |
|
import {homedir} from 'node:os' |
|
import {join} from 'node:path' |
|
import {parseArgs} from 'node:util' |
|
|
|
// Map.groupBy and top-level await need a recent Node; fail early with a clear message. |
|
const nodeMajor = Number(process.versions.node.split('.')[0]) |
|
if (nodeMajor < 22) { |
|
console.error(`work-log: Node 22 or newer is required, found ${process.version}`) |
|
process.exit(2) |
|
} |
|
|
|
const TEAM_FILE = process.env.WORK_LOG_TEAM ?? join(homedir(), '.config', 'work-log', 'team') |
|
|
|
const HELP = `work-log - summarize a week of GitHub pull requests, per author |
|
|
|
USAGE |
|
work-log [flags] |
|
|
|
With no --author or --team, work-log summarizes the pull requests of the |
|
account that gh is logged in to, for the last 7 days. Every run makes one |
|
model call per author. --dry-run and --data make no model call. |
|
|
|
FLAGS |
|
--days N Days to look back from today (default 7) |
|
--since DATE Start of window, YYYY-MM-DD (overrides --days) |
|
--until DATE End of window, YYYY-MM-DD (default: today) |
|
--author LOGIN GitHub login (repeatable; default: the authenticated user) |
|
--team Also read logins from the team file, one per line |
|
(${TEAM_FILE}, or $WORK_LOG_TEAM) |
|
--repo OWNER/NAME Restrict to one repo (repeatable) |
|
--reviews Also include PRs by others that the author reviewed |
|
--limit N Max PRs to fetch per author (default 100) |
|
--model NAME Model for the summary (default: sonnet, or $WORK_LOG_MODEL) |
|
--out DIR Output directory (default: ~/Documents/work-log) |
|
-p, --print Also write the report(s) to stdout |
|
--no-save Do not write files (implies --print) |
|
--no-progress Suppress OSC 9;4 terminal progress (or set WORK_LOG_NO_PROGRESS) |
|
--open Open the saved file, or the folder for several authors |
|
--data Print the collected PR JSON per author and exit, no model call |
|
--dry-run Print the prompt per author and exit, no model call |
|
-h, --help Show this help |
|
|
|
OUTPUT |
|
work-log DIR/YYYY-MM-DD.md, your own log |
|
--author or --team DIR/YYYY-MM-DD/LOGIN.md, one file per author |
|
|
|
EXAMPLES |
|
work-log # your last 7 days |
|
work-log --days 14 --reviews |
|
work-log --since 2026-08-01 --until 2026-08-08 |
|
work-log --team --open # everyone in the team file |
|
work-log --author alice --author bob |
|
` |
|
|
|
let values |
|
try { |
|
;({values} = parseArgs({ |
|
options: { |
|
days: {type: 'string', default: '7'}, |
|
since: {type: 'string'}, |
|
until: {type: 'string'}, |
|
author: {type: 'string', multiple: true, default: []}, |
|
team: {type: 'boolean', default: false}, |
|
repo: {type: 'string', multiple: true, default: []}, |
|
reviews: {type: 'boolean', default: false}, |
|
limit: {type: 'string', default: '100'}, |
|
model: {type: 'string', default: process.env.WORK_LOG_MODEL ?? 'sonnet'}, |
|
out: {type: 'string', default: join(homedir(), 'Documents', 'work-log')}, |
|
print: {type: 'boolean', short: 'p', default: false}, |
|
'no-save': {type: 'boolean', default: false}, |
|
'no-progress': {type: 'boolean', default: false}, |
|
open: {type: 'boolean', default: false}, |
|
data: {type: 'boolean', default: false}, |
|
'dry-run': {type: 'boolean', default: false}, |
|
help: {type: 'boolean', short: 'h', default: false}, |
|
}, |
|
strict: true, |
|
})) |
|
} catch (err) { |
|
console.error(`work-log: ${err.message}\nRun work-log --help for usage.`) |
|
process.exit(2) |
|
} |
|
|
|
if (values.help) { |
|
process.stdout.write(HELP) |
|
process.exit(0) |
|
} |
|
|
|
// ----------------------------------------------------------------- progress |
|
// OSC 9;4 taskbar progress: ESC ] 9 ; 4 ; state ; percent ST |
|
// state 0 clears, 1 is a determinate percentage, 3 is indeterminate. |
|
// Goes to stderr so `work-log --print | pbcopy` still gets clean markdown, and |
|
// only when stderr is a terminal, so escapes never land in a redirect. |
|
// |
|
// Ghostty (and others) hide the bar when no update arrives for a few seconds, |
|
// so every slow stage has to keep emitting. The run is one 0-100 bar rather |
|
// than a per-stage one, so it never jumps backwards. 0-5 is preflight; the |
|
// rest is split evenly between authors, and within one author's slice: |
|
// 0-25% fetch PRs | 25-30% fetch reviews | 30-95% model | 100% saved |
|
|
|
const showProgress = |
|
process.stderr.isTTY && process.env.TERM !== 'dumb' && !process.env.WORK_LOG_NO_PROGRESS && !values['no-progress'] |
|
let progressActive = false |
|
|
|
const osc = (state, percent = 0) => { |
|
if (!showProgress) return |
|
process.stderr.write(`\x1b]9;4;${state};${Math.round(percent)}\x1b\\`) |
|
progressActive = state !== 0 |
|
} |
|
const progress = { |
|
busy: () => osc(3), |
|
at: (percent) => osc(1, Math.max(0, Math.min(100, percent))), |
|
clear: () => { |
|
if (progressActive) osc(0) |
|
}, |
|
// A slice of the bar addressed as a 0-1 fraction. |
|
segment: (from, to) => (fraction) => progress.at(from + (to - from) * fraction), |
|
} |
|
|
|
// Every exit path clears: normal returns, process.exit, throws, and signals. |
|
let activeChild = null |
|
process.on('exit', progress.clear) |
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { |
|
process.on(signal, () => { |
|
progress.clear() |
|
activeChild?.kill('SIGTERM') |
|
process.exit(130) |
|
}) |
|
} |
|
for (const event of ['uncaughtException', 'unhandledRejection']) { |
|
process.on(event, (err) => { |
|
progress.clear() |
|
console.error(`work-log: ${err?.stack ?? err}`) |
|
process.exit(1) |
|
}) |
|
} |
|
|
|
const die = (msg, code = 2) => { |
|
progress.clear() |
|
console.error(`work-log: ${msg}`) |
|
process.exit(code) |
|
} |
|
|
|
// execFileSync stuffs the entire command line into err.message, and our system |
|
// prompt is long, so report what the child actually said instead. |
|
const childError = (err) => { |
|
const said = [err.stderr, err.stdout] |
|
.map((s) => (s ?? '').toString().trim()) |
|
.find(Boolean) |
|
return said || `exited with status ${err.status ?? err.code ?? 'unknown'}` |
|
} |
|
|
|
const run = (cmd, args, input) => |
|
execFileSync(cmd, args, { |
|
encoding: 'utf8', |
|
input, |
|
maxBuffer: 64 * 1024 * 1024, |
|
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], |
|
}) |
|
|
|
// Opens a file or folder with the desktop's default handler. |
|
const openPath = (path) => { |
|
const cmd = {darwin: 'open', linux: 'xdg-open'}[process.platform] |
|
if (!cmd) return console.error(`work-log: --open is not supported on ${process.platform}; see ${path}`) |
|
try { |
|
run(cmd, [path]) |
|
} catch { |
|
console.error(`work-log: could not open ${path}`) |
|
} |
|
} |
|
|
|
// Async twin of run(). The model call takes ~30s, and execFileSync would block |
|
// the event loop for all of it: no progress heartbeat, no working Ctrl-C. |
|
const runAsync = (cmd, args, input) => |
|
new Promise((resolve) => { |
|
const child = spawn(cmd, args, {stdio: ['pipe', 'pipe', 'pipe']}) |
|
activeChild = child |
|
let stdout = '' |
|
let stderr = '' |
|
child.stdout.on('data', (d) => (stdout += d)) |
|
child.stderr.on('data', (d) => (stderr += d)) |
|
child.on('error', (err) => { |
|
activeChild = null |
|
resolve({stdout, stderr: err.message, code: -1}) |
|
}) |
|
child.on('close', (code) => { |
|
activeChild = null |
|
resolve({stdout, stderr, code}) |
|
}) |
|
child.stdin.on('error', () => {}) // child may exit before the prompt is written |
|
child.stdin.end(input) |
|
}) |
|
|
|
// ---------------------------------------------------------------- preflight |
|
|
|
progress.busy() |
|
|
|
// One call does three jobs: proves gh is installed and logged in, names the |
|
// account, and returns the token scopes in a response header. Private repos |
|
// need the `repo` scope. A fine-grained token sends no scopes header at all. |
|
let ghLogin |
|
let ghScopes = null |
|
try { |
|
const raw = run('gh', ['api', 'user', '-i']) |
|
const [head, ...rest] = raw.split(/\r?\n\r?\n/) |
|
ghLogin = JSON.parse(rest.join('\n\n')).login |
|
const scopes = head.match(/^x-oauth-scopes:\s*(.*)$/im)?.[1] |
|
if (scopes !== undefined) ghScopes = scopes.split(',').map((x) => x.trim()).filter(Boolean) |
|
} catch (err) { |
|
if (err.code === 'ENOENT') die('gh is not installed (https://cli.github.com)') |
|
die(`gh is not logged in or GitHub is unreachable\n${childError(err)}`) |
|
} |
|
if (ghScopes && !ghScopes.includes('repo')) { |
|
console.error('work-log: warning: the gh token has no repo scope, so private repositories will not appear (gh auth refresh -s repo)') |
|
} |
|
|
|
// `claude auth status` prints JSON with a loggedIn field, and may exit non-zero |
|
// when logged out. Older CLIs lack the subcommand and print no JSON, so only a |
|
// clear false is fatal. |
|
let claudeAuth = null |
|
try { |
|
claudeAuth = JSON.parse(run('claude', ['auth', 'status'])) |
|
} catch (err) { |
|
if (err.code === 'ENOENT') die('claude CLI not found on PATH') |
|
try { |
|
claudeAuth = JSON.parse(err.stdout ?? '') |
|
} catch {} |
|
} |
|
if (claudeAuth?.loggedIn === false) die('claude is not logged in (run: claude auth login)') |
|
|
|
// ------------------------------------------------------------------ window |
|
|
|
const pad = (n) => String(n).padStart(2, '0') |
|
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` |
|
const parseDate = (s) => { |
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) die(`bad date "${s}", expected YYYY-MM-DD`) |
|
return new Date(`${s}T00:00:00`) |
|
} |
|
|
|
const days = Number(values.days) |
|
if (!Number.isFinite(days) || days < 1) die(`--days must be a positive number, got "${values.days}"`) |
|
|
|
const until = values.until ? parseDate(values.until) : new Date() |
|
const since = values.since |
|
? parseDate(values.since) |
|
: new Date(until.getTime() - days * 24 * 60 * 60 * 1000) |
|
if (since > until) die('--since is after --until') |
|
|
|
const sinceStr = localDate(since) |
|
const untilStr = localDate(until) |
|
const today = localDate(new Date()) |
|
const pretty = (d, withYear) => |
|
d.toLocaleDateString('en-US', {month: 'short', day: 'numeric', ...(withYear && {year: 'numeric'})}) |
|
const rangeLabel = `${pretty(since)} to ${pretty(until, true)}` |
|
|
|
const limit = Number(values.limit) |
|
if (!Number.isFinite(limit) || limit < 1) die(`--limit must be a positive number`) |
|
|
|
// ----------------------------------------------------------------- authors |
|
|
|
// GitHub's own rule for a login. It also keeps the login safe as a file name. |
|
const LOGIN = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i |
|
|
|
const readTeam = () => { |
|
let text |
|
try { |
|
text = readFileSync(TEAM_FILE, 'utf8') |
|
} catch { |
|
die(`--team: cannot read ${TEAM_FILE}`) |
|
} |
|
const logins = text |
|
.split('\n') |
|
.map((line) => line.replace(/#.*/, '').trim()) |
|
.filter(Boolean) |
|
if (!logins.length) die(`--team: no logins in ${TEAM_FILE}`) |
|
return logins |
|
} |
|
|
|
const requested = [...values.author, ...(values.team ? readTeam() : [])] |
|
for (const login of requested) { |
|
if (!LOGIN.test(login)) die(`bad GitHub login "${login}"`) |
|
} |
|
// Logins are case-insensitive on GitHub. Keep the first spelling given. |
|
const authors = [] |
|
for (const login of requested) { |
|
if (!authors.some((a) => a.toLowerCase() === login.toLowerCase())) authors.push(login) |
|
} |
|
const explicit = authors.length > 0 |
|
if (!explicit) authors.push(ghLogin) |
|
|
|
// Your own log keeps its flat DIR/DATE.md path. Any explicit author list goes |
|
// to DIR/DATE/LOGIN.md so a teammate's run never overwrites your own file. |
|
const perAuthor = values.team || values.author.length > 0 |
|
const outDir = perAuthor ? join(values.out, untilStr) : values.out |
|
const outFile = (author) => join(outDir, perAuthor ? `${author}.md` : `${untilStr}.md`) |
|
|
|
// Say up front whose work this is and where it goes, so a bare run is never a |
|
// surprise. stderr, so piped output stays clean. |
|
process.stderr.write( |
|
explicit |
|
? `work-log: gh logged in as ${ghLogin}; ${authors.length === 1 ? 'author' : `${authors.length} authors`}: ${authors.join(', ')}\n` |
|
: `work-log: gh logged in as ${ghLogin}; no --author or --team given, so summarizing ${ghLogin} (--help for flags)\n`, |
|
) |
|
const plan = values.data |
|
? 'printing PR data, no model call' |
|
: values['dry-run'] |
|
? 'printing prompts, no model call' |
|
: `model ${values.model}, ${values['no-save'] ? 'no file saved' : `saving to ${outDir}`}` |
|
process.stderr.write(`work-log: window ${sinceStr} to ${untilStr}, ${plan}\n`) |
|
|
|
// -------------------------------------------------------------------- fetch |
|
|
|
const PR_QUERY = `query($q: String!, $after: String) { |
|
search(query: $q, type: ISSUE, first: 50, after: $after) { |
|
issueCount |
|
pageInfo { hasNextPage endCursor } |
|
nodes { |
|
... on PullRequest { |
|
number title url state isDraft |
|
createdAt mergedAt closedAt updatedAt |
|
additions deletions changedFiles |
|
repository { nameWithOwner isPrivate } |
|
labels(first: 8) { nodes { name } } |
|
commits(first: 30) { totalCount nodes { commit { messageHeadline committedDate } } } |
|
body |
|
} |
|
} |
|
} |
|
}` |
|
|
|
const REVIEW_QUERY = `query($q: String!, $after: String) { |
|
search(query: $q, type: ISSUE, first: 50, after: $after) { |
|
issueCount |
|
pageInfo { hasNextPage endCursor } |
|
nodes { |
|
... on PullRequest { |
|
number title url state updatedAt |
|
repository { nameWithOwner } |
|
author { login } |
|
} |
|
} |
|
} |
|
}` |
|
|
|
// Pages through one GitHub search up to `limit` results. onPage, if given, |
|
// gets the fraction fetched so far. Throws with a readable message. |
|
const search = (query, q, onPage) => { |
|
const nodes = [] |
|
let after = null |
|
let total = null |
|
do { |
|
const args = ['api', 'graphql', '-f', `query=${query}`, '-f', `q=${q}`] |
|
if (after) args.push('-f', `after=${after}`) |
|
let raw |
|
try { |
|
raw = run('gh', args) |
|
} catch (err) { |
|
throw new Error(`GitHub query failed\n${childError(err)}`) |
|
} |
|
const parsed = JSON.parse(raw) |
|
if (parsed.errors?.length) throw new Error(`GitHub query failed: ${parsed.errors.map((e) => e.message).join('; ')}`) |
|
const page = parsed.data.search |
|
nodes.push(...page.nodes.filter((n) => n && Object.keys(n).length)) |
|
after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null |
|
total ??= Math.min(page.issueCount || limit, limit) |
|
onPage?.(Math.min(nodes.length / Math.max(total, 1), 1)) |
|
} while (after && nodes.length < limit) |
|
return nodes.slice(0, limit) |
|
} |
|
|
|
const repoFilter = values.repo.map((r) => `repo:${r}`).join(' ') |
|
const updated = `updated:${sinceStr}..${untilStr}` |
|
const authoredQuery = (author) => `is:pr author:${author} ${updated} sort:updated-desc ${repoFilter}`.trim() |
|
const reviewedQuery = (author) => |
|
`is:pr reviewed-by:${author} -author:${author} ${updated} sort:updated-desc ${repoFilter}`.trim() |
|
|
|
// ------------------------------------------------------------------ shape |
|
|
|
const inWindow = (iso) => iso && iso.slice(0, 10) >= sinceStr && iso.slice(0, 10) <= untilStr |
|
|
|
// PR bodies carry template comments, screenshots and bot footers. None of that |
|
// survives into a status update, so drop it before it costs tokens. |
|
const cleanBody = (body) => { |
|
const text = (body ?? '') |
|
.replace(/<!--[\s\S]*?-->/g, '') |
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '') |
|
.replace(/<img[\s\S]*?>/gi, '') |
|
.replace(/https:\/\/(github\.com\/user-attachments|\S*\.(png|jpg|jpeg|gif|mov|mp4))\S*/g, '') |
|
.replace(/^\s*[-*]\s*\[[ xX]\]\s*/gm, '- ') |
|
.replace(/\r/g, '') |
|
.replace(/\n{3,}/g, '\n\n') |
|
.trim() |
|
return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}...[truncated]` : text |
|
} |
|
|
|
const stateOf = (pr) => (pr.state === 'OPEN' && pr.isDraft ? 'draft' : pr.state.toLowerCase()) |
|
|
|
const shapePr = (pr) => { |
|
const commits = pr.commits.nodes.map((n) => n.commit) |
|
return { |
|
repo: pr.repository.nameWithOwner, |
|
private: pr.repository.isPrivate, |
|
number: pr.number, |
|
title: pr.title, |
|
url: pr.url, |
|
state: stateOf(pr), |
|
openedAt: pr.createdAt.slice(0, 10), |
|
closedAt: (pr.mergedAt ?? pr.closedAt)?.slice(0, 10) ?? null, |
|
additions: pr.additions, |
|
deletions: pr.deletions, |
|
changedFiles: pr.changedFiles, |
|
labels: pr.labels.nodes.map((l) => l.name), |
|
totalCommits: commits.length, |
|
windowCommits: commits.filter((c) => inWindow(c.committedDate)).map((c) => c.messageHeadline), |
|
openedInWindow: inWindow(pr.createdAt), |
|
closedInWindow: inWindow(pr.mergedAt ?? pr.closedAt), |
|
body: cleanBody(pr.body), |
|
} |
|
} |
|
|
|
const RANK = {merged: 0, open: 1, draft: 2, closed: 3} |
|
|
|
// [repo, prs][] with the busiest repo first, so the model's output leads with |
|
// the week's real work. Within a repo: merged, open, draft, closed, then size. |
|
const groupByRepo = (shaped) => { |
|
const byRepo = Map.groupBy(shaped, (pr) => pr.repo) |
|
for (const list of byRepo.values()) { |
|
list.sort((a, b) => RANK[a.state] - RANK[b.state] || b.additions - a.additions) |
|
} |
|
const merged = (list) => list.filter((p) => p.state === 'merged').length |
|
return [...byRepo].sort( |
|
([aRepo, a], [bRepo, b]) => merged(b) - merged(a) || b.length - a.length || aRepo.localeCompare(bRepo), |
|
) |
|
} |
|
|
|
// ------------------------------------------------------------------ digest |
|
|
|
const digestOf = (repos, reviewed) => { |
|
const lines = [] |
|
for (const [repo, list] of repos) { |
|
lines.push(`### ${repo}`) |
|
for (const pr of list) { |
|
const meta = [ |
|
pr.state === 'merged' ? `merged ${pr.closedAt}` : pr.state === 'closed' ? `closed unmerged ${pr.closedAt}` : pr.state, |
|
`opened ${pr.openedAt}`, |
|
`+${pr.additions}/-${pr.deletions} in ${pr.changedFiles} files`, |
|
] |
|
if (pr.labels.length) meta.push(`labels: ${pr.labels.join(', ')}`) |
|
lines.push(`\n[#${pr.number}] ${pr.title}`) |
|
lines.push(`url: ${pr.url}`) |
|
lines.push(meta.join(' | ')) |
|
if (pr.windowCommits.length) { |
|
lines.push(`commits in window (${pr.windowCommits.length}/${pr.totalCommits}): ${pr.windowCommits.slice(0, 10).join('; ')}`) |
|
} else { |
|
lines.push(`no new commits in window (${pr.totalCommits} total)`) |
|
} |
|
lines.push(pr.body ? `description:\n${pr.body}` : 'description: (empty)') |
|
} |
|
lines.push('') |
|
} |
|
if (reviewed.length) { |
|
lines.push('### Pull requests reviewed (authored by others)') |
|
for (const pr of reviewed) { |
|
lines.push(`${pr.repository.nameWithOwner} [#${pr.number}] ${pr.title} (by ${pr.author?.login ?? 'unknown'}, ${pr.state.toLowerCase()}) ${pr.url}`) |
|
} |
|
} |
|
return lines.join('\n').trim() |
|
} |
|
|
|
// ------------------------------------------------------------------ prompt |
|
|
|
const SYSTEM = `You write a weekly status update from GitHub pull request data. The reader is the author's manager: they have sixty seconds and no context on the codebase. |
|
|
|
Output the markdown document and nothing else. No preamble, no sign-off, no wrapping code fence. |
|
|
|
Structure, in this order: |
|
|
|
## Highlights |
|
- Two to four lines naming the week's shipped outcomes. Omit this whole section if there are fewer than four pull requests in the data. |
|
|
|
## owner/repo |
|
- What changed, in plain language. ([#12](url)) \`merged\` |
|
|
|
Rules: |
|
- Keep repositories and pull requests in the order the data gives them. Use the repository's full owner/name as the heading. |
|
- One bullet per pull request, under 25 words. Combine pull requests that are one effort into a single bullet listing each link. |
|
- Begin a bullet with the change or a verb. Never "This PR", "PR that", "Added support for adding". |
|
- End each bullet with the state in backticks: \`merged\`, \`open\`, \`draft\`, or \`closed\`. Write \`closed\` only for unmerged. |
|
- Say what changed and who it affects. Include implementation detail only when it is the point of the change. |
|
- Prefer the description over the title when they disagree. If the description is empty, work from the title and commit messages. |
|
- Never invent work, impact, motivation or metrics that are not in the data. No guessing at why something was done. |
|
- Plain words. Do not use: leverage, robust, comprehensive, seamless, enhance, streamline, significant, crucial, pivotal, foster, showcase, various, successfully, improved developer experience. No em dashes. No self-praise. |
|
- Do not restate line counts, file counts or dates. Those are already reported elsewhere. |
|
- Stop at the last bullet. No summary paragraph, no next steps, no outlook.` |
|
|
|
const REVIEW_RULE = `\n- Close with a "## Reviews" section: one line, "Reviewed N pull requests across owner/repo, owner/repo." List repository names only, no titles.` |
|
|
|
// ---------------------------------------------------------------- summarize |
|
|
|
// Runs `claude -p` and returns {body, cost}. Throws with a readable message. |
|
// tick(0..1) reports progress while the model runs. |
|
const summarize = async (system, prompt, tick) => { |
|
// There is no progress to read out of `claude -p`, but the bar has to keep |
|
// moving or Ghostty hides it. Ease toward 1 on elapsed time without reaching |
|
// it, so a slow run never looks finished or stalled. |
|
const startedAt = Date.now() |
|
const EXPECTED_MS = 30_000 |
|
const ticker = setInterval(() => tick(1 - Math.exp(-(Date.now() - startedAt) / EXPECTED_MS)), 500) |
|
|
|
// JSON rather than text: an interrupted `claude -p` exits 0 and prints |
|
// "Execution error" on stdout, which would otherwise be saved as the summary. |
|
// On API errors it exits non-zero but still prints the same result envelope. |
|
let raw |
|
let claudeErr |
|
let code |
|
try { |
|
;({stdout: raw, stderr: claudeErr, code} = await runAsync( |
|
'claude', |
|
[ |
|
'-p', |
|
'--model', values.model, |
|
'--system-prompt', system, |
|
'--tools', '', |
|
'--strict-mcp-config', |
|
'--no-session-persistence', |
|
'--output-format', 'json', |
|
], |
|
prompt, |
|
)) |
|
} finally { |
|
clearInterval(ticker) |
|
} |
|
if (!raw.trim()) throw new Error(`claude failed\n${claudeErr.trim() || `exited with status ${code}`}`) |
|
|
|
let result |
|
try { |
|
result = JSON.parse(raw) |
|
} catch { |
|
throw new Error(`claude returned unparseable output\n${raw.trim().slice(0, 400)}`) |
|
} |
|
if (result.is_error || result.subtype !== 'success') { |
|
const status = result.api_error_status ? ` (HTTP ${result.api_error_status})` : '' |
|
throw new Error(`claude did not finish${status}: ${result.result || result.subtype || 'unknown error'}`) |
|
} |
|
const body = (result.result ?? '').trim() |
|
if (!body) throw new Error('claude returned nothing') |
|
return {body, cost: result.total_cost_usd} |
|
} |
|
|
|
// ---------------------------------------------------------------- document |
|
|
|
const fmt = (x) => x.toLocaleString('en-US') |
|
|
|
const documentOf = (author, body, shaped, repos, reviewed) => { |
|
const counts = {} |
|
for (const pr of shaped) counts[pr.state] = (counts[pr.state] ?? 0) + 1 |
|
const tally = ['merged', 'open', 'draft', 'closed'] |
|
.filter((s) => counts[s]) |
|
.map((s) => `${counts[s]} ${s}`) |
|
.join(', ') |
|
const sum = (key) => shaped.reduce((n, pr) => n + pr[key], 0) |
|
return [ |
|
`# Work log for ${author}: ${rangeLabel}`, |
|
'', |
|
body, |
|
'', |
|
'---', |
|
`${shaped.length} pull requests (${tally}) across ${repos.length} repositories. +${fmt(sum('additions'))} / -${fmt(sum('deletions'))} lines in ${fmt(sum('changedFiles'))} files.${reviewed.length ? ` ${reviewed.length} reviewed for others.` : ''}`, |
|
`Generated ${today} for ${author}, window ${sinceStr} to ${untilStr}.`, |
|
'', |
|
].join('\n') |
|
} |
|
|
|
// ------------------------------------------------------------------ report |
|
|
|
// One author, fetch to finished document. tick(0..1) covers the whole run. |
|
// Returns null when there is nothing to save: no PRs in the window, or --data |
|
// / --dry-run already printed what was asked for. |
|
const report = async (author, tick) => { |
|
const prs = search(PR_QUERY, authoredQuery(author), (f) => tick(0.25 * f)) |
|
tick(0.25) |
|
const reviewed = values.reviews ? search(REVIEW_QUERY, reviewedQuery(author)) : [] |
|
tick(0.3) |
|
|
|
// The search matches on GitHub's `updated` date, which moves on events the |
|
// author did not cause: a label, a cross-reference, a force-push to the base |
|
// branch. Keep a PR only if it was opened, merged or closed, or got a commit |
|
// inside the window. |
|
const fetched = prs.map(shapePr) |
|
const shaped = fetched.filter((pr) => pr.openedInWindow || pr.closedInWindow || pr.windowCommits.length > 0) |
|
const stale = fetched.length - shaped.length |
|
if (stale) { |
|
process.stderr.write(`work-log: ${author}: skipped ${stale} PR${stale === 1 ? '' : 's'} with no activity by the author in the window\n`) |
|
} |
|
|
|
if (shaped.length === 0 && reviewed.length === 0) { |
|
process.stderr.write(`work-log: no pull requests by ${author} between ${sinceStr} and ${untilStr}\n`) |
|
return null |
|
} |
|
|
|
const repos = groupByRepo(shaped) |
|
|
|
if (values.data) { |
|
process.stdout.write(`${JSON.stringify({author, since: sinceStr, until: untilStr, prs: shaped, reviewed}, null, 2)}\n`) |
|
return null |
|
} |
|
|
|
const system = SYSTEM + (reviewed.length ? REVIEW_RULE : '') |
|
const prompt = `Here is ${author}'s pull request activity for ${rangeLabel}. Write the update.\n\n${digestOf(repos, reviewed)}` |
|
|
|
if (values['dry-run']) { |
|
process.stdout.write(`--- system ---\n${system}\n\n--- prompt ---\n${prompt}\n`) |
|
return null |
|
} |
|
|
|
process.stderr.write(`work-log: ${author}: ${shaped.length} PRs in ${repos.length} repos, summarizing...\n`) |
|
const startedAt = Date.now() |
|
const {body, cost} = await summarize(system, prompt, (f) => tick(0.3 + 0.65 * f)) |
|
const took = `${Math.round((Date.now() - startedAt) / 1000)}s` |
|
return {doc: documentOf(author, body, shaped, repos, reviewed), note: `${cost ? `$${cost.toFixed(2)}, ` : ''}${took}`} |
|
} |
|
|
|
// -------------------------------------------------------------------- main |
|
|
|
// Authors run one after another. A failure for one author is reported and the |
|
// rest still run; the exit status is 1 if any failed. |
|
const saved = [] |
|
let failed = 0 |
|
for (const [i, author] of authors.entries()) { |
|
const slice = 95 / authors.length |
|
const tick = progress.segment(5 + slice * i, 5 + slice * (i + 1)) |
|
let result |
|
try { |
|
result = await report(author, tick) |
|
} catch (err) { |
|
failed++ |
|
console.error(`work-log: ${author}: ${err.message}`) |
|
continue |
|
} |
|
if (!result) continue |
|
|
|
// The report goes to stdout only on request: the usual run just writes the |
|
// file and says where it went. |
|
if (values.print || values['no-save']) process.stdout.write(result.doc) |
|
if (values['no-save']) { |
|
process.stderr.write(`work-log: ${author}: done (${result.note})\n`) |
|
continue |
|
} |
|
mkdirSync(outDir, {recursive: true}) |
|
const file = outFile(author) |
|
writeFileSync(file, result.doc) |
|
saved.push(file) |
|
process.stderr.write(`work-log: saved ${file} (${result.note})\n`) |
|
tick(1) |
|
} |
|
progress.clear() |
|
|
|
if (values.open && saved.length) openPath(perAuthor ? outDir : saved[0]) |
|
process.exit(failed ? 1 : 0) |