@plainconceptsplatform/workflows 1.2.0 → 1.2.2
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/catalog-installation.js +4 -0
- package/loops/actions/capture-evidence/action.yml +9 -1
- package/loops/actions/capture-evidence/capture.mjs +113 -3
- package/loops/actions/classify-route/classify-route.sh +2 -2
- package/loops/docs/diagrams.md +7 -7
- package/loops/workflows/agent-triage.md +3 -5
- package/loops/workflows/evidence.yml +3 -2
- package/loops/workflows/work-router.yml +7 -46
- package/package.json +1 -1
|
@@ -326,7 +326,11 @@ async function preCommitHookUpdate(repositoryPath) {
|
|
|
326
326
|
if (!await exists(hookPath)) {
|
|
327
327
|
return { target, content: managedLines };
|
|
328
328
|
}
|
|
329
|
+
// A Windows clone with core.autocrlf checks the hook out with CRLF, which the LF patterns below
|
|
330
|
+
// never match: every update from Windows appended one more block (one consumer had ten). The
|
|
331
|
+
// hook is written back with LF, as every managed file is.
|
|
329
332
|
const content = (await readFile(hookPath, "utf8"))
|
|
333
|
+
.replace(/\r\n/g, "\n")
|
|
330
334
|
.replace("pnpm exec if git diff --cached --name-only -- .github | grep -q .; then", "if git diff --cached --name-only -- .github | grep -q .; then");
|
|
331
335
|
// Replace whatever managed block is there, never append beside it. The previous version
|
|
332
336
|
// recognised only two exact shapes -- the current block and the pre-`if` legacy lines -- and
|
|
@@ -21,6 +21,13 @@ inputs:
|
|
|
21
21
|
description: Where capture and unreached write the screenshots, manifest.json and comment.md.
|
|
22
22
|
required: false
|
|
23
23
|
default: ''
|
|
24
|
+
sign-in:
|
|
25
|
+
description: >
|
|
26
|
+
The repository's own sign-in steps for plans that start signed in (`login:`), relative to the
|
|
27
|
+
workspace, which is the default branch's checkout. The repository owns the file; without it
|
|
28
|
+
such a plan replays signed out and its comment says why.
|
|
29
|
+
required: false
|
|
30
|
+
default: '.github/evidence/sign-in.yml'
|
|
24
31
|
playwright-version:
|
|
25
32
|
description: playwright-core version. Must match the mcr.microsoft.com/playwright image tag the capture runs in.
|
|
26
33
|
required: false
|
|
@@ -75,6 +82,7 @@ runs:
|
|
|
75
82
|
ROOT: ${{ inputs.root }}
|
|
76
83
|
PLANS: ${{ inputs.plans }}
|
|
77
84
|
OUT_DIR: ${{ inputs.out-dir }}
|
|
85
|
+
EVIDENCE_SIGN_IN: ${{ inputs.sign-in }}
|
|
78
86
|
PLAYWRIGHT_VERSION: ${{ inputs.playwright-version }}
|
|
79
87
|
# Chromium's own stderr. A browser that dies on launch says why only here.
|
|
80
88
|
DEBUG: pw:browser
|
|
@@ -102,7 +110,7 @@ runs:
|
|
|
102
110
|
if command -v docker > /dev/null 2>&1; then
|
|
103
111
|
# The host network, so the browser reaches the app on localhost where it started;
|
|
104
112
|
# the runner's own user, so what it writes the publish job can read.
|
|
105
|
-
docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp -e DEBUG -e EVIDENCE_SHA -e EVIDENCE_RUN_URL -e EVIDENCE_STEP_TIMEOUT_MS -e EVIDENCE_NAVIGATION_TIMEOUT_MS -v "$runner:$runner:ro" -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" -v "$OUT_DIR:$OUT_DIR" -w "$GITHUB_WORKSPACE" "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
|
|
113
|
+
docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp -e DEBUG -e EVIDENCE_SHA -e EVIDENCE_RUN_URL -e EVIDENCE_SIGN_IN -e EVIDENCE_STEP_TIMEOUT_MS -e EVIDENCE_NAVIGATION_TIMEOUT_MS -v "$runner:$runner:ro" -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" -v "$OUT_DIR:$OUT_DIR" -w "$GITHUB_WORKSPACE" "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
|
|
106
114
|
else
|
|
107
115
|
node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
|
|
108
116
|
fi
|
|
@@ -37,6 +37,10 @@ export const EMBED_MARKER = "agentic-evidence-manifest";
|
|
|
37
37
|
export const EVIDENCE_DIR = "evidence";
|
|
38
38
|
// Stays well under GitHub's 65,536 character comment limit with the markdown above it.
|
|
39
39
|
const EMBED_LIMIT = 30000;
|
|
40
|
+
// The repository's own steps for signing in locally, read from the default branch so a replay of
|
|
41
|
+
// an older pull request finds them too. The repository owns the file: an update never writes it.
|
|
42
|
+
export const SIGN_IN_FILE = ".github/evidence/sign-in.yml";
|
|
43
|
+
const ACCOUNT_PLACEHOLDER = "{account}";
|
|
40
44
|
|
|
41
45
|
const ACTIONS = new Set(["open", "click", "dblclick", "fill", "press", "select", "check", "uncheck", "hover", "wait"]);
|
|
42
46
|
const LOCATOR_KEYS = ["role", "text", "label", "placeholder", "testid", "alt", "title", "css"];
|
|
@@ -129,15 +133,83 @@ export function normalizePlan(doc) {
|
|
|
129
133
|
return { n: index + 1, capture, expect: String(raw.expect ?? "").trim(), steps, ignored };
|
|
130
134
|
});
|
|
131
135
|
|
|
136
|
+
const login = parseLogin(doc.login ?? environment.login, environment);
|
|
132
137
|
return {
|
|
133
138
|
status: "runnable",
|
|
134
139
|
url: String(doc.url ?? environment.url ?? DEFAULT_URL).trim() || DEFAULT_URL,
|
|
135
140
|
start: String(doc.start ?? environment.start ?? "").trim(),
|
|
136
141
|
viewport: parseViewport(doc.viewport ?? environment.viewport),
|
|
142
|
+
...(login ? { login } : {}),
|
|
137
143
|
waypoints,
|
|
138
144
|
};
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
// A plan that starts signed in says so with `login`, rather than signing in in its first
|
|
148
|
+
// waypoint. The plans written before that waypoint signed in name a mechanism (`login: mock-sso`)
|
|
149
|
+
// and say whom in prose ("Sign in as Bob Builder"), so the prose travels along as a hint. A plan
|
|
150
|
+
// can also name the account (`login: { account: Bob Builder }`) or spell out its own steps.
|
|
151
|
+
export function parseLogin(value, environment = {}) {
|
|
152
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
153
|
+
if (Array.isArray(value)) return { steps: value };
|
|
154
|
+
if (isObject(value) && Array.isArray(value.steps)) return { steps: value.steps };
|
|
155
|
+
if (isObject(value) && value.account !== undefined) return { account: String(value.account) };
|
|
156
|
+
const hint = Object.values(environment).filter((text) => typeof text === "string").join("\n");
|
|
157
|
+
return { hint };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The account a plan's prose asks for: one of the repository's accounts right after "sign in as",
|
|
161
|
+
// the earliest when the prose names several. Only an account the repository lists can match.
|
|
162
|
+
export function accountNamed(text, accounts) {
|
|
163
|
+
let best;
|
|
164
|
+
for (const account of accounts) {
|
|
165
|
+
const escaped = account.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
166
|
+
const at = String(text ?? "").search(new RegExp(`sign(?:ed)?[\\s-]*in\\s+as\\s+(?:the\\s+)?${escaped}\\b`, "i"));
|
|
167
|
+
if (at >= 0 && (best === undefined || at < best.at)) best = { account, at };
|
|
168
|
+
}
|
|
169
|
+
return best?.account;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Reads the repository's sign-in: the steps, in the journey's own step syntax, with `{account}`
|
|
173
|
+
// where the account goes, and the accounts a plan can ask for, the first being the default.
|
|
174
|
+
export function parseSignIn(text, parseYaml) {
|
|
175
|
+
const doc = parseYaml(String(text));
|
|
176
|
+
if (!isObject(doc) || !Array.isArray(doc.steps) || doc.steps.length === 0) {
|
|
177
|
+
throw new Error("the sign-in needs a steps list");
|
|
178
|
+
}
|
|
179
|
+
return { accounts: asArray(doc.accounts).map((account) => String(account)), steps: doc.steps };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fillAccount = (value, account) =>
|
|
183
|
+
typeof value === "string"
|
|
184
|
+
? value.split(ACCOUNT_PLACEHOLDER).join(account)
|
|
185
|
+
: Array.isArray(value)
|
|
186
|
+
? value.map((item) => fillAccount(item, account))
|
|
187
|
+
: isObject(value)
|
|
188
|
+
? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fillAccount(item, account)]))
|
|
189
|
+
: value;
|
|
190
|
+
|
|
191
|
+
// A first waypoint that clicks one of the repository's accounts signs the plan in itself, as some
|
|
192
|
+
// plans that also say `login:` do. Signing in before it would leave that click nothing to find.
|
|
193
|
+
function signsItselfIn(plan, accounts) {
|
|
194
|
+
const names = new Set(accounts.map((account) => account.toLowerCase()));
|
|
195
|
+
return (plan.waypoints[0]?.steps ?? []).some((step) =>
|
|
196
|
+
step.action === "click" && [step.locator?.text, step.locator?.name].some((name) => name !== undefined && names.has(name.toLowerCase())),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The steps that sign a plan in before its first waypoint, or why there are none to run.
|
|
201
|
+
export function signInFor(plan, signIn) {
|
|
202
|
+
const login = plan.login;
|
|
203
|
+
if (!login) return undefined;
|
|
204
|
+
const parsed = (raw) => raw.map(parseStep).filter((step) => step.ignored === undefined);
|
|
205
|
+
if (login.steps) return { steps: parsed(login.steps) };
|
|
206
|
+
if (!signIn) return { steps: [], error: `the plan starts signed in, but the repository has no ${SIGN_IN_FILE}` };
|
|
207
|
+
if (signIn.error) return { steps: [], error: signIn.error };
|
|
208
|
+
if (signsItselfIn(plan, signIn.accounts)) return undefined;
|
|
209
|
+
const account = login.account ?? accountNamed(login.hint, signIn.accounts) ?? signIn.accounts[0] ?? "";
|
|
210
|
+
return { ...(account ? { account } : {}), steps: parsed(fillAccount(signIn.steps, account)) };
|
|
211
|
+
}
|
|
212
|
+
|
|
141
213
|
function locatorOf(value) {
|
|
142
214
|
if (typeof value === "string") return { text: value };
|
|
143
215
|
if (!isObject(value)) return undefined;
|
|
@@ -450,7 +522,23 @@ async function replay(browser, plan, outDir, dir) {
|
|
|
450
522
|
page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
451
523
|
|
|
452
524
|
const results = [];
|
|
525
|
+
let signedIn;
|
|
453
526
|
try {
|
|
527
|
+
if (plan.signIn) {
|
|
528
|
+
// Signing in is not a waypoint: nothing is captured, and a failure is recorded while the
|
|
529
|
+
// journey still runs, so its screenshots show where a missing sign-in left it.
|
|
530
|
+
const errors = plan.signIn.error ? [plan.signIn.error] : [];
|
|
531
|
+
for (const step of plan.signIn.steps) {
|
|
532
|
+
try {
|
|
533
|
+
await runStep(page, step, plan.url);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
errors.push(`${describeStep(step)}: ${firstLine(error)}`);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
signedIn = { ...(plan.signIn.account ? { account: plan.signIn.account } : {}), reached: errors.length === 0, errors };
|
|
540
|
+
console.log(`${dir} sign-in${plan.signIn.account ? ` as ${plan.signIn.account}` : ""}: ${errors.length === 0 ? "ok" : errors.join("; ")}`);
|
|
541
|
+
}
|
|
454
542
|
for (const waypoint of plan.waypoints) {
|
|
455
543
|
const errors = [];
|
|
456
544
|
for (const step of waypoint.steps) {
|
|
@@ -486,7 +574,7 @@ async function replay(browser, plan, outDir, dir) {
|
|
|
486
574
|
} finally {
|
|
487
575
|
await context.close();
|
|
488
576
|
}
|
|
489
|
-
return results;
|
|
577
|
+
return { waypoints: results, signedIn };
|
|
490
578
|
}
|
|
491
579
|
|
|
492
580
|
// ── Report ──────────────────────────────────────────────────────────────────────────────────
|
|
@@ -560,6 +648,10 @@ export function renderComment(manifest) {
|
|
|
560
648
|
lines.push(`Not replayed: ${escapeMarkdown(plan.reason || plan.status)}.`, "");
|
|
561
649
|
continue;
|
|
562
650
|
}
|
|
651
|
+
if (plan.signIn) {
|
|
652
|
+
const as = plan.signIn.account ? ` as ${escapeMarkdown(plan.signIn.account)}` : "";
|
|
653
|
+
lines.push(plan.signIn.reached ? `Signed in${as} first.` : `> Could not sign in${as} first: ${escapeMarkdown(plan.signIn.errors.join("; "))}`, "");
|
|
654
|
+
}
|
|
563
655
|
const missed = plan.waypoints.filter((waypoint) => !waypoint.reached).length;
|
|
564
656
|
const summary = `${plan.waypoints.length} waypoint${plan.waypoints.length === 1 ? "" : "s"}${missed > 0 ? `, ${missed} did not reach their state` : ""}`;
|
|
565
657
|
lines.push("<details>", `<summary>${summary}</summary>`, "");
|
|
@@ -606,6 +698,19 @@ async function inspect(root, paths) {
|
|
|
606
698
|
};
|
|
607
699
|
}
|
|
608
700
|
|
|
701
|
+
// The repository's sign-in, or null when it has none. The path is relative to the working
|
|
702
|
+
// directory, which is the default branch's checkout, not the pull request head.
|
|
703
|
+
async function loadSignIn(path, parseYaml) {
|
|
704
|
+
let text;
|
|
705
|
+
try {
|
|
706
|
+
text = await readFile(path, "utf8");
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (error?.code === "ENOENT") return null;
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
return parseSignIn(text, parseYaml);
|
|
712
|
+
}
|
|
713
|
+
|
|
609
714
|
async function capture(root, outDir, paths) {
|
|
610
715
|
const { parse } = await import("yaml");
|
|
611
716
|
const { chromium } = await import("playwright-core");
|
|
@@ -613,6 +718,11 @@ async function capture(root, outDir, paths) {
|
|
|
613
718
|
const plans = await loadPlans(root, paths, parse);
|
|
614
719
|
const runnable = plans.filter((plan) => plan.status === "runnable");
|
|
615
720
|
const manifest = { version: 2, plans: [] };
|
|
721
|
+
let signIn = null;
|
|
722
|
+
if (runnable.some((plan) => plan.login)) {
|
|
723
|
+
const path = process.env.EVIDENCE_SIGN_IN || SIGN_IN_FILE;
|
|
724
|
+
signIn = await loadSignIn(path, parse).catch((error) => ({ error: `${path}: ${firstLine(error)}` }));
|
|
725
|
+
}
|
|
616
726
|
|
|
617
727
|
const browser = runnable.length > 0 ? await chromium.launch({ headless: true }) : undefined;
|
|
618
728
|
try {
|
|
@@ -623,8 +733,8 @@ async function capture(root, outDir, paths) {
|
|
|
623
733
|
}
|
|
624
734
|
const dir = planDir(plan.path);
|
|
625
735
|
await mkdir(join(outDir, "files", dir, EVIDENCE_DIR), { recursive: true });
|
|
626
|
-
const waypoints = await replay(browser, plan, outDir, dir);
|
|
627
|
-
manifest.plans.push({ path: plan.path, change: plan.change, status: "captured", url: plan.url, waypoints });
|
|
736
|
+
const { waypoints, signedIn } = await replay(browser, { ...plan, signIn: signInFor(plan, signIn) }, outDir, dir);
|
|
737
|
+
manifest.plans.push({ path: plan.path, change: plan.change, status: "captured", url: plan.url, ...(signedIn ? { signIn: signedIn } : {}), waypoints });
|
|
628
738
|
}
|
|
629
739
|
} finally {
|
|
630
740
|
await browser?.close();
|
|
@@ -43,8 +43,8 @@ classify_route() {
|
|
|
43
43
|
if has_label refine || has_label implement; then
|
|
44
44
|
error="issue opened with a work label; triage skipped"
|
|
45
45
|
else
|
|
46
|
-
# Issue opened by
|
|
47
|
-
# gates the caller on is_outside_collaborator; this routes unconditionally
|
|
46
|
+
# Issue opened by a read-only outside collaborator → triage. The authorize
|
|
47
|
+
# job gates the caller on is_outside_collaborator; this routes unconditionally
|
|
48
48
|
# so write+ openers classify to triage but the caller job skips them.
|
|
49
49
|
route="triage"
|
|
50
50
|
triage_mode="first"
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -37,9 +37,9 @@ without touching the others.
|
|
|
37
37
|
flowchart TD
|
|
38
38
|
ev{"One GitHub event"} --> classify
|
|
39
39
|
classify["classify (rung 1)<br/>pure shell, no network<br/>one event in, one route out"] --> authorize
|
|
40
|
-
authorize{"authorize (rung 1)<br/>Write permission,
|
|
40
|
+
authorize{"authorize (rung 1)<br/>Write permission, or<br/>read only?"}
|
|
41
41
|
authorize -->|"human, trusted"| work
|
|
42
|
-
authorize -->|"
|
|
42
|
+
authorize -->|"read only"| triageOnly
|
|
43
43
|
authorize -.->|"no permission"| idle
|
|
44
44
|
work{"route"} -->|refine| wRefine
|
|
45
45
|
work -->|implement| wImpl
|
|
@@ -79,7 +79,7 @@ fewer events, not adding more guards.
|
|
|
79
79
|
```mermaid
|
|
80
80
|
flowchart LR
|
|
81
81
|
open("Issue opened") --> triage
|
|
82
|
-
triage{"triage<br/>
|
|
82
|
+
triage{"triage<br/>read-only opener"} -->|pass| refine
|
|
83
83
|
triage -.->|block| closed(("Closed"))
|
|
84
84
|
triage -.->|needs-info| author("Author replies") --> triage
|
|
85
85
|
write("Write+ user<br/>self-labels") --> refine
|
|
@@ -114,9 +114,9 @@ instead of one pull request that has to fix them all.
|
|
|
114
114
|
|
|
115
115
|
## agent-triage.md
|
|
116
116
|
|
|
117
|
-
The front door for issues opened
|
|
118
|
-
|
|
119
|
-
collaborator goes through it
|
|
117
|
+
The front door for issues opened by people with read-only access. The gate is permission level, not
|
|
118
|
+
membership: anybody with write skips triage and self-labels into the pipeline, org member or not, while
|
|
119
|
+
a read-only outside collaborator goes through it.
|
|
120
120
|
|
|
121
121
|
Four outcomes, and only one of them closes anything. `needs-maintainer` is the one worth knowing: the
|
|
122
122
|
request is legitimate but addressed to the wrong intake, so the issue stays open with `review` and a
|
|
@@ -125,7 +125,7 @@ maintainer takes it on by adding `refine`.
|
|
|
125
125
|
```mermaid
|
|
126
126
|
flowchart TD
|
|
127
127
|
triStart("Work Router<br/>triage route") --> triPick
|
|
128
|
-
triPick{"Opened by
|
|
128
|
+
triPick{"Opened by a read-only<br/>outside collaborator?"} -->|yes| triReserve
|
|
129
129
|
triPick -.->|"no, write+ user"| triIdle
|
|
130
130
|
triReserve("Reserve (rung 4)<br/>bot-working + triage") --> triFacts
|
|
131
131
|
triFacts("Facts (rung 3)<br/>Issue, comments and every open<br/>issue written to disk") --> triRound
|
|
@@ -54,11 +54,9 @@ description: |
|
|
|
54
54
|
cannot be done, is a security risk, is too dangerous, or is still ambiguous after
|
|
55
55
|
three rounds.
|
|
56
56
|
|
|
57
|
-
The gate is
|
|
58
|
-
is_outside_collaborator for read permission
|
|
59
|
-
|
|
60
|
-
organisation's own. An org member with write skips triage; an outside collaborator
|
|
61
|
-
with write does not.
|
|
57
|
+
The gate is permission level, not organisation membership. The authorize job sets
|
|
58
|
+
is_outside_collaborator for read permission only. Anybody with write or better skips
|
|
59
|
+
triage, org member or not: an outside collaborator given write is a colleague.
|
|
62
60
|
|
|
63
61
|
Router-only worker: triggered exclusively via workflow_call from work-router.yml.
|
|
64
62
|
Contract inputs: issue-number, mode(first|retriage).
|
|
@@ -11,8 +11,9 @@ name: "Agentics: Evidence"
|
|
|
11
11
|
# Two jobs, because one runs the pull request's code and the other writes. `capture` checks out
|
|
12
12
|
# the head, starts the app and drives Chromium with a read-only token and no secret. `publish`
|
|
13
13
|
# checks nothing out: it takes the images as an artifact, commits them into an `evidence/` folder
|
|
14
|
-
# beside each plan on the default branch and writes one comment. Both use GITHUB_TOKEN,
|
|
15
|
-
#
|
|
14
|
+
# beside each plan on the default branch and writes one comment. Both use GITHUB_TOKEN, and nothing
|
|
15
|
+
# either job writes raises another workflow event. The one thing a repository may add is its own
|
|
16
|
+
# .github/evidence/sign-in.yml, for plans that start signed in rather than signing in themselves.
|
|
16
17
|
#
|
|
17
18
|
# This replaced agent-visual-verify, which never produced a screenshot: it ran only on pull
|
|
18
19
|
# requests nobody would review, raced the merge it was dispatched before, re-planned with a model
|
|
@@ -287,11 +287,6 @@ jobs:
|
|
|
287
287
|
GH_TOKEN: ${{ github.token }}
|
|
288
288
|
ACTOR: ${{ github.actor }}
|
|
289
289
|
EVENT: ${{ github.event_name }}
|
|
290
|
-
# Who GitHub says this person is relative to the repository, straight from the
|
|
291
|
-
# payload. OWNER and MEMBER are the organisation's own; COLLABORATOR is repository
|
|
292
|
-
# access without organisation membership, which is what triage is for. Read from
|
|
293
|
-
# here because a repository-scoped GITHUB_TOKEN cannot query org membership.
|
|
294
|
-
AUTHOR_ASSOCIATION: ${{ github.event.issue.author_association || github.event.comment.author_association || github.event.pull_request.author_association }}
|
|
295
290
|
run: |
|
|
296
291
|
set -euo pipefail
|
|
297
292
|
|
|
@@ -320,48 +315,14 @@ jobs:
|
|
|
320
315
|
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" \
|
|
321
316
|
--jq '.permission' 2>/dev/null || echo "none")
|
|
322
317
|
|
|
323
|
-
# Is this person one of the organisation's own, or somebody with repository access
|
|
324
|
-
# from outside it? Triage exists for the second kind.
|
|
325
|
-
#
|
|
326
|
-
# The payload answers it. The org API cannot: `orgs/{org}/members/{user}` needs an
|
|
327
|
-
# org-scoped token and GITHUB_TOKEN is scoped to this repository, so that call failed
|
|
328
|
-
# for everybody and every admin who opened an issue was declared an outside
|
|
329
|
-
# collaborator and triaged. It is kept only as a fallback for events that carry no
|
|
330
|
-
# association, and it can no longer decide "outside" on its own.
|
|
331
|
-
org=$(echo "$GITHUB_REPOSITORY" | cut -d/ -f1)
|
|
332
|
-
case "${AUTHOR_ASSOCIATION:-}" in
|
|
333
|
-
OWNER | MEMBER)
|
|
334
|
-
is_member=true
|
|
335
|
-
;;
|
|
336
|
-
COLLABORATOR)
|
|
337
|
-
is_member=false
|
|
338
|
-
;;
|
|
339
|
-
*)
|
|
340
|
-
# No association in this payload. Ask the org, and believe a yes rather than a
|
|
341
|
-
# no: a failed call here is indistinguishable from a real non-member.
|
|
342
|
-
if gh api "orgs/$org/members/$ACTOR" >/dev/null 2>&1; then
|
|
343
|
-
is_member=true
|
|
344
|
-
else
|
|
345
|
-
is_member=unknown
|
|
346
|
-
fi
|
|
347
|
-
;;
|
|
348
|
-
esac
|
|
349
|
-
|
|
350
318
|
case "$permission" in
|
|
351
319
|
admin | maintain | write)
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
#
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
|
|
359
|
-
echo "::notice::$ACTOR has $permission permission and is an org member."
|
|
360
|
-
else
|
|
361
|
-
echo "trusted=true" >> "$GITHUB_OUTPUT"
|
|
362
|
-
echo "is_outside_collaborator=true" >> "$GITHUB_OUTPUT"
|
|
363
|
-
echo "::notice::$ACTOR has $permission permission but is NOT an org member (outside collaborator). Triage route eligible."
|
|
364
|
-
fi
|
|
320
|
+
# Write or better is a colleague, org member or not: an outside collaborator who
|
|
321
|
+
# was given write is somebody the team chose to work with, and triaging what they
|
|
322
|
+
# open starts an agent nobody asked for. Only read access is the outside door.
|
|
323
|
+
echo "trusted=true" >> "$GITHUB_OUTPUT"
|
|
324
|
+
echo "is_outside_collaborator=false" >> "$GITHUB_OUTPUT"
|
|
325
|
+
echo "::notice::$ACTOR has $permission permission."
|
|
365
326
|
;;
|
|
366
327
|
read)
|
|
367
328
|
echo "trusted=false" >> "$GITHUB_OUTPUT"
|
|
@@ -553,7 +514,7 @@ jobs:
|
|
|
553
514
|
|
|
554
515
|
call-triage:
|
|
555
516
|
needs: [classify, authorize]
|
|
556
|
-
# Triage on issue open is for outside collaborators only (handled by dispatch-triage).
|
|
517
|
+
# Triage on issue open is for read-only outside collaborators only (handled by dispatch-triage).
|
|
557
518
|
# A trusted actor who opens an issue should not start triage - they can add refine/implement/direct
|
|
558
519
|
# labels or manually dispatch triage. All other triage routes (labeled, comment, dispatch) are trusted.
|
|
559
520
|
if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.trusted == 'true' && !(github.event_name == 'issues' && github.event.action == 'opened')
|