@nanobpm/nano-workforce 0.168.2 → 0.170.0
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/CHANGELOG.md +12 -0
- package/app/mcpExclusions.test.ts +1 -0
- package/app/prHistory.ts +117 -0
- package/app/prParse.ts +34 -0
- package/app/service.ts +31 -47
- package/app/userTasks.test.ts +36 -0
- package/app/userTasks.ts +75 -0
- package/e2e/convergence-escalation.e2e.ts +19 -7
- package/e2e/retire-escalation-subsystem.e2e.ts +18 -5
- package/openapi.yaml +259 -1
- package/operations/getPrHistory.test.ts +137 -0
- package/operations/getPrHistory.ts +41 -0
- package/operations/listActivePrs.test.ts +31 -13
- package/operations/listEscalations.test.ts +215 -0
- package/operations/listEscalations.ts +32 -0
- package/package.json +1 -1
package/openapi.yaml
CHANGED
|
@@ -108,8 +108,31 @@ components:
|
|
|
108
108
|
type: string
|
|
109
109
|
nullable: true
|
|
110
110
|
openEscalation:
|
|
111
|
-
type:
|
|
111
|
+
type: object
|
|
112
112
|
nullable: true
|
|
113
|
+
additionalProperties: false
|
|
114
|
+
description: >-
|
|
115
|
+
The structured pointer to this PR's OPEN escalation when it is parked awaiting a human/agent
|
|
116
|
+
answer (issue #666), else null. Derived from the same `user_tasks` read model as
|
|
117
|
+
`listEscalations`, so `userTaskKey` is the completable key an agent answers via
|
|
118
|
+
`completeUserTask` / `agentCompleteEscalation` (no `/tasks/api/tasks` curl needed). `kind`
|
|
119
|
+
is the BPMN escalation elementId (e.g. `wait-answer`, `wait-merge-answer`); `summary` is the
|
|
120
|
+
raised question/findings (null when none was recorded).
|
|
121
|
+
required:
|
|
122
|
+
- userTaskKey
|
|
123
|
+
- kind
|
|
124
|
+
- summary
|
|
125
|
+
properties:
|
|
126
|
+
userTaskKey:
|
|
127
|
+
type: string
|
|
128
|
+
description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
|
|
129
|
+
kind:
|
|
130
|
+
type: string
|
|
131
|
+
description: The BPMN escalation elementId (the escalation kind).
|
|
132
|
+
summary:
|
|
133
|
+
type: string
|
|
134
|
+
nullable: true
|
|
135
|
+
description: The raised question / findings, denormalised for display; null when none.
|
|
113
136
|
updatedAt:
|
|
114
137
|
type: string
|
|
115
138
|
activeWorker:
|
|
@@ -130,6 +153,85 @@ components:
|
|
|
130
153
|
type: array
|
|
131
154
|
items:
|
|
132
155
|
$ref: "#/components/schemas/ActivePr"
|
|
156
|
+
Escalation:
|
|
157
|
+
type: object
|
|
158
|
+
description: >-
|
|
159
|
+
One OPEN native user-task escalation awaiting a human/agent decision (issue #666), projected
|
|
160
|
+
from the `user_tasks` read model. `userTaskKey` is the completable key an agent answers via
|
|
161
|
+
`completeUserTask` / `agentCompleteEscalation`; `kind` is the BPMN escalation elementId.
|
|
162
|
+
additionalProperties: false
|
|
163
|
+
required:
|
|
164
|
+
- userTaskKey
|
|
165
|
+
- kind
|
|
166
|
+
- kindLabel
|
|
167
|
+
- prKey
|
|
168
|
+
- subjectType
|
|
169
|
+
- subjectKey
|
|
170
|
+
- subjectTitle
|
|
171
|
+
- subjectUrl
|
|
172
|
+
- question
|
|
173
|
+
- formKey
|
|
174
|
+
- processKey
|
|
175
|
+
- formVariables
|
|
176
|
+
properties:
|
|
177
|
+
userTaskKey:
|
|
178
|
+
type: string
|
|
179
|
+
description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
|
|
180
|
+
kind:
|
|
181
|
+
type: string
|
|
182
|
+
description: The BPMN escalation elementId (e.g. wait-answer, wait-merge-answer, plan-review-decision, trial-merge-decision, feature-escalation).
|
|
183
|
+
kindLabel:
|
|
184
|
+
type: string
|
|
185
|
+
description: Human-readable kind label (e.g. "PR review", "Plan review", "Trial merge").
|
|
186
|
+
prKey:
|
|
187
|
+
type: string
|
|
188
|
+
nullable: true
|
|
189
|
+
description: The PR key when this escalation belongs to a PR (review/merge loop); null for feature / plan / delivery / agent subjects.
|
|
190
|
+
subjectType:
|
|
191
|
+
type: string
|
|
192
|
+
description: The domain subject kind — feature | plan | pr | delivery | agent.
|
|
193
|
+
subjectKey:
|
|
194
|
+
type: string
|
|
195
|
+
description: The subject aggregate key (feature_key / plan_key / pr_key).
|
|
196
|
+
subjectTitle:
|
|
197
|
+
type: string
|
|
198
|
+
description: The subject's human-readable title (coalesced to subjectKey when unknown).
|
|
199
|
+
subjectUrl:
|
|
200
|
+
type: string
|
|
201
|
+
nullable: true
|
|
202
|
+
description: An optional external link (the issue/PR URL); null when none.
|
|
203
|
+
question:
|
|
204
|
+
type: string
|
|
205
|
+
nullable: true
|
|
206
|
+
description: The raised question / findings / task text the loop or agent recorded; null when none.
|
|
207
|
+
formKey:
|
|
208
|
+
type: string
|
|
209
|
+
nullable: true
|
|
210
|
+
description: The deployed `.form` key of the parked user task, for rendering/answering; null when unresolved.
|
|
211
|
+
processKey:
|
|
212
|
+
type: string
|
|
213
|
+
nullable: true
|
|
214
|
+
description: The owning engine process-instance key; null when unknown.
|
|
215
|
+
formVariables:
|
|
216
|
+
type: object
|
|
217
|
+
additionalProperties: true
|
|
218
|
+
description: >-
|
|
219
|
+
The denormalised decision/form context the Tasks inbox renders for this task (the same
|
|
220
|
+
question + subject the deployed form is seeded with). The typed answer fields an agent
|
|
221
|
+
submits depend on `kind` (e.g. a PR `{ answer }`, a plan-review `{ directive, notes }`).
|
|
222
|
+
EscalationList:
|
|
223
|
+
type: object
|
|
224
|
+
additionalProperties: false
|
|
225
|
+
required:
|
|
226
|
+
- count
|
|
227
|
+
- escalations
|
|
228
|
+
properties:
|
|
229
|
+
count:
|
|
230
|
+
type: integer
|
|
231
|
+
escalations:
|
|
232
|
+
type: array
|
|
233
|
+
items:
|
|
234
|
+
$ref: "#/components/schemas/Escalation"
|
|
133
235
|
LineagePrView:
|
|
134
236
|
type: object
|
|
135
237
|
description: A member PR of a lineage thread (issue #245).
|
|
@@ -223,6 +325,93 @@ components:
|
|
|
223
325
|
type: array
|
|
224
326
|
items:
|
|
225
327
|
$ref: "#/components/schemas/LineageThreadView"
|
|
328
|
+
PrHistoryRound:
|
|
329
|
+
type: object
|
|
330
|
+
description: One convergence round in a PR's timeline (issue #668) — its status transition/outcome, owning worker, and timestamps. Sourced from the `rounds` table the Convergence page reads.
|
|
331
|
+
additionalProperties: false
|
|
332
|
+
required:
|
|
333
|
+
- roundNo
|
|
334
|
+
- status
|
|
335
|
+
- worker
|
|
336
|
+
- summary
|
|
337
|
+
- startedAt
|
|
338
|
+
- endedAt
|
|
339
|
+
properties:
|
|
340
|
+
roundNo:
|
|
341
|
+
type: integer
|
|
342
|
+
status:
|
|
343
|
+
type: string
|
|
344
|
+
nullable: true
|
|
345
|
+
description: The round's result/status transition (converged|addressed|waiting|needs_input|blocked).
|
|
346
|
+
worker:
|
|
347
|
+
type: string
|
|
348
|
+
nullable: true
|
|
349
|
+
description: The worker that ran this round.
|
|
350
|
+
summary:
|
|
351
|
+
type: string
|
|
352
|
+
nullable: true
|
|
353
|
+
description: The round outcome summary.
|
|
354
|
+
startedAt:
|
|
355
|
+
type: string
|
|
356
|
+
endedAt:
|
|
357
|
+
type: string
|
|
358
|
+
nullable: true
|
|
359
|
+
PrHistoryEscalation:
|
|
360
|
+
type: object
|
|
361
|
+
description: One escalation in a PR's history (issue #668) — its kind, question/answer, status, and timestamps. Sourced from the `escalations` table the Convergence page reads.
|
|
362
|
+
additionalProperties: false
|
|
363
|
+
required:
|
|
364
|
+
- roundNo
|
|
365
|
+
- kind
|
|
366
|
+
- worker
|
|
367
|
+
- question
|
|
368
|
+
- answer
|
|
369
|
+
- status
|
|
370
|
+
- askedAt
|
|
371
|
+
- answeredAt
|
|
372
|
+
properties:
|
|
373
|
+
roundNo:
|
|
374
|
+
type: integer
|
|
375
|
+
kind:
|
|
376
|
+
type: string
|
|
377
|
+
description: The escalation kind (question|blocker).
|
|
378
|
+
worker:
|
|
379
|
+
type: string
|
|
380
|
+
nullable: true
|
|
381
|
+
description: The worker that raised this escalation.
|
|
382
|
+
question:
|
|
383
|
+
type: string
|
|
384
|
+
answer:
|
|
385
|
+
type: string
|
|
386
|
+
nullable: true
|
|
387
|
+
description: The human's answer; null while the escalation is still open.
|
|
388
|
+
status:
|
|
389
|
+
type: string
|
|
390
|
+
description: open | answered | stale.
|
|
391
|
+
askedAt:
|
|
392
|
+
type: string
|
|
393
|
+
answeredAt:
|
|
394
|
+
type: string
|
|
395
|
+
nullable: true
|
|
396
|
+
PrHistory:
|
|
397
|
+
type: object
|
|
398
|
+
description: A PR's full escalation + round history (issue #668), as surfaced read-only on the Convergence page's PR detail. Empty arrays when the PR has no history (or is unknown).
|
|
399
|
+
additionalProperties: false
|
|
400
|
+
required:
|
|
401
|
+
- prKey
|
|
402
|
+
- rounds
|
|
403
|
+
- escalations
|
|
404
|
+
properties:
|
|
405
|
+
prKey:
|
|
406
|
+
type: string
|
|
407
|
+
rounds:
|
|
408
|
+
type: array
|
|
409
|
+
items:
|
|
410
|
+
$ref: "#/components/schemas/PrHistoryRound"
|
|
411
|
+
escalations:
|
|
412
|
+
type: array
|
|
413
|
+
items:
|
|
414
|
+
$ref: "#/components/schemas/PrHistoryEscalation"
|
|
226
415
|
AgenticSupplyWorker:
|
|
227
416
|
type: object
|
|
228
417
|
description: One connected worker in the supply mirror (H5 cockpit; sourced from the H1 presence registry).
|
|
@@ -2970,6 +3159,36 @@ paths:
|
|
|
2970
3159
|
application/json:
|
|
2971
3160
|
schema:
|
|
2972
3161
|
$ref: "#/components/schemas/ErrorBody"
|
|
3162
|
+
/escalations:
|
|
3163
|
+
get:
|
|
3164
|
+
operationId: listEscalations
|
|
3165
|
+
summary: List every OPEN escalation awaiting a human/agent decision, with the completable userTaskKey.
|
|
3166
|
+
description: >-
|
|
3167
|
+
Discovery for the escalation-answer path (epic #664, issue #666): across every surfaced
|
|
3168
|
+
escalation kind (PR review/merge loop, plan-review, empty-plan, trial-merge,
|
|
3169
|
+
conformance-review, delivery human-step, feature/blocked, agent-permission and the shared
|
|
3170
|
+
human-escalation cell), list each currently-open native user-task escalation with the
|
|
3171
|
+
completable `userTaskKey` an agent then answers via `completeUserTask` /
|
|
3172
|
+
`agentCompleteEscalation` — so a tool-aware agent never has to curl the un-projected
|
|
3173
|
+
`/tasks/api/tasks` inbox to find keys. Read-only projection over the ONE `user_tasks` read
|
|
3174
|
+
model the Tasks inbox and Convergence page consume (no second source of truth); a row is
|
|
3175
|
+
present iff its task is open, so the list reflects live pending work.
|
|
3176
|
+
security:
|
|
3177
|
+
- hookSecret: []
|
|
3178
|
+
- {}
|
|
3179
|
+
responses:
|
|
3180
|
+
"200":
|
|
3181
|
+
description: The open escalations.
|
|
3182
|
+
content:
|
|
3183
|
+
application/json:
|
|
3184
|
+
schema:
|
|
3185
|
+
$ref: "#/components/schemas/EscalationList"
|
|
3186
|
+
"401":
|
|
3187
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
3188
|
+
content:
|
|
3189
|
+
application/json:
|
|
3190
|
+
schema:
|
|
3191
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
2973
3192
|
/lineage:
|
|
2974
3193
|
get:
|
|
2975
3194
|
operationId: getLineage
|
|
@@ -2997,6 +3216,45 @@ paths:
|
|
|
2997
3216
|
application/json:
|
|
2998
3217
|
schema:
|
|
2999
3218
|
$ref: "#/components/schemas/ErrorBody"
|
|
3219
|
+
/prs/history:
|
|
3220
|
+
get:
|
|
3221
|
+
operationId: getPrHistory
|
|
3222
|
+
summary: "A PR's escalation + round history (issue #668): per-round status transitions and outcome, plus each escalation's kind, question/answer, and timestamps — the read that retires the DB-over-ssh fallback. Identify the PR by prKey, or by processKey. Reads the same rounds/escalations tables the Convergence page surfaces."
|
|
3223
|
+
security:
|
|
3224
|
+
- hookSecret: []
|
|
3225
|
+
- {}
|
|
3226
|
+
parameters:
|
|
3227
|
+
- name: prKey
|
|
3228
|
+
in: query
|
|
3229
|
+
required: false
|
|
3230
|
+
schema:
|
|
3231
|
+
type: string
|
|
3232
|
+
description: The PR key ("<owner>/<repo>#<number>"). Provide this or processKey.
|
|
3233
|
+
- name: processKey
|
|
3234
|
+
in: query
|
|
3235
|
+
required: false
|
|
3236
|
+
schema:
|
|
3237
|
+
type: string
|
|
3238
|
+
description: The engine process-instance key driving the PR; resolved to its prKey. Provide this or prKey.
|
|
3239
|
+
responses:
|
|
3240
|
+
"200":
|
|
3241
|
+
description: The PR's round + escalation history (empty arrays when there is none).
|
|
3242
|
+
content:
|
|
3243
|
+
application/json:
|
|
3244
|
+
schema:
|
|
3245
|
+
$ref: "#/components/schemas/PrHistory"
|
|
3246
|
+
"400":
|
|
3247
|
+
description: Neither prKey nor processKey supplied.
|
|
3248
|
+
content:
|
|
3249
|
+
application/json:
|
|
3250
|
+
schema:
|
|
3251
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
3252
|
+
"401":
|
|
3253
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
3254
|
+
content:
|
|
3255
|
+
application/json:
|
|
3256
|
+
schema:
|
|
3257
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
3000
3258
|
/agentic/supply:
|
|
3001
3259
|
get:
|
|
3002
3260
|
operationId: getAgenticSupply
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Tests for GET /app/api/prs/history → operation `getPrHistory` (issue #668, N4 of epic #664).
|
|
2
|
+
// The headline scenario: a PR that escalated and then RESUMED (its escalation answered, a fresh round
|
|
3
|
+
// recorded) exposes its full history — every round's status/outcome plus the answered escalation's
|
|
4
|
+
// question/answer — through the tool, with NO DB access (the handler reads the same `rounds`/
|
|
5
|
+
// `escalations` tables the Convergence page surfaces, via a minimal in-memory DataLayer). Also covers
|
|
6
|
+
// the processKey → prKey resolution, the prKey/processKey-required 400, and the shared-secret guard.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
10
|
+
import { noopLog } from "../test/log.ts";
|
|
11
|
+
import handler from "./getPrHistory.ts";
|
|
12
|
+
|
|
13
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row shapes.
|
|
14
|
+
type Row = any;
|
|
15
|
+
|
|
16
|
+
function memApp(tables: { rounds?: Row[]; escalations?: Row[]; pull_requests?: Row[] }): AppApi {
|
|
17
|
+
const stores: Record<string, Row[]> = {
|
|
18
|
+
rounds: tables.rounds ?? [],
|
|
19
|
+
escalations: tables.escalations ?? [],
|
|
20
|
+
pull_requests: tables.pull_requests ?? [],
|
|
21
|
+
};
|
|
22
|
+
const table = (name: string) => ({
|
|
23
|
+
async find(where: Record<string, unknown>) {
|
|
24
|
+
return (stores[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return { data: { table }, log: noopLog() } as unknown as AppApi;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function input(query: Record<string, string>, headers: Record<string, string> = {}) {
|
|
31
|
+
return {
|
|
32
|
+
req: {
|
|
33
|
+
method: "GET",
|
|
34
|
+
path: "/app/api/prs/history",
|
|
35
|
+
query: new URLSearchParams(query),
|
|
36
|
+
headers: new Headers(headers),
|
|
37
|
+
text: async () => "",
|
|
38
|
+
} as unknown,
|
|
39
|
+
params: {},
|
|
40
|
+
query,
|
|
41
|
+
body: undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A PR that escalated in round 1 (blocker), had that escalation answered, and RESUMED into round 2.
|
|
46
|
+
function escalatedThenResumed() {
|
|
47
|
+
return memApp({
|
|
48
|
+
pull_requests: [{ pr_key: "o/r#7", process_key: "proc-7" }],
|
|
49
|
+
rounds: [
|
|
50
|
+
{ id: 10, pr_key: "o/r#7", round_no: 1, status: "needs_input", summary: "hit an ambiguity", worker: "senior-a", started_at: "2026-03-01T00:00:00Z", ended_at: "2026-03-01T00:05:00Z" },
|
|
51
|
+
{ id: 11, pr_key: "o/r#7", round_no: 2, status: "converged", summary: "resolved after the answer", worker: "senior-a", started_at: "2026-03-01T01:00:00Z", ended_at: "2026-03-01T01:05:00Z" },
|
|
52
|
+
],
|
|
53
|
+
escalations: [
|
|
54
|
+
{ id: 20, pr_key: "o/r#7", round_no: 1, kind: "blocker", question: "Which base branch?", answer: "main", status: "answered", worker: "senior-a", asked_at: "2026-03-01T00:03:00Z", answered_at: "2026-03-01T00:50:00Z" },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("an escalated-then-resumed PR exposes its round + escalation history by prKey", async () => {
|
|
60
|
+
const res = (await handler(input({ prKey: "o/r#7" }), escalatedThenResumed())) as Row;
|
|
61
|
+
assertEquals(res.status, 200);
|
|
62
|
+
assertEquals(res.body.prKey, "o/r#7");
|
|
63
|
+
|
|
64
|
+
// Both rounds surface, in round order, with their status transition + outcome summary.
|
|
65
|
+
assertEquals(res.body.rounds.length, 2);
|
|
66
|
+
assertEquals(res.body.rounds[0].roundNo, 1);
|
|
67
|
+
assertEquals(res.body.rounds[0].status, "needs_input");
|
|
68
|
+
assertEquals(res.body.rounds[1].roundNo, 2);
|
|
69
|
+
assertEquals(res.body.rounds[1].status, "converged");
|
|
70
|
+
assertEquals(res.body.rounds[1].summary, "resolved after the answer");
|
|
71
|
+
|
|
72
|
+
// The escalation surfaces with its kind, question, answer, and timestamps — the "why did it
|
|
73
|
+
// escalate / what was answered" the tool exists to expose.
|
|
74
|
+
assertEquals(res.body.escalations.length, 1);
|
|
75
|
+
const e = res.body.escalations[0];
|
|
76
|
+
assertEquals(e.kind, "blocker");
|
|
77
|
+
assertEquals(e.question, "Which base branch?");
|
|
78
|
+
assertEquals(e.answer, "main");
|
|
79
|
+
assertEquals(e.status, "answered");
|
|
80
|
+
assertEquals(e.roundNo, 1);
|
|
81
|
+
assertEquals(e.askedAt, "2026-03-01T00:03:00Z");
|
|
82
|
+
assertEquals(e.answeredAt, "2026-03-01T00:50:00Z");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("resolves the PR by processKey when no prKey is given", async () => {
|
|
86
|
+
const res = (await handler(input({ processKey: "proc-7" }), escalatedThenResumed())) as Row;
|
|
87
|
+
assertEquals(res.status, 200);
|
|
88
|
+
assertEquals(res.body.prKey, "o/r#7");
|
|
89
|
+
assertEquals(res.body.rounds.length, 2);
|
|
90
|
+
assertEquals(res.body.escalations.length, 1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("orders rounds by round_no and escalations in asked order", async () => {
|
|
94
|
+
const app = memApp({
|
|
95
|
+
rounds: [
|
|
96
|
+
{ id: 2, pr_key: "o/r#9", round_no: 2, status: "converged", summary: null, worker: null, started_at: "b", ended_at: null },
|
|
97
|
+
{ id: 1, pr_key: "o/r#9", round_no: 1, status: "addressed", summary: null, worker: null, started_at: "a", ended_at: null },
|
|
98
|
+
],
|
|
99
|
+
escalations: [
|
|
100
|
+
{ id: 5, pr_key: "o/r#9", round_no: 2, kind: "question", question: "second", answer: null, status: "open", worker: null, asked_at: "b", answered_at: null },
|
|
101
|
+
{ id: 4, pr_key: "o/r#9", round_no: 1, kind: "question", question: "first", answer: null, status: "answered", worker: null, asked_at: "a", answered_at: "a2" },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
const res = (await handler(input({ prKey: "o/r#9" }), app)) as Row;
|
|
105
|
+
assertEquals(res.body.rounds.map((r: Row) => r.roundNo), [1, 2]);
|
|
106
|
+
assertEquals(res.body.escalations.map((e: Row) => e.question), ["first", "second"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("an unknown PR returns an empty history (not a 404)", async () => {
|
|
110
|
+
const res = (await handler(input({ prKey: "o/r#404" }), memApp({}))) as Row;
|
|
111
|
+
assertEquals(res.status, 200);
|
|
112
|
+
assertEquals(res.body.prKey, "o/r#404");
|
|
113
|
+
assertEquals(res.body.rounds, []);
|
|
114
|
+
assertEquals(res.body.escalations, []);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("neither prKey nor processKey → 400", async () => {
|
|
118
|
+
const res = (await handler(input({}), memApp({}))) as Row;
|
|
119
|
+
assertEquals(res.status, 400);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
123
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
124
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
125
|
+
try {
|
|
126
|
+
const mod = await import(`./getPrHistory.ts?guard=${Date.now()}`);
|
|
127
|
+
const guarded = mod.default as typeof handler;
|
|
128
|
+
const bad = (await guarded(input({ prKey: "o/r#7" }), escalatedThenResumed())) as Row;
|
|
129
|
+
assertEquals(bad.status, 401);
|
|
130
|
+
const ok = (await guarded(input({ prKey: "o/r#7" }, { "x-hook-secret": "s3cr3t" }), escalatedThenResumed())) as Row;
|
|
131
|
+
assertEquals(ok.status, 200);
|
|
132
|
+
assert(Array.isArray(ok.body.rounds));
|
|
133
|
+
} finally {
|
|
134
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
135
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// GET /app/api/prs/history → operationId `getPrHistory` (issue #668, N4 of epic #664). Surface a PR's
|
|
2
|
+
// escalation + round history — "why did this escalate?" / "what happened in prior rounds?" — as an
|
|
3
|
+
// MCP read tool, so an operator or external harness no longer has to ssh into the instance and query
|
|
4
|
+
// the `escalations`/`rounds` tables by hand.
|
|
5
|
+
//
|
|
6
|
+
// Read-only projection over the SAME `rounds`/`escalations` tables the Convergence page's PR detail
|
|
7
|
+
// reads (see app/prHistory.ts). Identify the PR by `prKey` directly, or by `processKey` (resolved to
|
|
8
|
+
// its pr_key). At least one is required; neither → 400.
|
|
9
|
+
//
|
|
10
|
+
// The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`):
|
|
11
|
+
// when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header.
|
|
12
|
+
import { prHistory, prKeyForProcess } from "../app/prHistory.ts";
|
|
13
|
+
import { envVar } from "../app/version.ts";
|
|
14
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
15
|
+
|
|
16
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
17
|
+
|
|
18
|
+
export default defineOperation("getPrHistory", async ({ query, req }, app) => {
|
|
19
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
20
|
+
app.log.warn("getPrHistory rejected: missing/invalid shared secret");
|
|
21
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
22
|
+
}
|
|
23
|
+
const rawPrKey = query.prKey;
|
|
24
|
+
const rawProcessKey = query.processKey;
|
|
25
|
+
const prKeyArg = typeof rawPrKey === "string" ? rawPrKey.trim() : "";
|
|
26
|
+
const processKeyArg = typeof rawProcessKey === "string" ? rawProcessKey.trim() : "";
|
|
27
|
+
|
|
28
|
+
if (!prKeyArg && !processKeyArg) {
|
|
29
|
+
return { status: 400, body: { error: "prKey or processKey is required" } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const prKey = prKeyArg || (await prKeyForProcess(app.data, processKeyArg));
|
|
33
|
+
if (!prKey) {
|
|
34
|
+
// A processKey with no tracked PR: report an empty history for a stable, echoable identity
|
|
35
|
+
// rather than a 404, mirroring getLineage's "empty if unknown" read semantics.
|
|
36
|
+
return { status: 200, body: { prKey: processKeyArg, rounds: [], escalations: [] } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const history = await prHistory(app.data, prKey);
|
|
40
|
+
return { status: 200, body: history };
|
|
41
|
+
});
|
|
@@ -8,14 +8,15 @@ import { noopLog } from "../test/log.ts";
|
|
|
8
8
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
9
|
import handler from "./listActivePrs.ts";
|
|
10
10
|
|
|
11
|
-
function memApp(rows: any[],
|
|
11
|
+
function memApp(rows: any[], userTaskRows: any[] = []): AppApi {
|
|
12
12
|
const table = (name: string) => {
|
|
13
|
-
if (name === "
|
|
13
|
+
if (name === "user_tasks") {
|
|
14
14
|
return {
|
|
15
|
+
async all() {
|
|
16
|
+
return userTaskRows;
|
|
17
|
+
},
|
|
15
18
|
async find(where: Record<string, unknown>) {
|
|
16
|
-
return
|
|
17
|
-
Object.entries(where).every(([k, v]) => e[k] === v)
|
|
18
|
-
);
|
|
19
|
+
return userTaskRows.filter((t) => Object.entries(where).every(([k, v]) => t[k] === v));
|
|
19
20
|
},
|
|
20
21
|
};
|
|
21
22
|
}
|
|
@@ -58,25 +59,42 @@ test("returns 200 with a count + projected active PRs", async () => {
|
|
|
58
59
|
assertEquals(r.body.prs[0].processKey, "9");
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
test("surfaces
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
62
|
+
test("surfaces the structured openEscalation from the user_tasks read model (both loops)", async () => {
|
|
63
|
+
// Issue #666: openEscalation is derived from the ONE `user_tasks` read model (the same surface
|
|
64
|
+
// `listEscalations` and the Convergence page consume), so `/status` carries the completable
|
|
65
|
+
// userTaskKey. A `user_tasks` row for a PR subject exists iff its review/merge-loop escalation task
|
|
66
|
+
// is currently open; a PR with no such row derives null. Cover a review-loop and a merge-loop PR.
|
|
65
67
|
const app = memApp(
|
|
66
68
|
[
|
|
67
69
|
{ pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
|
|
68
|
-
{ pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "
|
|
70
|
+
{ pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "no open task", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
|
|
69
71
|
],
|
|
70
72
|
[
|
|
71
|
-
{
|
|
72
|
-
|
|
73
|
+
{
|
|
74
|
+
user_task_key: "ut-10",
|
|
75
|
+
element_id: "wait-merge-answer",
|
|
76
|
+
kind_label: "PR merge",
|
|
77
|
+
subject_type: "pr",
|
|
78
|
+
subject_key: "o/r#10",
|
|
79
|
+
subject_title: "merge blocked",
|
|
80
|
+
subject_url: null,
|
|
81
|
+
question: "Resolve the conflict on the branch, then retry?",
|
|
82
|
+
process_key: "m1",
|
|
83
|
+
form_key: null,
|
|
84
|
+
created_at: "2026-02-02",
|
|
85
|
+
updated_at: "2026-02-02",
|
|
86
|
+
},
|
|
73
87
|
],
|
|
74
88
|
);
|
|
75
89
|
const res = (await handler(input(), app)) as any;
|
|
76
90
|
assertEquals(res.status, 200);
|
|
77
91
|
const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
|
|
78
92
|
const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
|
|
79
|
-
assertEquals(p10.openEscalation,
|
|
93
|
+
assertEquals(p10.openEscalation, {
|
|
94
|
+
userTaskKey: "ut-10",
|
|
95
|
+
kind: "wait-merge-answer",
|
|
96
|
+
summary: "Resolve the conflict on the branch, then retry?",
|
|
97
|
+
});
|
|
80
98
|
assertEquals(p11.openEscalation, null);
|
|
81
99
|
});
|
|
82
100
|
|