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/src/init.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { basename, join, posix, relative } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { isMap, parseDocument } from 'yaml'
|
|
14
|
+
import { RanniError } from './errors'
|
|
15
|
+
import { findProjectRoot } from './project'
|
|
16
|
+
|
|
17
|
+
export const WORK_KIND = 'work'
|
|
18
|
+
const DEFAULT_ROOT = '.relic/records/work'
|
|
19
|
+
|
|
20
|
+
const norm = (p: string) => posix.normalize(p).replace(/\/+$/, '')
|
|
21
|
+
|
|
22
|
+
export interface WorkRoot {
|
|
23
|
+
path: string
|
|
24
|
+
/** Why this path, for the user. */
|
|
25
|
+
why: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Picks the root for the work kind from the roots already declared. Pure. */
|
|
29
|
+
export function chooseWorkRoot(topology: { specs?: unknown; shared?: unknown; records?: Record<string, unknown> }): WorkRoot {
|
|
30
|
+
const records = Object.values(topology.records ?? {}).filter((v): v is string => typeof v === 'string').map(norm)
|
|
31
|
+
const taken = new Set(
|
|
32
|
+
[topology.specs, topology.shared, ...records].filter((v): v is string => typeof v === 'string').map(norm),
|
|
33
|
+
)
|
|
34
|
+
let candidate: string
|
|
35
|
+
let why: string
|
|
36
|
+
if (records.length === 0 || records.every(r => r.startsWith('.relic/records/'))) {
|
|
37
|
+
candidate = DEFAULT_ROOT
|
|
38
|
+
why = records.length === 0 ? 'no record roots declared yet' : 'the other records live under .relic/records'
|
|
39
|
+
} else {
|
|
40
|
+
// Sibling of the existing roots: the most common parent directory, first seen wins ties.
|
|
41
|
+
const counts = new Map<string, number>()
|
|
42
|
+
for (const r of records) counts.set(posix.dirname(r), (counts.get(posix.dirname(r)) ?? 0) + 1)
|
|
43
|
+
let parent = posix.dirname(records[0])
|
|
44
|
+
let best = 0
|
|
45
|
+
for (const [p, c] of counts) if (c > best) { best = c; parent = p }
|
|
46
|
+
candidate = parent === '.' ? WORK_KIND : `${parent}/${WORK_KIND}`
|
|
47
|
+
why = `a sibling of the existing record roots (${parent === '.' ? 'project root' : parent})`
|
|
48
|
+
}
|
|
49
|
+
if (taken.has(candidate)) {
|
|
50
|
+
candidate = `${candidate}-items`
|
|
51
|
+
why += `; ${norm(candidate.replace(/-items$/, ''))} is already a Relic root`
|
|
52
|
+
}
|
|
53
|
+
return { path: candidate, why }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface YamlEdit {
|
|
57
|
+
text: string
|
|
58
|
+
changed: boolean
|
|
59
|
+
/** The declared or added root of the work kind. */
|
|
60
|
+
workRoot: string
|
|
61
|
+
why?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Declares the work record kind when none exists, with the yaml Document API so comments
|
|
66
|
+
* and formatting survive. Pure.
|
|
67
|
+
*/
|
|
68
|
+
export function addWorkKind(source: string): YamlEdit {
|
|
69
|
+
const doc = parseDocument(source)
|
|
70
|
+
if (doc.errors.length) throw new RanniError(`relic.yaml is not valid YAML: ${doc.errors[0].message}`)
|
|
71
|
+
const topology = doc.get('topology', true)
|
|
72
|
+
if (!isMap(topology)) throw new RanniError('relic.yaml has no topology map. Run `relic verify` to see what Relic expects.')
|
|
73
|
+
const records = topology.get('records', true)
|
|
74
|
+
if (isMap(records) && records.has(WORK_KIND)) {
|
|
75
|
+
const existing = records.get(WORK_KIND)
|
|
76
|
+
return { text: source, changed: false, workRoot: String(existing) }
|
|
77
|
+
}
|
|
78
|
+
const plain = doc.toJS() as { topology: { specs?: unknown; shared?: unknown; records?: Record<string, unknown> } }
|
|
79
|
+
const { path, why } = chooseWorkRoot(plain.topology)
|
|
80
|
+
if (isMap(records)) records.set(WORK_KIND, path)
|
|
81
|
+
else topology.set('records', doc.createNode({ [WORK_KIND]: path }))
|
|
82
|
+
return { text: doc.toString({ lineWidth: 0 }), changed: true, workRoot: path, why }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SkillChange {
|
|
86
|
+
dir: string
|
|
87
|
+
action: 'installed' | 'updated' | 'unchanged'
|
|
88
|
+
detail: string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readTree(dir: string): Map<string, string> {
|
|
92
|
+
const out = new Map<string, string>()
|
|
93
|
+
const walk = (d: string) => {
|
|
94
|
+
for (const name of readdirSync(d).sort()) {
|
|
95
|
+
const p = join(d, name)
|
|
96
|
+
if (statSync(p).isDirectory()) walk(p)
|
|
97
|
+
else out.set(relative(dir, p).split('\\').join('/'), readFileSync(p, 'utf8'))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (existsSync(dir)) walk(dir)
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every `<dot-dir>/skills/relic/` of the project that holds a SKILL.md. */
|
|
105
|
+
export function findRelicSkillDirs(projectRoot: string): string[] {
|
|
106
|
+
const found: string[] = []
|
|
107
|
+
for (const name of readdirSync(projectRoot).sort()) {
|
|
108
|
+
if (!name.startsWith('.') || name === '.git') continue
|
|
109
|
+
const base = join(projectRoot, name)
|
|
110
|
+
try {
|
|
111
|
+
if (!statSync(base).isDirectory()) continue
|
|
112
|
+
} catch {
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (existsSync(join(base, 'skills', 'relic', 'SKILL.md'))) found.push(base)
|
|
116
|
+
}
|
|
117
|
+
return found
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function templateDir(): string {
|
|
121
|
+
return fileURLToPath(new URL('../templates/ranni', import.meta.url))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Copies the template over `<engine>/skills/ranni`, replacing the previous copy. */
|
|
125
|
+
export function syncSkill(engineDir: string, template: string): SkillChange {
|
|
126
|
+
const dest = join(engineDir, 'skills', 'ranni')
|
|
127
|
+
const next = readTree(template)
|
|
128
|
+
const prev = readTree(dest)
|
|
129
|
+
const detail: string[] = []
|
|
130
|
+
for (const [f, c] of next) {
|
|
131
|
+
if (!prev.has(f)) detail.push(`+ ${f}`)
|
|
132
|
+
else if (prev.get(f) !== c) detail.push(`~ ${f}`)
|
|
133
|
+
}
|
|
134
|
+
for (const f of prev.keys()) if (!next.has(f)) detail.push(`- ${f}`)
|
|
135
|
+
const dir = join(basename(engineDir), 'skills', 'ranni')
|
|
136
|
+
if (!existsSync(dest)) {
|
|
137
|
+
cpSync(template, dest, { recursive: true })
|
|
138
|
+
return { dir, action: 'installed', detail: [`${next.size} files`] }
|
|
139
|
+
}
|
|
140
|
+
if (detail.length === 0) return { dir, action: 'unchanged', detail }
|
|
141
|
+
rmSync(dest, { recursive: true, force: true })
|
|
142
|
+
cpSync(template, dest, { recursive: true })
|
|
143
|
+
return { dir, action: 'updated', detail }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface Io {
|
|
147
|
+
out: (s: string) => void
|
|
148
|
+
err: (s: string) => void
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function runInit(cwd: string, io: Io, template = templateDir()): number {
|
|
152
|
+
const root = findProjectRoot(cwd)
|
|
153
|
+
if (!root) {
|
|
154
|
+
io.err('ranni: no relic.yaml found in this directory or above. Run `relic init` first; ranni never creates one.\n')
|
|
155
|
+
return 1
|
|
156
|
+
}
|
|
157
|
+
// Check every precondition before changing anything.
|
|
158
|
+
const engines = findRelicSkillDirs(root)
|
|
159
|
+
if (engines.length === 0) {
|
|
160
|
+
io.err('ranni: no Relic skill is installed (looked for <dot-dir>/skills/relic/SKILL.md). Run `relic install` first.\n')
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
const yamlPath = join(root, 'relic.yaml')
|
|
164
|
+
const edit = addWorkKind(readFileSync(yamlPath, 'utf8'))
|
|
165
|
+
|
|
166
|
+
const lines: string[] = []
|
|
167
|
+
if (edit.changed) {
|
|
168
|
+
writeFileSync(yamlPath, edit.text)
|
|
169
|
+
lines.push(`relic.yaml: declared the "${WORK_KIND}" record kind at ${edit.workRoot} (${edit.why})`)
|
|
170
|
+
const dir = join(root, edit.workRoot)
|
|
171
|
+
if (!existsSync(dir)) {
|
|
172
|
+
mkdirSync(dir, { recursive: true })
|
|
173
|
+
lines.push(`created ${edit.workRoot}/`)
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
lines.push(`relic.yaml: already declares the "${WORK_KIND}" record kind at ${edit.workRoot}`)
|
|
177
|
+
}
|
|
178
|
+
for (const eng of engines) {
|
|
179
|
+
const ch = syncSkill(eng, template)
|
|
180
|
+
lines.push(`${ch.dir}: ${ch.action}${ch.detail.length ? ' (' + ch.detail.join(', ') + ')' : ''}`)
|
|
181
|
+
}
|
|
182
|
+
io.out(lines.join('\n') + '\n')
|
|
183
|
+
return 0
|
|
184
|
+
}
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { parse } from 'yaml'
|
|
4
|
+
|
|
5
|
+
/** Nearest ancestor (or self) directory holding a regular, non-symlinked relic.yaml. */
|
|
6
|
+
export function findProjectRoot(from: string): string | undefined {
|
|
7
|
+
let dir = from
|
|
8
|
+
for (;;) {
|
|
9
|
+
try {
|
|
10
|
+
if (lstatSync(join(dir, 'relic.yaml')).isFile()) return dir
|
|
11
|
+
} catch {
|
|
12
|
+
// not here, keep walking
|
|
13
|
+
}
|
|
14
|
+
const up = dirname(dir)
|
|
15
|
+
if (up === dir) return undefined
|
|
16
|
+
dir = up
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether relic.yaml at the root declares the record kind. Undefined when the file cannot
|
|
22
|
+
* be read or parsed: the caller then says nothing rather than guess.
|
|
23
|
+
*/
|
|
24
|
+
export function declaresRecordKind(root: string, kind: string): boolean | undefined {
|
|
25
|
+
try {
|
|
26
|
+
const doc = parse(readFileSync(join(root, 'relic.yaml'), 'utf8')) as { topology?: { records?: unknown } } | null
|
|
27
|
+
const records = doc?.topology?.records
|
|
28
|
+
return !!records && typeof records === 'object' && Object.hasOwn(records, kind)
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/relic.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { RanniError } from './errors'
|
|
2
|
+
import type { Runner } from './runner'
|
|
3
|
+
import type { LinkRef, WorkItem } from './types'
|
|
4
|
+
|
|
5
|
+
export const SUPPORTED_FORMAT_VERSION = 1
|
|
6
|
+
|
|
7
|
+
const MIN_RELIC =
|
|
8
|
+
'Ranni needs a relic with structured search: `relic search --kind <kind> --json` reporting formatVersion 1 with document metadata.'
|
|
9
|
+
|
|
10
|
+
/** Validates and converts the JSON of `relic search --json`. Pure. */
|
|
11
|
+
export function parseSearch(stdout: string): { items: WorkItem[]; federated: boolean } {
|
|
12
|
+
let json: unknown
|
|
13
|
+
try {
|
|
14
|
+
json = JSON.parse(stdout)
|
|
15
|
+
} catch {
|
|
16
|
+
throw new RanniError(`relic search did not return JSON. ${MIN_RELIC}`)
|
|
17
|
+
}
|
|
18
|
+
const root = json as { formatVersion?: unknown; results?: unknown }
|
|
19
|
+
if (root === null || typeof root !== 'object' || root.formatVersion === undefined) {
|
|
20
|
+
throw new RanniError(`relic search output has no formatVersion. ${MIN_RELIC}`)
|
|
21
|
+
}
|
|
22
|
+
if (root.formatVersion !== SUPPORTED_FORMAT_VERSION) {
|
|
23
|
+
throw new RanniError(
|
|
24
|
+
`relic search reports formatVersion ${String(root.formatVersion)}; this Ranni supports ${SUPPORTED_FORMAT_VERSION}. Update Ranni or Relic so they agree.`,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(root.results)) throw new RanniError('relic search output has no results array.')
|
|
28
|
+
|
|
29
|
+
const items: WorkItem[] = []
|
|
30
|
+
let federated = false
|
|
31
|
+
for (const r of root.results as Record<string, unknown>[]) {
|
|
32
|
+
if (r.type !== undefined && r.type !== 'document') continue
|
|
33
|
+
if (typeof r.id !== 'string' || typeof r.path !== 'string') continue
|
|
34
|
+
const project = Array.isArray(r.project) ? (r.project as unknown[]).map(String) : undefined
|
|
35
|
+
if (project && project.length > 1) federated = true
|
|
36
|
+
if (r.metadata === null || typeof r.metadata !== 'object' || Array.isArray(r.metadata)) {
|
|
37
|
+
throw new RanniError(`relic search results carry no metadata. ${MIN_RELIC}`)
|
|
38
|
+
}
|
|
39
|
+
const meta = r.metadata as Record<string, unknown>
|
|
40
|
+
items.push({
|
|
41
|
+
id: r.id,
|
|
42
|
+
path: r.path,
|
|
43
|
+
title: typeof r.label === 'string' ? r.label : r.id,
|
|
44
|
+
project,
|
|
45
|
+
waiting: 'waiting' in meta ? waitingText(meta.waiting) : undefined,
|
|
46
|
+
links: parseLinks(r.links),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return { items, federated }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseLinks(v: unknown): LinkRef[] {
|
|
53
|
+
if (!Array.isArray(v)) return []
|
|
54
|
+
const out: LinkRef[] = []
|
|
55
|
+
for (const l of v as Record<string, unknown>[]) {
|
|
56
|
+
const target = (l.targetPath ?? l.resolvedPath) as unknown
|
|
57
|
+
out.push({
|
|
58
|
+
href: String(l.href ?? ''),
|
|
59
|
+
text: String(l.text ?? ''),
|
|
60
|
+
targetPath: typeof target === 'string' ? target : undefined,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The waiting key is set: show its text. A bare key still escalates, with a placeholder. */
|
|
67
|
+
export function waitingText(v: unknown): string {
|
|
68
|
+
if (v === null || v === undefined || v === '') return ''
|
|
69
|
+
return String(v).replace(/\s+/g, ' ').trim()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function searchWork(
|
|
73
|
+
run: Runner,
|
|
74
|
+
cwd: string,
|
|
75
|
+
kind: string,
|
|
76
|
+
): Promise<{ items: WorkItem[]; federated: boolean }> {
|
|
77
|
+
const res = await run('relic', ['search', '--kind', kind, '--json'], { cwd })
|
|
78
|
+
if (res.missing) {
|
|
79
|
+
throw new RanniError(`relic was not found on PATH. Install Relic. ${MIN_RELIC}`)
|
|
80
|
+
}
|
|
81
|
+
if (res.code !== 0) {
|
|
82
|
+
const err = (res.stderr || res.stdout).trim()
|
|
83
|
+
if (/unknown option/i.test(err)) {
|
|
84
|
+
throw new RanniError(`The installed relic has no structured search (${err.split('\n')[0]}). ${MIN_RELIC}`)
|
|
85
|
+
}
|
|
86
|
+
throw new RanniError(`relic search failed (exit ${res.code}): ${err || 'no output'}`)
|
|
87
|
+
}
|
|
88
|
+
return parseSearch(res.stdout)
|
|
89
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isMergedPr } from './derive'
|
|
2
|
+
import type { ItemStatus, StatusModel } from './status'
|
|
3
|
+
import { LABEL_ORDER, LABEL_TEXT } from './types'
|
|
4
|
+
|
|
5
|
+
export function formatAge(seconds: number): string {
|
|
6
|
+
const m = Math.floor(seconds / 60)
|
|
7
|
+
if (m < 1) return 'now'
|
|
8
|
+
if (m < 60) return `${m}m`
|
|
9
|
+
const h = Math.floor(m / 60)
|
|
10
|
+
if (h < 24) return `${h}h`
|
|
11
|
+
const d = Math.floor(h / 24)
|
|
12
|
+
if (d < 14) return `${d}d`
|
|
13
|
+
const w = Math.floor(d / 7)
|
|
14
|
+
if (d < 90) return `${w}w`
|
|
15
|
+
const mo = Math.floor(d / 30)
|
|
16
|
+
if (d < 365) return `${mo}mo`
|
|
17
|
+
return `${Math.floor(d / 365)}y`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
21
|
+
|
|
22
|
+
function prLine(p: ItemStatus['pullRequests'][number]): string {
|
|
23
|
+
const bits: string[] = []
|
|
24
|
+
if (p.reviewDecision === 'CHANGES_REQUESTED') bits.push('changes requested')
|
|
25
|
+
else if (p.reviewDecision === 'APPROVED') bits.push('approved')
|
|
26
|
+
if (p.unresolvedThreads) bits.push(plural(p.unresolvedThreads, 'unresolved thread'))
|
|
27
|
+
if (p.failingChecks) bits.push(plural(p.failingChecks, 'failing check'))
|
|
28
|
+
const draft = p.isDraft ? ' (draft)' : ''
|
|
29
|
+
return `PR #${p.number}${draft}${bits.length ? ' ' + bits.join(' · ') : ''}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Evidence lines shown under an item, depending on its label. Pure. */
|
|
33
|
+
export function detailLines(it: ItemStatus, defaultBranch?: string): string[] {
|
|
34
|
+
const lines: string[] = []
|
|
35
|
+
if (it.label === 'waiting') lines.push(`? ${it.waiting || '(no question written)'}`)
|
|
36
|
+
const merged = it.pullRequests.filter(p => isMergedPr(p, defaultBranch))
|
|
37
|
+
const open = it.pullRequests.filter(p => p.state === 'OPEN')
|
|
38
|
+
if (it.label === 'ready-to-close') {
|
|
39
|
+
for (const p of merged) lines.push(`PR #${p.number} merged`)
|
|
40
|
+
const mb = it.branches.filter(b => b.merged)
|
|
41
|
+
if (merged.length === 0 && mb.length) lines.push(`branch ${mb[0].name} merged into ${defaultBranch ?? 'the default branch'}`)
|
|
42
|
+
else if (merged.length === 0 && it.mergedTrailerCommits) {
|
|
43
|
+
lines.push(`${plural(it.mergedTrailerCommits, 'trailer commit')} on ${defaultBranch ?? 'the default branch'}`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (it.label === 'in-review' || it.label === 'waiting') {
|
|
47
|
+
for (const p of open) lines.push(prLine(p))
|
|
48
|
+
}
|
|
49
|
+
if (it.label === 'in-flight') {
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
if (it.branches.length) parts.push(plural(it.branches.length, 'branch'))
|
|
52
|
+
if (it.trailerCommits) parts.push(plural(it.trailerCommits, 'trailer commit'))
|
|
53
|
+
lines.push(parts.join(' · '))
|
|
54
|
+
}
|
|
55
|
+
for (const l of it.links) lines.push(`→ ${l.id} ${LABEL_TEXT[l.label].toLowerCase()}`)
|
|
56
|
+
return lines
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderHuman(m: StatusModel): string {
|
|
60
|
+
const out: string[] = []
|
|
61
|
+
const n = m.items.length
|
|
62
|
+
out.push(`${m.kind} · ${plural(n, 'item')}${m.defaultBranch ? ` · default branch ${m.defaultBranch}` : ''}`)
|
|
63
|
+
for (const note of m.notes) out.push(`! ${note}`)
|
|
64
|
+
if (n === 0) {
|
|
65
|
+
out.push('', `No work items of kind "${m.kind}".`)
|
|
66
|
+
return out.join('\n') + '\n'
|
|
67
|
+
}
|
|
68
|
+
const idW = Math.max(...m.items.map(i => i.id.length))
|
|
69
|
+
for (const label of LABEL_ORDER) {
|
|
70
|
+
const group = m.items.filter(i => i.label === label)
|
|
71
|
+
if (group.length === 0) continue
|
|
72
|
+
out.push('', `${LABEL_TEXT[label]} (${group.length})`)
|
|
73
|
+
for (const it of group) {
|
|
74
|
+
const age = it.ageSeconds !== undefined ? formatAge(it.ageSeconds) : '-'
|
|
75
|
+
const where = it.project && it.project.length > 1 ? `[${it.project.slice(1).join('/')}] ` : ''
|
|
76
|
+
out.push(` ${it.id.padEnd(idW)} ${where}${it.title} · ${age}`)
|
|
77
|
+
for (const l of detailLines(it, m.defaultBranch)) out.push(` ${' '.repeat(idW)} ${l}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out.join('\n') + '\n'
|
|
81
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export interface RunResult {
|
|
4
|
+
/** Exit code; 127 when the executable could not be started. */
|
|
5
|
+
code: number
|
|
6
|
+
stdout: string
|
|
7
|
+
stderr: string
|
|
8
|
+
/** True when the executable does not exist or cannot be started. */
|
|
9
|
+
missing: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RunOptions {
|
|
13
|
+
cwd?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The only way Ranni talks to `relic`, `git` and `gh`. Tests inject a fake. */
|
|
17
|
+
export type Runner = (cmd: string, args: string[], opts?: RunOptions) => Promise<RunResult>
|
|
18
|
+
|
|
19
|
+
export const run: Runner = (cmd, args, opts = {}) =>
|
|
20
|
+
new Promise(resolve => {
|
|
21
|
+
execFile(
|
|
22
|
+
cmd,
|
|
23
|
+
args,
|
|
24
|
+
{ cwd: opts.cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8' },
|
|
25
|
+
(err, stdout, stderr) => {
|
|
26
|
+
if (!err) return resolve({ code: 0, stdout, stderr, missing: false })
|
|
27
|
+
const e = err as NodeJS.ErrnoException & { code?: number | string }
|
|
28
|
+
if (e.code === 'ENOENT' || e.code === 'EACCES') {
|
|
29
|
+
return resolve({ code: 127, stdout: '', stderr: e.message, missing: true })
|
|
30
|
+
}
|
|
31
|
+
resolve({
|
|
32
|
+
code: typeof e.code === 'number' ? e.code : 1,
|
|
33
|
+
stdout: stdout ?? '',
|
|
34
|
+
stderr: stderr ?? '',
|
|
35
|
+
missing: false,
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
})
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { derive } from './derive'
|
|
2
|
+
import { RanniError } from './errors'
|
|
3
|
+
import { gitEvidenceFor, markMerged, readMerged, readRepoGit } from './git'
|
|
4
|
+
import { prsFor, readHost, readThreads } from './host'
|
|
5
|
+
import { declaresRecordKind, findProjectRoot } from './project'
|
|
6
|
+
import { searchWork } from './relic'
|
|
7
|
+
import type { Runner } from './runner'
|
|
8
|
+
import { LABEL_ORDER } from './types'
|
|
9
|
+
import type { Evidence, Label, PullRequestEvidence, WorkItem } from './types'
|
|
10
|
+
|
|
11
|
+
export interface Deps {
|
|
12
|
+
run: Runner
|
|
13
|
+
now: Date
|
|
14
|
+
cwd: string
|
|
15
|
+
/** Defaults to the nearest relic.yaml ancestor of cwd. Tests may set it. */
|
|
16
|
+
projectRoot?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StatusOptions {
|
|
20
|
+
kind: string
|
|
21
|
+
ids: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ItemStatus {
|
|
25
|
+
id: string
|
|
26
|
+
title: string
|
|
27
|
+
path: string
|
|
28
|
+
project?: string[]
|
|
29
|
+
label: Label
|
|
30
|
+
waiting?: string
|
|
31
|
+
lastActivity?: string
|
|
32
|
+
ageSeconds?: number
|
|
33
|
+
branches: { name: string; remote: boolean; date: string; merged?: boolean }[]
|
|
34
|
+
trailerCommits: number
|
|
35
|
+
/** Trailer commits on the default branch. Only present in Git-only mode. */
|
|
36
|
+
mergedTrailerCommits?: number
|
|
37
|
+
pullRequests: PullRequestEvidence[]
|
|
38
|
+
links: { id: string; title: string; label: Label }[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SourceState {
|
|
42
|
+
available: boolean
|
|
43
|
+
reason?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StatusModel {
|
|
47
|
+
formatVersion: 1
|
|
48
|
+
kind: string
|
|
49
|
+
generatedAt: string
|
|
50
|
+
root: string
|
|
51
|
+
defaultBranch?: string
|
|
52
|
+
sources: { relic: SourceState; git: SourceState; host: SourceState }
|
|
53
|
+
/** Limitations the reader should know about, in plain language. */
|
|
54
|
+
notes: string[]
|
|
55
|
+
items: ItemStatus[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sameProject = (a?: string[], b?: string[]) => (a ?? []).join('/') === (b ?? []).join('/')
|
|
59
|
+
|
|
60
|
+
export async function buildStatus(deps: Deps, opts: StatusOptions): Promise<StatusModel> {
|
|
61
|
+
const { run, cwd, now } = deps
|
|
62
|
+
const root = deps.projectRoot ?? findProjectRoot(cwd)
|
|
63
|
+
if (!root) throw new RanniError('No relic.yaml found above this directory. Run `relic init`, then `ranni init`.')
|
|
64
|
+
|
|
65
|
+
const notes: string[] = []
|
|
66
|
+
const { items: all, federated } = await searchWork(run, root, opts.kind)
|
|
67
|
+
|
|
68
|
+
const wanted = opts.ids.map(i => i.toUpperCase())
|
|
69
|
+
for (const w of wanted) {
|
|
70
|
+
if (!all.some(i => i.id.toUpperCase() === w)) {
|
|
71
|
+
throw new RanniError(`No work item ${w} of kind "${opts.kind}"${all.length ? '' : ' (the kind has no items; has `ranni init` run?)'}.`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (all.length === 0 && !federated && declaresRecordKind(root, opts.kind) === false) {
|
|
76
|
+
notes.push(
|
|
77
|
+
`relic.yaml declares no "${opts.kind}" record kind. Run \`ranni init\` to declare the work kind, or pass --kind with a declared one.`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (federated) {
|
|
82
|
+
notes.push(
|
|
83
|
+
'Federation: Relic reports member project addresses but not their filesystem roots, so Git evidence comes from the root repository only. Work items of members may show less activity than they have.',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Git: one for-each-ref and one log for the whole repository.
|
|
88
|
+
const repoGit = await readRepoGit(run, root)
|
|
89
|
+
const git = { available: repoGit.available, reason: repoGit.reason }
|
|
90
|
+
if (!repoGit.available) notes.push(`Git evidence unavailable: ${repoGit.reason}.`)
|
|
91
|
+
|
|
92
|
+
// Git host: one PR listing, plus one bounded GraphQL call for open matching PRs.
|
|
93
|
+
const host = await readHost(run, root)
|
|
94
|
+
let defaultBranch = host.defaultBranch ?? repoGit.defaultBranch
|
|
95
|
+
if (!host.available) {
|
|
96
|
+
notes.push(
|
|
97
|
+
`Git host evidence unavailable: ${host.reason}. Showing Git evidence only; pull request state, review decisions, threads and checks are missing.`,
|
|
98
|
+
)
|
|
99
|
+
if (repoGit.available) {
|
|
100
|
+
markMerged(repoGit, await readMerged(run, root, repoGit))
|
|
101
|
+
if (!defaultBranch) notes.push('Default branch unknown: merged branches could not be detected.')
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
const matchedOpen = new Map<number, PullRequestEvidence>()
|
|
105
|
+
for (const it of all) {
|
|
106
|
+
for (const p of prsFor(host.pullRequests, it.id)) if (p.state === 'OPEN') matchedOpen.set(p.number, p)
|
|
107
|
+
}
|
|
108
|
+
const th = await readThreads(run, root, host, [...matchedOpen.keys()])
|
|
109
|
+
for (const [n, c] of th.counts) matchedOpen.get(n)!.unresolvedThreads = c
|
|
110
|
+
if (th.error) notes.push(`Unresolved review threads unavailable: ${th.error}.`)
|
|
111
|
+
if (th.skipped > 0) notes.push(`Review threads were read for ${matchedOpen.size - th.skipped} of ${matchedOpen.size} open pull requests (bounded).`)
|
|
112
|
+
if (host.truncated) notes.push('The pull request listing hit its limit; older pull requests may be missing.')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const derived = new Map<WorkItem, { ev: Evidence; label: Label; last?: string }>()
|
|
116
|
+
for (const it of all) {
|
|
117
|
+
const gitEv = repoGit.available ? gitEvidenceFor(repoGit, it.id) : { branches: [], commits: [] }
|
|
118
|
+
const prs: PullRequestEvidence[] = host.available ? prsFor(host.pullRequests, it.id) : []
|
|
119
|
+
const ev: Evidence = {
|
|
120
|
+
git: gitEv,
|
|
121
|
+
pullRequests: prs,
|
|
122
|
+
mergedByGit: !host.available && (gitEv.branches.some(b => b.merged === true) || gitEv.commits.some(c => c.merged === true)),
|
|
123
|
+
}
|
|
124
|
+
const d = derive(it, ev, defaultBranch)
|
|
125
|
+
derived.set(it, { ev, label: d.label, last: d.lastActivity })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const byPath = (it: WorkItem, path: string) =>
|
|
129
|
+
all.find(o => o !== it && o.path === path && sameProject(o.project, it.project))
|
|
130
|
+
|
|
131
|
+
const statuses: ItemStatus[] = all
|
|
132
|
+
.filter(it => wanted.length === 0 || wanted.includes(it.id.toUpperCase()))
|
|
133
|
+
.map(it => {
|
|
134
|
+
const { ev, label, last } = derived.get(it)!
|
|
135
|
+
const seen = new Set<string>()
|
|
136
|
+
const links: ItemStatus['links'] = []
|
|
137
|
+
for (const l of it.links) {
|
|
138
|
+
if (!l.targetPath) continue
|
|
139
|
+
const t = byPath(it, l.targetPath)
|
|
140
|
+
if (!t || seen.has(t.id)) continue
|
|
141
|
+
seen.add(t.id)
|
|
142
|
+
links.push({ id: t.id, title: t.title, label: derived.get(t)!.label })
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
id: it.id,
|
|
146
|
+
title: it.title,
|
|
147
|
+
path: it.path,
|
|
148
|
+
...(it.project ? { project: it.project } : {}),
|
|
149
|
+
label,
|
|
150
|
+
...(it.waiting !== undefined ? { waiting: it.waiting } : {}),
|
|
151
|
+
...(last ? { lastActivity: last, ageSeconds: Math.max(0, Math.round((now.getTime() - Date.parse(last)) / 1000)) } : {}),
|
|
152
|
+
branches: ev.git.branches.map(b => ({ name: b.name, remote: b.remote, date: b.date, ...(b.merged !== undefined ? { merged: b.merged } : {}) })),
|
|
153
|
+
trailerCommits: ev.git.commits.length,
|
|
154
|
+
...(ev.git.commits.some(c => c.merged !== undefined)
|
|
155
|
+
? { mergedTrailerCommits: ev.git.commits.filter(c => c.merged).length }
|
|
156
|
+
: {}),
|
|
157
|
+
pullRequests: ev.pullRequests,
|
|
158
|
+
links,
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
statuses.sort((a, b) => {
|
|
163
|
+
const la = LABEL_ORDER.indexOf(a.label)
|
|
164
|
+
const lb = LABEL_ORDER.indexOf(b.label)
|
|
165
|
+
if (la !== lb) return la - lb
|
|
166
|
+
const ta = a.lastActivity ? Date.parse(a.lastActivity) : -Infinity
|
|
167
|
+
const tb = b.lastActivity ? Date.parse(b.lastActivity) : -Infinity
|
|
168
|
+
if (ta !== tb) return tb - ta
|
|
169
|
+
return a.id.localeCompare(b.id)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
formatVersion: 1,
|
|
174
|
+
kind: opts.kind,
|
|
175
|
+
generatedAt: now.toISOString(),
|
|
176
|
+
root,
|
|
177
|
+
...(defaultBranch ? { defaultBranch } : {}),
|
|
178
|
+
sources: { relic: { available: true }, git, host: { available: host.available, ...(host.reason ? { reason: host.reason } : {}) } },
|
|
179
|
+
notes,
|
|
180
|
+
items: statuses,
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type Label = 'waiting' | 'ready-to-close' | 'in-review' | 'in-flight' | 'not-started'
|
|
2
|
+
|
|
3
|
+
export const LABEL_ORDER: Label[] = ['waiting', 'ready-to-close', 'in-review', 'in-flight', 'not-started']
|
|
4
|
+
|
|
5
|
+
export const LABEL_TEXT: Record<Label, string> = {
|
|
6
|
+
waiting: 'Waiting on you',
|
|
7
|
+
'ready-to-close': 'Ready to close',
|
|
8
|
+
'in-review': 'In review',
|
|
9
|
+
'in-flight': 'In flight',
|
|
10
|
+
'not-started': 'Not started',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A work item as Relic's search read model describes it. */
|
|
14
|
+
export interface WorkItem {
|
|
15
|
+
id: string
|
|
16
|
+
/** Path relative to the owning Relic project. */
|
|
17
|
+
path: string
|
|
18
|
+
title: string
|
|
19
|
+
/** Project address in a federated view, root first. Undefined without federation. */
|
|
20
|
+
project?: string[]
|
|
21
|
+
/** Set when the waiting key is present; empty string when it carries no question. */
|
|
22
|
+
waiting?: string
|
|
23
|
+
/** Outgoing links as resolved by Relic. */
|
|
24
|
+
links: LinkRef[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LinkRef {
|
|
28
|
+
href: string
|
|
29
|
+
text: string
|
|
30
|
+
targetPath?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BranchEvidence {
|
|
34
|
+
/** Short name, for example work/WORK-042-push or origin/work/WORK-042-push. */
|
|
35
|
+
name: string
|
|
36
|
+
remote: boolean
|
|
37
|
+
tip: string
|
|
38
|
+
/** ISO date of the tip commit. */
|
|
39
|
+
date: string
|
|
40
|
+
/** Fully merged into the default branch. Only computed in Git-only mode. */
|
|
41
|
+
merged?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CommitEvidence {
|
|
45
|
+
sha: string
|
|
46
|
+
date: string
|
|
47
|
+
/** On the default branch. Only computed in Git-only mode. */
|
|
48
|
+
merged?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GitEvidence {
|
|
52
|
+
branches: BranchEvidence[]
|
|
53
|
+
/** Commits carrying a Work: trailer for the identity. */
|
|
54
|
+
commits: CommitEvidence[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ReviewDecision = 'CHANGES_REQUESTED' | 'APPROVED' | 'REVIEW_REQUIRED' | ''
|
|
58
|
+
|
|
59
|
+
export interface PullRequestEvidence {
|
|
60
|
+
number: number
|
|
61
|
+
title: string
|
|
62
|
+
url: string
|
|
63
|
+
state: 'OPEN' | 'MERGED' | 'CLOSED'
|
|
64
|
+
isDraft: boolean
|
|
65
|
+
baseRef: string
|
|
66
|
+
reviewDecision: ReviewDecision
|
|
67
|
+
failingChecks: number
|
|
68
|
+
/** Undefined when threads were not fetched or the fetch failed. */
|
|
69
|
+
unresolvedThreads?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Evidence {
|
|
73
|
+
git: GitEvidence
|
|
74
|
+
pullRequests: PullRequestEvidence[]
|
|
75
|
+
/** Branch fully merged into the default branch counts as merged. */
|
|
76
|
+
mergedByGit: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Derived {
|
|
80
|
+
label: Label
|
|
81
|
+
/** Newest commit or branch tip tied to the identity, ISO. */
|
|
82
|
+
lastActivity?: string
|
|
83
|
+
}
|