Skip to content

Instantly share code, notes, and snippets.

@tbeseda
Last active September 12, 2026 23:10
Show Gist options
  • Select an option

  • Save tbeseda/e7a27665e7553ddf300ae0ac2bd55b98 to your computer and use it in GitHub Desktop.

Select an option

Save tbeseda/e7a27665e7553ddf300ae0ac2bd55b98 to your computer and use it in GitHub Desktop.
work-log: summarize a week of GitHub pull requests into a markdown status update, per author, with gh and claude -p

work-log

Summarize a week of GitHub pull requests into a short markdown status update, one file per author. The script pulls PR data with gh, asks Claude to write plain-language bullets with claude -p, and saves the result under ~/Documents/work-log.

It was written for a weekly manager update. Run it for yourself, or for a whole team so each person can cherry-pick lines into their own notes.

What a bare run does

With no --author or --team, work-log summarizes the account that gh is logged in to, for the last 7 days. That is one model call and one file. The first two lines it prints say which account, which window, which model, and where the file goes, before any work starts:

work-log: gh logged in as octocat; no --author or --team given, so summarizing octocat (--help for flags)
work-log: window 2026-08-28 to 2026-09-04, model sonnet, saving to /Users/octocat/Documents/work-log

--dry-run and --data make no model call. Use them to see what would be sent.

Requirements

  • Node 22 or newer.
  • GitHub CLI (gh), logged in. The token needs the repo scope to see private repositories.
  • Claude Code CLI (claude), logged in. Tested with 2.1.x.
  • macOS or Linux. --open uses open or xdg-open. Everything else is plain Node.

The script checks all of these before it does any work, in this order:

  1. Node version. Below 22 it exits with a message.
  2. gh api user. This proves gh is installed and logged in, names the account, and reads the token scopes. A token without the repo scope produces a warning, because private repositories will not appear.
  3. claude auth status. A clear "logged out" answer stops the run. Older CLIs without this subcommand skip the check, and a login problem then surfaces at the model step.

Install

curl -fsSL https://gist.githubusercontent.com/tbeseda/e7a27665e7553ddf300ae0ac2bd55b98/raw/work-log.mjs -o ~/.local/bin/work-log
chmod +x ~/.local/bin/work-log

~/.local/bin must be on your PATH. Any other directory on your PATH works the same way.

Usage

work-log --help                   # all flags
work-log --dry-run                # show the prompt that would be sent, no model call
work-log                          # your last 7 days
work-log --days 14 --reviews      # two weeks, plus PRs you reviewed for others
work-log --since 2026-08-01 --until 2026-08-08
work-log --author alice --author bob
work-log --team --open            # everyone in the team file, then open the folder

The usual run writes a file and prints where it went. Add -p to also print the report, or --no-save to print without a file.

Team file

--team reads GitHub logins from ~/.config/work-log/team, one per line. Text after # is ignored. Set WORK_LOG_TEAM to use another path. See team.example. You can combine --team with extra --author flags. Duplicates are merged without regard to case.

Output

Run File
work-log ~/Documents/work-log/YYYY-MM-DD.md
--author or --team ~/Documents/work-log/YYYY-MM-DD/LOGIN.md, one per author

--out DIR changes the base directory. The date is the end of the window.

Each document has a ## Highlights section when there are four or more PRs, then one ## owner/repo section per repository with one bullet per PR, and a footer with counts. --reviews adds a one-line ## Reviews section.

Authors run one after another. If GitHub or the model fails for one author, the script reports it and continues with the rest. The exit status is 1 if any author failed.

What is sent to the model

For each PR in the window: the repository name, PR number, title, URL, state, labels, commit headlines, and the description with HTML comments, images, and attachment links removed and cut at 1200 characters. No diffs or code are sent. The claude -p call runs with --tools '', --strict-mcp-config, and --no-session-persistence, so the model has no tools, loads no MCP servers, and saves no transcript. Check your organization's policy before you run this on private repositories.

Use --dry-run to see the exact prompt, or --data to see the collected JSON. Neither calls the model.

Cost and time

One claude -p call per author. With the default sonnet model, a week with 23 PRs took about 90 seconds and cost about $0.12. A week with 6 PRs took about 20 seconds and cost about $0.03. Set WORK_LOG_MODEL or pass --model to change the model.

Environment variables

Variable Effect
WORK_LOG_MODEL Default for --model (otherwise sonnet)
WORK_LOG_TEAM Path of the team file
WORK_LOG_NO_PROGRESS Disable the terminal progress bar, same as --no-progress

The progress bar uses the OSC 9;4 escape sequence. Terminals that do not support it ignore it. It is only written when stderr is a terminal.

License

MIT. Copyright (c) 2026 Taylor Beseda.

# work-log team file: one GitHub login per line. Text after # is ignored.
# Copy this to ~/.config/work-log/team, or point $WORK_LOG_TEAM at your copy.
octocat
# hubot
#!/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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment