@biffo/cli 0.253.3 → 0.253.5
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.
|
@@ -34,11 +34,52 @@ describe('extractErrorMessage', () => {
|
|
|
34
34
|
expect(extractErrorMessage('{"message":"nope"}', 'Bad Request')).toBe('{"message":"nope"}')
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Renamed from "falls back to the raw text when detail is not a string" —
|
|
38
|
+
// that name generalised a decision only ever made about a LIST `detail`
|
|
39
|
+
// (biffo-template#1350). It is no longer true of every non-string detail: a
|
|
40
|
+
// dict carrying a string `message` (below) now unwraps instead of falling
|
|
41
|
+
// back. FastAPI's own 422 is the case this test still pins.
|
|
42
|
+
it('falls back to the raw text when detail is a list (FastAPI 422 field errors)', () => {
|
|
38
43
|
const body = '{"detail":[{"loc":["body","x"],"msg":"bad"}]}'
|
|
39
44
|
expect(extractErrorMessage(body, 'Unprocessable Entity')).toBe(body)
|
|
40
45
|
})
|
|
41
46
|
|
|
47
|
+
// The browser-side twin of `_extract_detail`'s identical rule in
|
|
48
|
+
// core_client.py. Core's generic CRUD layer answers an integrity error with
|
|
49
|
+
// `{"detail": {"message": "...", "constraint": "..."}}`
|
|
50
|
+
// (`routing/crud_handlers._integrity_error_response`); until this rule
|
|
51
|
+
// existed here, that shape fell straight through to the raw JSON blob, even
|
|
52
|
+
// in a sibling whose BFF had already unwrapped it server-side
|
|
53
|
+
// (biffo-template#1350, tabsii-crm#272).
|
|
54
|
+
it('unwraps a dict detail carrying a string message', () => {
|
|
55
|
+
const body =
|
|
56
|
+
'{"detail":{"message":"course c1 cannot be deleted: 3 enrolment(s) depend on it.","constraint":"fk_enrolments_course_id"}}'
|
|
57
|
+
expect(extractErrorMessage(body, 'Conflict')).toBe(
|
|
58
|
+
'course c1 cannot be deleted: 3 enrolment(s) depend on it.',
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// `constraint` is deliberately dropped, not appended, in EVERY case — a
|
|
63
|
+
// database object name is schema reconnaissance (tabsii-platform#473) and
|
|
64
|
+
// says nothing to the person reading the sentence. This asserts the drop
|
|
65
|
+
// rather than just the unwrap above, so a future "helpfully" append would
|
|
66
|
+
// fail here even if it left the previous test passing.
|
|
67
|
+
it('drops constraint rather than appending it to the message', () => {
|
|
68
|
+
const body = '{"detail":{"message":"cannot delete","constraint":"fk_enrolments_course_id"}}'
|
|
69
|
+
const message = extractErrorMessage(body, 'Conflict')
|
|
70
|
+
expect(message).toBe('cannot delete')
|
|
71
|
+
expect(message).not.toContain('fk_enrolments_course_id')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Only the declared `message` key is trusted. A dict `detail` with no
|
|
75
|
+
// `message` is a shape nobody has declared, so it falls back to the raw
|
|
76
|
+
// text — same posture as the list case above — rather than inventing a
|
|
77
|
+
// summary that could hide information the caller had before.
|
|
78
|
+
it('falls back to the raw text when a dict detail has no message', () => {
|
|
79
|
+
const body = '{"detail":{"constraint":"fk_enrolments_course_id"}}'
|
|
80
|
+
expect(extractErrorMessage(body, 'Conflict')).toBe(body)
|
|
81
|
+
})
|
|
82
|
+
|
|
42
83
|
it('uses the status text when the body is empty', () => {
|
|
43
84
|
expect(extractErrorMessage('', 'Internal Server Error')).toBe('Internal Server Error')
|
|
44
85
|
})
|
|
@@ -30,9 +30,22 @@ export class ApiError extends Error {
|
|
|
30
30
|
* - **Parsed, never assumed.** A non-JSON body (a proxy timeout page, a
|
|
31
31
|
* CloudFront error), or JSON with no `detail`, falls back to the raw text
|
|
32
32
|
* unchanged — so this can never hide information the caller had before.
|
|
33
|
-
* - **A
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* - **A dict `detail` carrying a string `message` unwraps to that message.**
|
|
34
|
+
* Core's generic CRUD layer answers an integrity error with
|
|
35
|
+
* `{"detail": {"message": "...", "constraint": "..."}}`
|
|
36
|
+
* (`routing/crud_handlers._integrity_error_response`). Until this rule
|
|
37
|
+
* existed here, a sibling whose BFF had already unwrapped this shape
|
|
38
|
+
* (`core_client.py`) still rendered the raw JSON blob in the browser,
|
|
39
|
+
* because this layer only knew how to unwrap a *string* `detail`
|
|
40
|
+
* (biffo-template#1350). `constraint` is deliberately dropped, not
|
|
41
|
+
* appended — it is a database object name, which is schema
|
|
42
|
+
* reconnaissance (tabsii-platform#473) and says nothing to the person
|
|
43
|
+
* reading the sentence.
|
|
44
|
+
* - **Any other non-string, non-message-bearing `detail` still falls
|
|
45
|
+
* back.** FastAPI's own 422 makes `detail` a list of field errors, and a
|
|
46
|
+
* dict `detail` with no `message` key is a shape nobody has declared —
|
|
47
|
+
* picking something out of either would move the problem rather than fix
|
|
48
|
+
* it.
|
|
36
49
|
*
|
|
37
50
|
* The one addition over the Python version: an empty body yields the HTTP
|
|
38
51
|
* status text, because a browser rendering `''` shows the user nothing at all.
|
|
@@ -66,10 +79,25 @@ function parsedDetail(body: string): string | null {
|
|
|
66
79
|
}
|
|
67
80
|
if (typeof parsed === 'object' && parsed !== null && 'detail' in parsed) {
|
|
68
81
|
const { detail } = parsed
|
|
69
|
-
// A non-string `detail` also falls back. FastAPI's own 422 makes it a list
|
|
70
|
-
// of field errors; picking something out of it would move the problem
|
|
71
|
-
// rather than fix it.
|
|
72
82
|
if (typeof detail === 'string') return detail
|
|
83
|
+
// A dict `detail` carrying a string `message` unwraps to that message —
|
|
84
|
+
// the browser-side twin of `_extract_detail`'s identical rule in
|
|
85
|
+
// core_client.py (biffo-template#1350). `constraint` is deliberately
|
|
86
|
+
// dropped, not appended: it is a database object name (schema
|
|
87
|
+
// reconnaissance, tabsii-platform#473) and says nothing to the person
|
|
88
|
+
// reading the sentence. Only the declared `message` key is trusted — a
|
|
89
|
+
// dict `detail` with anything else (no `message`, or a non-string one),
|
|
90
|
+
// or a non-dict non-string `detail` (FastAPI's own 422 makes it a list
|
|
91
|
+
// of field errors), falls through to the raw text below rather than
|
|
92
|
+
// inventing a summary from a shape nobody has declared.
|
|
93
|
+
if (
|
|
94
|
+
typeof detail === 'object' &&
|
|
95
|
+
detail !== null &&
|
|
96
|
+
!Array.isArray(detail) &&
|
|
97
|
+
typeof (detail as { message?: unknown }).message === 'string'
|
|
98
|
+
) {
|
|
99
|
+
return (detail as { message: string }).message
|
|
100
|
+
}
|
|
73
101
|
}
|
|
74
102
|
return null
|
|
75
103
|
}
|
package/package.json
CHANGED
|
@@ -37,6 +37,27 @@
|
|
|
37
37
|
# same count seen on two consecutive polls — so a fast check concluding while
|
|
38
38
|
# slower ones are still registering does not end the wait early.
|
|
39
39
|
#
|
|
40
|
+
# ## A re-run does not replace its old entry, and `statusCheckRollup` never drops it
|
|
41
|
+
#
|
|
42
|
+
# `statusCheckRollup` returns **every** check run against the PR's head commit,
|
|
43
|
+
# including ones a later run has superseded — a re-run adds a second row under
|
|
44
|
+
# the same name rather than replacing the first. Read naively, a check that
|
|
45
|
+
# failed once and was then fixed by a re-run still shows a FAILURE row forever,
|
|
46
|
+
# so "any row with a FAILURE conclusion" reports the wrong thing on exactly the
|
|
47
|
+
# PRs most likely to need this script: anything that failed and was corrected.
|
|
48
|
+
# GitHub's own merge gate, and `gh pr checks`, both resolve a required context to
|
|
49
|
+
# the **latest** run of that name — this script has to do the same, or it
|
|
50
|
+
# disagrees with the authority it exists to reflect (#1333, class #1362).
|
|
51
|
+
#
|
|
52
|
+
# So the rollup is deduped by name, keeping the entry with the latest
|
|
53
|
+
# `completedAt`/`startedAt` (falling back to `updatedAt`/`createdAt` for a plain
|
|
54
|
+
# commit status, which carries no `startedAt`/`completedAt` at all), **before**
|
|
55
|
+
# any conclusion is evaluated. Same `group_by | max_by(latest timestamp)` shape
|
|
56
|
+
# `branch-health.sh` already uses for its per-workflow rollup — that script reads
|
|
57
|
+
# `gh run list`, whose rows are one per distinct run rather than per check name,
|
|
58
|
+
# so it was never exposed to this defect, but the resolution method is the same
|
|
59
|
+
# one worth keeping consistent.
|
|
60
|
+
#
|
|
40
61
|
# ## Exit codes, and why 2 exists
|
|
41
62
|
#
|
|
42
63
|
# 0 every required/observed check concluded, none failed
|
|
@@ -200,9 +221,14 @@ while :; do
|
|
|
200
221
|
rollup=$(gh_pr view "$PR" --json statusCheckRollup --jq '
|
|
201
222
|
[ .statusCheckRollup[]?
|
|
202
223
|
| { name: (.name // .context),
|
|
203
|
-
state: (.conclusion // .state // (if .status == "COMPLETED" then "" else null end))
|
|
224
|
+
state: (.conclusion // .state // (if .status == "COMPLETED" then "" else null end)),
|
|
225
|
+
when: (.completedAt // .startedAt // .updatedAt // .createdAt // "")
|
|
204
226
|
}
|
|
205
|
-
]
|
|
227
|
+
]
|
|
228
|
+
| group_by(.name)
|
|
229
|
+
| map(max_by(.when))
|
|
230
|
+
| .[]
|
|
231
|
+
| "\(.name)\t\(.state // "")"') || rollup=""
|
|
206
232
|
|
|
207
233
|
count=0
|
|
208
234
|
[ -n "$rollup" ] && count=$(printf '%s\n' "$rollup" | grep -c .)
|