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/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
+ }