ranni-cli 0.0.0-stage → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # Ranni
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The work frontier agents leave behind. Ranni keeps the living state of in-flight work as
4
+ ordinary Relic records, so any agent, harness or human can
5
+ pick it up. It never runs agents, keeps no queue and stores no status: `ranni status`
6
+ derives the board from Relic, Git and the Git host every time you ask.
7
+
8
+ Spec: [`.relic/specs/001-work-frontier/index.html`](.relic/specs/001-work-frontier/index.html)
9
+
10
+ ## Requirements
11
+
12
+ - Relic 2.3.0 or later, with structured search (`relic search --kind <kind> --json`,
13
+ `formatVersion` 1). Ranni consumes Relic's read model and never parses the corpus.
14
+ - `git`. `gh` is optional: without it (or without auth or network) `status` shows Git evidence
15
+ only and says what is missing.
16
+ - Bun.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ bun add -g ranni-cli
22
+ ```
23
+
24
+ The 0.1 MCP orchestrator was published as `ranni-mcp`; Ranni 2 is published as `ranni-cli`, and the command is `ranni`.
25
+
26
+ ## `ranni init`
27
+
28
+ ```bash
29
+ relic init && relic install # once, if the project has no Relic yet
30
+ ranni init
31
+ ```
32
+
33
+ Idempotent. It requires an existing `relic.yaml` (it never creates one), adds a `work` record
34
+ kind to `topology.records` only if none is declared (comments and formatting are kept), and
35
+ installs the Ranni skill next to every installed Relic skill (`<dot-dir>/skills/ranni/`).
36
+ Running it again only refreshes the skill and prints what changed.
37
+
38
+ ## `ranni status`
39
+
40
+ ```bash
41
+ ranni status [id...] [--json] [--kind <kind>]
42
+ ```
43
+
44
+ A read-only board grouped by the first label that applies, with the age of the last activity
45
+ and links to related work items (`-> WORK-041 in review`):
46
+
47
+ 1. Waiting on you: the `waiting` frontmatter key is set.
48
+ 2. Ready to close: a pull request mentioning it is merged (or, without `gh`, a branch fully merged).
49
+ 3. In review: an open pull request, with requested changes, unresolved threads, failing checks or approval.
50
+ 4. In flight: branches or commits, no pull request yet.
51
+ 5. Not started: no traces.
52
+
53
+ There is no staleness threshold and no computed blocking; the reader decides.
54
+
55
+ ## The executor contract
56
+
57
+ Any agent or human leaves traces the board can read:
58
+
59
+ - the branch name contains the identity, for example `work/WORK-042-push`;
60
+ - commits carry a trailer, `Work: WORK-042`;
61
+ - the pull request title or head branch mentions the identity, or its body carries `Work: WORK-042`;
62
+ - the current frontier section of the work item is rewritten at natural stopping points.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ bun install
68
+ bun test
69
+ bun run typecheck
70
+ bun run ranni -- status
71
+ ```
package/bin/ranni.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/index.ts'
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "ranni-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.3",
4
+ "description": "Ranni 2: the work frontier agents leave behind, derived from Relic, Git and the Git host",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/filfp/ranni"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "templates"
14
+ ],
15
+ "bin": {
16
+ "ranni": "bin/ranni.js"
17
+ },
18
+ "scripts": {
19
+ "ranni": "bun run src/index.ts",
20
+ "test": "bun test",
21
+ "typecheck": "tsc --noEmit",
22
+ "release": "bun run scripts/publish.ts"
23
+ },
24
+ "dependencies": {
25
+ "yaml": "^2.4.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "latest",
29
+ "typescript": "^5.4.0"
30
+ }
31
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { RanniError } from './errors'
3
+ import { runInit } from './init'
4
+ import type { Io } from './init'
5
+ import { renderHuman } from './render'
6
+ import { run } from './runner'
7
+ import { buildStatus } from './status'
8
+
9
+ const HELP = `ranni: the work frontier agents leave behind
10
+
11
+ Usage:
12
+ ranni init Declare the work kind in relic.yaml and install the Ranni skill
13
+ ranni status [id...] [--json] [--kind <kind>]
14
+ Board derived from Relic, Git and the Git host (default kind: work)
15
+ ranni --help | --version
16
+ `
17
+
18
+
19
+ export async function main(argv: string[], cwd: string, io: Io): Promise<number> {
20
+ const [cmd, ...rest] = argv
21
+ try {
22
+ switch (cmd) {
23
+ case undefined:
24
+ case '-h':
25
+ case '--help':
26
+ case 'help':
27
+ io.out(HELP)
28
+ return cmd === undefined ? 1 : 0
29
+ case '-V':
30
+ case '--version': {
31
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
32
+ io.out(`${pkg.version}\n`)
33
+ return 0
34
+ }
35
+ case 'init': {
36
+ if (rest.length) throw new RanniError(`ranni init takes no arguments (got "${rest[0]}").`)
37
+ return runInit(cwd, io)
38
+ }
39
+ case 'status': {
40
+ let json = false
41
+ let kind = 'work'
42
+ const ids: string[] = []
43
+ for (let i = 0; i < rest.length; i++) {
44
+ const a = rest[i]
45
+ if (a === '--json') json = true
46
+ else if (a === '--kind') {
47
+ const v = rest[++i]
48
+ if (!v || v.startsWith('-')) throw new RanniError('--kind needs a value.')
49
+ kind = v
50
+ } else if (a.startsWith('--kind=')) kind = a.slice('--kind='.length)
51
+ else if (a.startsWith('-')) throw new RanniError(`Unknown option ${a}.`)
52
+ else ids.push(a)
53
+ }
54
+ const model = await buildStatus(
55
+ { run, now: new Date(), cwd },
56
+ { kind, ids },
57
+ )
58
+ io.out(json ? JSON.stringify(model, null, 2) + '\n' : renderHuman(model))
59
+ return 0
60
+ }
61
+ default:
62
+ throw new RanniError(`Unknown command "${cmd}". Try ranni --help.`)
63
+ }
64
+ } catch (e) {
65
+ if (e instanceof RanniError) {
66
+ io.err(`ranni: ${e.message}\n`)
67
+ return 1
68
+ }
69
+ throw e
70
+ }
71
+ }
package/src/derive.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { Derived, Evidence, Label, PullRequestEvidence } from './types'
2
+
3
+ /**
4
+ * Whether `text` mentions the identity, case-insensitively. The identity must not be
5
+ * glued to more letters or digits, so WORK-04 is not found inside WORK-042.
6
+ */
7
+ export function mentions(text: string, id: string): boolean {
8
+ if (!text || !id) return false
9
+ const esc = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
10
+ return new RegExp(`(?<![A-Za-z0-9])${esc}(?![A-Za-z0-9])`, 'i').test(text)
11
+ }
12
+
13
+ /**
14
+ * Whether `body` declares the identity on a `Work:` line, the same shape as the commit
15
+ * trailer. Several identities may share one line, separated by commas or spaces. Plain
16
+ * mentions elsewhere in the body ("depends on WORK-041") do not count.
17
+ */
18
+ export function declaresWork(body: string, id: string): boolean {
19
+ if (!body || !id) return false
20
+ for (const match of body.matchAll(/^[ \t]*Work:[ \t]*(.+)$/gim)) {
21
+ if (match[1]!.split(/[\s,]+/).some(token => token.toLowerCase() === id.toLowerCase())) return true
22
+ }
23
+ return false
24
+ }
25
+
26
+ export function newest(dates: (string | undefined)[]): string | undefined {
27
+ let best: string | undefined
28
+ let bestT = -Infinity
29
+ for (const d of dates) {
30
+ if (!d) continue
31
+ const t = Date.parse(d)
32
+ if (Number.isNaN(t)) continue
33
+ if (t > bestT) {
34
+ bestT = t
35
+ best = d
36
+ }
37
+ }
38
+ return best
39
+ }
40
+
41
+ export function lastActivity(e: Evidence): string | undefined {
42
+ return newest([...e.git.branches.map(b => b.date), ...e.git.commits.map(c => c.date)])
43
+ }
44
+
45
+ export function isMergedPr(pr: PullRequestEvidence, defaultBranch: string | undefined): boolean {
46
+ if (pr.state !== 'MERGED') return false
47
+ // A PR merged into another branch has not landed on the default branch yet.
48
+ return !defaultBranch || !pr.baseRef || pr.baseRef === defaultBranch
49
+ }
50
+
51
+ export function openPrs(e: Evidence): PullRequestEvidence[] {
52
+ return e.pullRequests.filter(p => p.state === 'OPEN')
53
+ }
54
+
55
+ /**
56
+ * The first label that applies, as in the spec:
57
+ * waiting, ready to close, in review, in flight, not started.
58
+ */
59
+ export function deriveLabel(
60
+ item: { waiting?: string },
61
+ e: Evidence,
62
+ defaultBranch: string | undefined,
63
+ ): Label {
64
+ if (item.waiting !== undefined) return 'waiting'
65
+ if (e.mergedByGit || e.pullRequests.some(p => isMergedPr(p, defaultBranch))) return 'ready-to-close'
66
+ if (openPrs(e).length > 0) return 'in-review'
67
+ if (e.git.branches.length > 0 || e.git.commits.length > 0) return 'in-flight'
68
+ return 'not-started'
69
+ }
70
+
71
+ export function derive(
72
+ item: { waiting?: string },
73
+ e: Evidence,
74
+ defaultBranch: string | undefined,
75
+ ): Derived {
76
+ return { label: deriveLabel(item, e, defaultBranch), lastActivity: lastActivity(e) }
77
+ }
78
+
79
+ /** Failing check conclusions as GitHub reports them for check runs and status contexts. */
80
+ const FAILING = new Set(['FAILURE', 'TIMED_OUT', 'STARTUP_FAILURE', 'ERROR'])
81
+
82
+ export function countFailingChecks(rollup: unknown): number {
83
+ if (!Array.isArray(rollup)) return 0
84
+ let n = 0
85
+ for (const c of rollup as Record<string, unknown>[]) {
86
+ const v = String(c.conclusion ?? c.state ?? '').toUpperCase()
87
+ if (FAILING.has(v)) n++
88
+ }
89
+ return n
90
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** An error whose message is meant to be shown to the user as is. */
2
+ export class RanniError extends Error {}
package/src/git.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { mentions } from './derive'
2
+ import type { Runner } from './runner'
3
+ import type { BranchEvidence, CommitEvidence, GitEvidence } from './types'
4
+
5
+ export interface RefInfo extends BranchEvidence {
6
+ /** Short name without the remote prefix, used for identity matching. */
7
+ bare: string
8
+ full: string
9
+ /** Reachable from the default branch tip. Set only in Git-only mode. */
10
+ inDefault?: boolean
11
+ }
12
+
13
+ export interface RepoGit {
14
+ available: boolean
15
+ reason?: string
16
+ refs: RefInfo[]
17
+ /** Commits that carry at least one Work: trailer. */
18
+ trailers: { sha: string; date: string; value: string }[]
19
+ /** Default branch short name as origin/HEAD reports it (or main/master as a last resort). */
20
+ defaultBranch?: string
21
+ defaultTip?: string
22
+ /** Work: trailer commits on the default branch. Set only in Git-only mode. */
23
+ defaultCommits?: Set<string>
24
+ }
25
+
26
+ const NO_GIT: RepoGit = { available: false, refs: [], trailers: [] }
27
+
28
+ /** One for-each-ref read and one log read. Both are bounded by the repository's own size. */
29
+ export async function readRepoGit(run: Runner, cwd: string): Promise<RepoGit> {
30
+ const refsRes = await run(
31
+ 'git',
32
+ [
33
+ 'for-each-ref',
34
+ '--format=%(refname)%09%(objectname)%09%(committerdate:iso-strict)%09%(symref)',
35
+ 'refs/heads',
36
+ 'refs/remotes',
37
+ ],
38
+ { cwd },
39
+ )
40
+ if (refsRes.missing) return { ...NO_GIT, reason: 'git is not installed' }
41
+ if (refsRes.code !== 0) {
42
+ return { ...NO_GIT, reason: `not a Git repository (${firstLine(refsRes.stderr)})` }
43
+ }
44
+ const { refs, originHead } = parseRefs(refsRes.stdout)
45
+
46
+ const logRes = await run(
47
+ 'git',
48
+ [
49
+ 'log',
50
+ '--all',
51
+ '--no-color',
52
+ '--format=%H%x1f%cI%x1f%(trailers:key=Work,valueonly,unfold,separator=%x1d)%x1e',
53
+ ],
54
+ { cwd },
55
+ )
56
+ // A repository without commits has no log; that is evidence of nothing, not a failure.
57
+ const trailers = logRes.code === 0 ? parseLog(logRes.stdout) : []
58
+
59
+ let defaultBranch = originHead
60
+ if (!defaultBranch) {
61
+ const local = ['main', 'master'].find(n => refs.some(r => !r.remote && r.bare === n))
62
+ defaultBranch = local
63
+ }
64
+ const tipRef = defaultBranch ? findDefaultRef(refs, defaultBranch) : undefined
65
+ return {
66
+ available: true,
67
+ refs,
68
+ trailers,
69
+ defaultBranch,
70
+ defaultTip: tipRef?.tip,
71
+ }
72
+ }
73
+
74
+ function firstLine(s: string): string {
75
+ return s.trim().split('\n')[0] ?? ''
76
+ }
77
+
78
+ export function parseRefs(out: string): { refs: RefInfo[]; originHead?: string } {
79
+ const refs: RefInfo[] = []
80
+ let originHead: string | undefined
81
+ for (const line of out.split('\n')) {
82
+ if (!line) continue
83
+ const [full, tip, date, symref] = line.split('\t')
84
+ if (!full) continue
85
+ if (full.startsWith('refs/remotes/')) {
86
+ const rest = full.slice('refs/remotes/'.length)
87
+ const slash = rest.indexOf('/')
88
+ const remote = rest.slice(0, slash)
89
+ const bare = rest.slice(slash + 1)
90
+ if (symref) {
91
+ if (remote === 'origin' && bare === 'HEAD') {
92
+ originHead = symref.replace(/^refs\/remotes\/origin\//, '')
93
+ }
94
+ continue
95
+ }
96
+ if (bare === 'HEAD') continue
97
+ refs.push({ full, name: rest, bare, remote: true, tip, date })
98
+ } else if (full.startsWith('refs/heads/')) {
99
+ const bare = full.slice('refs/heads/'.length)
100
+ refs.push({ full, name: bare, bare, remote: false, tip, date })
101
+ }
102
+ }
103
+ return { refs, originHead }
104
+ }
105
+
106
+ export function parseLog(out: string): RepoGit['trailers'] {
107
+ const res: RepoGit['trailers'] = []
108
+ for (const rec of out.split('\x1e')) {
109
+ const [sha, date, value] = rec.replace(/^\n+/, '').split('\x1f')
110
+ if (!sha || !value || !value.trim()) continue
111
+ res.push({ sha, date, value: value.replace(/\x1d/g, '\n') })
112
+ }
113
+ return res
114
+ }
115
+
116
+ function findDefaultRef(refs: RefInfo[], name: string): RefInfo | undefined {
117
+ return refs.find(r => r.full === `refs/remotes/origin/${name}`) ?? refs.find(r => r.full === `refs/heads/${name}`)
118
+ }
119
+
120
+ export interface MergedRead {
121
+ /** Full names of refs fully merged into the default branch. */
122
+ refs: Set<string>
123
+ /** Commits on the default branch whose message has a Work: line. */
124
+ commits: Set<string>
125
+ }
126
+
127
+ /**
128
+ * Git-only mode: which refs and Work: trailer commits are on the default branch. Two
129
+ * extra reads, made only when the Git host is unavailable. The commit read keeps a work
130
+ * item ready to close after its merged branch is deleted.
131
+ */
132
+ export async function readMerged(run: Runner, cwd: string, repo: RepoGit): Promise<MergedRead> {
133
+ const none: MergedRead = { refs: new Set(), commits: new Set() }
134
+ if (!repo.defaultBranch) return none
135
+ const ref = findDefaultRef(repo.refs, repo.defaultBranch)
136
+ if (!ref) return none
137
+ const [refsRes, commitsRes] = await Promise.all([
138
+ run('git', ['for-each-ref', `--merged=${ref.full}`, '--format=%(refname)', 'refs/heads', 'refs/remotes'], { cwd }),
139
+ run('git', ['rev-list', '--regexp-ignore-case', '--extended-regexp', '--grep=^[[:space:]]*Work:', ref.full], { cwd }),
140
+ ])
141
+ const lines = (s: string) => new Set(s.split('\n').map(l => l.trim()).filter(Boolean))
142
+ return {
143
+ refs: refsRes.code === 0 ? lines(refsRes.stdout) : new Set(),
144
+ commits: commitsRes.code === 0 ? lines(commitsRes.stdout) : new Set(),
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Evidence for one identity out of the repository-wide reads. A branch counts as merged
150
+ * only when it is fully merged into the default branch and is not merely sitting on the
151
+ * default tip: a fresh, empty branch is trivially merged. A fast-forwarded branch still
152
+ * counts when commits carry the identity in a Work: trailer.
153
+ */
154
+ export function gitEvidenceFor(repo: RepoGit, id: string): GitEvidence {
155
+ const commits: CommitEvidence[] = repo.trailers
156
+ .filter(t => mentions(t.value, id))
157
+ .map(t => ({ sha: t.sha, date: t.date, ...(repo.defaultCommits ? { merged: repo.defaultCommits.has(t.sha) } : {}) }))
158
+ const branches: BranchEvidence[] = repo.refs
159
+ .filter(r => mentions(r.bare, id))
160
+ .map(r => ({
161
+ name: r.name,
162
+ remote: r.remote,
163
+ tip: r.tip,
164
+ date: r.date,
165
+ merged: r.inDefault === undefined ? undefined : r.inDefault && (r.tip !== repo.defaultTip || commits.length > 0),
166
+ }))
167
+ return { branches, commits }
168
+ }
169
+
170
+ export function markMerged(repo: RepoGit, merged: MergedRead): void {
171
+ for (const r of repo.refs) r.inDefault = merged.refs.has(r.full)
172
+ repo.defaultCommits = merged.commits
173
+ }
package/src/host.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { countFailingChecks, declaresWork, mentions } from './derive'
2
+ import type { Runner } from './runner'
3
+ import type { PullRequestEvidence, ReviewDecision } from './types'
4
+
5
+ export const PR_LIMIT = 200
6
+ /** Upper bound of open pull requests whose review threads are fetched in one call. */
7
+ export const THREAD_PR_LIMIT = 50
8
+
9
+ export interface HostRead {
10
+ available: boolean
11
+ /** Why the host evidence is missing. */
12
+ reason?: string
13
+ defaultBranch?: string
14
+ owner?: string
15
+ repo?: string
16
+ /** Every listed pull request, unfiltered. */
17
+ pullRequests: (PullRequestEvidence & { body: string; head: string })[]
18
+ /** True when the listing hit the limit and older pull requests may be missing. */
19
+ truncated: boolean
20
+ }
21
+
22
+ const NONE: HostRead = { available: false, pullRequests: [], truncated: false }
23
+
24
+ export function classifyGhFailure(stderr: string, missing: boolean): string {
25
+ if (missing) return 'gh is not installed'
26
+ const s = stderr.trim()
27
+ if (/no git remotes|none of the git remotes|not a git repository|could not resolve to a repository/i.test(s)) {
28
+ return 'this repository has no GitHub remote'
29
+ }
30
+ if (/auth|log in|login|token|credentials|401|403/i.test(s)) return 'gh is not authenticated (run `gh auth login`)'
31
+ if (/resolve host|network|timed? ?out|dial tcp|connection|offline/i.test(s)) return 'no network to reach the Git host'
32
+ return `gh failed: ${s.split('\n')[0] || 'no output'}`
33
+ }
34
+
35
+ export async function readHost(run: Runner, cwd: string): Promise<HostRead> {
36
+ const repoRes = await run('gh', ['repo', 'view', '--json', 'nameWithOwner,defaultBranchRef'], { cwd })
37
+ if (repoRes.code !== 0) return { ...NONE, reason: classifyGhFailure(repoRes.stderr, repoRes.missing) }
38
+ let defaultBranch: string | undefined
39
+ let owner: string | undefined
40
+ let repo: string | undefined
41
+ try {
42
+ const j = JSON.parse(repoRes.stdout) as { nameWithOwner?: string; defaultBranchRef?: { name?: string } }
43
+ defaultBranch = j.defaultBranchRef?.name
44
+ const [o, r] = (j.nameWithOwner ?? '').split('/')
45
+ owner = o
46
+ repo = r
47
+ } catch {
48
+ return { ...NONE, reason: 'gh repo view returned unreadable output' }
49
+ }
50
+
51
+ const prRes = await run(
52
+ 'gh',
53
+ [
54
+ 'pr',
55
+ 'list',
56
+ '--state',
57
+ 'all',
58
+ '--limit',
59
+ String(PR_LIMIT),
60
+ '--json',
61
+ 'number,title,body,headRefName,baseRefName,state,isDraft,reviewDecision,statusCheckRollup,url',
62
+ ],
63
+ { cwd },
64
+ )
65
+ if (prRes.code !== 0) return { ...NONE, reason: classifyGhFailure(prRes.stderr, prRes.missing) }
66
+ let list: Record<string, unknown>[]
67
+ try {
68
+ list = JSON.parse(prRes.stdout)
69
+ if (!Array.isArray(list)) throw new Error('not a list')
70
+ } catch {
71
+ return { ...NONE, reason: 'gh pr list returned unreadable output' }
72
+ }
73
+ return {
74
+ available: true,
75
+ defaultBranch,
76
+ owner,
77
+ repo,
78
+ pullRequests: list.map(parsePr),
79
+ truncated: list.length >= PR_LIMIT,
80
+ }
81
+ }
82
+
83
+ export function parsePr(p: Record<string, unknown>): PullRequestEvidence & { body: string; head: string } {
84
+ const state = String(p.state ?? '').toUpperCase()
85
+ return {
86
+ number: Number(p.number),
87
+ title: String(p.title ?? ''),
88
+ body: String(p.body ?? ''),
89
+ head: String(p.headRefName ?? ''),
90
+ url: String(p.url ?? ''),
91
+ state: state === 'MERGED' ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN',
92
+ isDraft: p.isDraft === true,
93
+ baseRef: String(p.baseRefName ?? ''),
94
+ reviewDecision: String(p.reviewDecision ?? '') as ReviewDecision,
95
+ failingChecks: countFailingChecks(p.statusCheckRollup),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Pull requests tied to the identity: mentioned in the title or head branch, or declared on
101
+ * a `Work:` line in the body. A plain body mention is not evidence, so a PR saying
102
+ * "depends on WORK-041" never makes WORK-041 look landed.
103
+ */
104
+ export function prsFor<T extends { title: string; body: string; head: string }>(prs: T[], id: string): T[] {
105
+ return prs.filter(p => mentions(p.title, id) || mentions(p.head, id) || declaresWork(p.body, id))
106
+ }
107
+
108
+ /**
109
+ * Unresolved review threads for the given open pull requests, in one bounded GraphQL
110
+ * call. Returns undefined for a pull request whose count could not be read.
111
+ */
112
+ export async function readThreads(
113
+ run: Runner,
114
+ cwd: string,
115
+ host: Pick<HostRead, 'owner' | 'repo'>,
116
+ numbers: number[],
117
+ ): Promise<{ counts: Map<number, number>; error?: string; skipped: number }> {
118
+ const counts = new Map<number, number>()
119
+ if (numbers.length === 0) return { counts, skipped: 0 }
120
+ const take = numbers.slice(0, THREAD_PR_LIMIT)
121
+ const skipped = numbers.length - take.length
122
+ if (!host.owner || !host.repo || !/^[\w.-]+$/.test(host.owner) || !/^[\w.-]+$/.test(host.repo)) {
123
+ return { counts, error: 'repository owner or name unavailable', skipped }
124
+ }
125
+ const fields = take
126
+ .map(n => `p${n}: pullRequest(number: ${Number(n)}) { reviewThreads(first: 100) { nodes { isResolved } } }`)
127
+ .join(' ')
128
+ const query = `query { repository(owner: "${host.owner}", name: "${host.repo}") { ${fields} } }`
129
+ const res = await run('gh', ['api', 'graphql', '-f', `query=${query}`], { cwd })
130
+ if (res.code !== 0) return { counts, error: classifyGhFailure(res.stderr, res.missing), skipped }
131
+ try {
132
+ const j = JSON.parse(res.stdout) as {
133
+ data?: { repository?: Record<string, { reviewThreads?: { nodes?: { isResolved?: boolean }[] } } | null> }
134
+ }
135
+ const repo = j.data?.repository ?? {}
136
+ for (const n of take) {
137
+ const nodes = repo[`p${n}`]?.reviewThreads?.nodes
138
+ if (Array.isArray(nodes)) counts.set(n, nodes.filter(t => t.isResolved === false).length)
139
+ }
140
+ } catch {
141
+ return { counts, error: 'review thread response was unreadable', skipped }
142
+ }
143
+ return { counts, skipped }
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { main } from './cli'
2
+
3
+ const code = await main(process.argv.slice(2), process.cwd(), {
4
+ out: s => process.stdout.write(s),
5
+ err: s => process.stderr.write(s),
6
+ })
7
+ process.exit(code)