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 +70 -2
- package/bin/ranni.js +2 -0
- package/package.json +29 -4
- package/src/cli.ts +71 -0
- package/src/derive.ts +90 -0
- package/src/errors.ts +2 -0
- package/src/git.ts +173 -0
- package/src/host.ts +144 -0
- package/src/index.ts +7 -0
- package/src/init.ts +184 -0
- package/src/project.ts +32 -0
- package/src/relic.ts +89 -0
- package/src/render.ts +81 -0
- package/src/runner.ts +39 -0
- package/src/status.ts +182 -0
- package/src/types.ts +83 -0
- package/templates/ranni/SKILL.md +83 -0
- package/templates/ranni/references/closing.md +37 -0
- package/templates/ranni/references/work-items.md +60 -0
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ranni
|
|
2
2
|
|
|
3
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ranni-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
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
|
+
}
|