@plainconceptsplatform/workflows 0.20.1 → 0.20.3
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/dist/index.js +0 -0
- package/dist/stack-defaults.js +16 -16
- package/dist/workflow-catalog.d.ts +1 -1
- package/dist/workflow-catalog.js +2 -0
- package/loops/actions/add-issue-labels/action.yml +50 -50
- package/loops/actions/agent-output.cjs +17 -17
- package/loops/actions/apply-agent-bundle/action.yml +24 -24
- package/loops/actions/apply-agent-comments/action.yml +42 -42
- package/loops/actions/apply-agent-labels/action.yml +55 -55
- package/loops/actions/apply-agent-output/action.yml +108 -108
- package/loops/actions/classify-route/action.yml +100 -100
- package/loops/actions/cleanup-artifacts/action.yml +91 -91
- package/loops/actions/close-agent-issues/action.yml +43 -43
- package/loops/actions/collect-app-errors/action.yml +297 -0
- package/loops/actions/collect-app-errors/group-and-redact.mjs +252 -0
- package/loops/actions/collect-app-errors/query-app-errors.sh +104 -0
- package/loops/actions/create-agent-issues/action.yml +52 -52
- package/loops/actions/create-issue-comment/action.yml +29 -29
- package/loops/actions/download-agent-output/action.yml +53 -53
- package/loops/actions/housekeeping/action.yml +7 -1
- package/loops/actions/link-pr-to-issue/action.yml +40 -40
- package/loops/actions/list-open-issues/action.yml +33 -33
- package/loops/actions/load-issue-context/action.yml +45 -45
- package/loops/actions/merge-agent-pr/action.yml +49 -49
- package/loops/actions/push-agent-branch/action.yml +45 -45
- package/loops/actions/remove-issue-labels/action.yml +37 -37
- package/loops/actions/update-agent-issues/action.yml +58 -58
- package/loops/actions/validate-review-output/action.yml +35 -35
- package/loops/actions/validate-review-output/validate-review-output.sh +16 -2
- package/loops/actions/validate-triage-output/action.yml +36 -36
- package/loops/actions/verify-app-errors/action.yml +11 -0
- package/loops/actions/verify-app-errors/verify-app-errors.mjs +216 -0
- package/loops/actions/verify-composite-actions/action.yml +9 -9
- package/loops/actions/verify-refine-output/action.yml +9 -9
- package/loops/actions/verify-route-matrix/action.yml +9 -9
- package/loops/actions/verify-route-matrix/verify-gate-metrics.mjs +15 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +134 -0
- package/loops/scripts/compile-agent-workflows.mjs +331 -331
- package/loops/templates/agentics/agentics-app-errors.yml +168 -0
- package/loops/templates/agentics/agentics-maintenance.yml +121 -121
- package/loops/templates/ci/app-ci-dotnet-next.yml +330 -330
- package/loops/templates/ci/app-ci-node-monorepo.yml +260 -260
- package/loops/templates/issues/bug_report.yml +109 -109
- package/loops/templates/issues/feature_request.yml +75 -75
- package/loops/templates/release/github-release.yml +30 -30
- package/loops/workflows/authorize-bot-work.yml +105 -105
- package/loops/workflows/shared/opencode-ci.md +206 -206
- package/loops/workflows/shared/platform-defaults.md +19 -19
- package/loops/workflows/work-router.yml +7 -0
- package/package.json +9 -8
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-app-errors/verify-app-errors.mjs. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
//
|
|
3
|
+
// Executes the real grouping and redaction against rows shaped the way Application Insights
|
|
4
|
+
// actually returns them.
|
|
5
|
+
//
|
|
6
|
+
// It runs the shipped module rather than grepping it. The workflow-error report's logic lives
|
|
7
|
+
// inside a `script:` string in its action.yml, so nothing can execute it and its tests are
|
|
8
|
+
// greps; that was the reason to put this one in a module, and this is the payoff.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
FINDING_FIELDS,
|
|
12
|
+
MAX_FRAMES,
|
|
13
|
+
clip,
|
|
14
|
+
fingerprint,
|
|
15
|
+
leaks,
|
|
16
|
+
ownFrames,
|
|
17
|
+
prepare,
|
|
18
|
+
render,
|
|
19
|
+
scrub,
|
|
20
|
+
toFindings,
|
|
21
|
+
unexpectedFields,
|
|
22
|
+
} from "../collect-app-errors/group-and-redact.mjs";
|
|
23
|
+
|
|
24
|
+
let pass = 0;
|
|
25
|
+
let fail = 0;
|
|
26
|
+
|
|
27
|
+
function check(label, condition, detail = "") {
|
|
28
|
+
if (condition) {
|
|
29
|
+
pass += 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fail += 1;
|
|
34
|
+
console.error(`FAIL: ${label}${detail ? `\n ${detail}` : ""}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function section(title) {
|
|
38
|
+
console.log(`\n── ${title} ──`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A row in the shape `az monitor log-analytics query` returns, from a real exception. */
|
|
42
|
+
function row(overrides = {}) {
|
|
43
|
+
return {
|
|
44
|
+
ProblemId: "System.InvalidOperationException at Pliny.Application.Agents.RunExecutor.ExecuteAsync",
|
|
45
|
+
ExceptionType: "System.InvalidOperationException",
|
|
46
|
+
OperationName: "POST Runs/Start",
|
|
47
|
+
AppRoleName: "plinybot-pre-app-01",
|
|
48
|
+
Occurrences: 42,
|
|
49
|
+
Operations: 3,
|
|
50
|
+
FirstSeen: "2026-09-13T01:10:00Z",
|
|
51
|
+
LastSeen: "2026-09-13T06:40:00Z",
|
|
52
|
+
AnyOperationId: "6f1c1b0e9a2f4d55",
|
|
53
|
+
AnyDetails: [
|
|
54
|
+
{
|
|
55
|
+
parsedStack: [
|
|
56
|
+
{ method: "Pliny.Application.Agents.RunExecutor.ExecuteAsync", fileName: "/home/vsts/work/1/s/src/RunExecutor.cs", line: 512 },
|
|
57
|
+
{ method: "Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke", fileName: "/_/src/Http/Routing.cs", line: 90 },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const options = { environment: "pre", lookbackHours: "24", ownCodePrefix: "Pliny" };
|
|
66
|
+
|
|
67
|
+
section("Scrubbing");
|
|
68
|
+
|
|
69
|
+
check(
|
|
70
|
+
"a GUID is masked",
|
|
71
|
+
scrub("run 6f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e failed") === "run {guid} failed",
|
|
72
|
+
scrub("run 6f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e failed"));
|
|
73
|
+
|
|
74
|
+
check(
|
|
75
|
+
"every GUID is masked, not just the first",
|
|
76
|
+
!/[0-9a-f]{8}-[0-9a-f]{4}/i.test(scrub("a 6f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e b 7f1c1b0e-9a2f-4d55-8b3e-1f2a3b4c5d6e")));
|
|
77
|
+
|
|
78
|
+
check("an email address is masked", scrub("from someone@example.com") === "from {email}");
|
|
79
|
+
|
|
80
|
+
check(
|
|
81
|
+
"a build-machine path is masked",
|
|
82
|
+
clip("at /home/vsts/work/1/s/src/RunExecutor.cs line 5") === "at {path} line 5",
|
|
83
|
+
clip("at /home/vsts/work/1/s/src/RunExecutor.cs line 5"));
|
|
84
|
+
|
|
85
|
+
check(
|
|
86
|
+
"a query string is masked but the address survives",
|
|
87
|
+
clip("GET https://api.example.com/v1/items?token=abc123") === "GET https://api.example.com/v1/items?{query}",
|
|
88
|
+
clip("GET https://api.example.com/v1/items?token=abc123"));
|
|
89
|
+
|
|
90
|
+
check(
|
|
91
|
+
"a pattern split across a newline is still caught",
|
|
92
|
+
!/example\.com/.test(scrub("mail\nto")) && clip("someone@example.com\nnext") === "{email} next",
|
|
93
|
+
clip("someone@example.com\nnext"));
|
|
94
|
+
|
|
95
|
+
section("Frames");
|
|
96
|
+
|
|
97
|
+
check("only our own frames survive", ownFrames(row().AnyDetails, "Pliny").length === 1);
|
|
98
|
+
|
|
99
|
+
check(
|
|
100
|
+
"a frame carries the method and never the path",
|
|
101
|
+
ownFrames(row().AnyDetails, "Pliny")[0] === "Pliny.Application.Agents.RunExecutor.ExecuteAsync");
|
|
102
|
+
|
|
103
|
+
check(
|
|
104
|
+
"an empty prefix publishes no frames at all",
|
|
105
|
+
ownFrames(row().AnyDetails, "").length === 0);
|
|
106
|
+
|
|
107
|
+
check(
|
|
108
|
+
"frames are capped",
|
|
109
|
+
ownFrames(
|
|
110
|
+
[{ parsedStack: Array.from({ length: 40 }, (_, i) => ({ method: `Pliny.Frame${i}` })) }],
|
|
111
|
+
"Pliny",
|
|
112
|
+
).length === MAX_FRAMES);
|
|
113
|
+
|
|
114
|
+
check("a row with no details does not throw", ownFrames(undefined, "Pliny").length === 0);
|
|
115
|
+
|
|
116
|
+
section("Fingerprints");
|
|
117
|
+
|
|
118
|
+
check(
|
|
119
|
+
"the same problem in the same place is the same fingerprint",
|
|
120
|
+
fingerprint("a", "b", "pre") === fingerprint("a", "b", "pre"));
|
|
121
|
+
|
|
122
|
+
check(
|
|
123
|
+
"the same problem in another environment is a different one",
|
|
124
|
+
fingerprint("a", "b", "pre") !== fingerprint("a", "b", "pro"));
|
|
125
|
+
|
|
126
|
+
check("a fingerprint is short enough to read", fingerprint("a", "b", "pre").length === 12);
|
|
127
|
+
|
|
128
|
+
section("Thresholds");
|
|
129
|
+
|
|
130
|
+
check(
|
|
131
|
+
"a finding below the floor is dropped",
|
|
132
|
+
toFindings([row({ Occurrences: 2 })], { ...options, minOccurrences: 5 }).length === 0);
|
|
133
|
+
|
|
134
|
+
check(
|
|
135
|
+
"a finding on the floor is kept",
|
|
136
|
+
toFindings([row({ Occurrences: 5 })], { ...options, minOccurrences: 5 }).length === 1);
|
|
137
|
+
|
|
138
|
+
check(
|
|
139
|
+
"the loudest comes first",
|
|
140
|
+
toFindings([row({ Occurrences: 5, ProblemId: "quiet" }), row({ Occurrences: 99, ProblemId: "loud" })], options)[0]
|
|
141
|
+
.problemId === "loud");
|
|
142
|
+
|
|
143
|
+
section("The field allowlist");
|
|
144
|
+
|
|
145
|
+
check(
|
|
146
|
+
"a finding carries exactly the agreed fields",
|
|
147
|
+
unexpectedFields(toFindings([row()], options)[0]).length === 0);
|
|
148
|
+
|
|
149
|
+
check(
|
|
150
|
+
"a field nobody agreed to publish is named",
|
|
151
|
+
unexpectedFields({ ...toFindings([row()], options)[0], runId: "6f1c1b0e" }).join() === "runId");
|
|
152
|
+
|
|
153
|
+
check(
|
|
154
|
+
"an ordinary batch is not refused",
|
|
155
|
+
prepare([row()], options).refused === "" && prepare([row()], options).prepared.length === 1);
|
|
156
|
+
|
|
157
|
+
check(
|
|
158
|
+
"run_id is not a reportable field",
|
|
159
|
+
!FINDING_FIELDS.includes("runId") && !FINDING_FIELDS.includes("run_id"));
|
|
160
|
+
|
|
161
|
+
section("Rendering and the scanner");
|
|
162
|
+
|
|
163
|
+
const rendered = render(toFindings([row()], options)[0], options);
|
|
164
|
+
|
|
165
|
+
check("the marker is the first line", rendered.body.startsWith("<!-- pcp-app-error: "));
|
|
166
|
+
|
|
167
|
+
check("the marker carries the fingerprint", rendered.body.includes(toFindings([row()], options)[0].fingerprint));
|
|
168
|
+
|
|
169
|
+
check("the count is said plainly", rendered.body.includes("**42 occurrence(s)**"));
|
|
170
|
+
|
|
171
|
+
check("a clean report trips nothing", leaks(`${rendered.title}\n${rendered.body}`).length === 0,
|
|
172
|
+
leaks(`${rendered.title}\n${rendered.body}`).join("; "));
|
|
173
|
+
|
|
174
|
+
check(
|
|
175
|
+
"no build-machine path reaches the body",
|
|
176
|
+
!rendered.body.includes("/home/vsts"));
|
|
177
|
+
|
|
178
|
+
// Two ways to have no frames, and they ask the reader for different things. Pliny-Bot #191 was
|
|
179
|
+
// filed with OWN_CODE_PREFIX set to "Pliny" and still said the repository had not said which
|
|
180
|
+
// assemblies were its own -- it sent a reader to configure a knob that had been set the day
|
|
181
|
+
// before. Only the unconfigured case was covered here, which is why the wording survived.
|
|
182
|
+
check(
|
|
183
|
+
"no prefix configured says which knob to set",
|
|
184
|
+
render(toFindings([row()], { ...options, ownCodePrefix: "" })[0], { ...options, ownCodePrefix: "" })
|
|
185
|
+
.body.includes("has not said which assemblies are its own"));
|
|
186
|
+
|
|
187
|
+
check(
|
|
188
|
+
"a prefix that matched nothing does not claim the prefix is missing",
|
|
189
|
+
!render(toFindings([row()], { ...options, ownCodePrefix: "NoSuchAssembly" })[0], { ...options, ownCodePrefix: "NoSuchAssembly" })
|
|
190
|
+
.body.includes("has not said which assemblies are its own"));
|
|
191
|
+
|
|
192
|
+
check(
|
|
193
|
+
"a prefix that matched nothing names the prefix it tried",
|
|
194
|
+
render(toFindings([row()], { ...options, ownCodePrefix: "NoSuchAssembly" })[0], { ...options, ownCodePrefix: "NoSuchAssembly" })
|
|
195
|
+
.body.includes("No frame in this exception belongs to `NoSuchAssembly`"));
|
|
196
|
+
|
|
197
|
+
section("Fail-closed, but not fail-dead");
|
|
198
|
+
|
|
199
|
+
const mixed = prepare(
|
|
200
|
+
[row(), row({ ProblemId: "Secret", ExceptionType: "AccountKey=abc123def456;Other" })],
|
|
201
|
+
options);
|
|
202
|
+
|
|
203
|
+
check("the clean report is still prepared", mixed.prepared.length === 1, JSON.stringify(mixed.withheld));
|
|
204
|
+
|
|
205
|
+
check("the tripped report is withheld", mixed.withheld.length === 1);
|
|
206
|
+
|
|
207
|
+
check("the caller is told what matched", (mixed.withheld[0]?.matched ?? []).length > 0);
|
|
208
|
+
|
|
209
|
+
check("a withheld report does not refuse the run", mixed.refused === "");
|
|
210
|
+
|
|
211
|
+
check("nothing is prepared from no rows", prepare([], options).prepared.length === 0);
|
|
212
|
+
|
|
213
|
+
check("a non-array does not throw", prepare(null, options).prepared.length === 0);
|
|
214
|
+
|
|
215
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
216
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-composite-actions/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
-
name: Verify composite actions
|
|
3
|
-
description: Check every local composite action manifest parses and uses only contexts a composite action actually has.
|
|
4
|
-
runs:
|
|
5
|
-
using: composite
|
|
6
|
-
steps:
|
|
7
|
-
- name: Validate composite action manifests
|
|
8
|
-
shell: bash
|
|
9
|
-
run: bash "${GITHUB_ACTION_PATH}/verify-composite-actions.sh"
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-composite-actions/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Verify composite actions
|
|
3
|
+
description: Check every local composite action manifest parses and uses only contexts a composite action actually has.
|
|
4
|
+
runs:
|
|
5
|
+
using: composite
|
|
6
|
+
steps:
|
|
7
|
+
- name: Validate composite action manifests
|
|
8
|
+
shell: bash
|
|
9
|
+
run: bash "${GITHUB_ACTION_PATH}/verify-composite-actions.sh"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
-
name: Verify refine output validation
|
|
3
|
-
description: Exercise deterministic Refine output validation regressions.
|
|
4
|
-
runs:
|
|
5
|
-
using: composite
|
|
6
|
-
steps:
|
|
7
|
-
- name: Verify refinement outcomes
|
|
8
|
-
shell: bash
|
|
9
|
-
run: bash "${{ github.action_path }}/verify-refine-output.sh"
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Verify refine output validation
|
|
3
|
+
description: Exercise deterministic Refine output validation regressions.
|
|
4
|
+
runs:
|
|
5
|
+
using: composite
|
|
6
|
+
steps:
|
|
7
|
+
- name: Verify refinement outcomes
|
|
8
|
+
shell: bash
|
|
9
|
+
run: bash "${{ github.action_path }}/verify-refine-output.sh"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-route-matrix/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
-
name: Verify route matrix
|
|
3
|
-
description: Exercise the router's classifier against every supported event, and check that each route and dispatch operation has a job in work-router.yml.
|
|
4
|
-
runs:
|
|
5
|
-
using: composite
|
|
6
|
-
steps:
|
|
7
|
-
- name: Run route matrix verification
|
|
8
|
-
shell: bash
|
|
9
|
-
run: bash "${GITHUB_ACTION_PATH}/verify-route-matrix.sh"
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/verify-route-matrix/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Verify route matrix
|
|
3
|
+
description: Exercise the router's classifier against every supported event, and check that each route and dispatch operation has a job in work-router.yml.
|
|
4
|
+
runs:
|
|
5
|
+
using: composite
|
|
6
|
+
steps:
|
|
7
|
+
- name: Run route matrix verification
|
|
8
|
+
shell: bash
|
|
9
|
+
run: bash "${GITHUB_ACTION_PATH}/verify-route-matrix.sh"
|
|
@@ -48,4 +48,19 @@ check("lists every disposition it saw",
|
|
|
48
48
|
run({ "auto-merge": 1, blocked: 2 }, [], []),
|
|
49
49
|
(s) => s.includes("`blocked` · 2") && s.includes("`auto-merge` · 1"));
|
|
50
50
|
|
|
51
|
+
|
|
52
|
+
// The triage loop's entry guard. An issue carrying `stalled` is the janitor's to retry; one
|
|
53
|
+
// carrying `stalled` without `review` used to be skipped before any branch could see it, so it
|
|
54
|
+
// was neither retried nor reported. Extracted and run rather than grepped, because "which
|
|
55
|
+
// issues does this loop even look at" is exactly the kind of condition a grep reads past.
|
|
56
|
+
const guardSrc = yml.match(/if \(!labels\.includes\('review'\)[^\n]*\n/);
|
|
57
|
+
if (!guardSrc) { console.error("FAIL: housekeeping has no triage entry guard"); process.exit(1); }
|
|
58
|
+
const looksAt = (labels) => !new Function("labels", `return ${guardSrc[0].trim().replace(/^if \(/, "").replace(/\)\s*continue;$/, "")};`)(labels);
|
|
59
|
+
|
|
60
|
+
check("looks at a parked issue (review + stalled)", looksAt(["review", "stalled", "implement"]), true);
|
|
61
|
+
check("looks at a decided issue (review only)", looksAt(["review", "implement"]), true);
|
|
62
|
+
check("looks at an orphaned stall (stalled, no review)", looksAt(["stalled", "implement"]), true);
|
|
63
|
+
check("ignores an issue with neither", looksAt(["implement", "sp-2"]), false);
|
|
64
|
+
check("ignores a plain refined issue", looksAt(["refined"]), false);
|
|
65
|
+
|
|
51
66
|
process.exit(failed);
|
|
@@ -1319,6 +1319,52 @@ level=low")
|
|
|
1319
1319
|
if [ "$BLAST_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1320
1320
|
fi
|
|
1321
1321
|
|
|
1322
|
+
# apply-review's validator compares the unresolved review threads on a pull request against the
|
|
1323
|
+
# thread ids the agent reported handling. Two jq shapes broke it for every input: `add` on an
|
|
1324
|
+
# empty array is null, so a pull-request-level review with no inline thread crashed on `unique`,
|
|
1325
|
+
# and `scan` without a capture group emits strings, so `add` concatenated them and `unique`
|
|
1326
|
+
# crashed on a string. The agent had already pushed the fix; the run was marked failed and the
|
|
1327
|
+
# issue labelled stalled while the commit sat on the branch.
|
|
1328
|
+
REVIEW_VALIDATOR="${HERE}/../validate-review-output/validate-review-output.sh"
|
|
1329
|
+
if [ -f "$REVIEW_VALIDATOR" ] && worker_installed apply-review; then
|
|
1330
|
+
REVIEW_OK=1
|
|
1331
|
+
rv_fixture="${TMPDIR:-/tmp}/route-matrix-review-$$"
|
|
1332
|
+
|
|
1333
|
+
rv_case() {
|
|
1334
|
+
local name="$1" want="$2" threads="$3" items="$4" got
|
|
1335
|
+
printf '%s' "$threads" > "${rv_fixture}-threads.json"
|
|
1336
|
+
printf '%s' "$items" > "${rv_fixture}-out.json"
|
|
1337
|
+
# `|| true`: a validator that crashes must show up as a failed case, not kill this script.
|
|
1338
|
+
got=$(bash "$REVIEW_VALIDATOR" "${rv_fixture}-out.json" 181 "${rv_fixture}-threads.json" 2>&1 || true)
|
|
1339
|
+
if [ "$got" != "$want" ]; then
|
|
1340
|
+
REVIEW_OK=0
|
|
1341
|
+
echo "FAIL: the apply-review validator called '${name}' ${got}, expected ${want}" >&2
|
|
1342
|
+
fi
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
rv_impl() { printf '{"items":[{"type":"add_comment","item_number":181,"body":"**Review outcome:** implemented %s"},{"type":"push_to_pull_request_branch","pr_number":181}]}' "$1"; }
|
|
1346
|
+
rv_open() { printf '{"id":"%s","isResolved":false,"isOutdated":false}' "$1"; }
|
|
1347
|
+
|
|
1348
|
+
# The case that was failing in production: requesting changes from the Files tab creates a
|
|
1349
|
+
# review and no thread, so both sides are empty and must agree rather than crash.
|
|
1350
|
+
rv_case "a review with no threads at all" implemented "[]" "$(rv_impl '')"
|
|
1351
|
+
rv_case "no threads and no push needed" needs-human "[]" '{"items":[{"type":"add_comment","item_number":181,"body":"**Review outcome:** needs-human"}]}'
|
|
1352
|
+
rv_case "one thread, reported" implemented "[$(rv_open PRRT_aaa)]" "$(rv_impl 'PRRT_aaa')"
|
|
1353
|
+
rv_case "one thread, silently skipped" invalid "[$(rv_open PRRT_aaa)]" "$(rv_impl '')"
|
|
1354
|
+
rv_case "a thread id that was never opened" invalid "[$(rv_open PRRT_aaa)]" "$(rv_impl 'PRRT_bbb')"
|
|
1355
|
+
rv_case "two threads, both reported" implemented "[$(rv_open PRRT_a),$(rv_open PRRT_b)]" "$(rv_impl 'PRRT_a and PRRT_b')"
|
|
1356
|
+
rv_case "two threads, only one reported" invalid "[$(rv_open PRRT_a),$(rv_open PRRT_b)]" "$(rv_impl 'PRRT_a')"
|
|
1357
|
+
rv_case "a resolved thread is not expected" implemented '[{"id":"PRRT_aaa","isResolved":true,"isOutdated":false}]' "$(rv_impl '')"
|
|
1358
|
+
rv_case "implemented without the push" invalid "[]" '{"items":[{"type":"add_comment","item_number":181,"body":"**Review outcome:** implemented"}]}'
|
|
1359
|
+
# Nothing addressed to this pull request at all. `map` over no comments is [], `add` on that is
|
|
1360
|
+
# null, and `unique` dies: the run fails instead of reporting the output as unusable. This is
|
|
1361
|
+
# the case the `// []` guards; the bracketed scan alone does not reach it.
|
|
1362
|
+
rv_case "a comment aimed at another pull request" invalid "[]" '{"items":[{"type":"add_comment","item_number":999,"body":"**Review outcome:** implemented"},{"type":"push_to_pull_request_branch","pr_number":181}]}'
|
|
1363
|
+
|
|
1364
|
+
rm -f "${rv_fixture}-threads.json" "${rv_fixture}-out.json"
|
|
1365
|
+
if [ "$REVIEW_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
1366
|
+
fi
|
|
1367
|
+
|
|
1322
1368
|
GATE_VALIDATOR="${HERE}/../validate-merge-gate-output/validate-merge-gate-output.sh"
|
|
1323
1369
|
if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
|
|
1324
1370
|
VALIDATOR_OK=1
|
|
@@ -2014,6 +2060,94 @@ fi
|
|
|
2014
2060
|
if [ "$GATE_RUN_ID_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2015
2061
|
fi
|
|
2016
2062
|
|
|
2063
|
+
echo "── App error privacy ─────────────────────────────────────────────────────"
|
|
2064
|
+
|
|
2065
|
+
# The application error report files into the private repository rather than out of it, so
|
|
2066
|
+
# its danger is the opposite one: not that a name escapes, but that an identifier joining a
|
|
2067
|
+
# stack trace back to one person's conversation gets written down beside it.
|
|
2068
|
+
#
|
|
2069
|
+
# Four properties hold that line and each is a line an edit could drop with nothing going red.
|
|
2070
|
+
# Asserted the way the error report's are: call sites and set equality, never a bare symbol,
|
|
2071
|
+
# because a grep for a name passes against a guard that has been renamed rather than removed.
|
|
2072
|
+
APP_ERRORS_MJS="${HERE}/../collect-app-errors/group-and-redact.mjs"
|
|
2073
|
+
APP_ERRORS_SH="${HERE}/../collect-app-errors/query-app-errors.sh"
|
|
2074
|
+
APP_ERRORS_YML="${HERE}/../collect-app-errors/action.yml"
|
|
2075
|
+
|
|
2076
|
+
if [ -f "$APP_ERRORS_MJS" ]; then
|
|
2077
|
+
AE_OK=1
|
|
2078
|
+
ae() {
|
|
2079
|
+
grep -qE "$1" "$2" || { AE_OK=0; echo "FAIL: collect-app-errors ${3}" >&2; }
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
# 1. The query reads the two tables the application's own telemetry goes to, and no others.
|
|
2083
|
+
# AppServiceConsoleLogs and ContainerAppConsoleLogs live in the same workspace and carry raw
|
|
2084
|
+
# engine stdout, which the no-content rule does not govern. A union across them is an
|
|
2085
|
+
# incident rather than a wider query.
|
|
2086
|
+
#
|
|
2087
|
+
# AppTraces is deliberate and was added after the first dry run against a real workspace
|
|
2088
|
+
# returned nought exceptions on a service that had been failing all morning: nothing in
|
|
2089
|
+
# these applications calls RecordException, so every failure they care about is caught in
|
|
2090
|
+
# code, logged through ILogger, and lands in AppTraces alone.
|
|
2091
|
+
ae '^ AppExceptions$' "$APP_ERRORS_SH" 'does not query AppExceptions by name'
|
|
2092
|
+
ae '^ AppTraces$' "$APP_ERRORS_SH" 'does not query AppTraces, where caught failures land'
|
|
2093
|
+
# The heredoc alone. The comment above it names the console tables in order to say they are
|
|
2094
|
+
# never read, and a grep over the whole file cannot tell the warning from the offence.
|
|
2095
|
+
query_body="$(sed -n '/^read -r -d .. QUERY <<KQL/,/^KQL$/p' "$APP_ERRORS_SH")"
|
|
2096
|
+
if printf '%s' "$query_body" | grep -qE 'ConsoleLogs|AppRequests|AppDependencies|union \*|search '; then
|
|
2097
|
+
AE_OK=0
|
|
2098
|
+
echo "FAIL: collect-app-errors reads a table it has no business reading" >&2
|
|
2099
|
+
fi
|
|
2100
|
+
|
|
2101
|
+
# 2. Counts are scaled for sampling. A pre environment samples at 0.3, so count() reads a
|
|
2102
|
+
# problem that happened two hundred times as sixty, and it falls under a floor set to catch
|
|
2103
|
+
# it. This is a correctness guard that looks like a style one.
|
|
2104
|
+
ae 'sum\(ItemCount\)' "$APP_ERRORS_SH" 'counts rows instead of summing ItemCount'
|
|
2105
|
+
|
|
2106
|
+
# 3. The field allowlist is what actually holds the line, because a redaction rule filters
|
|
2107
|
+
# values and cannot see a field somebody adds next year. Compared as a set, so reordering is
|
|
2108
|
+
# fine and an addition is not. run_id is the one that matters: it is unhashed on this
|
|
2109
|
+
# telemetry and joins to a conversation and to a person.
|
|
2110
|
+
expected_fields="exceptionType frames fingerprint firstSeen lastSeen occurrences operationName operations problemId roleName"
|
|
2111
|
+
actual_fields="$(sed -n '/export const FINDING_FIELDS = \[/,/\];/p' "$APP_ERRORS_MJS" |
|
|
2112
|
+
grep -oE '"[a-zA-Z]+"' | tr -d '"' | sort | tr '\n' ' ' | sed 's/ $//')"
|
|
2113
|
+
if [ "$actual_fields" = "$(echo "$expected_fields" | tr ' ' '\n' | sort | tr '\n' ' ' | sed 's/ $//')" ]; then
|
|
2114
|
+
PASS=$((PASS + 1))
|
|
2115
|
+
else
|
|
2116
|
+
FAIL=$((FAIL + 1))
|
|
2117
|
+
echo "FAIL: collect-app-errors reportable fields changed: ${actual_fields}" >&2
|
|
2118
|
+
fi
|
|
2119
|
+
# Plain grep, not the count() helper: that is `grep || true`, so an `if count -q` is true
|
|
2120
|
+
# whatever it finds and the guard never fires. Three of these were written that way first
|
|
2121
|
+
# and all three failed open, which is the same shape of bug this file exists to catch.
|
|
2122
|
+
if sed -n '/export const FINDING_FIELDS = \[/,/\];/p' "$APP_ERRORS_MJS" | grep -qiE 'run_?id'; then
|
|
2123
|
+
AE_OK=0
|
|
2124
|
+
echo "FAIL: collect-app-errors lists a run id as reportable; it joins telemetry to a person" >&2
|
|
2125
|
+
fi
|
|
2126
|
+
|
|
2127
|
+
# 4. Scrub, then scan, then refuse. Scrubbing masks what is common and safely replaceable so
|
|
2128
|
+
# one unlucky operation name does not leave the job red and filing nothing forever; the
|
|
2129
|
+
# scanner is the backstop for whatever that missed; and a withheld report still turns the
|
|
2130
|
+
# run red so the field that carried it gets fixed.
|
|
2131
|
+
ae 'export const SCRUBS = \[' "$APP_ERRORS_MJS" 'declares no scrubbing'
|
|
2132
|
+
ae 'export const LEAK_CHECKS = \[' "$APP_ERRORS_MJS" 'declares no leak scanner'
|
|
2133
|
+
ae 'core\.setFailed.*withheld by the leak scanner' "$APP_ERRORS_YML" 'does not go red on a withheld report'
|
|
2134
|
+
ae 'not in the reportable field list' "$APP_ERRORS_MJS" 'does not refuse an unlisted field'
|
|
2135
|
+
ae 'core\.setFailed\(`Nothing was filed' "$APP_ERRORS_YML" 'does not stop when the module refuses'
|
|
2136
|
+
|
|
2137
|
+
# A GUID must be masked on the way in, not merely detected on the way out. Both, in fact:
|
|
2138
|
+
# the scrub is what keeps the job alive and the check is what keeps it honest.
|
|
2139
|
+
ae '\{guid\}' "$APP_ERRORS_MJS" 'does not mask GUIDs'
|
|
2140
|
+
|
|
2141
|
+
# 5. No model. This is the same assertion the error report carries, for the same reason: a
|
|
2142
|
+
# model summarising a stack trace paraphrases it, and a paraphrase is a wrong issue.
|
|
2143
|
+
if grep -qiE '(engine:|opencode|safe-outputs|OPENAI_API_KEY)' "$APP_ERRORS_YML"; then
|
|
2144
|
+
AE_OK=0
|
|
2145
|
+
echo "FAIL: collect-app-errors has grown a model" >&2
|
|
2146
|
+
fi
|
|
2147
|
+
|
|
2148
|
+
if [ "$AE_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
2149
|
+
fi
|
|
2150
|
+
|
|
2017
2151
|
echo
|
|
2018
2152
|
if [ "$FAIL" -eq 0 ]; then
|
|
2019
2153
|
echo "Route matrix: ${PASS} passed"
|