Skip to content

Commit 3a56f64

Browse files
jangel97claude
andauthored
feat: add batch person metrics endpoint (#41)
Allows fetching metrics for multiple people in a single request instead of N individual calls, reducing HTTP overhead for consumers like the chatbot agent. POST /api/modules/team-tracker/person/metrics/batch Body: { "names": ["Alice", "Bob"] } Returns: { "results": { "Alice": {...}, "Bob": null } } Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4f81eab commit 3a56f64

2 files changed

Lines changed: 219 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
3+
4+
function makeStorage(initial = {}) {
5+
const data = { ...initial }
6+
return {
7+
async readFromStorage(key) { return data[key] ? JSON.parse(JSON.stringify(data[key])) : null },
8+
writeToStorage: vi.fn(async (key, val) => { data[key] = JSON.parse(JSON.stringify(val)) }),
9+
listStorageFiles: vi.fn(async (dir) => {
10+
return Object.keys(data)
11+
.filter(k => k.startsWith(dir + '/') && k.endsWith('.json'))
12+
.map(k => k.split('/').pop())
13+
}),
14+
deleteStorageDirectory: vi.fn().mockResolvedValue(),
15+
_data: data
16+
}
17+
}
18+
19+
async function setupRoutes(storageData) {
20+
const handlers = {}
21+
const mockRouter = {
22+
get(path, ...args) { handlers[`GET ${path}`] = args[args.length - 1] },
23+
post(path, ...args) { handlers[`POST ${path}`] = args[args.length - 1] },
24+
put(path, ...args) { handlers[`PUT ${path}`] = args[args.length - 1] },
25+
patch(path, ...args) { handlers[`PATCH ${path}`] = args[args.length - 1] },
26+
delete(path, ...args) { handlers[`DELETE ${path}`] = args[args.length - 1] }
27+
}
28+
29+
const storage = makeStorage(storageData)
30+
const context = {
31+
storage,
32+
requireAdmin: (req, res, next) => next(),
33+
requireTeamAdmin: (req, res, next) => next(),
34+
requireScope: () => (req, res, next) => next(),
35+
roleStore: {
36+
getRoles: vi.fn(() => []),
37+
isAdmin: vi.fn(() => false),
38+
isTeamAdmin: vi.fn(() => false)
39+
},
40+
registerScopes: vi.fn()
41+
}
42+
43+
const registerRoutes = require('../../server/index.js')
44+
await registerRoutes(mockRouter, context)
45+
46+
return { handlers, storage }
47+
}
48+
49+
function mockRes() {
50+
const res = {
51+
_status: 200,
52+
_body: null,
53+
status(code) { res._status = code; return res },
54+
json(body) { res._body = body; return res }
55+
}
56+
return res
57+
}
58+
59+
function baseStorageData() {
60+
return {
61+
'team-data/registry.json': {
62+
meta: { generatedAt: '2026-01-01', provider: 'test', orgRoots: ['org1'] },
63+
people: {}
64+
},
65+
'team-data/teams.json': { teams: {} },
66+
'team-data/field-definitions.json': { personFields: [], teamFields: [] },
67+
'team-data/config.json': { orgRoots: [], teamDataSource: 'in-app' },
68+
'audit-log.json': { entries: [] },
69+
'people/alice_smith.json': {
70+
jiraDisplayName: 'Alice Smith',
71+
fetchedAt: '2026-01-01T00:00:00.000Z',
72+
resolved: { count: 10, storyPoints: 20, issues: [] },
73+
inProgress: { count: 2, storyPoints: 5, issues: [] },
74+
cycleTime: { avgDays: 3.0, medianDays: 2.5 }
75+
},
76+
'people/bob_jones.json': {
77+
jiraDisplayName: 'Bob Jones',
78+
fetchedAt: '2026-01-01T00:00:00.000Z',
79+
resolved: { count: 5, storyPoints: 8, issues: [] },
80+
inProgress: { count: 1, storyPoints: 3, issues: [] },
81+
cycleTime: { avgDays: 4.0, medianDays: 3.0 }
82+
}
83+
}
84+
}
85+
86+
describe('POST /person/metrics/batch', () => {
87+
it('returns metrics for valid names', async () => {
88+
const { handlers } = await setupRoutes(baseStorageData())
89+
const res = mockRes()
90+
const req = { body: { names: ['Alice Smith', 'Bob Jones'] } }
91+
92+
await handlers['POST /person/metrics/batch'](req, res)
93+
94+
expect(res._status).toBe(200)
95+
expect(res._body.results['Alice Smith'].jiraDisplayName).toBe('Alice Smith')
96+
expect(res._body.results['Alice Smith'].resolved.count).toBe(10)
97+
expect(res._body.results['Bob Jones'].jiraDisplayName).toBe('Bob Jones')
98+
expect(res._body.results['Bob Jones'].resolved.count).toBe(5)
99+
})
100+
101+
it('returns null for unknown names', async () => {
102+
const { handlers } = await setupRoutes(baseStorageData())
103+
const res = mockRes()
104+
const req = { body: { names: ['Alice Smith', 'Nobody'] } }
105+
106+
await handlers['POST /person/metrics/batch'](req, res)
107+
108+
expect(res._status).toBe(200)
109+
expect(res._body.results['Alice Smith']).not.toBeNull()
110+
expect(res._body.results['Nobody']).toBeNull()
111+
})
112+
113+
it('rejects non-array input', async () => {
114+
const { handlers } = await setupRoutes(baseStorageData())
115+
const res = mockRes()
116+
const req = { body: { names: 'not an array' } }
117+
118+
await handlers['POST /person/metrics/batch'](req, res)
119+
120+
expect(res._status).toBe(400)
121+
expect(res._body.error).toMatch(/must be an array/)
122+
})
123+
124+
it('rejects array with non-string elements', async () => {
125+
const { handlers } = await setupRoutes(baseStorageData())
126+
const res = mockRes()
127+
const req = { body: { names: ['Alice Smith', 123, null] } }
128+
129+
await handlers['POST /person/metrics/batch'](req, res)
130+
131+
expect(res._status).toBe(400)
132+
expect(res._body.error).toMatch(/must be an array/)
133+
})
134+
135+
it('rejects oversized input', async () => {
136+
const { handlers } = await setupRoutes(baseStorageData())
137+
const res = mockRes()
138+
const names = Array.from({ length: 201 }, (_, i) => `Person ${i}`)
139+
const req = { body: { names } }
140+
141+
await handlers['POST /person/metrics/batch'](req, res)
142+
143+
expect(res._status).toBe(400)
144+
expect(res._body.error).toMatch(/Too many names/)
145+
})
146+
147+
it('returns empty results for empty array', async () => {
148+
const { handlers } = await setupRoutes(baseStorageData())
149+
const res = mockRes()
150+
const req = { body: { names: [] } }
151+
152+
await handlers['POST /person/metrics/batch'](req, res)
153+
154+
expect(res._status).toBe(200)
155+
expect(res._body.results).toEqual({})
156+
})
157+
})

modules/team-tracker/server/index.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3558,6 +3558,68 @@ module.exports = async function registerRoutes(router, context) {
35583558
}
35593559
});
35603560

3561+
/**
3562+
* @openapi
3563+
* /api/modules/team-tracker/person/metrics/batch:
3564+
* post:
3565+
* tags: ['TT: Metrics']
3566+
* summary: Get metrics for multiple people in a single request
3567+
* requestBody:
3568+
* required: true
3569+
* content:
3570+
* application/json:
3571+
* schema:
3572+
* type: object
3573+
* required: [names]
3574+
* properties:
3575+
* names:
3576+
* type: array
3577+
* items:
3578+
* type: string
3579+
* description: List of Jira display names
3580+
* responses:
3581+
* 200:
3582+
* description: Batch person metrics results
3583+
* content:
3584+
* application/json:
3585+
* schema:
3586+
* type: object
3587+
* properties:
3588+
* results:
3589+
* type: object
3590+
* description: Map of name to metrics object (null if not found)
3591+
*/
3592+
router.post('/person/metrics/batch', requireScope('metrics:read'), async function(req, res) {
3593+
try {
3594+
const { names } = req.body;
3595+
if (!Array.isArray(names) || !names.every(n => typeof n === 'string')) {
3596+
return res.status(400).json({ error: '"names" must be an array of strings' });
3597+
}
3598+
if (names.length > 200) {
3599+
return res.status(400).json({ error: `Too many names (${names.length}). Maximum is 200.` });
3600+
}
3601+
3602+
const results = {};
3603+
await Promise.all(names.map(async (name) => {
3604+
const key = sanitizeFilename(name);
3605+
const cached = await readFromStorage(`people/${key}.json`);
3606+
if (cached) {
3607+
if (!cached.jiraAccountId && jiraNameCache[name]?.accountId) {
3608+
cached.jiraAccountId = jiraNameCache[name].accountId;
3609+
}
3610+
results[name] = cached;
3611+
} else {
3612+
results[name] = null;
3613+
}
3614+
}));
3615+
3616+
res.json({ results });
3617+
} catch (error) {
3618+
console.error('Batch person metrics error:', error);
3619+
res.status(500).json({ error: error.message });
3620+
}
3621+
});
3622+
35613623
/**
35623624
* @openapi
35633625
* /api/modules/team-tracker/team/{teamKey}/metrics:

0 commit comments

Comments
 (0)