diff --git a/docs/WORK_MAP_IMPORT.md b/docs/WORK_MAP_IMPORT.md new file mode 100644 index 0000000..1daa1c2 --- /dev/null +++ b/docs/WORK_MAP_IMPORT.md @@ -0,0 +1,80 @@ +# Gira Work-Map Import + +Parent roadmap: [#15](https://github.com/StatPan/agentree/issues/15). This slice implements the first read-only import path for [#18](https://github.com/StatPan/agentree/issues/18). + +## Boundary + +Agentree consumes Gira workspace/status JSON as presentation input only. GitHub issues, pull requests, branches, labels, checks, and reviews remain canonical through GitHub and Gira. The importer does not write to Agentree's SQLite overlay, GitHub, Gira, or any worker runtime. + +The first endpoint is: + +```text +POST /api/work-map/import/gira +``` + +Body: a `gira workspace status --json` payload containing `workspace_queues`, or a direct `workspace-queues/v1` object. + +Response: `agentree-work-map/v1`. + +The response exposes only the mapped work-map fields below plus minimal source metadata such as schema version and queue name. It does not echo raw Gira queue items or arbitrary unmapped payload fields. + +## Work Node Mapping + +Each Gira queue item becomes one `gira-work-item` node keyed by: + +```text +repo + issue + branch +``` + +If the branch is present, the identity segment is `branch:`. If the branch is not present, the node keeps `branch: null` and uses a `missing-branch` identity segment. Agentree must not infer branch ownership from issue title, timestamps, or agent labels. + +Mapped fields: + +| Agentree field | Source | +| --- | --- | +| `identity.repo` | `repo` or `repository` | +| `identity.issue` | `issue`, `issue_number`, `ticket`, or `number` | +| `identity.branch` | `branch`, `branch_name`, PR `head_ref`, or evidence `branch` | +| `title` | `title` or `issue_title` | +| `status` | queue membership first, then item status/state fallback | +| `pullRequest` | `pull_request` or `pr` | +| `checks` | `checks_status` / `checks`, including evidence variants | +| `review` | `review_status`, evidence review, or PR review decision | +| `nextAction` | `next_action`, evidence `next_action`, and `next_safe_command` | +| `sourceLinks` | source URLs when present, otherwise derived GitHub repo/issue/PR/branch links | + +Queue overlap is preserved by merging duplicate work identities and retaining all queue names, reason codes, blockers, and source links. + +## Attribution + +The importer preserves low-confidence attribution rather than inventing owners: + +- `assignees` and explicit `owner`/`assignee` fields are `source` confidence. +- `agent:*` labels become `agent-label` attribution with `low` confidence. +- Missing attribution remains `unknown`. + +No productivity, availability, token-spend, or time-online metrics are imported or computed. + +## Edges + +The first edge mapping is conservative: + +- `parent`, `parent_issue`, or `parent_ticket` creates a `parent` edge. +- `depends_on` creates `depends-on` edges. +- `linked_issues` creates low-confidence `linked` edges. + +Edges are emitted only when both endpoints are present in the imported queue data. Missing endpoints produce warnings instead of placeholder tracker records. + +## Fixture + +The sample fixture lives at: + +```text +src/server/work-map/testdata/gira-workspace-status.sample.json +``` + +It covers ready work, PR review work, failed checks, a human-decision item with unknown attribution, and a source-linked parent edge. + +## Follow-Up + +The production UI follow-up should add a work-map canvas mode that renders `agentree-work-map/v1` nodes separately from live opencode session nodes, then correlates sessions/runs by `repo + branch + issue` when that evidence exists. diff --git a/src/server/app.ts b/src/server/app.ts index 78fc2e2..45c3054 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -11,6 +11,7 @@ import { systemRouter } from './routes/system.js' import { relationRouter } from './routes/relation.js' import { projectRouter } from './routes/project.js' import { agentRouter } from './routes/agent.js' +import { workMapRouter } from './routes/workMap.js' import { sseHandler, isOpencodeConnected } from './sse/broadcaster.js' type AppOptions = { @@ -44,6 +45,7 @@ export function createApp(options: AppOptions = {}) { app.route('/', relationRouter) app.route('/', projectRouter) app.route('/', agentRouter) + app.route('/', workMapRouter) // Static file serving for production CLI mode — must come after all /api routes if (options.staticDir) { diff --git a/src/server/routes/workMap.test.ts b/src/server/routes/workMap.test.ts new file mode 100644 index 0000000..34c7bec --- /dev/null +++ b/src/server/routes/workMap.test.ts @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises' +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import { workMapRouter } from './workMap.js' + +const app = new Hono() +app.route('/', workMapRouter) + +async function readFixtureText() { + return readFile(new URL('../work-map/testdata/gira-workspace-status.sample.json', import.meta.url), 'utf8') +} + +describe('POST /api/work-map/import/gira', () => { + it('imports Gira workspace status JSON without mutating canonical state', async () => { + const res = await app.request('/api/work-map/import/gira', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: await readFixtureText(), + }) + + expect(res.status).toBe(200) + const body = await res.json() as { source: { readOnly: boolean }; nodes: unknown[]; edges: unknown[] } + expect(body.source.readOnly).toBe(true) + expect(body.nodes).toHaveLength(3) + expect(body.edges).toHaveLength(1) + }) + + it('rejects invalid JSON', async () => { + const res = await app.request('/api/work-map/import/gira', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{invalid', + }) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'request body must be valid JSON' }) + }) +}) diff --git a/src/server/routes/workMap.ts b/src/server/routes/workMap.ts new file mode 100644 index 0000000..08ce60c --- /dev/null +++ b/src/server/routes/workMap.ts @@ -0,0 +1,16 @@ +import { Hono } from 'hono' +import { importGiraWorkspaceStatus } from '../work-map/giraWorkspaceImport.js' + +export const workMapRouter = new Hono() + +workMapRouter.post('/api/work-map/import/gira', async (c) => { + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'request body must be valid JSON' }, 400) + } + + const workMap = importGiraWorkspaceStatus(body) + return c.json(workMap) +}) diff --git a/src/server/work-map/giraWorkspaceImport.test.ts b/src/server/work-map/giraWorkspaceImport.test.ts new file mode 100644 index 0000000..c8758f5 --- /dev/null +++ b/src/server/work-map/giraWorkspaceImport.test.ts @@ -0,0 +1,181 @@ +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { importGiraWorkspaceStatus } from './giraWorkspaceImport.js' + +async function readFixture() { + const text = await readFile(new URL('./testdata/gira-workspace-status.sample.json', import.meta.url), 'utf8') + return JSON.parse(text) as unknown +} + +describe('importGiraWorkspaceStatus', () => { + it('maps workspace queue items into read-only Agentree work nodes', async () => { + const workMap = importGiraWorkspaceStatus(await readFixture()) + + expect(workMap.schemaVersion).toBe('agentree-work-map/v1') + expect(workMap.source).toMatchObject({ + kind: 'gira-workspace-status', + schemaVersion: 'workspace-queues/v1', + readOnly: true, + }) + expect(workMap.nodes).toHaveLength(3) + + const agentree = workMap.nodes.find((node) => node.identity.repo === 'StatPan/agentree' && node.identity.issue === 18) + expect(agentree).toMatchObject({ + kind: 'gira-work-item', + identity: { + repo: 'StatPan/agentree', + issue: 18, + branch: 'issue-18-gira-work-map-import', + }, + status: 'ready', + queues: ['agent_ready'], + nextAction: { + action: 'start_agent', + command: 'gira ticket start --repo StatPan/agentree --ticket 18 --apply', + }, + }) + expect(agentree?.sourceLinks.find((link) => link.kind === 'issue')?.url).toBe('https://github.com/StatPan/agentree/issues/18') + expect(agentree?.sourceLinks.find((link) => link.kind === 'branch')?.url).toBe('https://github.com/StatPan/agentree/tree/issue-18-gira-work-map-import') + }) + + it('merges queue overlap without losing checks, review state, blockers, or reason codes', async () => { + const workMap = importGiraWorkspaceStatus(await readFixture()) + const gira = workMap.nodes.find((node) => node.identity.repo === 'StatPan/gira' && node.identity.issue === 686) + + expect(gira?.queues).toEqual(['failed_check', 'review_needed']) + expect(gira?.status).toBe('failed-check') + expect(gira?.pullRequest).toMatchObject({ + number: 687, + url: 'https://github.com/StatPan/gira/pull/687', + state: 'OPEN', + draft: false, + reviewDecision: 'REVIEW_REQUIRED', + }) + expect(gira?.checks).toMatchObject({ + status: 'failing', + conclusion: 'failure', + summary: 'docs build failed', + }) + expect(gira?.review).toMatchObject({ + state: 'review_required', + decision: 'REVIEW_REQUIRED', + }) + expect(gira?.blockers).toContain('docs build failed') + expect(gira?.reasonCodes).toEqual(['checks_failed', 'review_required']) + expect(gira?.attribution).toEqual({ + kind: 'agent-label', + value: 'codex', + confidence: 'low', + }) + }) + + it('preserves unknown attribution instead of inventing an owner', async () => { + const workMap = importGiraWorkspaceStatus(await readFixture()) + const backlog = workMap.nodes.find((node) => node.identity.repo === 'StatPan/backlog' && node.identity.issue === 31) + + expect(backlog?.status).toBe('needs-human') + expect(backlog?.attribution).toEqual({ + kind: 'unknown', + value: null, + confidence: 'unknown', + }) + }) + + it('creates edges only for source-linked work items present in the import', async () => { + const workMap = importGiraWorkspaceStatus(await readFixture()) + const parent = workMap.nodes.find((node) => node.identity.repo === 'StatPan/agentree' && node.identity.issue === 18) + const child = workMap.nodes.find((node) => node.identity.repo === 'StatPan/gira' && node.identity.issue === 686) + + expect(workMap.edges).toHaveLength(1) + expect(workMap.edges[0]).toMatchObject({ + source: parent?.id, + target: child?.id, + kind: 'parent', + confidence: 'source', + }) + }) + + it('does not export arbitrary raw Gira queue item fields', () => { + const workMap = importGiraWorkspaceStatus({ + schema_version: 'workspace-queues/v1', + queues: { + agent_ready: [ + { + repo: 'R/O', + issue: 1, + branch: 'work', + title: 'Allowed mapped title', + productivity_score: 99, + token_spend: 12345, + accidental_sensitive_metadata: 'do-not-export', + nested_unmapped_payload: { + secret: 'nested-do-not-export', + }, + }, + ], + }, + }) + + expect(workMap.nodes).toHaveLength(1) + expect(workMap.nodes[0]?.source).toEqual({ + schemaVersion: 'workspace-queues/v1', + rawQueue: 'agent_ready', + }) + + const exported = JSON.stringify(workMap) + expect(exported).not.toContain('productivity_score') + expect(exported).not.toContain('token_spend') + expect(exported).not.toContain('accidental_sensitive_metadata') + expect(exported).not.toContain('nested_unmapped_payload') + expect(exported).not.toContain('do-not-export') + expect(exported).not.toContain('nested-do-not-export') + }) + + it('keeps a missing branch identity distinct from a real branch named unknown-branch', () => { + const workMap = importGiraWorkspaceStatus({ + queues: { + agent_ready: [ + { repo: 'R/O', issue: 1, branch: 'unknown-branch', title: 'explicit' }, + { repo: 'R/O', issue: 1, title: 'missing' }, + ], + }, + }) + + const explicitBranch = workMap.nodes.find((node) => node.identity.branch === 'unknown-branch') + const missingBranch = workMap.nodes.find((node) => node.identity.branch === null) + + expect(workMap.nodes).toHaveLength(2) + expect(explicitBranch?.title).toBe('explicit') + expect(missingBranch?.title).toBe('missing') + expect(explicitBranch?.id).not.toBe(missingBranch?.id) + expect(explicitBranch?.identity.key).toBe('R/O#1@branch:unknown-branch') + expect(missingBranch?.identity.key).toBe('R/O#1@missing-branch') + }) + + it('skips ambiguous branchless parent edges instead of choosing the first matching branch node', () => { + const workMap = importGiraWorkspaceStatus({ + queues: { + agent_ready: [ + { repo: 'R/O', issue: 1, branch: 'a' }, + { repo: 'R/O', issue: 1, branch: 'b' }, + { repo: 'R/O', issue: 2, parent: { repo: 'R/O', issue: 1 } }, + ], + }, + }) + + expect(workMap.nodes).toHaveLength(3) + expect(workMap.edges).toEqual([]) + expect(workMap.warnings).toContain( + 'skipped parent edge because branchless ref R/O#1 matches multiple imported queue items (R/O#1@branch:a, R/O#1@branch:b)', + ) + }) + + it('returns warnings, not writes, for missing workspace queue data', () => { + const workMap = importGiraWorkspaceStatus({ schema_version: 'unknown' }) + + expect(workMap.nodes).toEqual([]) + expect(workMap.edges).toEqual([]) + expect(workMap.warnings).toContain('input does not contain workspace_queues or a workspace-queues/v1 object') + expect(workMap.source.readOnly).toBe(true) + }) +}) diff --git a/src/server/work-map/giraWorkspaceImport.ts b/src/server/work-map/giraWorkspaceImport.ts new file mode 100644 index 0000000..c5059fa --- /dev/null +++ b/src/server/work-map/giraWorkspaceImport.ts @@ -0,0 +1,604 @@ +export type WorkNodeStatus = + | 'ready' + | 'review' + | 'finish-ready' + | 'blocked' + | 'failed-check' + | 'needs-human' + | 'open' + | 'closed' + | 'unknown' + +export type Confidence = 'source' | 'derived' | 'low' | 'unknown' + +export type WorkSourceLink = { + kind: 'repo' | 'issue' | 'pull_request' | 'branch' | 'source' + url: string + confidence: Confidence +} + +export type WorkNodeAttribution = { + kind: 'assignee' | 'explicit-owner' | 'agent-label' | 'unknown' + value: string | null + confidence: Confidence +} + +export type WorkNode = { + id: string + kind: 'gira-work-item' + identity: { + repo: string + issue: number + branch: string | null + key: string + } + title: string + status: WorkNodeStatus + queues: string[] + state: string | null + labels: string[] + milestone: string | null + pullRequest: { + number: number | null + url: string | null + state: string | null + draft: boolean | null + reviewDecision: string | null + } | null + checks: { + status: string | null + conclusion: string | null + summary: string | null + } + review: { + state: string | null + decision: string | null + } + nextAction: { + action: string | null + command: string | null + } + blockers: string[] + reasonCodes: string[] + attribution: WorkNodeAttribution + sourceLinks: WorkSourceLink[] + source: { + schemaVersion: string | null + rawQueue: string | null + } +} + +export type WorkEdge = { + id: string + source: string + target: string + kind: 'parent' | 'depends-on' | 'linked' + confidence: Confidence +} + +export type GiraWorkMap = { + schemaVersion: 'agentree-work-map/v1' + source: { + kind: 'gira-workspace-status' + schemaVersion: string | null + workspace: Record | null + readOnly: true + } + nodes: WorkNode[] + edges: WorkEdge[] + warnings: string[] +} + +type ImportCandidate = { + node: WorkNode + edgeRefs: EdgeRef[] +} + +type EdgeRef = { + from: WorkIdentityRef + to: WorkIdentityRef + kind: WorkEdge['kind'] + confidence: Confidence +} + +type WorkIdentityRef = { + repo: string + issue: number + branch: string | null +} + +type RefResolution = + | { kind: 'found'; node: WorkNode } + | { kind: 'missing' } + | { kind: 'ambiguous'; ref: WorkIdentityRef; candidates: WorkNode[] } + +const STATUS_PRIORITY: Record = { + 'failed-check': 80, + blocked: 70, + 'needs-human': 60, + review: 50, + 'finish-ready': 40, + ready: 30, + open: 20, + closed: 10, + unknown: 0, +} + +const QUEUE_STATUS: Record = { + failed_check: 'failed-check', + blocked: 'blocked', + human_decision: 'needs-human', + review_needed: 'review', + finish_ready: 'finish-ready', + agent_ready: 'ready', +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function numberValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && /^\d+$/.test(value.trim())) return Number(value) + return null +} + +function booleanValue(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.map(stringValue).filter((item): item is string => Boolean(item)) +} + +function unique(items: T[]): T[] { + return [...new Set(items)] +} + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + const result = stringValue(value) + if (result) return result + } + return null +} + +function firstNumber(...values: unknown[]): number | null { + for (const value of values) { + const result = numberValue(value) + if (result !== null) return result + } + return null +} + +function repoUrl(repo: string) { + return `https://github.com/${repo}` +} + +function issueUrl(repo: string, issue: number) { + return `${repoUrl(repo)}/issues/${issue}` +} + +function pullRequestUrl(repo: string, prNumber: number) { + return `${repoUrl(repo)}/pull/${prNumber}` +} + +function branchUrl(repo: string, branch: string) { + return `${repoUrl(repo)}/tree/${encodeURIComponent(branch)}` +} + +function branchIdentityPart(branch: string | null) { + return branch === null ? 'missing-branch' : `branch:${branch}` +} + +function workNodeId(ref: WorkIdentityRef) { + return [ + 'gira', + encodeURIComponent(ref.repo), + String(ref.issue), + encodeURIComponent(branchIdentityPart(ref.branch)), + ].join(':') +} + +function workIdentityKey(ref: WorkIdentityRef) { + return `${ref.repo}#${ref.issue}@${branchIdentityPart(ref.branch)}` +} + +function collectQueueItems(queuesRoot: Record, warnings: string[]): Array<{ queue: string; item: Record }> { + const queues = queuesRoot.queues + if (!isRecord(queues)) { + warnings.push('workspace queues object is missing a queues map') + return [] + } + + const result: Array<{ queue: string; item: Record }> = [] + for (const [queue, value] of Object.entries(queues)) { + if (!Array.isArray(value)) { + warnings.push(`queue ${queue} is not an array and was skipped`) + continue + } + for (const item of value) { + if (isRecord(item)) { + result.push({ queue, item }) + } else { + warnings.push(`queue ${queue} contains a non-object item and it was skipped`) + } + } + } + return result +} + +function workspaceQueuesFromInput(input: unknown, warnings: string[]): Record | null { + if (!isRecord(input)) { + warnings.push('input is not a JSON object') + return null + } + + if (isRecord(input.workspace_queues)) return input.workspace_queues + if (isRecord(input.workspaceQueues)) return input.workspaceQueues + if (isRecord(input.derived) && isRecord(input.derived.workspace_queues)) return input.derived.workspace_queues + if (isRecord(input.queues)) return input + + warnings.push('input does not contain workspace_queues or a workspace-queues/v1 object') + return null +} + +function readNestedRecord(item: Record, key: string): Record { + const value = item[key] + return isRecord(value) ? value : {} +} + +function extractEvidence(item: Record): Record { + return readNestedRecord(item, 'evidence') +} + +function extractPullRequest(item: Record, repo: string): WorkNode['pullRequest'] { + const pr = isRecord(item.pull_request) ? item.pull_request : readNestedRecord(item, 'pr') + const number = firstNumber(pr.number, pr.pr_number, item.pr, item.pr_number) + const url = firstString(pr.url, pr.html_url) ?? (number ? pullRequestUrl(repo, number) : null) + const state = firstString(pr.state) + const draft = booleanValue(pr.draft) + const reviewDecision = firstString(pr.review_decision, pr.reviewDecision) + + if (number === null && !url && !state && draft === null && !reviewDecision) return null + return { number, url, state, draft, reviewDecision } +} + +function extractBranch(item: Record, pr: Record): { branch: string | null; confidence: Confidence } { + const evidence = extractEvidence(item) + const branch = firstString(item.branch, item.branch_name, pr.head_ref, pr.branch, evidence.branch) + if (!branch) return { branch: null, confidence: 'unknown' } + if (stringValue(item.branch) || stringValue(item.branch_name)) return { branch, confidence: 'source' } + if (stringValue(pr.head_ref) || stringValue(pr.branch)) return { branch, confidence: 'source' } + return { branch, confidence: 'low' } +} + +function extractChecks(item: Record): WorkNode['checks'] { + const evidence = extractEvidence(item) + const candidate = item.checks_status ?? item.checks ?? evidence.checks_status ?? evidence.checks + if (typeof candidate === 'string') { + return { status: candidate, conclusion: null, summary: null } + } + if (isRecord(candidate)) { + return { + status: firstString(candidate.status, candidate.state), + conclusion: firstString(candidate.conclusion, candidate.result), + summary: firstString(candidate.summary, candidate.description), + } + } + return { status: null, conclusion: null, summary: null } +} + +function extractReview(item: Record, pullRequest: WorkNode['pullRequest']): WorkNode['review'] { + const evidence = extractEvidence(item) + const candidate = item.review_status ?? evidence.review_status ?? evidence.review + if (typeof candidate === 'string') return { state: candidate, decision: pullRequest?.reviewDecision ?? null } + if (isRecord(candidate)) { + return { + state: firstString(candidate.status, candidate.state), + decision: firstString(candidate.decision, candidate.review_decision) ?? pullRequest?.reviewDecision ?? null, + } + } + return { state: null, decision: pullRequest?.reviewDecision ?? null } +} + +function extractNextAction(item: Record): WorkNode['nextAction'] { + const evidence = extractEvidence(item) + const action = firstString(item.next_action, evidence.next_action) + const command = firstString(item.next_safe_command, evidence.next_safe_command) + return { action, command } +} + +function extractBlockers(item: Record): string[] { + const evidence = extractEvidence(item) + const blockers = item.blockers ?? evidence.blockers + if (typeof blockers === 'string') return [blockers] + return stringArray(blockers) +} + +function extractAttribution(item: Record, labels: string[]): WorkNodeAttribution { + const assignees = stringArray(item.assignees) + if (assignees.length > 0) { + return { kind: 'assignee', value: assignees.join(', '), confidence: 'source' } + } + + const owner = firstString(item.owner, item.assignee) + if (owner) { + return { kind: 'explicit-owner', value: owner, confidence: 'source' } + } + + const agentLabel = labels.find((label) => label.startsWith('agent:')) + if (agentLabel) { + return { kind: 'agent-label', value: agentLabel.slice('agent:'.length), confidence: 'low' } + } + + return { kind: 'unknown', value: null, confidence: 'unknown' } +} + +function extractStatus(queue: string, item: Record): WorkNodeStatus { + const queueStatus = QUEUE_STATUS[queue] + if (queueStatus) return queueStatus + + const status = firstString(item.status, item.state)?.toLowerCase() + if (!status) return 'unknown' + if (status.includes('blocked')) return 'blocked' + if (status.includes('review')) return 'review' + if (status.includes('ready')) return 'ready' + if (status.includes('closed') || status.includes('done') || status.includes('merged')) return 'closed' + if (status.includes('open')) return 'open' + return 'unknown' +} + +function mergeStatus(left: WorkNodeStatus, right: WorkNodeStatus): WorkNodeStatus { + return STATUS_PRIORITY[right] > STATUS_PRIORITY[left] ? right : left +} + +function hasChecks(checks: WorkNode['checks']) { + return Boolean(checks.status || checks.conclusion || checks.summary) +} + +function checkLooksFailed(checks: WorkNode['checks']) { + return `${checks.status ?? ''} ${checks.conclusion ?? ''}`.toLowerCase().includes('fail') +} + +function mergeChecks(left: WorkNode['checks'], right: WorkNode['checks']) { + if (!hasChecks(left)) return right + if (!hasChecks(right)) return left + return checkLooksFailed(right) ? right : left +} + +function sourceLinksFromItem( + item: Record, + repo: string, + issue: number, + branch: string | null, + branchConfidence: Confidence, + pullRequest: WorkNode['pullRequest'], +): WorkSourceLink[] { + const sourceLinks = readNestedRecord(item, 'source_links') + const links: WorkSourceLink[] = [ + { kind: 'repo', url: firstString(sourceLinks.repo, item.repo_url) ?? repoUrl(repo), confidence: 'derived' }, + { kind: 'issue', url: firstString(sourceLinks.issue, item.url, item.html_url, item.issue_url) ?? issueUrl(repo, issue), confidence: 'derived' }, + ] + + if (branch) { + links.push({ + kind: 'branch', + url: firstString(sourceLinks.branch, item.branch_url) ?? branchUrl(repo, branch), + confidence: branchConfidence === 'unknown' ? 'derived' : branchConfidence, + }) + } + if (pullRequest?.url) { + links.push({ kind: 'pull_request', url: pullRequest.url, confidence: pullRequest.number ? 'derived' : 'source' }) + } + + const extra = sourceLinks.source + if (typeof extra === 'string') { + links.push({ kind: 'source', url: extra, confidence: 'source' }) + } + + return dedupeLinks(links) +} + +function dedupeLinks(links: WorkSourceLink[]) { + const seen = new Set() + return links.filter((link) => { + const key = `${link.kind}:${link.url}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function refFromUnknown(value: unknown, fallbackRepo: string): WorkIdentityRef | null { + if (typeof value === 'number') return { repo: fallbackRepo, issue: value, branch: null } + if (typeof value === 'string') { + const crossRepo = value.match(/^([^#\s]+\/[^#\s]+)#(\d+)$/) + if (crossRepo) return { repo: crossRepo[1], issue: Number(crossRepo[2]), branch: null } + const issue = numberValue(value.replace(/^#/, '')) + return issue === null ? null : { repo: fallbackRepo, issue, branch: null } + } + if (isRecord(value)) { + const repo = firstString(value.repo, value.repository) ?? fallbackRepo + const issue = firstNumber(value.issue, value.issue_number, value.ticket, value.number) + const branch = firstString(value.branch) + if (issue !== null) return { repo, issue, branch } + } + return null +} + +function extractEdgeRefs(item: Record, self: WorkIdentityRef): EdgeRef[] { + const refs: EdgeRef[] = [] + const parent = refFromUnknown(item.parent ?? item.parent_issue ?? item.parent_ticket, self.repo) + if (parent) refs.push({ from: parent, to: self, kind: 'parent', confidence: 'source' }) + + for (const dependency of Array.isArray(item.depends_on) ? item.depends_on : []) { + const ref = refFromUnknown(dependency, self.repo) + if (ref) refs.push({ from: ref, to: self, kind: 'depends-on', confidence: 'source' }) + } + + for (const linked of Array.isArray(item.linked_issues) ? item.linked_issues : []) { + const ref = refFromUnknown(linked, self.repo) + if (ref) refs.push({ from: ref, to: self, kind: 'linked', confidence: 'low' }) + } + + return refs +} + +function parseQueueItem( + item: Record, + queue: string, + schemaVersion: string | null, + warnings: string[], +): ImportCandidate | null { + const repo = firstString(item.repo, item.repository) + const issue = firstNumber(item.issue, item.issue_number, item.ticket, item.number) + if (!repo || issue === null) { + warnings.push(`queue ${queue} item missing repo or issue and was skipped`) + return null + } + + const rawPr = isRecord(item.pull_request) ? item.pull_request : readNestedRecord(item, 'pr') + const { branch, confidence: branchConfidence } = extractBranch(item, rawPr) + const ref = { repo, issue, branch } + const pullRequest = extractPullRequest(item, repo) + const labels = stringArray(item.labels) + + const node: WorkNode = { + id: workNodeId(ref), + kind: 'gira-work-item', + identity: { + repo, + issue, + branch, + key: workIdentityKey(ref), + }, + title: firstString(item.title, item.issue_title) ?? `${repo}#${issue}`, + status: extractStatus(queue, item), + queues: unique([firstString(item.queue) ?? queue]), + state: firstString(item.state), + labels, + milestone: firstString(item.milestone), + pullRequest, + checks: extractChecks(item), + review: extractReview(item, pullRequest), + nextAction: extractNextAction(item), + blockers: extractBlockers(item), + reasonCodes: unique(stringArray(item.reason_codes)), + attribution: extractAttribution(item, labels), + sourceLinks: sourceLinksFromItem(item, repo, issue, branch, branchConfidence, pullRequest), + source: { + schemaVersion, + rawQueue: queue, + }, + } + + return { node, edgeRefs: extractEdgeRefs(item, ref) } +} + +function mergeNodes(left: WorkNode, right: WorkNode): WorkNode { + return { + ...left, + status: mergeStatus(left.status, right.status), + queues: unique([...left.queues, ...right.queues]).sort(), + labels: unique([...left.labels, ...right.labels]).sort(), + blockers: unique([...left.blockers, ...right.blockers]), + reasonCodes: unique([...left.reasonCodes, ...right.reasonCodes]).sort(), + pullRequest: left.pullRequest ?? right.pullRequest, + checks: mergeChecks(left.checks, right.checks), + review: left.review.state || left.review.decision ? left.review : right.review, + nextAction: left.nextAction.action || left.nextAction.command ? left.nextAction : right.nextAction, + attribution: left.attribution.kind === 'unknown' ? right.attribution : left.attribution, + sourceLinks: dedupeLinks([...left.sourceLinks, ...right.sourceLinks]), + source: left.source, + } +} + +function refLabel(ref: WorkIdentityRef) { + return ref.branch === null ? `${ref.repo}#${ref.issue}` : `${ref.repo}#${ref.issue}@${ref.branch}` +} + +function findNodeForRef(ref: WorkIdentityRef, nodesById: Map): RefResolution { + const exact = nodesById.get(workNodeId(ref)) + if (exact) return { kind: 'found', node: exact } + if (ref.branch !== null) return { kind: 'missing' } + + const candidates = [...nodesById.values()].filter((node) => node.identity.repo === ref.repo && node.identity.issue === ref.issue) + if (candidates.length === 1) return { kind: 'found', node: candidates[0]! } + if (candidates.length > 1) return { kind: 'ambiguous', ref, candidates } + return { kind: 'missing' } +} + +function ambiguousRefWarning(edgeKind: WorkEdge['kind'], resolutions: RefResolution[]) { + const refs = resolutions + .filter((resolution): resolution is Extract => resolution.kind === 'ambiguous') + .map((resolution) => { + const candidates = resolution.candidates.map((node) => node.identity.key).sort().join(', ') + return `${refLabel(resolution.ref)} matches multiple imported queue items (${candidates})` + }) + .join('; ') + + return `skipped ${edgeKind} edge because branchless ref ${refs}` +} + +function buildEdges(edgeRefs: EdgeRef[], nodesById: Map, warnings: string[]): WorkEdge[] { + const edges = new Map() + + for (const ref of edgeRefs) { + const source = findNodeForRef(ref.from, nodesById) + const target = findNodeForRef(ref.to, nodesById) + if (source.kind === 'ambiguous' || target.kind === 'ambiguous') { + warnings.push(ambiguousRefWarning(ref.kind, [source, target])) + continue + } + if (source.kind === 'missing' || target.kind === 'missing') { + warnings.push(`skipped ${ref.kind} edge because one endpoint is not present in the imported queue items`) + continue + } + + const id = `${ref.kind}:${source.node.id}:${target.node.id}` + edges.set(id, { id, source: source.node.id, target: target.node.id, kind: ref.kind, confidence: ref.confidence }) + } + + return [...edges.values()].sort((left, right) => left.id.localeCompare(right.id)) +} + +export function importGiraWorkspaceStatus(input: unknown): GiraWorkMap { + const warnings: string[] = [] + const queuesRoot = workspaceQueuesFromInput(input, warnings) + const schemaVersion = queuesRoot ? firstString(queuesRoot.schema_version, queuesRoot.schemaVersion) : null + const workspace = queuesRoot && isRecord(queuesRoot.workspace) ? queuesRoot.workspace : null + const nodesById = new Map() + const edgeRefs: EdgeRef[] = [] + + if (queuesRoot) { + for (const { queue, item } of collectQueueItems(queuesRoot, warnings)) { + const candidate = parseQueueItem(item, queue, schemaVersion, warnings) + if (!candidate) continue + const existing = nodesById.get(candidate.node.id) + nodesById.set(candidate.node.id, existing ? mergeNodes(existing, candidate.node) : candidate.node) + edgeRefs.push(...candidate.edgeRefs) + } + } + + const nodes = [...nodesById.values()].sort((left, right) => left.identity.key.localeCompare(right.identity.key)) + const edges = buildEdges(edgeRefs, nodesById, warnings) + + return { + schemaVersion: 'agentree-work-map/v1', + source: { + kind: 'gira-workspace-status', + schemaVersion, + workspace, + readOnly: true, + }, + nodes, + edges, + warnings, + } +} diff --git a/src/server/work-map/testdata/gira-workspace-status.sample.json b/src/server/work-map/testdata/gira-workspace-status.sample.json new file mode 100644 index 0000000..a1ae04a --- /dev/null +++ b/src/server/work-map/testdata/gira-workspace-status.sample.json @@ -0,0 +1,141 @@ +{ + "schema_version": "workspace-status/v1", + "workspace": { + "name": "personal", + "owner": "StatPan" + }, + "workspace_queues": { + "schema_version": "workspace-queues/v1", + "workspace": { + "name": "personal", + "owner": "StatPan" + }, + "queues": { + "agent_ready": [ + { + "queue": "agent_ready", + "repo": "StatPan/agentree", + "issue": 18, + "title": "Add read-only Gira work-map import from workspace status JSON", + "state": "open", + "status": "status:ready", + "labels": ["status:ready", "area:agentree"], + "milestone": "Phase 7", + "branch": "issue-18-gira-work-map-import", + "reason_codes": ["ready_without_pr"], + "evidence": { + "ticket_readiness": "ready", + "next_action": "start_agent" + }, + "next_safe_command": "gira ticket start --repo StatPan/agentree --ticket 18 --apply" + } + ], + "review_needed": [ + { + "queue": "review_needed", + "repo": "StatPan/gira", + "issue": 686, + "title": "Define worker-run/v1 manifest", + "state": "open", + "status": "status:in-review", + "labels": ["status:in-review", "agent:codex"], + "branch": "issue-686-worker-run-manifest", + "pull_request": { + "number": 687, + "url": "https://github.com/StatPan/gira/pull/687", + "state": "OPEN", + "draft": false, + "review_decision": "REVIEW_REQUIRED" + }, + "evidence": { + "pr_readiness": "review_needed", + "checks_status": { + "status": "passing", + "conclusion": "success", + "summary": "all required checks passed" + }, + "review_status": { + "state": "review_required", + "decision": "REVIEW_REQUIRED" + }, + "next_action": "request_review" + }, + "reason_codes": ["review_required"], + "next_safe_command": "gira ticket review --repo StatPan/gira --ticket 686 --diff-summary --json", + "parent": { + "repo": "StatPan/agentree", + "issue": 18 + } + } + ], + "failed_check": [ + { + "queue": "failed_check", + "repo": "StatPan/gira", + "issue": 686, + "title": "Define worker-run/v1 manifest", + "state": "open", + "status": "status:in-review", + "labels": ["status:in-review", "agent:codex"], + "branch": "issue-686-worker-run-manifest", + "pull_request": { + "number": 687, + "url": "https://github.com/StatPan/gira/pull/687", + "state": "OPEN", + "draft": false, + "review_decision": "REVIEW_REQUIRED" + }, + "evidence": { + "checks_status": { + "status": "failing", + "conclusion": "failure", + "summary": "docs build failed" + }, + "next_action": "inspect_failed_checks" + }, + "reason_codes": ["checks_failed"], + "blockers": ["docs build failed"], + "next_safe_command": "gira ticket status --repo StatPan/gira --ticket 686 --json", + "parent": { + "repo": "StatPan/agentree", + "issue": 18 + } + } + ], + "human_decision": [ + { + "queue": "human_decision", + "repo": "StatPan/backlog", + "issue": 31, + "title": "Keycloak brokered social auth cross-repo goal", + "state": "open", + "status": "status:blocked", + "labels": ["needs:decision"], + "reason_codes": ["human_decision_required"], + "evidence": { + "next_action": "ask_human", + "blockers": ["choose execution repo split"] + }, + "next_safe_command": "gira ticket handoff --repo StatPan/backlog --ticket 31 planner --json" + } + ] + }, + "counts": { + "agent_ready": 1, + "review_needed": 1, + "finish_ready": 0, + "blocked": 0, + "failed_check": 1, + "human_decision": 1 + }, + "privacy_boundary": { + "scope": "work_item_state_only", + "prohibited": [ + "personal_productivity_ranking", + "agent_productivity_ranking", + "time_online_scoring", + "token_spend_scoring" + ] + } + } +}