Skip to content

Commit d637ca3

Browse files
committed
release: v0.3.0
Private-Snapshot-RevId: 278b538926b1c51d7e718a91867a6a5545412d4d
1 parent 3305dfa commit d637ca3

33 files changed

Lines changed: 1696 additions & 198 deletions

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Check out all text files with LF on every platform. Tests compare bytes
2+
# from checked-out files (skill templates, snapshots); a CRLF working tree
3+
# (core.autocrlf on Windows) broke those comparisons.
4+
* text=auto eol=lf
5+
6+
*.png binary

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ agree on the approach before you invest time — see CONTRIBUTING.md.
1212

1313
## Related issue
1414

15-
<!-- e.g. "Fixes #123" or "Refs #123". Link an issue for non-trivial changes. -->
15+
<!-- e.g. "Closes #123". Required for features and behavior changes — open an
16+
issue first and get it assigned (comment `/assign` there); see
17+
CONTRIBUTING.md → Contribution model. Docs and small fixes don't need one. -->
1618

1719
## Type of change
1820

.github/workflows/ci-nudge.yml

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# "Fix this and we merge" nudge (InsForge `agent-zhang-beihai` pattern, part 3).
2+
# When a PR's CI finishes red, posts ONE sticky comment listing exactly which
3+
# jobs failed and the local one-liner that reproduces/fixes each (the automated
4+
# version of the hand-written "run `npm run format` and you're green" review
5+
# comments). The comment flips to a green confirmation once all checks pass.
6+
#
7+
# Runs in base-repo context via workflow_run — no PR code is checked out, so
8+
# fork PRs are safe. State is recomputed from check-runs each time, so the two
9+
# CI workflows (CI + Test Coverage) can complete in any order.
10+
#
11+
# Bot identity: same App-token-first / GITHUB_TOKEN-fallback pattern as
12+
# pr-triage.yml (the App needs the "Pull requests: Read & write" permission to
13+
# post as testsprite-hob[bot]; until then comments come from github-actions[bot]).
14+
name: CI failure nudge
15+
16+
on:
17+
workflow_run:
18+
workflows: ['CI', 'Test Coverage']
19+
types: [completed]
20+
21+
permissions:
22+
checks: read # read the head SHA's check-run state
23+
pull-requests: write
24+
issues: write # PR comments ride the issues API
25+
26+
env:
27+
MARKER: '<!-- hob:ci-nudge -->'
28+
29+
jobs:
30+
nudge:
31+
# Public repo only; PR-triggered runs only (pushes to main have no PR to nudge).
32+
if: >-
33+
github.repository == 'TestSprite/testsprite-cli' &&
34+
github.event.workflow_run.event == 'pull_request'
35+
runs-on: ubuntu-latest
36+
steps:
37+
- id: app-token
38+
continue-on-error: true
39+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
40+
with:
41+
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
42+
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
43+
44+
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
45+
env:
46+
APP_TOKEN: ${{ steps.app-token.outputs.token }}
47+
with:
48+
script: |
49+
const { owner, repo } = context.repo;
50+
const run = context.payload.workflow_run;
51+
const marker = process.env.MARKER;
52+
53+
const appClient = process.env.APP_TOKEN
54+
? require('@actions/github').getOctokit(process.env.APP_TOKEN)
55+
: null;
56+
async function write(fn) {
57+
if (appClient) {
58+
try { return await fn(appClient.rest); }
59+
catch (e) { if (e.status !== 403 && e.status !== 404) throw e; }
60+
}
61+
return await fn(github.rest);
62+
}
63+
64+
// Resolve the PR for this run. `workflow_run.pull_requests` is empty
65+
// for fork PRs, so fall back to the commit→PRs lookup.
66+
let pr = (run.pull_requests || [])[0];
67+
if (!pr) {
68+
const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit(
69+
{ owner, repo, commit_sha: run.head_sha });
70+
pr = data.find(p => p.state === 'open');
71+
} else {
72+
pr = (await github.rest.pulls.get({ owner, repo, pull_number: pr.number })).data;
73+
}
74+
if (!pr || pr.state !== 'open' || pr.user.type === 'Bot') return;
75+
76+
// Recompute full CI state from this SHA's github-actions check runs.
77+
const checks = await github.paginate(github.rest.checks.listForRef,
78+
{ owner, repo, ref: run.head_sha, per_page: 100 });
79+
const ours = checks.filter(c => c.app && c.app.slug === 'github-actions');
80+
const failing = ours.filter(c => ['failure', 'timed_out'].includes(c.conclusion));
81+
const pending = ours.filter(c => c.status !== 'completed');
82+
83+
const comments = await github.paginate(github.rest.issues.listComments,
84+
{ owner, repo, issue_number: pr.number, per_page: 100 });
85+
const sticky = comments.find(c => (c.body || '').includes(marker));
86+
87+
// Job name → the local command that reproduces/fixes it.
88+
const FIX = {
89+
'Lint & Format': 'run `npm run lint:fix && npm run format`, then commit',
90+
'Typecheck': 'run `npm run typecheck` and fix the reported type errors',
91+
'Unit Tests': 'run `npm test` and fix the failing tests',
92+
'Build': 'run `npm run build` and fix the compile errors',
93+
'Local E2E Tests': 'run `npm run test:e2e` (it builds first)',
94+
'Coverage (>= 80%)': 'run `npm run test:coverage` — new code needs tests until every metric is back at 80%',
95+
};
96+
97+
if (failing.length === 0) {
98+
// Only speak up on success if we previously flagged a failure, and
99+
// only once everything has actually finished.
100+
if (sticky && pending.length === 0 && !sticky.body.includes('all green')) {
101+
await write(rest => rest.issues.updateComment({ owner, repo, comment_id: sticky.id,
102+
body: `${marker}\n✅ CI is **all green** now — thanks, @${pr.user.login}!` }));
103+
}
104+
return;
105+
}
106+
107+
const lines = failing
108+
.sort((a, b) => a.name.localeCompare(b.name))
109+
.map(c => {
110+
const fix = FIX[c.name] || 'see the logs for details';
111+
return `- **${c.name}** — ${fix} ([logs](${c.html_url}))`;
112+
});
113+
const body = `${marker}\nThanks, @${pr.user.login}! CI is red on this PR — `
114+
+ `here's what failed and how to reproduce it locally:\n\n${lines.join('\n')}\n\n`
115+
+ `Everything runs on Node 22 after \`npm ci\`. Push a fix and this comment `
116+
+ `flips green automatically once all checks pass.`;
117+
118+
if (sticky) {
119+
if (sticky.body !== body) {
120+
await write(rest => rest.issues.updateComment(
121+
{ owner, repo, comment_id: sticky.id, body }));
122+
}
123+
} else {
124+
await write(rest => rest.issues.createComment(
125+
{ owner, repo, issue_number: pr.number, body }));
126+
}

.github/workflows/issue-triage.yml

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# P1-2 issue auto-assign + 3-slot-cap bot (InsForge `agent-zhang-beihai` pattern).
2-
# Posts as the `alfheim-agent` GitHub App ⇒ `alfheim-agent[bot]`.
2+
# Posts as the `testsprite-hob` GitHub App ⇒ `testsprite-hob[bot]`.
33
#
44
# Setup (one-time, by an org admin):
5-
# 1. Create a GitHub App named `alfheim-agent` (org Settings → Developer settings →
5+
# 1. Create a GitHub App named `testsprite-hob` (org Settings → Developer settings →
66
# GitHub Apps → New). Permissions: Issues = Read & write, Metadata = Read. No webhook.
77
# Generate a private key; install the App on the public testsprite-cli repo.
88
# 2. Add two repo (or org) secrets:
9-
# ALFHEIM_AGENT_APP_ID = the App's numeric App ID
10-
# ALFHEIM_AGENT_PRIVATE_KEY = the App's .pem private key (full contents)
9+
# TESTSPRITE_HOB_APP_ID = the App's numeric App ID
10+
# TESTSPRITE_HOB_PRIVATE_KEY = the App's .pem private key (full contents)
11+
# (The ALFHEIM_AGENT_* fallbacks are this App's original secret names from
12+
# before it was renamed — same App ID + key; either naming works.)
1113
# Until those exist, the bot gracefully falls back to github-actions[bot] (still works).
1214
#
1315
# Fires on each new issue comment; assigns the commenter when they claim an issue
@@ -30,23 +32,23 @@ env:
3032
jobs:
3133
triage:
3234
# issues only (issue_comment also fires on PRs), and never react to a bot's own
33-
# comment — incl. our own alfheim-agent[bot], which (unlike GITHUB_TOKEN) would
35+
# comment — incl. our own testsprite-hob[bot], which (unlike GITHUB_TOKEN) would
3436
# otherwise re-trigger this workflow. `type == 'Bot'` covers both bot identities.
3537
if: ${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' }}
3638
runs-on: ubuntu-latest
3739
steps:
38-
# Mint a token for the alfheim-agent App so comments post as alfheim-agent[bot].
40+
# Mint a token for the testsprite-hob App so comments post as testsprite-hob[bot].
3941
# continue-on-error: until the App + secrets exist this no-ops and we fall back below.
4042
- id: app-token
4143
continue-on-error: true
4244
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
4345
with:
44-
app-id: ${{ secrets.ALFHEIM_AGENT_APP_ID }}
45-
private-key: ${{ secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
46+
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
47+
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
4648

4749
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
4850
with:
49-
# App token when available (→ alfheim-agent[bot]); else the default
51+
# App token when available (→ testsprite-hob[bot]); else the default
5052
# GITHUB_TOKEN (→ github-actions[bot]). Either way the logic is identical.
5153
github-token: ${{ steps.app-token.outputs.token || github.token }}
5254
script: |

.github/workflows/pr-triage.yml

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# PR-side twin of issue-triage.yml (InsForge `agent-zhang-beihai` pattern, part 2).
2+
# Enforces the issue-first workflow on community PRs: every non-docs PR must
3+
# carry a closing link ("Closes #123") to an issue that is ASSIGNED to the PR
4+
# author (claimed via `/assign`, which issue-triage.yml handles). Violations
5+
# get a `needs-issue` label + a sticky comment, and — for PRs opened after
6+
# GATE_SINCE — a failing check, so unclaimed work is visibly not review-ready.
7+
# PRs opened before GATE_SINCE are grandfathered: nudge only, never a red check.
8+
#
9+
# Assignment happens on the ISSUE, so it cannot re-trigger this PR workflow;
10+
# the comment tells the contributor to edit the PR description or push a
11+
# commit (`edited` / `synchronize`) to re-run the gate.
12+
#
13+
# Runs with NO checkout — metadata-only, so it is safe under pull_request_target
14+
# (which is required for fork PRs to get a write-capable token).
15+
#
16+
# Bot identity: posts as `testsprite-hob[bot]` when the App token works. The
17+
# App currently has Issues R/W + Metadata only — commenting/labeling a PULL
18+
# REQUEST needs the "Pull requests: Read & write" App permission (issues-API
19+
# endpoints are permission-checked by target type). Until an org admin adds
20+
# that permission and re-approves the installation, every call gracefully falls
21+
# back to the default GITHUB_TOKEN and posts as github-actions[bot].
22+
# Secrets: TESTSPRITE_HOB_* preferred; the ALFHEIM_AGENT_* fallbacks are the
23+
# original names from before the App was renamed (same App ID + key).
24+
name: PR triage (issue-link gate)
25+
26+
on:
27+
pull_request_target:
28+
types: [opened, edited, reopened, synchronize]
29+
30+
permissions:
31+
pull-requests: write # add/remove the needs-issue label
32+
issues: write # create/update the nudge comment (PR comments ride the issues API)
33+
34+
env:
35+
LABEL: 'needs-issue'
36+
MARKER: '<!-- hob:needs-issue -->'
37+
# PRs created before this instant are grandfathered (nudge, no failing check).
38+
GATE_SINCE: '2026-07-04T00:00:00Z'
39+
40+
jobs:
41+
gate:
42+
# Public repo only — this file also lives in the private mirror, where PRs
43+
# are internal work that never links public issues. Skip bot authors
44+
# (dependabot etc.), maintainers, and docs-only changes (CONTRIBUTING
45+
# exempts docs/small fixes from the issue-first ask).
46+
if: >-
47+
github.repository == 'TestSprite/testsprite-cli' &&
48+
github.event.pull_request.state == 'open' &&
49+
github.event.pull_request.user.type != 'Bot' &&
50+
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) &&
51+
!startsWith(github.event.pull_request.title, 'docs')
52+
runs-on: ubuntu-latest
53+
steps:
54+
# Mint an App token so actions post as testsprite-hob[bot]. Until the App +
55+
# secrets + Pull-requests permission exist this no-ops / gets 403 and the
56+
# script below falls back to the default token per-call.
57+
- id: app-token
58+
continue-on-error: true
59+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
60+
with:
61+
app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }}
62+
private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }}
63+
64+
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1
65+
env:
66+
APP_TOKEN: ${{ steps.app-token.outputs.token }}
67+
with:
68+
# `github` client = default GITHUB_TOKEN: used for all reads (the App
69+
# can't read PRs without the Pull-requests permission) and as the
70+
# write fallback. App client (when mintable) is preferred for writes.
71+
script: |
72+
const { owner, repo } = context.repo;
73+
const pr = context.payload.pull_request;
74+
const label = process.env.LABEL;
75+
const marker = process.env.MARKER;
76+
const author = pr.user.login;
77+
78+
const appClient = process.env.APP_TOKEN
79+
? require('@actions/github').getOctokit(process.env.APP_TOKEN)
80+
: null;
81+
// Prefer the App identity; fall back to github-actions[bot] when the
82+
// App is absent or lacks the Pull-requests permission (403/404).
83+
async function write(fn) {
84+
if (appClient) {
85+
try { return await fn(appClient.rest); }
86+
catch (e) { if (e.status !== 403 && e.status !== 404) throw e; }
87+
}
88+
return await fn(github.rest);
89+
}
90+
91+
// Linked issues: GitHub-computed closing references carry number +
92+
// assignees in one query.
93+
const gql = await github.graphql(
94+
`query($owner:String!,$repo:String!,$num:Int!){
95+
repository(owner:$owner,name:$repo){
96+
pullRequest(number:$num){
97+
closingIssuesReferences(first:10){
98+
nodes{ number assignees(first:10){ nodes{ login } } }
99+
}
100+
}
101+
}
102+
}`, { owner, repo, num: pr.number });
103+
const linkedIssues = gql.repository.pullRequest.closingIssuesReferences.nodes
104+
.map(n => ({ number: n.number, assignees: n.assignees.nodes.map(a => a.login) }));
105+
106+
// Body-text fallback for the brief window before GitHub computes the
107+
// link (same-repo bare "#N" references only).
108+
const bodyRefs = [...(pr.body || '')
109+
.matchAll(/(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)/gi)]
110+
.map(m => Number(m[3]))
111+
.filter(n => !linkedIssues.some(i => i.number === n))
112+
.slice(0, 5);
113+
for (const num of bodyRefs) {
114+
try {
115+
const { data } = await github.rest.issues.get({ owner, repo, issue_number: num });
116+
if (data.pull_request) continue; // "#N" pointed at a PR, not an issue
117+
linkedIssues.push({ number: num, assignees: (data.assignees || []).map(a => a.login) });
118+
} catch (e) { /* unknown number — ignore */ }
119+
}
120+
121+
const linked = linkedIssues.length > 0;
122+
const assignedToAuthor = linkedIssues.some(i => i.assignees.includes(author));
123+
const state = assignedToAuthor ? 'ok' : (linked ? 'unassigned' : 'unlinked');
124+
125+
const hasLabel = (pr.labels || []).some(l => l.name === label);
126+
const comments = await github.paginate(github.rest.issues.listComments,
127+
{ owner, repo, issue_number: pr.number, per_page: 100 });
128+
const nudge = comments.find(c => (c.body || '').includes(marker));
129+
const stateLine = `<!-- hob:state:${state} -->`;
130+
131+
const contributingUrl =
132+
`https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#contribution-model`;
133+
const rerunHint = 'After fixing it, edit the PR description or push a commit to re-run this check.';
134+
135+
let body;
136+
if (state === 'ok') {
137+
body = `${marker}\n${stateLine}\n`
138+
+ `✅ This PR is linked to an issue assigned to @${author} — thanks! `
139+
+ `The \`${label}\` label has been removed.`;
140+
} else if (state === 'unassigned') {
141+
const list = linkedIssues.map(i => {
142+
const holders = i.assignees.filter(a => a !== author);
143+
return `#${i.number}` + (holders.length ? ` (currently assigned to @${holders.join(', @')})` : ' (unassigned)');
144+
}).join(', ');
145+
body = `${marker}\n${stateLine}\n`
146+
+ `Thanks for the PR, @${author}! It links an issue, but that issue isn't assigned to you yet: ${list}. `
147+
+ `Per our workflow, **claim the issue first by commenting \`/assign\` on it** (the triage bot assigns you automatically). `
148+
+ `If it's already assigned to someone else, please coordinate with them or pick another issue — `
149+
+ `unclaimed-issue PRs are not reviewed. ${rerunHint} `
150+
+ `See [CONTRIBUTING → Contribution model](${contributingUrl}).`;
151+
} else {
152+
body = `${marker}\n${stateLine}\n`
153+
+ `Thanks for the PR, @${author}! A quick note on our workflow: for **features and behavior changes** `
154+
+ `we require contributors to **open an issue first, claim it by commenting \`/assign\` on the issue, `
155+
+ `then submit a PR that links it** (e.g. \`Closes #123\`). This PR isn't linked to any issue yet, `
156+
+ `so it is not review-ready. ${rerunHint} `
157+
+ `See [CONTRIBUTING → Contribution model](${contributingUrl}).`;
158+
}
159+
160+
// Sticky comment: create once, update when the state changes.
161+
if (!nudge) {
162+
if (state !== 'ok') {
163+
await write(rest => rest.issues.createComment(
164+
{ owner, repo, issue_number: pr.number, body }));
165+
}
166+
} else if (!nudge.body.includes(stateLine)) {
167+
await write(rest => rest.issues.updateComment(
168+
{ owner, repo, comment_id: nudge.id, body }));
169+
}
170+
171+
// Label tracks the gate state.
172+
if (state === 'ok' && hasLabel) {
173+
await write(rest => rest.issues.removeLabel(
174+
{ owner, repo, issue_number: pr.number, name: label })).catch(() => {});
175+
}
176+
if (state !== 'ok' && !hasLabel) {
177+
await write(rest => rest.issues.addLabels(
178+
{ owner, repo, issue_number: pr.number, labels: [label] }));
179+
}
180+
181+
// Hard gate for PRs opened after the cutoff; older PRs are nudged only.
182+
const gated = new Date(pr.created_at) >= new Date(process.env.GATE_SINCE);
183+
if (state !== 'ok' && gated) {
184+
core.setFailed(state === 'unlinked'
185+
? 'No closing-linked issue. Open/claim an issue, add "Closes #<n>" to the PR description, then re-run.'
186+
: 'Linked issue is not assigned to the PR author. Comment /assign on the issue, then re-run.');
187+
} else if (state !== 'ok') {
188+
core.notice(`Grandfathered PR (opened before ${process.env.GATE_SINCE}): gate not enforced, nudge only.`);
189+
}

0 commit comments

Comments
 (0)