Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/WORK_MAP_IMPORT.md
Original file line number Diff line number Diff line change
@@ -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:<name>`. 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.
2 changes: 2 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions src/server/routes/workMap.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
16 changes: 16 additions & 0 deletions src/server/routes/workMap.ts
Original file line number Diff line number Diff line change
@@ -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)
})
181 changes: 181 additions & 0 deletions src/server/work-map/giraWorkspaceImport.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading