@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.
- 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/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-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-route-matrix.sh +88 -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/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
|
+
}
|