@plainconceptsplatform/workflows 0.20.2 → 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.
Files changed (46) hide show
  1. package/dist/index.js +0 -0
  2. package/dist/stack-defaults.js +16 -16
  3. package/dist/workflow-catalog.d.ts +1 -1
  4. package/dist/workflow-catalog.js +2 -0
  5. package/loops/actions/add-issue-labels/action.yml +50 -50
  6. package/loops/actions/agent-output.cjs +17 -17
  7. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  8. package/loops/actions/apply-agent-comments/action.yml +42 -42
  9. package/loops/actions/apply-agent-labels/action.yml +55 -55
  10. package/loops/actions/apply-agent-output/action.yml +108 -108
  11. package/loops/actions/classify-route/action.yml +100 -100
  12. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  13. package/loops/actions/close-agent-issues/action.yml +43 -43
  14. package/loops/actions/collect-app-errors/action.yml +297 -0
  15. package/loops/actions/collect-app-errors/group-and-redact.mjs +252 -0
  16. package/loops/actions/collect-app-errors/query-app-errors.sh +104 -0
  17. package/loops/actions/create-agent-issues/action.yml +52 -52
  18. package/loops/actions/create-issue-comment/action.yml +29 -29
  19. package/loops/actions/download-agent-output/action.yml +53 -53
  20. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  21. package/loops/actions/list-open-issues/action.yml +33 -33
  22. package/loops/actions/load-issue-context/action.yml +45 -45
  23. package/loops/actions/merge-agent-pr/action.yml +49 -49
  24. package/loops/actions/push-agent-branch/action.yml +45 -45
  25. package/loops/actions/remove-issue-labels/action.yml +37 -37
  26. package/loops/actions/update-agent-issues/action.yml +58 -58
  27. package/loops/actions/validate-review-output/action.yml +35 -35
  28. package/loops/actions/validate-triage-output/action.yml +36 -36
  29. package/loops/actions/verify-app-errors/action.yml +11 -0
  30. package/loops/actions/verify-app-errors/verify-app-errors.mjs +216 -0
  31. package/loops/actions/verify-composite-actions/action.yml +9 -9
  32. package/loops/actions/verify-refine-output/action.yml +9 -9
  33. package/loops/actions/verify-route-matrix/action.yml +9 -9
  34. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +88 -0
  35. package/loops/scripts/compile-agent-workflows.mjs +331 -331
  36. package/loops/templates/agentics/agentics-app-errors.yml +168 -0
  37. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  38. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -330
  39. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -260
  40. package/loops/templates/issues/bug_report.yml +109 -109
  41. package/loops/templates/issues/feature_request.yml +75 -75
  42. package/loops/templates/release/github-release.yml +30 -30
  43. package/loops/workflows/authorize-bot-work.yml +105 -105
  44. package/loops/workflows/shared/opencode-ci.md +206 -206
  45. package/loops/workflows/shared/platform-defaults.md +19 -19
  46. package/package.json +9 -8
@@ -1,43 +1,43 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/close-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Close agent issues
3
- description: Close every close_issue item from agent_output.json.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- token:
9
- description: GitHub token with issues:write.
10
- required: true
11
- runs:
12
- using: composite
13
- steps:
14
- - name: Close issues
15
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
16
- env:
17
- AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
18
- OUTPUT_FILE: ${{ inputs.output-file }}
19
- with:
20
- github-token: ${{ inputs.token }}
21
- script: |
22
- const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
23
- const items = readAgentItems(process.env.OUTPUT_FILE, 'close_issue');
24
-
25
- let closed = 0;
26
-
27
- for (const item of items) {
28
- if (!item.item_number) {
29
- core.warning(`close_issue item has no item_number, skipping: ${JSON.stringify(item)}`);
30
- continue;
31
- }
32
-
33
- await github.rest.issues.update({
34
- ...context.repo,
35
- issue_number: Number(item.item_number),
36
- state: 'closed',
37
- state_reason: item.state_reason === 'not_planned' ? 'not_planned' : 'completed',
38
- });
39
-
40
- closed += 1;
41
- }
42
-
43
- core.info(`Closed ${closed} issue(s).`);
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/close-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Close agent issues
3
+ description: Close every close_issue item from agent_output.json.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ token:
9
+ description: GitHub token with issues:write.
10
+ required: true
11
+ runs:
12
+ using: composite
13
+ steps:
14
+ - name: Close issues
15
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
16
+ env:
17
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
18
+ OUTPUT_FILE: ${{ inputs.output-file }}
19
+ with:
20
+ github-token: ${{ inputs.token }}
21
+ script: |
22
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
23
+ const items = readAgentItems(process.env.OUTPUT_FILE, 'close_issue');
24
+
25
+ let closed = 0;
26
+
27
+ for (const item of items) {
28
+ if (!item.item_number) {
29
+ core.warning(`close_issue item has no item_number, skipping: ${JSON.stringify(item)}`);
30
+ continue;
31
+ }
32
+
33
+ await github.rest.issues.update({
34
+ ...context.repo,
35
+ issue_number: Number(item.item_number),
36
+ state: 'closed',
37
+ state_reason: item.state_reason === 'not_planned' ? 'not_planned' : 'completed',
38
+ });
39
+
40
+ closed += 1;
41
+ }
42
+
43
+ core.info(`Closed ${closed} issue(s).`);
@@ -0,0 +1,297 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/collect-app-errors/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Collect application errors
3
+ description: >-
4
+ Ask Application Insights what the deployed application threw, group it, and file one issue per
5
+ distinct problem in THIS repository, labelled so the refine and implement workers pick it up.
6
+ Deterministic: no model runs. Nothing leaves this repository.
7
+
8
+ inputs:
9
+ token:
10
+ description: Token for this repository. Needs issues:write to file anything.
11
+ required: true
12
+ workspace-id:
13
+ description: >-
14
+ The Log Analytics workspace GUID (its customerId). Empty means do nothing and say so,
15
+ which is what a repository with no Application Insights should get.
16
+ required: false
17
+ default: ''
18
+ environment:
19
+ description: Which deployed environment this is, as it appears in issue titles and fingerprints.
20
+ required: false
21
+ default: pre
22
+ lookback-hours:
23
+ description: How far back to look. Match the schedule, or problems are counted twice.
24
+ required: false
25
+ default: '24'
26
+ min-occurrences:
27
+ description: Below this many occurrences in the window, nothing is filed.
28
+ required: false
29
+ default: '5'
30
+ min-operations:
31
+ description: Below this many distinct operations affected, nothing is filed.
32
+ required: false
33
+ default: '1'
34
+ max-findings:
35
+ description: >-
36
+ The most issues one run may open. The rest are named in the job summary and picked up
37
+ next run, never dropped in silence.
38
+ required: false
39
+ default: '3'
40
+ max-open:
41
+ description: >-
42
+ Backpressure. While at least this many issues carrying the label are already open,
43
+ nothing new is filed.
44
+ required: false
45
+ default: '5'
46
+ label:
47
+ description: The label every issue from here carries, and the one backpressure counts.
48
+ required: false
49
+ default: app-error
50
+ extra-labels:
51
+ description: >-
52
+ Comma-separated labels added alongside, which is how a finding reaches the belt.
53
+ Empty files a bare issue that nothing picks up.
54
+ required: false
55
+ default: 'bug,refine'
56
+ accepted-label:
57
+ description: >-
58
+ On a closed issue, this label means the problem is known and accepted, and it is never
59
+ filed again.
60
+ required: false
61
+ default: error-accepted
62
+ own-code-prefix:
63
+ description: >-
64
+ The assembly or namespace prefix that marks a stack frame as this repository's own.
65
+ Empty publishes no frames at all, which loses detail rather than leaking somebody else's.
66
+ required: false
67
+ default: ''
68
+ dry-run:
69
+ description: Work out what would be filed, write it to the job summary, and file nothing.
70
+ required: false
71
+ default: 'false'
72
+
73
+ outputs:
74
+ filed:
75
+ description: The issue numbers opened or updated, comma separated. Empty if nothing was.
76
+ value: ${{ steps.file.outputs.filed }}
77
+
78
+ runs:
79
+ using: composite
80
+ steps:
81
+ - name: Query the workspace
82
+ id: query
83
+ if: inputs.workspace-id != ''
84
+ shell: bash
85
+ env:
86
+ WORKSPACE_ID: ${{ inputs.workspace-id }}
87
+ LOOKBACK_HOURS: ${{ inputs.lookback-hours }}
88
+ MIN_OCCURRENCES: ${{ inputs.min-occurrences }}
89
+ MAX_FINDINGS: ${{ inputs.max-findings }}
90
+ run: |
91
+ set -euo pipefail
92
+ # Four times the ceiling, so the grouping step can drop what is already filed or
93
+ # accepted and still have something left to open. Asking for exactly the ceiling
94
+ # means one known problem at the top of the list starves everything behind it.
95
+ bash "${GITHUB_ACTION_PATH}/query-app-errors.sh" \
96
+ "$WORKSPACE_ID" "$LOOKBACK_HOURS" "$MIN_OCCURRENCES" "$(( MAX_FINDINGS * 4 ))" \
97
+ "${RUNNER_TEMP}/app-errors.json"
98
+
99
+ - name: Group, redact and file
100
+ id: file
101
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
102
+ env:
103
+ WORKSPACE_ID: ${{ inputs.workspace-id }}
104
+ APP_ENVIRONMENT: ${{ inputs.environment }}
105
+ LOOKBACK_HOURS: ${{ inputs.lookback-hours }}
106
+ MIN_OCCURRENCES: ${{ inputs.min-occurrences }}
107
+ MIN_OPERATIONS: ${{ inputs.min-operations }}
108
+ MAX_FINDINGS: ${{ inputs.max-findings }}
109
+ MAX_OPEN: ${{ inputs.max-open }}
110
+ LABEL: ${{ inputs.label }}
111
+ EXTRA_LABELS: ${{ inputs.extra-labels }}
112
+ ACCEPTED_LABEL: ${{ inputs.accepted-label }}
113
+ OWN_CODE_PREFIX: ${{ inputs.own-code-prefix }}
114
+ DRY_RUN: ${{ inputs.dry-run }}
115
+ ACTION_PATH: ${{ github.action_path }}
116
+ with:
117
+ github-token: ${{ inputs.token }}
118
+ script: |
119
+ // No runner expressions anywhere in this block, comments included: the runner
120
+ // substitutes them before Node parses this, and CI rejects the file if it finds one.
121
+ const path = require('node:path');
122
+ const fs = require('node:fs');
123
+ const { pathToFileURL } = require('node:url');
124
+
125
+ const workspaceId = process.env.WORKSPACE_ID ?? '';
126
+ const environment = process.env.APP_ENVIRONMENT ?? 'pre';
127
+ const lookbackHours = process.env.LOOKBACK_HOURS ?? '24';
128
+ const label = process.env.LABEL ?? 'app-error';
129
+ const acceptedLabel = process.env.ACCEPTED_LABEL ?? 'error-accepted';
130
+ const extraLabels = (process.env.EXTRA_LABELS ?? '').split(',').map((n) => n.trim()).filter(Boolean);
131
+ const maxFindings = Number(process.env.MAX_FINDINGS ?? '3');
132
+ const maxOpen = Number(process.env.MAX_OPEN ?? '5');
133
+ const dryRun = process.env.DRY_RUN === 'true';
134
+
135
+ const summary = core.summary.addHeading('Application errors', 2);
136
+ const done = async (lines) => {
137
+ await summary.addList(lines).write();
138
+ core.setOutput('filed', '');
139
+ };
140
+
141
+ // A repository with no Application Insights installed this by accident, or has not
142
+ // been configured yet. A warning and a green run, never a failure: a red run every
143
+ // morning is how a workflow gets switched off for good.
144
+ if (!workspaceId) {
145
+ core.warning('No workspace was given, so nothing was queried. Set the workspace id, or uninstall this workflow.');
146
+ return done(['No workspace configured. Nothing was queried.']);
147
+ }
148
+
149
+ const rowsPath = path.join(process.env.RUNNER_TEMP, 'app-errors.json');
150
+ const rows = fs.existsSync(rowsPath) ? JSON.parse(fs.readFileSync(rowsPath, 'utf8')) : [];
151
+ if (rows.length === 0) {
152
+ return done([`Nothing was thrown in the last ${lookbackHours}h in ${environment}. Good.`]);
153
+ }
154
+
155
+ // The real module, imported rather than restated. It is a file precisely so it can be
156
+ // executed by its own tests; inlining it here would put it back out of reach.
157
+ const grouping = await import(pathToFileURL(path.join(process.env.ACTION_PATH, 'group-and-redact.mjs')).href);
158
+
159
+ const outcome = grouping.prepare(rows, {
160
+ environment,
161
+ lookbackHours,
162
+ ownCodePrefix: process.env.OWN_CODE_PREFIX ?? '',
163
+ minOccurrences: Number(process.env.MIN_OCCURRENCES ?? '5'),
164
+ minOperations: Number(process.env.MIN_OPERATIONS ?? '1'),
165
+ });
166
+
167
+ // A field nobody agreed to publish is a change to the module that got past review.
168
+ // Nothing is filed, because the new field is probably on all of them.
169
+ if (outcome.refused) {
170
+ core.setFailed(`Nothing was filed: ${outcome.refused}.`);
171
+ return;
172
+ }
173
+
174
+ const open = await github.paginate(github.rest.issues.listForRepo, {
175
+ ...context.repo, state: 'open', labels: label, per_page: 100,
176
+ });
177
+
178
+ // Backpressure, the audit's rule in a workflow that cannot use skip-if-match:
179
+ // do not pile reports on top of reports nobody has actioned.
180
+ if (open.length >= maxOpen) {
181
+ return done([
182
+ `${open.length} issue(s) labelled \`${label}\` are already open, which is the limit.`,
183
+ 'Nothing new was filed. Close or accept some, and the next run will carry on.',
184
+ ]);
185
+ }
186
+
187
+ // Made before anything is filed. A label that does not exist yet is a 422 on the
188
+ // create, which reads as "the issue could not be filed" and sends whoever is
189
+ // debugging it looking at permissions.
190
+ for (const name of [label, acceptedLabel, ...extraLabels]) {
191
+ try {
192
+ await github.rest.issues.getLabel({ ...context.repo, name });
193
+ } catch (missing) {
194
+ if (missing.status !== 404) { throw missing; }
195
+ if (dryRun) { continue; }
196
+ await github.rest.issues.createLabel({
197
+ ...context.repo, name, color: 'd73a4a',
198
+ description: 'Filed automatically from Application Insights.',
199
+ });
200
+ }
201
+ }
202
+
203
+ const marker = (fingerprint) => `<!-- pcp-app-error: ${fingerprint} -->`;
204
+
205
+ // Everything ever filed from here, open or closed. Closed matters as much as open:
206
+ // it is how "we know, leave it" and "this came back" are told apart.
207
+ const seen = await github.paginate(github.rest.issues.listForRepo, {
208
+ ...context.repo, state: 'all', labels: label, per_page: 100,
209
+ });
210
+ const find = (fingerprint) => seen.find((issue) => (issue.body ?? '').includes(marker(fingerprint)));
211
+
212
+ const today = new Date().toISOString().slice(0, 10);
213
+ const filed = [];
214
+ const notes = [];
215
+ let opened = 0;
216
+
217
+ for (const report of outcome.prepared) {
218
+ const existing = find(report.fingerprint);
219
+ const accepted = existing
220
+ && (existing.labels ?? []).some((l) => (typeof l === 'string' ? l : l.name) === acceptedLabel);
221
+
222
+ // Somebody looked at this and said it is fine. That decision does not expire.
223
+ if (existing && existing.state === 'closed' && accepted) {
224
+ notes.push(`\`${report.fingerprint}\` is accepted (#${existing.number}); left alone.`);
225
+ continue;
226
+ }
227
+
228
+ const line = `- ${today}: ${report.occurrences} in the last ${lookbackHours}h`;
229
+
230
+ if (existing && existing.state === 'open') {
231
+ if (!dryRun) {
232
+ await github.rest.issues.createComment({
233
+ ...context.repo, issue_number: existing.number, body: line,
234
+ });
235
+ }
236
+ filed.push(existing.number);
237
+ notes.push(`\`${report.fingerprint}\` still happening; noted on #${existing.number}.`);
238
+ continue;
239
+ }
240
+
241
+ // Closed, and not accepted. It came back. Reopening keeps the history of what was
242
+ // tried last time, which is most of what makes the second attempt cheaper.
243
+ if (existing) {
244
+ if (!dryRun) {
245
+ await github.rest.issues.update({
246
+ ...context.repo, issue_number: existing.number, state: 'open',
247
+ });
248
+ await github.rest.issues.createComment({
249
+ ...context.repo,
250
+ issue_number: existing.number,
251
+ body: `This was closed and has come back.\n${line}`,
252
+ });
253
+ }
254
+ filed.push(existing.number);
255
+ notes.push(`\`${report.fingerprint}\` regressed; reopened #${existing.number}.`);
256
+ continue;
257
+ }
258
+
259
+ // The ceiling applies only to new issues. Commenting on one that already exists is
260
+ // not noise, and stopping early would hide a problem that is getting worse.
261
+ if (opened >= maxFindings) {
262
+ notes.push(`\`${report.fingerprint}\` (${report.occurrences}) waits for the next run: the ceiling is ${maxFindings}.`);
263
+ continue;
264
+ }
265
+
266
+ if (!dryRun) {
267
+ const created = await github.rest.issues.create({
268
+ ...context.repo,
269
+ title: report.title,
270
+ body: report.body,
271
+ labels: [label, ...extraLabels],
272
+ });
273
+ filed.push(created.data.number);
274
+ notes.push(`\`${report.fingerprint}\` filed as #${created.data.number}.`);
275
+ } else {
276
+ notes.push(`\`${report.fingerprint}\` would be filed: ${report.title}`);
277
+ }
278
+
279
+ opened += 1;
280
+ }
281
+
282
+ for (const held of outcome.withheld) {
283
+ core.error(`A report was withheld: the leak scanner matched ${held.matched.join('; ')}.`);
284
+ }
285
+
286
+ await summary
287
+ .addRaw(dryRun ? '\n_Dry run: nothing was filed._\n' : '')
288
+ .addList(notes.length > 0 ? notes : ['Nothing to report.'])
289
+ .write();
290
+
291
+ core.setOutput('filed', filed.join(','));
292
+
293
+ // Red only after everything clean has been filed. A single unlucky operation name
294
+ // must not mean the belt never hears about anything again.
295
+ if (outcome.withheld.length > 0) {
296
+ core.setFailed(`${outcome.withheld.length} report(s) were withheld by the leak scanner; fix the field that carried private text.`);
297
+ }
@@ -0,0 +1,252 @@
1
+ // Managed by @plainconceptsplatform/workflows. Source: loops/actions/collect-app-errors/group-and-redact.mjs. Update with workflows update --force; consumer edits may be overwritten.
2
+ //
3
+ // Turn rows from an Application Insights query into findings that are safe to write into an
4
+ // issue, or refuse.
5
+ //
6
+ // A module rather than a block of JavaScript inside an `action.yml`, and that is the point.
7
+ // The logic in `report-workflow-errors/action.yml` lives inside a `script:` string, so nothing
8
+ // can execute it and its tests are greps over the file. This one runs, so its tests run it,
9
+ // and the "replay real history" pass can be done on a laptop with no Azure at all.
10
+ //
11
+ // Pure: rows in, findings out. It reads no environment, opens no socket and files nothing.
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ /**
16
+ * The only fields that may reach an issue body. Anything else means nothing is filed.
17
+ *
18
+ * The same shape the workflow-error report uses, for the same reason: a redaction rule is a
19
+ * filter over values and it cannot see a field somebody adds next year. An allowlist can.
20
+ *
21
+ * `run_id` is deliberately absent. It is the one unhashed identifier on this telemetry and it
22
+ * joins to a conversation and to a person, so putting it in an issue is a decision somebody
23
+ * has to take on purpose rather than by adding a line here.
24
+ */
25
+ export const FINDING_FIELDS = [
26
+ "exceptionType",
27
+ "problemId",
28
+ "operationName",
29
+ "roleName",
30
+ "occurrences",
31
+ "operations",
32
+ "firstSeen",
33
+ "lastSeen",
34
+ "frames",
35
+ "fingerprint",
36
+ ];
37
+
38
+ /**
39
+ * What must never appear in a finding.
40
+ *
41
+ * Narrower than the upstream report's scanner, because the audience is different: this issue
42
+ * is filed in the private repository the application belongs to, so its own name is not a
43
+ * leak. What is still a leak is anything that identifies a customer, anything that would let
44
+ * a reader join this back to one person's conversation, and any credential.
45
+ */
46
+ export const LEAK_CHECKS = [
47
+ { what: "a GUID", test: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i },
48
+ { what: "a connection or instrumentation string", test: /(InstrumentationKey|IngestionEndpoint|AccountKey|SharedAccessKey|Password)\s*=/i },
49
+ { what: "a bearer token or long secret", test: /\b(eyJ[A-Za-z0-9_-]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|[A-Za-z0-9+/]{60,}={0,2})\b/ },
50
+ { what: "an absolute path", test: /(^|[\s"'(])(\/home\/|\/Users\/|\/root\/|\/mnt\/|\/var\/|[A-Za-z]:\\)/ },
51
+ { what: "an email address", test: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
52
+ { what: "a URL with a query string", test: /https?:\/\/\S+\?\S+/ },
53
+ ];
54
+
55
+ /**
56
+ * What is masked on the way in, before anything is rendered or scanned.
57
+ *
58
+ * Scrubbing rather than refusing, for the things that are both common and safely
59
+ * replaceable. An operation name that carries a GUID is ordinary -- a route that was never
60
+ * templated, a queue name with an id in it -- and refusing the whole run over one would be a
61
+ * workflow that is red every morning and files nothing, which is worse than one that says
62
+ * `{guid}`. The scanner below is still there for whatever this does not catch.
63
+ */
64
+ export const SCRUBS = [
65
+ [/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "{guid}"],
66
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "{email}"],
67
+ [/(\/home\/|\/Users\/|\/root\/|\/mnt\/|\/var\/)[^\s"'),]*/g, "{path}"],
68
+ [/[A-Za-z]:\\[^\s"'),]*/g, "{path}"],
69
+ [/(https?:\/\/[^\s"'),?]+)\?[^\s"'),]*/g, "$1?{query}"],
70
+ ];
71
+
72
+ /** How long a single message may be before it is cut. */
73
+ export const MAX_MESSAGE_LENGTH = 300;
74
+
75
+ /** How many stack frames are worth reading in an issue. */
76
+ export const MAX_FRAMES = 8;
77
+
78
+ /**
79
+ * A stable name for one problem, so the same error tomorrow finds today's issue.
80
+ *
81
+ * The environment is in it on purpose: the same exception in pre and in pro are two different
82
+ * pieces of news, and one closing should not silence the other.
83
+ */
84
+ export function fingerprint(problemId, operationName, environment) {
85
+ return createHash("sha256")
86
+ .update(`${problemId}|${operationName}|${environment}`)
87
+ .digest("hex")
88
+ .slice(0, 12);
89
+ }
90
+
91
+ /**
92
+ * The frames that belong to us.
93
+ *
94
+ * `Details[].parsedStack` carries our own assemblies mixed in with the framework's and with
95
+ * whatever a package vendored. An empty prefix returns nothing at all, which is the safe
96
+ * direction: a consumer that has not said which code is its own loses the stack rather than
97
+ * publishing somebody else's file paths.
98
+ */
99
+ export function ownFrames(details, prefix) {
100
+ if (!prefix) {
101
+ return [];
102
+ }
103
+
104
+ const stacks = (Array.isArray(details) ? details : []).flatMap((detail) =>
105
+ Array.isArray(detail?.parsedStack) ? detail.parsedStack : []);
106
+
107
+ return stacks
108
+ .filter((frame) => typeof frame?.method === "string" && frame.method.startsWith(prefix))
109
+ // The method alone. `frame.fileName` is an absolute path from the build machine and
110
+ // `frame.line` without it says nothing, so neither is worth the risk.
111
+ .map((frame) => clip(frame.method))
112
+ .slice(0, MAX_FRAMES);
113
+ }
114
+
115
+ /** Masks everything in SCRUBS. Applied to every string before it is rendered or scanned. */
116
+ export function scrub(value) {
117
+ return SCRUBS.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), value);
118
+ }
119
+
120
+ /**
121
+ * One field, ready to publish: whitespace flattened, masked, and cut.
122
+ *
123
+ * Scrubbed after flattening and before cutting, so a pattern cannot be split across a line
124
+ * break and survive, and a cut cannot leave half a GUID behind looking like ordinary hex.
125
+ */
126
+ export function clip(value) {
127
+ const text = scrub(String(value ?? "").replace(/\s+/g, " ").trim());
128
+ return text.length <= MAX_MESSAGE_LENGTH ? text : `${text.slice(0, MAX_MESSAGE_LENGTH)}…`;
129
+ }
130
+
131
+ /**
132
+ * Whether every field on this finding is one we agreed to publish.
133
+ *
134
+ * Returns the offending names rather than a boolean, so the caller can say which field
135
+ * stopped the run instead of "something did".
136
+ */
137
+ export function unexpectedFields(finding) {
138
+ return Object.keys(finding).filter((key) => !FINDING_FIELDS.includes(key));
139
+ }
140
+
141
+ /** Which leak checks a piece of text trips. Empty means it is safe to write. */
142
+ export function leaks(text) {
143
+ return LEAK_CHECKS.filter((check) => check.test.test(text)).map((check) => check.what);
144
+ }
145
+
146
+ /**
147
+ * Rows from the query, as findings.
148
+ *
149
+ * Ordered by how often it happened, because that is the order somebody would want to fix
150
+ * them in, and the ceiling is applied by the caller so the ones left over can be named
151
+ * rather than silently dropped.
152
+ */
153
+ export function toFindings(rows, { environment, ownCodePrefix = "", minOccurrences = 1, minOperations = 1 } = {}) {
154
+ return (Array.isArray(rows) ? rows : [])
155
+ .map((row) => ({
156
+ exceptionType: clip(row.ExceptionType),
157
+ problemId: clip(row.ProblemId),
158
+ operationName: clip(row.OperationName),
159
+ roleName: clip(row.AppRoleName),
160
+ occurrences: Number(row.Occurrences) || 0,
161
+ operations: Number(row.Operations) || 0,
162
+ firstSeen: clip(row.FirstSeen),
163
+ lastSeen: clip(row.LastSeen),
164
+ frames: ownFrames(row.AnyDetails, ownCodePrefix),
165
+ fingerprint: fingerprint(clip(row.ProblemId), clip(row.OperationName), environment),
166
+ }))
167
+ .filter((finding) => finding.occurrences >= minOccurrences && finding.operations >= minOperations)
168
+ .sort((first, second) => second.occurrences - first.occurrences);
169
+ }
170
+
171
+ /**
172
+ * The issue this finding becomes.
173
+ *
174
+ * The marker is the first line, which is what the dedupe searches for, and the body says
175
+ * plainly that a machine wrote it: somebody reading this in six months should not have to
176
+ * work out whether a person investigated.
177
+ */
178
+ export function render(finding, { environment, lookbackHours, ownCodePrefix = "" }) {
179
+ const marker = `<!-- pcp-app-error: ${finding.fingerprint} -->`;
180
+ const title = `${finding.exceptionType} in ${finding.operationName || finding.roleName || environment}`;
181
+
182
+ const body = [
183
+ marker,
184
+ `**${finding.occurrences} occurrence(s)** in the last ${lookbackHours}h in \`${environment}\`, across ${finding.operations} operation(s).`,
185
+ "",
186
+ `- Exception: \`${finding.exceptionType}\``,
187
+ `- Operation: \`${finding.operationName || "(none recorded)"}\``,
188
+ `- Role: \`${finding.roleName || "(none recorded)"}\``,
189
+ `- First seen: ${finding.firstSeen}`,
190
+ `- Last seen: ${finding.lastSeen}`,
191
+ finding.frames.length > 0
192
+ ? `\n**Our own frames, outermost first**\n\n\`\`\`\n${finding.frames.join("\n")}\n\`\`\``
193
+ : ownCodePrefix
194
+ // The prefix is set and nothing matched: every frame belongs to the framework or to a
195
+ // dependency. Routine for an exception raised inside framework code -- a cancelled task
196
+ // is the common one -- and nothing for the reader to go and configure.
197
+ ? `\n_No frame in this exception belongs to \`${clip(ownCodePrefix)}\`: all of them are the framework's or a dependency's. That is usual for an exception thrown inside framework code, such as a cancelled task._`
198
+ // No prefix, so no frame can be recognised as this repository's own and the
199
+ // stack is dropped rather than publishing somebody else's file paths.
200
+ : "\n_No stack frames are shown: this repository has not said which assemblies are its own. Set `OWN_CODE_PREFIX` in the workflow to name them._",
201
+ "",
202
+ "---",
203
+ "",
204
+ "Filed automatically from Application Insights. Nothing here was read by a model, and no",
205
+ "identifier that joins back to a person is included, so the reproduction is still somebody's",
206
+ "to work out. Close this with the `error-accepted` label to stop it being filed again.",
207
+ ].join("\n");
208
+
209
+ return { title, body, marker };
210
+ }
211
+
212
+ /**
213
+ * Everything the caller needs, and what it must complain about.
214
+ *
215
+ * Two different failures, handled two different ways.
216
+ *
217
+ * A field nobody agreed to publish is a change to this file that got past review, and it
218
+ * stops everything: `refused`, nothing prepared, nothing filed. One report is not worth the
219
+ * risk that the new field is on all of them.
220
+ *
221
+ * A body that still trips the scanner after scrubbing is one bad row. That report is
222
+ * withheld and the others are prepared as usual, because a single unlucky operation name
223
+ * must not mean the belt never hears about anything again. The caller files what it has and
224
+ * then goes red, naming what was withheld -- the same shape the workflow-error report uses.
225
+ */
226
+ export function prepare(rows, options) {
227
+ const findings = toFindings(rows, options);
228
+ const prepared = [];
229
+ const withheld = [];
230
+
231
+ for (const finding of findings) {
232
+ const unexpected = unexpectedFields(finding);
233
+ if (unexpected.length > 0) {
234
+ return {
235
+ refused: `a finding carries ${unexpected.join(", ")}, which is not in the reportable field list`,
236
+ prepared: [],
237
+ withheld: [],
238
+ };
239
+ }
240
+
241
+ const issue = render(finding, options);
242
+ const tripped = leaks(`${issue.title}\n${issue.body}`);
243
+ if (tripped.length > 0) {
244
+ withheld.push({ fingerprint: finding.fingerprint, matched: tripped });
245
+ continue;
246
+ }
247
+
248
+ prepared.push({ ...issue, fingerprint: finding.fingerprint, occurrences: finding.occurrences });
249
+ }
250
+
251
+ return { refused: "", prepared, withheld };
252
+ }