chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
||||
../skills
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"emdash": minor
|
||||
"@emdash-cms/admin": minor
|
||||
"@emdash-cms/auth": patch
|
||||
---
|
||||
|
||||
Adds a Backups page to admin settings: download a complete content backup (all content including drafts and trash, schema, taxonomies, menus, widgets, media metadata, and site settings — never user accounts or secrets) with one click, and optionally enable daily automatic backups to the site's storage bucket with configurable retention. A new `backups:manage` permission gates the feature to admins.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"emdash": patch
|
||||
---
|
||||
|
||||
The admin shell now falls back to the Site Icon configured in Settings → General for its favicon, so the EmDash backend is branded like the public site. An explicit build-time `admin.favicon` still takes precedence, and the default EmDash mark is used when neither is set.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@emdash-cms/admin": patch
|
||||
---
|
||||
|
||||
Centers the empty-state placeholder labels in Image, File, and media picker fields.
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
|
||||
"changelog": [
|
||||
"@changesets/changelog-github",
|
||||
{
|
||||
"repo": "emdash-cms/emdash"
|
||||
}
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
[
|
||||
"emdash",
|
||||
"@emdash-cms/admin",
|
||||
"@emdash-cms/auth",
|
||||
"@emdash-cms/blocks",
|
||||
"@emdash-cms/cloudflare",
|
||||
"@emdash-cms/gutenberg-to-portable-text",
|
||||
"@emdash-cms/x402",
|
||||
"create-emdash"
|
||||
]
|
||||
],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
},
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "minor",
|
||||
"bumpVersionsWithWorkspaceProtocolOnly": true,
|
||||
"ignore": [
|
||||
"@emdash-cms/aggregator",
|
||||
"@emdash-cms/blocks-playground",
|
||||
"@emdash-cms/demo-cloudflare",
|
||||
"@emdash-cms/demo-postgres",
|
||||
"@emdash-cms/demo-preview",
|
||||
"@emdash-cms/marketplace",
|
||||
"@emdash-cms/playground",
|
||||
"@emdash-cms/plugin-api-test",
|
||||
"@emdash-cms/plugin-marketplace-test",
|
||||
"@emdash-cms/plugin-sandboxed-test",
|
||||
"@emdash-cms/template-blank",
|
||||
"@emdash-cms/template-blog",
|
||||
"@emdash-cms/template-blog-cloudflare",
|
||||
"@emdash-cms/template-marketing",
|
||||
"@emdash-cms/template-marketing-cloudflare",
|
||||
"@emdash-cms/template-portfolio",
|
||||
"@emdash-cms/template-portfolio-cloudflare",
|
||||
"@emdash-cms/template-starter",
|
||||
"@emdash-cms/template-starter-cloudflare",
|
||||
"docs",
|
||||
"emdash-demo",
|
||||
"emdash-e2e-fixture",
|
||||
"emdash-e2e-fixture-cloudflare",
|
||||
"emdash-plugins-demo"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@emdash-cms/admin": minor
|
||||
---
|
||||
|
||||
Redesigns the content editor layout. The editor now fills the viewport with a distraction-lighter writing column, and all publish actions live in a structural settings panel on the end side: a single Save control transitions between Save, Saving, and Saved alongside Live View, Preview, and publish controls in an action bar pinned above the panel's sections. Publish-state badges now live in the Publish section with slug, scheduling, ownership, bylines, translations, taxonomies, SEO, outline, and revisions, with Move to Trash isolated at the bottom. Below the `lg` breakpoint the panel becomes a slide-in sheet behind a Settings button, while Save, preview/live-view access, and publish controls stay visible in the editor header. The layout mirrors correctly in RTL locales.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"emdash": patch
|
||||
---
|
||||
|
||||
Fixes unsaved inline (visual) editor changes being silently lost when navigating away, e.g. via the browser back button. Edits are now flushed with a keepalive request when the page unloads.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@emdash-cms/auth": minor
|
||||
"emdash": minor
|
||||
"@emdash-cms/admin": minor
|
||||
---
|
||||
|
||||
Invited users can now accept their invite by signing in with Google or GitHub, instead of only creating a passkey.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"emdash": patch
|
||||
---
|
||||
|
||||
Fixes taxonomy-filtered collection listings reading the entire collection on SQLite/D1 when the term is selective. Such listings now seek the matching entries directly, cutting D1 rows-read from tens of thousands to the page size.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AGENTS.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../skills
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.github
|
||||
.changeset
|
||||
.vscode
|
||||
slidev
|
||||
@@ -0,0 +1,5 @@
|
||||
.build/
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Investigate bot
|
||||
|
||||
Experimental Flue-powered investigation bot for `emdash-cms/emdash` issues. Runs as a GitHub Actions workflow when a maintainer applies the `bot:repro` label. Not deployed as a Cloudflare Worker.
|
||||
|
||||
For the design rationale, see the [PR description](https://github.com/emdash-cms/emdash/pull/1090). Astro's analogous setup (`.flue/agents/issue-triage.ts` in `withastro/astro`) is the closest reference.
|
||||
|
||||
## What it does
|
||||
|
||||
When a maintainer adds `bot:repro` to an issue:
|
||||
|
||||
1. **Classify** — kimi-k2.7-code decides issue kind/area/whether a browser is needed.
|
||||
2. **Reproduce** — opus runs in a `local()` sandbox on the GH Actions runner. Picks one of three sub-skills:
|
||||
- `repro-api` — `pnpm test`, CLI commands, direct API hits, no browser
|
||||
- `repro-admin` — `agent-browser` against `pnpm dev` with the dev-bypass auth shortcut
|
||||
- `repro-public` — `agent-browser` against the rendered public site
|
||||
3. **Diagnose** — read the source paths that explain the symptom, rate confidence in the root cause, choose a fix approach (`mechanical` / `clear-best-option` / `needs-design-decision`), and write a concrete proposed fix.
|
||||
4. **Verify** — decide whether the behaviour is a bug or intended-by-design. Gates the fix stage.
|
||||
5. **Fix** — conditional on `verdict=bug`, `confidence!=low`, and `fixApproach!=needs-design-decision`. Runs on a cheaper coding model (kimi-k2.7-code) in its own session — diagnose already produced the plan, so this stage is guided implementation. Writes the change, runs the reproduce test, the broader package tests, typecheck, lint, format. Stages but does not commit.
|
||||
|
||||
The orchestrator (`.github/workflows/investigate.yml`) reads the structured JSON output and performs all GitHub writes — labels, comments, branch pushes, PR creation. The agent itself has no write access to GitHub.
|
||||
|
||||
## Trigger and label state
|
||||
|
||||
| Label | Set by | Meaning |
|
||||
| -------------------------- | ---------- | ------------------------------------------------------------ |
|
||||
| `bot:repro` | Maintainer | Investigation requested |
|
||||
| `triage/reproducing` | Bot | Investigation in progress |
|
||||
| `triage/reproduced` | Bot | Confirmed bug; needs a maintainer (no fix, or fix abandoned) |
|
||||
| `triage/by-design` | Bot | Reproduced, but the behaviour appears intentional |
|
||||
| `triage/awaiting-reporter` | Bot | Fix pushed; reporter asked to verify |
|
||||
| `triage/verified` | Bot | Reporter confirmed; PR opened |
|
||||
| `triage/not-reproduced` | Bot | Could not observe the reported behaviour |
|
||||
| `triage/skipped` | Bot | Declined (non-bug, requires external data, etc.) |
|
||||
| `triage/failed` | Bot | Gave up after retries |
|
||||
|
||||
The bot owns every label except `bot:repro`. Maintainers don't manage state directly — they trigger by adding `bot:repro` and re-trigger by removing/re-adding it.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
.flue/
|
||||
├── lib/
|
||||
│ └── classifier.ts # Shared kimi classifier + reply-classifier schemas
|
||||
├── skills/
|
||||
│ ├── _INVESTIGATE.md # Reference doc; not imported as a Flue skill
|
||||
│ ├── diagnose/SKILL.md
|
||||
│ ├── fix/SKILL.md
|
||||
│ ├── repro-admin/SKILL.md
|
||||
│ ├── repro-api/SKILL.md
|
||||
│ ├── repro-public/SKILL.md
|
||||
│ └── verify/SKILL.md
|
||||
├── workflows/
|
||||
│ ├── investigate.ts # 4-stage pipeline
|
||||
│ ├── classify-reply.ts # Reporter-reply classifier
|
||||
│ └── classify-maintainer-reply.ts # Maintainer-directive classifier
|
||||
├── scripts/
|
||||
│ └── run-local.ts # Local prototype runner
|
||||
├── fixtures/ # 5 real issues for local iteration
|
||||
└── package.json # Flue 0.8
|
||||
|
||||
.github/workflows/
|
||||
├── investigate.yml # bot:repro → investigate workflow
|
||||
├── reporter-reply.yml # Reporter comments on a bot-awaited issue
|
||||
├── maintainer-reply.yml # Maintainer @emdashbot directive on a triage issue
|
||||
└── bot-cleanup.yml # Branch cleanup on issue close + daily cron
|
||||
```
|
||||
|
||||
## Token model
|
||||
|
||||
Two distinct tokens per investigation, mirroring `withastro/astro`'s split:
|
||||
|
||||
- **Sandbox token** (`AGENT_GH_TOKEN`): default `secrets.GITHUB_TOKEN`, scoped to `contents: read, issues: read` via the job's `permissions:`. The only token in `local({ env })`. The agent's bash can clone the repo and run `gh issue view`; it cannot comment, label, or push.
|
||||
- **Orchestrator token**: a GitHub App installation token minted by `actions/create-github-app-token`, scoped to `issues: write, contents: write, pull-requests: write` on this repo only. Lives in the workflow YAML and is used for all writes. Never crosses into the sandbox env.
|
||||
|
||||
A complete jailbreak of the agent's bash cannot escalate to comment, label, branch-push, or PR-create writes — those require the orchestrator token, which the sandbox never sees.
|
||||
|
||||
## Local prototyping
|
||||
|
||||
The `prototype` script invokes the real Flue workflow against a fixture issue and dumps the structured result. No GitHub writes — the orchestrator that does writes lives in the YAML.
|
||||
|
||||
```bash
|
||||
cd .flue
|
||||
pnpm install
|
||||
|
||||
# Cloudflare AI Gateway creds (same secrets bonk.yml and review.yml use)
|
||||
export CLOUDFLARE_ACCOUNT_ID=...
|
||||
export CLOUDFLARE_GATEWAY_ID=...
|
||||
export CLOUDFLARE_API_KEY=...
|
||||
|
||||
# GitHub read-only token for the sandbox's `gh issue view`
|
||||
export AGENT_GH_TOKEN=... # or GITHUB_TOKEN / GH_TOKEN — the script picks any
|
||||
|
||||
# Run against a saved fixture (under .flue/fixtures/)
|
||||
pnpm prototype 1021
|
||||
|
||||
# Or against a live issue
|
||||
pnpm prototype --live 1183
|
||||
|
||||
# Try a different model
|
||||
FLUE_INVESTIGATE_MODEL=cloudflare-ai-gateway/claude-sonnet-4-6 pnpm prototype 1021
|
||||
```
|
||||
|
||||
The fixtures directory holds five real issues from the queue (#1021, #1042, #1046, #1049, #1080) so prompt iteration can happen without burning live `gh` API quota.
|
||||
|
||||
## One-time setup (when this lands on `main`)
|
||||
|
||||
1. **GitHub App.** The bot uses an existing App (the same one `bonk.yml`, `review.yml`, `release.yml`, `auto-format.yml` use). The `APP_ID` and `APP_PRIVATE_KEY` repository secrets already exist. The App's installation must include the `issues: write`, `contents: write`, and `pull_requests: write` permissions on `emdash-cms/emdash`.
|
||||
2. **Labels.** `investigate.yml`'s first step does `gh label create --force` for each of the eight `bot:*` labels. No manual setup needed; the labels appear after the first run.
|
||||
3. **GitHub Project board (optional).** Create a project in the UI with one column per `bot:*` label and a saved query like `repo:emdash-cms/emdash label:triage/reproducing` per column. The bot moves labels; cards follow automatically. Not required for the bot to function.
|
||||
|
||||
## What this PR does not do
|
||||
|
||||
- No Cloudflare Worker is deployed.
|
||||
- No `app.ts`, no `wrangler.jsonc`.
|
||||
- No `/repro` or `/verify` slash commands. Triggers are labels and comment replies only.
|
||||
- No auto-fire on every new issue. The bot only runs when a maintainer explicitly requests it.
|
||||
- No auto-merging or auto-PR-opening without reporter verification.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"author": { "id": "MDQ6VXNlcjE5NDg0NTc=", "is_bot": false, "login": "sinum", "name": "" },
|
||||
"body": "### Description\n\nMigration 036_i18n_menus_and_taxonomies calls PRAGMA foreign_keys = OFF before executing DROP TABLE taxonomies as part of rebuildTaxonomies. On Cloudflare D1, this pragma is silently ignored — D1 always enforces foreign keys (PRAGMA foreign_keys always returns 1 and cannot be changed).\n\nAs a result, DROP TABLE taxonomies triggers the ON DELETE CASCADE declared in content_taxonomies:\n\n-- From 001_initial migration:\n constraint \"content_taxonomies_taxonomy_fk\"\n foreign key (\"taxonomy_id\") references \"taxonomies\"(\"id\")\n on delete cascade\n\nAll rows in content_taxonomies are deleted before the table is even rebuilt. Since rebuildContentTaxonomies runs after rebuildTaxonomies, it finds an empty source table and produces an empty result. The data loss is silent — the migration reports success.\n\n **Impact**\n\n - All post–taxonomy associations are destroyed\n - Category archive pages show no content\n - getEntryTerms() returns empty for all entries\n - Homepage links to articles use root category path (2 segments) instead of canonical leaf path, causing 404s\n\n**Workaround**\n\n Before deploying the upgrade:\n\n1. D1 Time Travel restore to pre-upgrade state\n2. Back up content_taxonomies: CREATE TABLE ct_backup AS SELECT * FROM content_taxonomies\n3. Clear it: DELETE FROM content_taxonomies\n4. Recreate without FK (dropping the child table does not trigger cascade): \nCREATE TABLE ct_new (collection text not null, entry_id text not null, taxonomy_id text not null, constraint content_taxonomies_pk primary key (collection, entry_id, taxonomy_id)); \nINSERT INTO ct_new SELECT * FROM content_taxonomies; \nDROP TABLE content_taxonomies; \nALTER TABLE ct_new RENAME TO content_taxonomies;\n5. Deploy — migration 036 detects no FK on content_taxonomies → skips rebuildContentTaxonomies → data preserved\n6. Restore data: INSERT INTO content_taxonomies SELECT * FROM ct_backup; DROP TABLE ct_backup\n\n**Suggested fix**\n\nIn rebuildTaxonomies, instead of DROP TABLE taxonomies, use the same table-rebuild pattern used elsewhere in migration 036: create a new table, copy data, drop old, rename. This avoids triggering any CASCADE on dependent tables, regardless of whether FK enforcement can be disabled.\n\nAlternatively, before dropping taxonomies, explicitly save and restore content_taxonomies data within the same migration, making the operation idempotent on D1.\n\n### Steps to reproduce\n\n1. Deploy EmDash on Cloudflare D1 with a populated content_taxonomies table (e.g. after a WXR import or manual taxonomy tagging)\n2. Upgrade to a version that includes migration 036 (≥ 0.10.0)\n3. Migration runs → content_taxonomies is now empty\n\n\n### Environment\n\n- Cloudflare D1 (SQLite), compatibility_date: 2025-01-01\n- PRAGMA foreign_keys always returns 1, cannot be set to 0\n- EmDash 0.12.0, migrating from 0.6.0\n\n### Logs / error output\n\n```shell\n\n```",
|
||||
"labels": [
|
||||
{
|
||||
"id": "LA_kwDOR2vLLM8AAAACdovkaQ",
|
||||
"name": "bug",
|
||||
"description": "Something isn't working",
|
||||
"color": "d73a4a"
|
||||
}
|
||||
],
|
||||
"number": 1021,
|
||||
"title": "Migration 036 (0.12.0) silently destroys content_taxonomies data on Cloudflare D1"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"author": {
|
||||
"id": "MDQ6VXNlcjI1ODY1OTE=",
|
||||
"is_bot": false,
|
||||
"login": "takustaqu",
|
||||
"name": "Harada \"Yayane\" Kiyohide"
|
||||
},
|
||||
"body": "### Description\n\nWhen the EmDash setup probe fails due to a transient D1 error (e.g., on cold start), **all requests** — including fully prerendered public pages like `/about` and `/privacy` — are redirected to `/_emdash/admin/setup`, regardless of the request path.\n\n- **What happened**: Navigating to a public page shows \"Redirecting from /about/ to /_emdash/admin/setup\" for ~2 seconds, then the browser is taken to the admin setup wizard.\n- **What I expected**: Public pages should render normally. The setup redirect should only occur for requests to `/_emdash/*` paths.\n\n\n### Steps to reproduce\n\n1. Deploy an Astro hybrid site (mix of prerendered + SSR pages) with EmDash to Cloudflare Workers.\n2. Configure a Cloudflare Zone Route (`example.com/*` → Worker) so all requests go through the Worker — a common production setup.\n3. Wait a few minutes for the Worker isolate to be evicted (triggering a cold start).\n4. Navigate to any public page (e.g., `/about`) in a browser.\n5. Observe: after ~2 seconds, the page redirects to `/_emdash/admin/setup`.\n\n**Quick verification via curl:**\n\n```bash\ncurl -sL https://example.com/about | head -5\n```\n\nReturns:\n\n```html\n<!doctype html>\n<title>Redirecting to: /_emdash/admin/setup</title>\n<meta http-equiv=\"refresh\" content=\"2;url=/_emdash/admin/setup\">\n```\n\n> **Note:** The response status is `200 OK`, not `302`. This means any middleware that inspects `response.status` to intercept setup redirects will not catch it.\n\n\n\n### Environment\n\n- emdash version: 0.12.0\n- Node.js version: 22.x\n- Runtime: Cloudflare Workers (`@astrojs/cloudflare` adapter)\n- Astro: 6.x (`output: \"hybrid\"`)\n- Database: Cloudflare D1 with Sessions API\n- OS: macOS (dev) / Cloudflare edge (production)\n\n\n### Logs / error output\n\n```shell\nFrom `wrangler tail` at the time of the incident:\n\n\nD1_ERROR: no such table: _emdash_migrations\n\n\n> **Note:** The `_emdash_migrations` table **does exist** in the database. This is a transient D1 Sessions API initialization error on cold start, not a genuine \"not set up\" state.\n\n**Problematic code** (`dist/astro/middleware.mjs`):\n\n\n} catch {\n // ❌ Redirects ALL paths to setup — including public pages\n return context.redirect(\"/_emdash/admin/setup\");\n}\n\n\n**Proposed fix:**\n\n\n-} catch {\n- return context.redirect(\"/_emdash/admin/setup\");\n+} catch (probeError) {\n+ const probeMsg = probeError instanceof Error ? probeError.message : String(probeError);\n+ const isAdminRequest = context.request.url.includes(\"/_emdash\");\n+ if (isAdminRequest) {\n+ const isNotSetup =\n+ probeMsg.includes(\"no such table\") || probeMsg.includes(\"does not exist\");\n+ if (isNotSetup) {\n+ return context.redirect(\"/_emdash/admin/setup\");\n+ }\n+ }\n+ // Public pages or transient errors: treat as verified and continue\n+ console.error(\"[emdash] Setup probe failed (non-fatal for public path):\", probeMsg);\n+ setupVerified = true;\n }\n\n\nThis ensures:\n- Admin paths (`/_emdash/*`) still correctly detect an uninitialized database and show the setup wizard.\n- Public pages are **never** redirected to the setup wizard, even under transient D1 failures.\n```",
|
||||
"labels": [
|
||||
{
|
||||
"id": "LA_kwDOR2vLLM8AAAACdovkaQ",
|
||||
"name": "bug",
|
||||
"description": "Something isn't working",
|
||||
"color": "d73a4a"
|
||||
}
|
||||
],
|
||||
"number": 1042,
|
||||
"title": "Setup probe redirects public pages to `/_emdash/admin/setup` on D1 transient errors / cold starts"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"author": { "id": "U_kgDOEJnaPw", "is_bot": false, "login": "xpressmike", "name": "Mike" },
|
||||
"body": "## Summary\n\nEmDash's Cloudflare D1 session adapter (`@emdash-cms/cloudflare/src/db/d1.ts`) routes reads to either `first-primary`, the bookmark from a prior write, or `first-unconstrained` based on `opts.isAuthenticated`. In the Astro middleware (`src/astro/middleware.ts:282`), `isAuthenticated` is set to `!!sessionUser`, and `sessionUser` is read only from the `astro-session` cookie:\n\n```ts\nconst hasSessionCookie = cookies.get(\"astro-session\") !== undefined;\nconst sessionUser = context.isPrerendered || !hasSessionCookie\n ? null\n : await context.session?.get(\"user\");\n```\n\nAs a result, requests authenticated via `Authorization: Bearer <PAT>` (issued by `_emdash/api/auth` or directly to D1) are treated as anonymous by the D1 adapter:\n\n- **Writes** still hit primary (correct — `isWrite=true` forces `first-primary`).\n- **Reads** go to `first-unconstrained` (any replica), and the bookmark cookie is never persisted because `commit()` early-returns when `!opts.isAuthenticated`.\n\nThis means: a Bearer-authenticated client can POST data, get 200 back, and a subsequent GET (even seconds later) returns stale results from a replica that hasn't caught up.\n\n## Repro\n\nA Python script using `Authorization: Bearer ec_pat_…` to call `POST /content/posts/{slug}/terms/{tax}` and then immediately `GET` the same path: POST returns the term in the response body, GET returns empty.\n\n## Workaround we adopted\n\nSwitched `astro.config.mjs` `d1({ binding: \"DB\", session: \"primary-first\" })`. This forces every read (anonymous or not) to start at primary — kills replica reads entirely. Tolerable on a small site, but defeats the purpose of read replicas on larger ones.\n\n## Suggested fix\n\nPopulate `sessionUser` (or a sibling flag like `isApiAuthenticated`) from the resolved user when Bearer auth succeeded. The auth resolver already runs upstream of `createRequestScopedDb`; passing `user` rather than re-reading the cookie would let API clients benefit from primary-first / bookmark-resumed reads.\n\nTested against emdash 0.9.0. Happy to discuss approach if helpful.",
|
||||
"labels": [],
|
||||
"number": 1046,
|
||||
"title": "Bearer-token API clients don't get D1 read-your-writes (sessionUser only set from astro-session cookie)"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"author": {
|
||||
"id": "MDQ6VXNlcjI3MTA2Mg==",
|
||||
"is_bot": false,
|
||||
"login": "mrmt",
|
||||
"name": "MORIMOTO Jun"
|
||||
},
|
||||
"body": "## Summary\n\n`sanitize-html` is currently a runtime dependency of `emdash` (visible in `npm view emdash@1.0.0 dependencies`).\nThe upstream repository [`apostrophecms/sanitize-html`](https://github.com/apostrophecms/sanitize-html) was **archived (read-only) on 2026-02-27**, which means no further patches will be released — including for known vulnerabilities.\n\n## Concrete impact\n\n[GHSA-rpr9-rxv7-x643](https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rpr9-rxv7-x643) / CVE-2026-44990:\n\n- Severity: **critical**\n- Vulnerable: `sanitize-html <= 2.17.3` (i.e. all published versions)\n- Patched: **none** — upstream is archived\n\nThis advisory propagates to every downstream project using `emdash`, including mine ([mrmt/metafictions-web](https://github.com/mrmt/metafictions-web)). Dependabot opens a critical alert that has no actionable fix path while `emdash` keeps `sanitize-html` as a dependency.\n\n## Background in this repo\n\n`sanitize-html` was introduced for the SSR sanitization work tracked in #644. The choice predates the upstream archival.\n\n## Suggested options\n\nA few directions I can think of (any of these would help downstream users):\n\n1. **Migrate to an actively maintained sanitizer** — e.g. [`isomorphic-dompurify`](https://github.com/kkomelin/isomorphic-dompurify) (Workers-friendly), [`xss`](https://github.com/leizongmin/js-xss), or a maintained community fork of `sanitize-html`.\n2. **Fork & maintain** `sanitize-html` under `emdash-cms/` so the patch level can be controlled here.\n3. **Document the situation** in the README / security notes so downstream users know the alert is upstream-blocked and how to handle it (e.g. `pnpm.overrides`).\n\nHappy to help with a PR if a direction is decided. Thanks for `emdash`!\n\n## References\n\n- Archived upstream: https://github.com/apostrophecms/sanitize-html\n- Advisory: https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-rpr9-rxv7-x643\n- Original sanitization work: #644",
|
||||
"labels": [],
|
||||
"number": 1049,
|
||||
"title": "security: sanitize-html upstream is archived — CVE-2026-44990 will never be fixed"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
|
||||
// Graceful handling for Workers AI capacity (HTTP 429) errors and stalled
|
||||
// inference calls.
|
||||
//
|
||||
// Workers AI returns 429 when a model is over capacity. Under sustained load
|
||||
// the binding can also hold a request open well past any useful deadline, which
|
||||
// (without a bound) leaves a review workflow hung forever -- the agent call
|
||||
// never returns, nothing posts, and the only artifact is the container DO's
|
||||
// keep-alive alarm firing for minutes. `withCapacityRetry` bounds each attempt
|
||||
// with a hard timeout (so a stalled call fails loudly instead of hanging) and
|
||||
// retries genuine capacity errors with exponential backoff + jitter.
|
||||
//
|
||||
// `ModelConfig` in @flue/runtime is just a model-id string, so there is no
|
||||
// provider-level retry/timeout knob to set; this is the application-level
|
||||
// contract. The wrapped call receives an AbortSignal it must forward to the
|
||||
// model call (`session.skill(..., { signal })`) for the timeout to take effect.
|
||||
|
||||
/** Thrown when every attempt was exhausted by capacity errors. */
|
||||
export class CapacityExhaustedError extends Error {
|
||||
constructor(label: string, attempts: number, cause: unknown) {
|
||||
super(`${label}: model over capacity after ${attempts} attempt(s)`, { cause });
|
||||
this.name = "CapacityExhaustedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a single attempt exceeded its per-attempt timeout. */
|
||||
export class ModelTimeoutError extends Error {
|
||||
constructor(label: string, timeoutMs: number, cause: unknown) {
|
||||
super(`${label}: model call exceeded ${timeoutMs}ms timeout`, { cause });
|
||||
this.name = "ModelTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface CapacityRetryOptions {
|
||||
/** Human-readable label for logs/errors, e.g. "review" or "classify-reply". */
|
||||
label: string;
|
||||
/** Total attempts including the first. Default 3. */
|
||||
attempts?: number;
|
||||
/**
|
||||
* Hard per-attempt deadline. On expiry the attempt is aborted and a
|
||||
* `ModelTimeoutError` is thrown (NOT retried -- a timeout can't be told apart
|
||||
* from slow-but-working progress, so we fail loudly and bounded rather than
|
||||
* burning the full attempt budget). Omit to disable the timeout.
|
||||
*/
|
||||
perAttemptTimeoutMs?: number;
|
||||
/** Base backoff delay. Default 3000ms. */
|
||||
baseDelayMs?: number;
|
||||
/** Backoff ceiling. Default 30000ms. */
|
||||
maxDelayMs?: number;
|
||||
/** Caller cancellation, merged with the per-attempt timeout signal. */
|
||||
signal?: AbortSignal;
|
||||
/** Invoked before each backoff sleep. */
|
||||
onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void;
|
||||
}
|
||||
|
||||
const CAPACITY_MARKERS = [
|
||||
"429",
|
||||
"too many requests",
|
||||
"capacity",
|
||||
"over capacity",
|
||||
"rate limit",
|
||||
"overloaded",
|
||||
"3040", // Workers AI "Capacity temporarily exceeded" code
|
||||
];
|
||||
|
||||
/** Best-effort classification of a Workers AI / gateway capacity (429) error. */
|
||||
export function isCapacityError(error: unknown): boolean {
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
return CAPACITY_MARKERS.some((marker) => message.includes(marker));
|
||||
}
|
||||
|
||||
function isTimeoutAbort(error: unknown, timeoutSignal: AbortSignal | undefined): boolean {
|
||||
if (!timeoutSignal?.aborted) return false;
|
||||
return (
|
||||
(error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) ||
|
||||
// Some SDKs reject with the signal's reason rather than an AbortError.
|
||||
error === timeoutSignal.reason
|
||||
);
|
||||
}
|
||||
|
||||
function backoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
|
||||
const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
|
||||
// Full jitter: random within [0, exponential] to spread retries across many
|
||||
// concurrent callers hammering the same overloaded model.
|
||||
return Math.round(Math.random() * exponential);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal?.reason);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a model-bearing call with a per-attempt timeout and capacity-aware retry.
|
||||
*
|
||||
* - Capacity (429) errors are retried with exponential backoff + full jitter.
|
||||
* - A per-attempt timeout aborts a stalled call and throws `ModelTimeoutError`
|
||||
* (loud, bounded -- the workflow's at-least-once restart handles re-running).
|
||||
* - Any other error is rethrown immediately.
|
||||
*
|
||||
* @param fn receives the per-attempt `AbortSignal`; forward it to the model call.
|
||||
* Returns a `PromiseLike` so Flue's awaitable `CallHandle` can be passed through
|
||||
* directly (`session.skill(..., { signal })`).
|
||||
*/
|
||||
export async function withCapacityRetry<T>(
|
||||
fn: (signal: AbortSignal) => PromiseLike<T>,
|
||||
options: CapacityRetryOptions,
|
||||
): Promise<T> {
|
||||
const attempts = options.attempts ?? 3;
|
||||
const baseDelayMs = options.baseDelayMs ?? 3000;
|
||||
const maxDelayMs = options.maxDelayMs ?? 30000;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const timeoutSignal =
|
||||
options.perAttemptTimeoutMs !== undefined
|
||||
? AbortSignal.timeout(options.perAttemptTimeoutMs)
|
||||
: undefined;
|
||||
const signals = [options.signal, timeoutSignal].filter(
|
||||
(s): s is AbortSignal => s !== undefined,
|
||||
);
|
||||
const signal =
|
||||
signals.length > 1 ? AbortSignal.any(signals) : (signals[0] ?? new AbortController().signal);
|
||||
|
||||
try {
|
||||
return await fn(signal);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// A per-attempt timeout: fail loudly, do not retry.
|
||||
if (isTimeoutAbort(error, timeoutSignal)) {
|
||||
throw new ModelTimeoutError(options.label, options.perAttemptTimeoutMs ?? 0, error);
|
||||
}
|
||||
// Caller cancellation: propagate untouched.
|
||||
if (options.signal?.aborted) throw error;
|
||||
// Non-capacity error: not our concern, rethrow.
|
||||
if (!isCapacityError(error)) throw error;
|
||||
// Capacity error on the final attempt: give up loudly.
|
||||
if (attempt === attempts) break;
|
||||
|
||||
const delayMs = backoffDelay(attempt, baseDelayMs, maxDelayMs);
|
||||
options.onRetry?.({ attempt, delayMs, error });
|
||||
await sleep(delayMs, options.signal);
|
||||
}
|
||||
}
|
||||
|
||||
throw new CapacityExhaustedError(options.label, attempts, lastError);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Lightweight classifier shared between investigate and classify-reply
|
||||
// workflows. Uses kimi-k2.7-code via our Cloudflare AI Gateway -- cheap and
|
||||
// fast for structured classification tasks.
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
import { createAgent } from "@flue/runtime";
|
||||
import * as v from "valibot";
|
||||
|
||||
/**
|
||||
* Shared classifier agent. Default sandbox (in-memory, no host access).
|
||||
* Used for cheap structured-output prompts that don't need a shell.
|
||||
*/
|
||||
export const classifier = createAgent(() => ({
|
||||
model: "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code",
|
||||
}));
|
||||
|
||||
/**
|
||||
* Persist a classifier result to `CLASSIFY_RESULT_PATH` (set by the calling
|
||||
* workflow) so the GitHub Actions orchestrator reads it from a file instead of
|
||||
* scraping it out of `flue run`'s stdout. flue interleaves build-log lines and
|
||||
* pretty-prints the returned value, which defeats both line- and slurp-based
|
||||
* stdout parsing -- the parse then silently falls back to `unclear`, stranding
|
||||
* every reply. Mirrors investigate.ts's `INVESTIGATE_RESULT_PATH` handoff. When
|
||||
* the env var is unset (local prototyping) the write is skipped.
|
||||
*/
|
||||
export function persistClassifierResult<T>(result: T): T {
|
||||
const path = process.env.CLASSIFY_RESULT_PATH;
|
||||
if (path) {
|
||||
try {
|
||||
writeFileSync(path, JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.error("[classify] failed to write result file:", error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for the issue-classification step that runs at the top of the
|
||||
* investigate pipeline. The orchestrator uses the classification to
|
||||
* pick which `repro-*` sub-skill to invoke and to decide whether to
|
||||
* skip non-bug issues entirely.
|
||||
*/
|
||||
export const issueClassificationSchema = v.object({
|
||||
kind: v.pipe(
|
||||
v.picklist(["bug", "enhancement", "documentation", "question"]),
|
||||
v.description("What kind of issue this is. Only `bug` triggers the full pipeline."),
|
||||
),
|
||||
area: v.pipe(
|
||||
v.picklist(["api", "admin", "public", "migration", "build", "other"]),
|
||||
v.description("Which part of EmDash the issue lives in. Drives sub-skill choice."),
|
||||
),
|
||||
requiresBrowser: v.pipe(
|
||||
v.boolean(),
|
||||
v.description(
|
||||
"True for admin or public bugs; selects between agent-browser and pure CLI repro.",
|
||||
),
|
||||
),
|
||||
summary: v.pipe(
|
||||
v.string(),
|
||||
v.minLength(10),
|
||||
v.maxLength(200),
|
||||
v.description("One-sentence factual summary of the reported behaviour."),
|
||||
),
|
||||
});
|
||||
|
||||
export type IssueClassification = v.InferOutput<typeof issueClassificationSchema>;
|
||||
|
||||
/**
|
||||
* Schema for the reporter-reply classifier. Decides whether the issue
|
||||
* author's reply confirms the fix worked, says it didn't, or is
|
||||
* ambiguous and needs a clarifying ask.
|
||||
*/
|
||||
export const replyClassificationSchema = v.object({
|
||||
classification: v.pipe(
|
||||
v.picklist(["positive", "negative", "unclear"]),
|
||||
v.description(
|
||||
"positive: the reporter confirms the fix works. negative: it doesn't, or the fix is wrong. unclear: neither clearly stated.",
|
||||
),
|
||||
),
|
||||
reasoning: v.pipe(
|
||||
v.string(),
|
||||
v.minLength(5),
|
||||
v.maxLength(400),
|
||||
v.description("Short justification quoting the relevant phrase from the reply."),
|
||||
),
|
||||
});
|
||||
|
||||
export type ReplyClassification = v.InferOutput<typeof replyClassificationSchema>;
|
||||
|
||||
/**
|
||||
* Schema for the maintainer-reply classifier. A maintainer addresses the
|
||||
* bot (`@emdashbot ...`) on a triage issue; this maps their freeform
|
||||
* instruction to one of a fixed set of intents the orchestrator can act
|
||||
* on. The `directive` is the implementation guidance passed through to a
|
||||
* directed investigate run -- never used to build identifiers or shell.
|
||||
*/
|
||||
export const maintainerIntentSchema = v.object({
|
||||
intent: v.pipe(
|
||||
v.picklist(["implement", "close", "takeover", "unclear"]),
|
||||
v.description(
|
||||
"implement: the maintainer wants the fix built (approving the proposal or naming a changed approach). close: not a bug / wontfix / by design. takeover: a human is taking over, the bot should disengage. unclear: no actionable instruction.",
|
||||
),
|
||||
),
|
||||
directive: v.pipe(
|
||||
v.string(),
|
||||
v.maxLength(2000),
|
||||
v.description(
|
||||
"For implement: the concrete instruction to hand the fix agent (which option to take, what to change). Empty for close/takeover/unclear.",
|
||||
),
|
||||
),
|
||||
reasoning: v.pipe(
|
||||
v.string(),
|
||||
v.minLength(5),
|
||||
v.maxLength(400),
|
||||
v.description("Short justification quoting the relevant phrase from the maintainer's comment."),
|
||||
),
|
||||
});
|
||||
|
||||
export type MaintainerIntent = v.InferOutput<typeof maintainerIntentSchema>;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "emdash-flue-triage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Experimental Flue-powered issue investigation bot for emdash-cms/emdash. Invoked from GitHub Actions; not deployed as a Worker.",
|
||||
"scripts": {
|
||||
"build": "flue build --target node --output .build",
|
||||
"prototype": "tsx scripts/run-local.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flue/runtime": "^0.11.1",
|
||||
"valibot": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flue/cli": "^0.11.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.9.0",
|
||||
"wrangler": "^4.100.0"
|
||||
}
|
||||
}
|
||||
Generated
+4180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
# Standalone pnpm workspace config for the triage bot. The repo root's
|
||||
# pnpm-workspace.yaml doesn't list .flue as a workspace, and this file
|
||||
# is what pnpm picks up when invoked from inside .flue/. Mirrors the
|
||||
# relevant build-allow settings from the root config.
|
||||
|
||||
strictDepBuilds: false
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
workerd: true
|
||||
"@google/genai": false
|
||||
"@mongodb-js/zstd": false
|
||||
"node-liblzma": false
|
||||
protobufjs: false
|
||||
sharp: false
|
||||
@@ -0,0 +1,136 @@
|
||||
// Local prototype runner.
|
||||
//
|
||||
// Wraps `flue run investigate` for convenience. Reads an issue fixture
|
||||
// (or pulls one live with `gh issue view`), constructs the payload, and
|
||||
// prints the structured InvestigateResult. No GitHub writes -- the
|
||||
// orchestrator that does writes lives in .github/workflows/investigate.yml,
|
||||
// not here.
|
||||
//
|
||||
// The investigate workflow expects AGENT_GH_TOKEN to be set; we forward
|
||||
// whichever of GITHUB_TOKEN / GH_TOKEN the user has, treating it as the
|
||||
// "agent" token even though locally there's no orchestrator/agent split.
|
||||
// The agent's read-only token only affects what `gh issue view` etc.
|
||||
// inside its sandbox can do; on a maintainer's laptop the host user
|
||||
// already has those reads, so the sandbox token mostly stops the agent
|
||||
// from doing accidental writes from inside its bash.
|
||||
//
|
||||
// Required env:
|
||||
// CLOUDFLARE_ACCOUNT_ID
|
||||
// CLOUDFLARE_GATEWAY_ID
|
||||
// CLOUDFLARE_API_KEY
|
||||
//
|
||||
// Usage:
|
||||
// pnpm prototype 1021 # one fixture
|
||||
// pnpm prototype 1021 1049 1080 # several
|
||||
// pnpm prototype --live 1083 # fetch live with gh
|
||||
// FLUE_INVESTIGATE_MODEL=cloudflare-ai-gateway/claude-sonnet-4-6 pnpm prototype 1021
|
||||
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
interface Fixture {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
labels?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES_DIR = resolve(HERE, "..", "fixtures");
|
||||
const FLUE_DIR = resolve(HERE, "..");
|
||||
const ISSUE_NUMBER_RE = /^\d+$/;
|
||||
|
||||
async function loadFixture(arg: string, live: boolean): Promise<Fixture> {
|
||||
// `arg` is interpolated into a shell command (`gh issue view`) and a
|
||||
// file path (`fixtures/issue-<arg>.json`). Restrict to plain integers
|
||||
// so a `'1 && rm -rf /'` style input cannot smuggle metachars through
|
||||
// execSync or path traversal through the fixture lookup.
|
||||
if (!ISSUE_NUMBER_RE.test(arg)) {
|
||||
throw new Error(`issueNumber must be a positive integer, got: ${JSON.stringify(arg)}`);
|
||||
}
|
||||
if (live) {
|
||||
const raw = execSync(
|
||||
`gh issue view ${arg} --repo emdash-cms/emdash --json number,title,body,labels`,
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const parsed: Fixture = JSON.parse(raw);
|
||||
return parsed;
|
||||
}
|
||||
const path = join(FIXTURES_DIR, `issue-${arg}.json`);
|
||||
const parsed: Fixture = JSON.parse(await readFile(path, "utf8"));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function runOne(fixture: Fixture): Promise<void> {
|
||||
const payload = JSON.stringify({
|
||||
issueNumber: fixture.number,
|
||||
issueTitle: fixture.title,
|
||||
issueBody: fixture.body,
|
||||
owner: "emdash-cms",
|
||||
repo: "emdash",
|
||||
});
|
||||
|
||||
console.error(`\n=== issue #${fixture.number}: ${fixture.title}`);
|
||||
const start = Date.now();
|
||||
|
||||
// `pnpm exec` (not `npx`) so we invoke the lockfile-pinned Flue.
|
||||
// `flue run` in 0.8 generates the workflow run id itself; no --id flag.
|
||||
const result = spawnSync(
|
||||
"pnpm",
|
||||
["exec", "flue", "run", "investigate", "--target", "node", "--payload", payload],
|
||||
{
|
||||
cwd: FLUE_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.error(`[${elapsed}ms] exit=${result.status}`);
|
||||
if (result.stderr) console.error(result.stderr);
|
||||
if (result.stdout) console.log(result.stdout);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const live = args.includes("--live");
|
||||
const issueArgs = args.filter((a) => !a.startsWith("--"));
|
||||
|
||||
if (issueArgs.length === 0) {
|
||||
console.error("usage: tsx scripts/run-local.ts [--live] <issueNumber> [<issueNumber>...]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const missingGateway = [
|
||||
"CLOUDFLARE_ACCOUNT_ID",
|
||||
"CLOUDFLARE_GATEWAY_ID",
|
||||
"CLOUDFLARE_API_KEY",
|
||||
].filter((k) => !process.env[k]);
|
||||
if (missingGateway.length > 0) {
|
||||
console.error(`missing required env: ${missingGateway.join(", ")}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Normalise GITHUB_TOKEN / GH_TOKEN into AGENT_GH_TOKEN, which is
|
||||
// what investigate.ts reads. The workflow itself sets this explicitly
|
||||
// from `secrets.GITHUB_TOKEN`; locally we accept the user's gh CLI
|
||||
// token, treating it the same way.
|
||||
const agentToken = process.env.AGENT_GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
||||
if (!agentToken) {
|
||||
console.error("AGENT_GH_TOKEN (or GITHUB_TOKEN / GH_TOKEN) required for the agent's sandbox");
|
||||
process.exit(2);
|
||||
}
|
||||
process.env.AGENT_GH_TOKEN = agentToken;
|
||||
|
||||
for (const arg of issueArgs) {
|
||||
const fixture = await loadFixture(arg, live);
|
||||
await runOne(fixture);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
# Investigation pipeline (reference document)
|
||||
|
||||
> **Not a Flue skill.** This document describes the four-stage
|
||||
> pipeline implemented in `.flue/workflows/investigate.ts`. It is not
|
||||
> loaded as a `SkillReference` and editing it has no runtime effect.
|
||||
> The leaf skills the workflow does load are siblings of this file
|
||||
> (`diagnose/SKILL.md`, `verify/SKILL.md`, `fix/SKILL.md`,
|
||||
> `repro-api/SKILL.md`, `repro-admin/SKILL.md`, `repro-public/SKILL.md`).
|
||||
> The leading underscore in the filename keeps this directory from
|
||||
> being mistaken for a Flue skill directory by Vite's skill loader.
|
||||
|
||||
The bot investigates a single GitHub issue on `emdash-cms/emdash` that a maintainer flagged with the `bot:repro` label. It runs on a GitHub Actions runner with a clean EmDash checkout in the working directory. It walks a four-stage pipeline and returns one structured result that downstream code uses to post a comment on the issue.
|
||||
|
||||
You are read-only on GitHub. The `GH_TOKEN` available to bash has read scope only. You cannot comment, label, edit, close, or push branches from inside this skill. The orchestrator handles all writes after you return.
|
||||
|
||||
## Hard prohibitions
|
||||
|
||||
- No `git commit`. No `git push`. No `git tag`.
|
||||
- No `gh pr ...` writes, no `gh issue comment`, no `gh issue edit`, no `gh issue close`. Read-only `gh` calls (`gh issue view`, `gh api` GETs) are fine.
|
||||
- No `curl` to arbitrary external hosts. Stay on `localhost`, the GitHub API, the npm registry, and EmDash docs.
|
||||
- Do not modify, label, or close any issue other than the one you are investigating, and even on that one your role is read-only.
|
||||
- No `pnpm publish`. No `npm publish`. No changeset commits.
|
||||
|
||||
## Stages
|
||||
|
||||
You drive four stages in order. The first stage produces a classification that selects which reproduce sub-skill to load. The reproduce result then feeds into diagnose, then verify, then conditionally fix.
|
||||
|
||||
### 1. Read and classify
|
||||
|
||||
1. Use `gh issue view <number> --json number,title,body,labels,author,comments` to load the issue. Read the full body and the comment thread.
|
||||
2. Decide `kind`: `bug`, `enhancement`, `documentation`, or `question`. Use the existing labels as a hint, not as ground truth -- a maintainer can mislabel and still flag for repro.
|
||||
3. Decide `area`: `api`, `admin`, `public`, `migration`, `build`, or `other`.
|
||||
- `api` -- REST handlers under `packages/core/src/api/`, the CLI in `packages/core/src/cli/`, the MCP server, anything exercised without a browser.
|
||||
- `admin` -- the React SPA in `packages/admin`, anything served under `/_emdash/admin/*`.
|
||||
- `public` -- the rendered public site (Astro pages outside `/_emdash`), routing, SSR output, query patterns visible to anonymous readers.
|
||||
- `migration` -- database migrations in `packages/core/src/database/migrations/`, schema registry, content tables.
|
||||
- `build` -- bundling, `tsdown`, Vite, type generation, package exports, monorepo wiring.
|
||||
- `other` -- anything that doesn't fit, including infra issues, security disclosure replies, meta-discussion.
|
||||
4. Decide `requiresBrowser`: true when `area` is `admin` or `public`. False otherwise. Migration or build issues that surface through the admin UI count as the underlying area, not the surface.
|
||||
5. If `kind` is anything other than `bug`, you do not run the reproduce / diagnose / verify / fix stages. Return early with the classification and a note explaining what kind of issue this is. The orchestrator will post a short acknowledgement rather than a triage report.
|
||||
|
||||
### 2. Reproduce
|
||||
|
||||
Dispatch based on `area`:
|
||||
|
||||
- `api`, `migration`, `build`, `other` -> follow `../repro-api.md`.
|
||||
- `admin` -> follow `../repro-admin.md`.
|
||||
- `public` -> follow `../repro-public.md`.
|
||||
|
||||
Each reproduce sub-skill returns whether it managed to reproduce the failure, the approach it used (failing test, repro script, agent-browser session, or none), free-form notes, and any screenshots it captured. Carry that result forward unchanged.
|
||||
|
||||
If the reproduce stage returns `skipped: true`, do not run diagnose or fix. Run verify only if there is enough static evidence in the issue body and source to form an opinion -- if not, skip verify too and return the classification plus the skip reason.
|
||||
|
||||
### 3. Diagnose
|
||||
|
||||
Follow `../diagnose.md`. Feed it the reproduce notes. It returns a root cause (file plus approximate line plus prose), a confidence rating in that cause, a fix approach (`mechanical`, `clear-best-option`, or `needs-design-decision`), a concrete proposed fix, and hypothesis notes covering alternative causes. Confidence rates the _cause_; fix approach rates the _fix_ -- the two are independent, so a confidently-located bug whose fix is one clear backwards-compatible change is `high` + `clear-best-option`, not `medium`.
|
||||
|
||||
If the reproduce stage failed to reproduce (`reproduced: false`, not skipped), still run diagnose -- often the issue text alone is enough to identify the code path, and the bot's comment is more useful with a guess than without one. Diagnose should lower its own confidence accordingly.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
Follow `../verify.md`. It looks at the diagnosed code, the surrounding documentation, and the related tests, and decides whether the behaviour is a bug, intentional, or unclear. This is the gate that prevents the bot from "fixing" something that is working as designed.
|
||||
|
||||
### 5. Fix (conditional)
|
||||
|
||||
Only run `../fix.md` when **all** of the following hold:
|
||||
|
||||
- `verify.verdict === 'bug'`
|
||||
- `diagnose.confidence !== 'low'` (the cause is pinned with at least medium confidence)
|
||||
- `diagnose.fixApproach !== 'needs-design-decision'` (the fix is `mechanical` or `clear-best-option`)
|
||||
|
||||
Any other combination: skip fix. The bot posts the diagnosis (including the proposed fix or, for a design decision, the options) and verify reasoning as a comment, and a human takes it from there. The gate is deliberately broader than the old `confidence === 'high'` rule, which conflated "is the cause certain?" with "is the fix obvious?" and starved the fix stage of real, fixable bugs. The output is not a merge -- it is a candidate branch the reporter is asked to verify and a maintainer reviews -- so a clear, test-backed fix is worth attempting even when it is more than a one-line change.
|
||||
|
||||
The fix stage runs on a cheaper model than the reasoning stages: diagnose has already produced a concrete plan, so fix is guided implementation rather than open-ended investigation. Carry its result forward. Fix returns whether the change actually built and tested clean, a conventional-commit-style message, the list of files changed, and notes. The orchestrator is responsible for committing and pushing -- you do not.
|
||||
|
||||
## Output
|
||||
|
||||
Return a single structured result combining the classification, the reproduce result, the diagnose result, the verify result, and the fix result if it ran. Omitted stages should be explicitly absent rather than filled with placeholders. Notes from each stage should be specific enough that a maintainer reading the eventual comment can follow what you did without re-running the pipeline.
|
||||
|
||||
Keep prose factual. If you guessed, say you guessed. If you skipped a stage, say why in one sentence.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: diagnose
|
||||
description: Trace from a reproduced symptom to the source code that causes it. Identify the specific file and approximate line, then rate confidence honestly.
|
||||
---
|
||||
|
||||
# Diagnose
|
||||
|
||||
The reproduce stage gave you a symptom -- a failing test, a captured screenshot, a console error, a wrong HTTP response. Your job is to find the code that produces that symptom and explain why, in enough detail that the verify stage can decide whether it is a bug and the fix stage can act if it is.
|
||||
|
||||
You read code. You do not modify it. No edits, no test runs, no demo boots. The state of the working tree should be the same when you finish as when you started.
|
||||
|
||||
## Hard prohibitions
|
||||
|
||||
- No `git commit`, no `git push`, no edits to source.
|
||||
- No GitHub writes. Read-only `gh` reads only.
|
||||
- No `curl` to arbitrary external hosts.
|
||||
- Do not touch any issue other than the one being investigated.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Anchor on the reproduce notes.** The reproduce stage already named at least one file, command, or URL. Start there. If reproduce was skipped, anchor on the file paths, error messages, or stack frames in the issue body.
|
||||
2. **Walk from symptom to source.**
|
||||
- For a thrown exception with a stack trace: read each frame in order, starting from the deepest application frame (not framework internals). Confirm the call sequence matches what reproduce actually executed.
|
||||
- For a wrong return value: grep for the function that produced it, then trace its inputs back to where they enter the system (handler boundary, CLI entry point, render call).
|
||||
- For wrong HTML or wrong DOM: identify the component or Astro page that renders it. Check what data it consumes and where that data comes from -- often the bug is in the data layer, not the render layer.
|
||||
- For migration or schema bugs: read the migration file in question, the SchemaRegistry path that invoked it, and the surrounding migrations to understand ordering assumptions.
|
||||
3. **Read the candidate code in full.** Do not skim. Read the whole function, the whole route handler, the whole component. Bugs hide in adjacent branches.
|
||||
4. **Check the obvious culprits first.**
|
||||
- Missing `locale` filter on a content-table query -- a known recurring class.
|
||||
- SQL identifier interpolated unsafely.
|
||||
- Off-by-one in pagination cursor encoding or decoding.
|
||||
- Missing `await` on a promise whose return value is ignored.
|
||||
- `noUncheckedIndexedAccess` undefined-handling that was patched with `!` and is now wrong.
|
||||
- Permission check missing or invoked on the wrong actor.
|
||||
- Lingui `t` called at module scope.
|
||||
- Physical Tailwind class (`ml-*`, `text-left`) where a logical class belongs.
|
||||
5. **Pin the location.** Identify the file and the smallest range of lines that contain the bug. A single line is ideal; a function-sized range is acceptable when the bug is structural. If you cannot get below file-level, you do not yet have a diagnosis -- search more.
|
||||
6. **Rate your confidence in the root cause.** This axis is only about how sure you are that you have found the code responsible -- _not_ about how easy the fix is. Keep the two separate; the next step rates the fix.
|
||||
- **High** -- you traced the symptom to a specific file and line range and can explain the mechanism end to end. Another engineer reading your diagnosis would agree this is the cause.
|
||||
- **Medium** -- you have the right area and a strong candidate, but you could not fully confirm the mechanism (reproduce was skipped or failed, or there is a second plausible cause you cannot rule out by reading alone).
|
||||
- **Low** -- multiple plausible causes you cannot distinguish without instrumentation, or the candidate code is the right area but no specific defect is visible in it.
|
||||
Rate honestly in both directions. The fix stage does not run at `low`, but it _does_ run at `medium` when the fix is clear, so do not reflexively rate down -- a confidently-located cause is `high` even when the fix involves choosing between options. That choice is the next field's job, not this one's.
|
||||
7. **Choose a fix approach.** This is independent of confidence. Judge how clear the _fix_ is, given the cause:
|
||||
- **mechanical** -- there is one obviously-correct change: a single line or tightly-scoped block, no judgement calls. (A missing `await`, a wrong comparison operator, a missing `locale` filter.)
|
||||
- **clear-best-option** -- the fix is bigger than a one-liner, or several shapes exist, but one is clearly the right call: it is backwards-compatible, matches patterns already in the codebase, and the reproduce test can confirm it. Name that option and say why it beats the alternatives. (Example: issue #1178 hard-codes `c.title` in a SELECT; probing the column list and selecting `title` only when it exists is backwards-compatible and matches the bug's shape, whereas every alternative either breaks the documented API or is a larger redesign. The sibling code in the same file is often direct evidence of intended behaviour -- if one branch already does the right thing, mirroring it is `clear-best-option`, not a design decision.)
|
||||
- **needs-design-decision** -- choosing correctly requires a judgement only a maintainer should make: a new public API or option, a shared component that does not exist yet, a behavioural-contract change, or a security / performance tradeoff. Do not guess; lay out the options.
|
||||
The fix stage runs for `mechanical` and `clear-best-option` and defers `needs-design-decision` to a human. Do not retreat to `needs-design-decision` just because more than one fix is conceivable -- reserve it for when the _right_ choice genuinely belongs to a maintainer.
|
||||
8. **Write the proposed fix, always.** For `mechanical` / `clear-best-option`: describe the specific change -- which file, what to add/remove/change, and how the reproduce test proves it -- in enough detail that the fix stage can implement it directly without re-deriving your reasoning. (A cheaper model implements it; the more concrete your plan, the better the result.) For `needs-design-decision`: lay out the viable options and the tradeoff that distinguishes them, and name your recommendation if you have one. This becomes the maintainer's starting point.
|
||||
9. **Write hypothesis notes for alternative _causes_.** Distinct from the proposed fix (which is about the remedy): what _other_ root causes did you consider, and how did you rule them in or out? Empty only when the cause is genuinely unambiguous. This is the most valuable part of the comment for a maintainer reading a `medium` or `low` diagnosis.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
- A root cause: the file path with approximate line number (e.g. `packages/core/src/api/handlers/menus.ts:142`), followed by prose explaining what is wrong and why it produces the reported symptom.
|
||||
- A confidence rating in the root cause: `high`, `medium`, or `low`.
|
||||
- A fix approach: `mechanical`, `clear-best-option`, or `needs-design-decision`.
|
||||
- A proposed fix: the concrete change to make (`mechanical` / `clear-best-option`) or the options a maintainer must choose between (`needs-design-decision`). Never empty.
|
||||
- Hypothesis notes: the alternative _causes_ you considered and what distinguishes them; empty only when the cause is unambiguous.
|
||||
|
||||
Be specific. "Probably in the menu code somewhere" is not a diagnosis. "`resolveContentUrl` in `packages/core/src/menus/index.ts:87` issues three queries per item and the third is the missing-locale fallback path -- on a primary-locale request it is dead code, but it still runs" is.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: fix
|
||||
description: Write the fix when verify says bug and diagnose says high confidence. Follow EmDash conventions, confirm the reproduce test now passes, run lint and typecheck, stage but do not commit.
|
||||
---
|
||||
|
||||
# Fix
|
||||
|
||||
You are here because verify returned `bug`, diagnose pinned the cause with at least `medium` confidence, and diagnose rated the fix `mechanical` or `clear-best-option`. Diagnose handed you a **proposed fix** -- a concrete plan naming the file and the change. Your job is to implement that plan, prove it works, leave the working tree in a state the orchestrator can commit, and report what you did. The hard reasoning is already done; do not re-litigate the diagnosis unless reading the code convinces you it is wrong (in which case abandon -- see below).
|
||||
|
||||
Read diagnose's proposed fix first and treat it as your spec. Implement that change. If, once you are in the code, the plan turns out to be wrong or incomplete, do not improvise a different large change -- abandon with `fixed: false` and say why, so a human can re-diagnose.
|
||||
|
||||
**What your output is, and is not.** You are not merging anything, and you are not even opening a PR. The orchestrator pushes your staged change to a `bot/fix-<n>` branch and asks the original reporter to install a preview build and confirm it resolves their issue. A maintainer reviews before anything lands on `main`. So the bar is "a correct, conventions-respecting change that makes the reproduce test pass" -- not "a perfect, unimprovable patch." A clear, test-backed fix is worth shipping for verification even when it is more than a one-liner. Equally: do not gold-plate, do not expand scope, do not refactor beyond the diagnosed bug.
|
||||
|
||||
You can edit source. You can run tests, lint, typecheck, and format. You cannot commit, push, open a PR, or touch any GitHub state.
|
||||
|
||||
## Hard prohibitions
|
||||
|
||||
- No `git commit`. No `git push`. No `git tag`. No branch creation that survives. `git add` is allowed and expected at the end.
|
||||
- No GitHub writes. Read-only `gh` reads only.
|
||||
- No `curl` to arbitrary external hosts.
|
||||
- Do not touch any issue other than the one being investigated.
|
||||
- No `pnpm publish` or `npm publish`. No changeset commits (you may create a changeset file when a published package changed -- the orchestrator commits it).
|
||||
- No drive-by edits. Touch only the files needed for the diagnosed bug and its test. If you see another problem in a nearby file, leave it for a human (AGENTS.md scope discipline rule).
|
||||
- Do not modify Lingui catalogs (`packages/admin/src/locales/*/messages.po`). The extract workflow handles those on merge to `main`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Re-read the diagnose root cause.** That is your target. The fix should land in the file and approximate line diagnose named. If your work drifts to a different file, stop and reconsider -- diagnose may have been wrong, in which case the right answer is to abandon, not to wander.
|
||||
2. **Establish a regression test where one is feasible.** Reproduce confirmed the bug through agent-browser, not a test, so there is usually no failing test on disk yet. If the bug is unit- or integration-testable (a handler, a query, a pure function, an API route), write a `vitest` test now that fails for the reported reason -- run it with `pnpm --filter <package> test <path>` and confirm it fails before you touch the fix. A bug with a testable surface and no regression test is not fixed. If the bug only manifests in the browser (admin UI interaction, rendered output), do not write a browser test -- the bot cannot run one reliably here; instead verify the fix through agent-browser and describe the manual verification in your notes so the maintainer can add a durable test when landing it.
|
||||
3. **Implement diagnose's proposed fix -- the smallest change that fully resolves the bug.** Start from the plan diagnose gave you; the change should land in the file and approximate line it named. Follow EmDash's conventions:
|
||||
- Internal imports end with `.js`. Type-only imports use `import type`.
|
||||
- Routes that change state start with `export const prerender = false;`.
|
||||
- Never interpolate values into SQL. Use Kysely's `sql` tagged template; use `sql.ref()` for identifiers; validate dynamic identifiers with `validateIdentifier()` before any `sql.raw()`.
|
||||
- Handlers return `ApiResult<T>`. Errors use `apiError`, `handleError`, and `SCREAMING_SNAKE_CASE` error codes. Never expose `error.message` to clients.
|
||||
- Use `requirePerm` / `requireOwnerPerm` from `#api/authorize.js` for authorization. Permissions live in `packages/auth/src/rbac.ts` -- do not invent new permission strings inline.
|
||||
- Pagination returns `{ items, nextCursor? }`. Use `encodeCursor` / `decodeCursor`.
|
||||
- Content-table queries filter by `locale`.
|
||||
- Admin user-facing strings go through Lingui. Logical Tailwind classes only.
|
||||
- Use `import.meta.env.DEV`, never `process.env.NODE_ENV`.
|
||||
- Migrations are forward-only and additive. Register in `runner.ts` via `StaticMigrationProvider`.
|
||||
- Prefer additive changes. Breaking changes need an explicit changeset; do not introduce one for an automated fix without compelling justification.
|
||||
4. **Run the reproduce test.** It must now pass. If it does not, your fix is wrong or incomplete. Investigate, adjust, or abandon -- do not weaken the test to make it pass.
|
||||
5. **Run the broader test suite for the affected package.** `pnpm --filter <package> test`. Read the output. Any new failures in tests you did not write are regressions -- investigate and fix, or abandon the entire change. Do not push regressions through.
|
||||
6. **Run typecheck.** `pnpm typecheck` for packages, `pnpm typecheck:demos` if a demo was involved. No new errors.
|
||||
7. **Run lint quickly.** `pnpm lint:quick`. Snapshot the diagnostic count with `pnpm lint:json | jq '.diagnostics | length'` if the count looks suspicious -- a clean baseline should remain clean after your edits.
|
||||
8. **Format.** `pnpm format`. The repo uses oxfmt with tabs; do not bypass it.
|
||||
9. **Add a changeset when a published package changed.** Use the changeset CLI (`pnpm changeset`) non-interactively if possible, or create the file directly under `.changeset/`. Patch bump for a bug fix unless the diagnosis explicitly says otherwise. The summary should reference the issue number.
|
||||
10. **Stage everything.** `git add -A`. Verify with `git status` that the staged set is what you expect -- source change, regression test, and changeset if applicable. Nothing else.
|
||||
11. **Do not commit.** The orchestrator handles the commit, the branch, the push, and the PR. If you commit yourself you will desynchronise with the orchestrator and your work will likely be discarded.
|
||||
|
||||
## When to abandon
|
||||
|
||||
Return `fixed: false` with a clear explanation in notes when:
|
||||
|
||||
- The reproduce test does not actually fail before your change (diagnose or reproduce was wrong).
|
||||
- Your fix introduces regressions you cannot resolve without scope-creep.
|
||||
- The fix turns out to require breaking-change-level design decisions a human should make.
|
||||
- Lint, typecheck, or format produces errors you cannot resolve cleanly.
|
||||
|
||||
A failed fix attempt is still useful -- the bot will post the diagnose and verify output and explain why the automated attempt was abandoned.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
- Whether the fix succeeded.
|
||||
- A conventional commit message the orchestrator can use: `fix(<scope>): <short description> (#<issue>)` for a fix, with the scope matching the package or area (`fix(core/menus)`, `fix(admin/seo)`, `fix(migrations)`).
|
||||
- The list of file paths changed (relative to repo root).
|
||||
- Whether the reproduce test currently passes against your staged changes.
|
||||
- Notes: any context the maintainer should know -- design choices you made, alternatives you rejected, edge cases you considered, or, when `fixed: false`, the specific reason you abandoned.
|
||||
|
||||
The orchestrator reads this output, decides whether to commit, names the branch, opens the PR, and posts the triage comment that links to it.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: repro-admin
|
||||
description: Reproduce an EmDash admin UI bug. Boots a demo with bgproc, drives the admin with agent-browser using the dev-bypass session, and captures the reproduction as screenshots plus a written transcript.
|
||||
---
|
||||
|
||||
# Reproduce: Admin UI
|
||||
|
||||
The issue is in the React admin under `/_emdash/admin/*`. You need a running demo, an authenticated session, and a way to drive the UI through the steps the reporter described. Reproduce and confirm the bug entirely through `agent-browser`: the durable artifact is your screenshots plus a precise, replayable transcript of the steps. Do not write Playwright (or any other) tests -- the bot cannot reliably run them here, so an unrun test is unverified guesswork. A regression test belongs to whoever lands the fix.
|
||||
|
||||
## Hard prohibitions
|
||||
|
||||
- No `git commit`, no `git push`, no branch creation that survives the workflow.
|
||||
- No GitHub writes (`gh issue comment`, `gh pr ...`, `gh issue edit`). Read-only `gh` reads only.
|
||||
- No `curl` to arbitrary external hosts. `localhost:4321` only.
|
||||
- Do not touch any issue other than the one being investigated.
|
||||
- Do not modify Lingui catalogs (`packages/admin/src/locales/*/messages.po`). They are regenerated by a workflow on merge to `main`; touching them from the bot creates merge churn.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Re-read the issue.** Note the exact steps the reporter described, the page they were on, the browser they used, and any screenshots or stack traces. If the steps reference a collection or content item, decide whether the default demo seed covers it or whether you need to create content first.
|
||||
2. **Pick a demo.** `demos/simple` is the default starting point and works for most admin reproductions. Use a more specific demo only when the issue explicitly mentions it.
|
||||
3. **Start the demo with `bgproc`.** Run `bgproc start -n demo -w -- pnpm --filter ./demos/simple dev`. The `-w` flag makes `bgproc` wait until the dev server opens a port before returning -- Astro listens on `localhost:4321` -- so do not move on until it does. Inspect progress with `bgproc status -n demo` and `bgproc logs -n demo`. If the server never opens a port, capture `bgproc logs -n demo` and treat it as a setup failure, not a reproduction.
|
||||
4. **Get a session.** Open `agent-browser open "http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin"`. This runs migrations, creates a dev admin user, sets a session cookie, and lands you on the admin home. The endpoint is gated to `import.meta.env.DEV` so it only exists locally -- do not try to use it against any deployed environment.
|
||||
5. **Drive the UI.** Use `agent-browser snapshot -i -c` to get an accessibility tree with `@e<n>` refs. Interact with `click @e<n>`, `fill @e<n> "text"`, `select @e<n> "option"`. Refs are stable only within a snapshot -- re-snapshot after each navigation or DOM change.
|
||||
6. **Screenshot at meaningful steps.** Save to `.bot-artifacts/step-<n>.png`. Take one when you land on the page, one at the point where the reporter says the bug appears, and one of the broken state. Use `--full` only when the bug is below the fold. Keep file sizes reasonable.
|
||||
7. **Watch for JS errors.** After each interaction, run `agent-browser console` and `agent-browser errors`. Capture anything that looks related to the symptom. Console warnings about React keys or unmounted setState are almost never the bug; runtime exceptions usually are.
|
||||
8. **Confirm the failure mode matches.** A snapshot that shows a different broken state is not a reproduction. If you can only get the page into an adjacent broken state, say so in notes. Write down the exact replayable step sequence (URL, refs/selectors, inputs, the observed broken state) so a maintainer can follow it without you.
|
||||
|
||||
## When to skip
|
||||
|
||||
Mark `skipped: true` and explain in notes when:
|
||||
|
||||
- The bug requires a specific browser engine that agent-browser's headless Chromium cannot drive faithfully (rare; usually a Safari-specific layout quirk).
|
||||
- The bug requires OS-level interaction beyond what a headless browser supports -- native file pickers in non-trivial drag-drop, OS clipboard internals, IME flows, hardware key combinations.
|
||||
- The bug only reproduces with a real user's browser extensions or profile (e.g. a password manager or autofill that injects into inputs), which a clean headless browser does not have. Say so in notes -- this is a real bug class the bot cannot trigger.
|
||||
- The bug requires real Cloudflare Access in front of the admin. The dev-bypass path skips Access; if the symptom is specifically "Access redirects me incorrectly", you cannot reproduce it locally.
|
||||
- The reporter's repro depends on production data, third-party OAuth, or a hosted environment.
|
||||
- The demo will not boot for an unrelated reason and the failure is in setup, not in the admin code.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
- Whether you reproduced the bug.
|
||||
- Whether you skipped (with reason if so).
|
||||
- The approach you used: `agent-browser-only` or `none`.
|
||||
- Notes: a short paragraph naming the demo, the URL path where the symptom appeared, the interaction sequence in plain prose, and any console or runtime errors.
|
||||
- A list of screenshots, each with the relative filename under `.bot-artifacts/` and a one-line description of what it shows.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: repro-api
|
||||
description: Reproduce an EmDash bug that lives below the browser layer -- REST handlers, CLI, MCP, migrations, schema registry, or build tooling. No agent-browser. Prefer a failing vitest test in the affected package.
|
||||
---
|
||||
|
||||
# Reproduce: API / CLI / Migration / Build
|
||||
|
||||
The issue you are reproducing does not need a browser. It is in a handler, the CLI, the MCP server, a migration, the schema registry, or the build pipeline. Your goal is a deterministic local reproduction the bot can describe in a comment, ideally as a failing vitest test that becomes the regression fixture once the bug is fixed.
|
||||
|
||||
## Hard prohibitions
|
||||
|
||||
- No `git commit`, no `git push`, no branch creation that survives the workflow.
|
||||
- No writes to GitHub (no `gh issue comment`, `gh pr ...`, `gh issue edit`).
|
||||
- No `curl` to arbitrary external hosts. Local processes only.
|
||||
- Do not touch any issue other than the one being investigated.
|
||||
- No `pnpm publish` or `npm publish`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Re-read the issue body.** Pull out the exact commands, file paths, package names, and stack traces. The reproduction you write should match the user's words, not a paraphrase of them. If the body links to a repo or gist, fetch it (read-only) before deciding on the approach.
|
||||
2. **Identify the package.** Use `area` plus any file paths in the issue body. CLI bugs live in `packages/core/src/cli/`. REST handlers in `packages/core/src/api/handlers/`. Migrations in `packages/core/src/database/migrations/`. Build tooling typically in `packages/*/tsdown.config.ts` or the root `pnpm-workspace.yaml`. MCP in `packages/core/src/mcp/`. If multiple packages are plausible, search with `grep` before guessing.
|
||||
3. **Install if needed.** If `node_modules` looks stale or missing, run `pnpm install`. Otherwise skip it -- installs are slow and the runner usually has the deps already.
|
||||
4. **Build only what you must.** Most reproductions can target source directly via vitest. Only run `pnpm --filter <package> build` if the bug is in compiled output or in cross-package type generation.
|
||||
5. **Choose an approach.** In order of preference:
|
||||
- **Failing vitest test** in the affected package's `tests/` directory. Use `setupTestDatabase()` / `setupForDialect()` from `tests/utils/test-db.ts` for anything that touches the database. Mirror the source structure (`packages/core/src/api/handlers/foo.ts` -> `packages/core/tests/integration/api/handlers/foo.test.ts`). Name the test for the issue: `it("reproduces #<number>: <short description>", ...)`. Run it with `pnpm --filter <package> test <path>` and confirm it fails for the reason the user reported, not for an unrelated setup error.
|
||||
- **Repro script** under `/tmp/repro-<issueNumber>/` when a vitest test would need too much scaffolding (e.g. needs a built CLI binary, needs to spawn child processes in a specific order). Keep it to a single file when possible. Capture stdout, stderr, and exit code.
|
||||
- **`pnpm exec emdash ...` command** when the bug is a single CLI invocation and the failure is obvious from the output.
|
||||
6. **Capture evidence.** For each attempt, record the exact command, the relevant stdout/stderr (trim to the meaningful slice -- do not dump thousands of lines), and the exit code.
|
||||
7. **Confirm the failure mode matches.** A reproduction that crashes for a different reason than the user reported is not a reproduction. If you can only trigger an adjacent failure, say so in notes and lower your confidence in the result.
|
||||
|
||||
## When to skip
|
||||
|
||||
Mark `skipped: true` and explain in notes when any of the following apply. Do not burn runner minutes trying to work around these.
|
||||
|
||||
- The bug requires a specific WordPress export file, customer dataset, or other artifact the user did not attach.
|
||||
- The bug only manifests on a deployed Cloudflare Worker -- cold starts, eventual consistency, transient D1 errors, Worker isolate eviction. Local `wrangler dev` does not reproduce these faithfully.
|
||||
- The bug requires Postgres at production scale (table sizes, connection pool exhaustion, planner choices). A handful of rows in `pg` will not surface the same plan.
|
||||
- The bug requires real Cloudflare Access, R2 credentials, AI Gateway routing, or other bindings that the runner does not have.
|
||||
- The bug is timing-dependent in a way that is not reliably reproducible across runs (heisenbug). Note the symptom, leave it for a human.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
- Whether you reproduced the bug.
|
||||
- Whether you skipped (with reason if so).
|
||||
- The approach you used: `failing-test`, `repro-script`, `pnpm-command`, or `none`.
|
||||
- Notes: a short paragraph with the exact command(s), the failure output, and any context the diagnose stage will need. Include the test file path if you wrote one.
|
||||
- An empty screenshots list. This skill does not produce screenshots.
|
||||
|
||||
If you wrote a failing test, leave it in place. Do not stage or commit it. The fix stage may pick it up; if no fix runs, the orchestrator decides what to do with the working tree.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: repro-public
|
||||
description: Reproduce a bug in the public-facing rendered site (not the admin). Boots a demo with bgproc, drives the public routes with agent-browser, and captures the reproduction as screenshots plus a written transcript.
|
||||
---
|
||||
|
||||
# Reproduce: Public Site
|
||||
|
||||
The issue is in the rendered public site -- Astro pages outside `/_emdash`, the SSR output a normal site visitor sees, public routing, sitemap, RSS, image rendering, or query patterns visible to anonymous readers. You do not need an admin session. Reproduce and confirm the bug entirely through `agent-browser`: the durable artifact is your screenshots, a captured DOM slice, and a precise, replayable transcript of the steps. Do not write Playwright (or any other) tests -- the bot cannot reliably run them here, so an unrun test is unverified guesswork. A regression test belongs to whoever lands the fix.
|
||||
|
||||
## Hard prohibitions
|
||||
|
||||
- No `git commit`, no `git push`, no branch creation that survives the workflow.
|
||||
- No GitHub writes. Read-only `gh` reads only.
|
||||
- No `curl` to arbitrary external hosts. `localhost:4321` only.
|
||||
- Do not touch any issue other than the one being investigated.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Re-read the issue.** Note the exact URL or route pattern, the content the reporter expected versus what they saw, and any headers or query strings that mattered. Public-site bugs often depend on the locale, the requested format (HTML vs RSS), or the presence of specific content rows -- be precise.
|
||||
2. **Pick a demo.** `demos/simple` is the default. If the issue is locale-specific, pick a demo with multiple locales seeded. If the issue is collection-specific, pick a demo that already has that collection.
|
||||
3. **Seed content if necessary.** If the issue requires a content item that the demo seed does not provide, create it with the CLI: `pnpm exec emdash content create <collection> --data '...'` (consult `skills/emdash-cli/SKILL.md` if you need the exact flags). Avoid editing seed files -- ephemeral content created via CLI is enough to reproduce and disappears with the workspace.
|
||||
4. **Start the demo.** Run `bgproc start -n demo -w -- pnpm --filter ./demos/simple dev`. The `-w` flag waits until the dev server opens a port before returning -- Astro listens on `localhost:4321`. Inspect progress with `bgproc logs -n demo` if it does not come up.
|
||||
5. **Open the affected route.** `agent-browser open "http://localhost:4321/<path>"`. Use the exact path from the issue. If the issue mentions a query string or specific `Accept` header, include it.
|
||||
6. **Inspect the rendered output.** `agent-browser snapshot -i -c` gives you the accessibility tree. `agent-browser get text @e<n>` extracts text from a region. For RSS or non-HTML output, fetch via the browser's network panel rather than `curl` -- the browser will follow the demo's Astro routing the same way a visitor does.
|
||||
7. **Check for runtime errors.** `agent-browser console` for warnings about hydration, missing data, or 404 sub-requests. `agent-browser errors` for thrown exceptions during render or hydration.
|
||||
8. **Screenshot at meaningful states.** Save to `.bot-artifacts/step-<n>.png`. One of the page as loaded, one of the specific broken element if it is visible.
|
||||
9. **Confirm the failure mode matches.** Public-site bugs are easy to misidentify because rendering differences can be caused by missing seed data, a cached build artifact, or an unrelated route. If you cannot produce exactly the symptom in the issue, say so in notes. Write down the exact replayable steps (URL, any query string or `Accept` header, the observed-vs-expected output) so a maintainer can follow it without you.
|
||||
|
||||
## When to skip
|
||||
|
||||
Mark `skipped: true` and explain in notes when:
|
||||
|
||||
- The bug requires a specific search engine crawler user-agent, OG card validator, or other third-party fetcher you cannot impersonate from `localhost`.
|
||||
- The bug requires production-scale content (pagination edge cases, sitemap chunking) that the demo cannot realistically produce in workflow time.
|
||||
- The bug only manifests on a deployed Worker -- caching headers from the CF edge, geographic routing, image transformation through the production R2 binding.
|
||||
- The bug requires a specific source dataset (e.g. WordPress import) the reporter did not attach.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
- Whether you reproduced the bug.
|
||||
- Whether you skipped (with reason if so).
|
||||
- The approach you used: `agent-browser-only` or `none`.
|
||||
- Notes: the demo used, the exact URL, the interaction sequence in plain prose, and any console or runtime errors.
|
||||
- A list of screenshots, each with the relative filename under `.bot-artifacts/` and a one-line description.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: verify
|
||||
description: Decide whether the diagnosed behaviour is actually a bug or whether the code is doing what it was designed to do. Gate the fix stage.
|
||||
---
|
||||
|
||||
# Verify
|
||||
|
||||
Diagnose found code that explains the symptom. That does not mean the code is wrong. Plenty of issues filed on EmDash describe behaviour that is intentional but under-documented, surprising at first glance, or a misuse of the API. Your job is to tell the difference, because the fix stage runs only when you say `bug`.
|
||||
|
||||
You read code, comments, docs, tests, and AGENTS.md. You do not modify anything. No source edits, no test runs, no demo boots.
|
||||
|
||||
## Hard prohibitions
|
||||
|
||||
- No `git commit`, no `git push`, no edits to source.
|
||||
- No GitHub writes. Read-only `gh` reads only.
|
||||
- No `curl` to arbitrary external hosts.
|
||||
- Do not touch any issue other than the one being investigated.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Re-read the diagnose output.** The file, the line range, the prose. Keep this in mind as you cross-reference.
|
||||
2. **Read the surrounding code, not just the line.** Look at:
|
||||
- Comments immediately above and below the diagnosed line.
|
||||
- The function's docstring or JSDoc, if any.
|
||||
- The function's name and signature -- often documents intent.
|
||||
- Adjacent branches and other call sites of the same function.
|
||||
3. **Cross-reference documentation.**
|
||||
- `AGENTS.md` and `CONTRIBUTING.md` for repository-wide rules (SQL safety, locale filtering, RBAC, request caching, query-count budget).
|
||||
- `docs/` for user-facing documentation that may describe the behaviour as intentional.
|
||||
- The package's own README or top-level docstring.
|
||||
4. **Cross-reference tests.** If there is an existing test that asserts the current behaviour, the behaviour is intentional unless the test itself is wrong. Open the test and read what it asserts and why. A test named for the diagnosed function is the strongest signal of intent the repo has.
|
||||
5. **Decide.** Three verdicts only:
|
||||
- **bug** -- the behaviour matches the code, the code does not match documented or clearly implied intent, and the reporter's expectation is reasonable. Examples: missing `locale` filter on a content query, off-by-one in pagination, a route that returns 500 where it should return 404, a permission check that admits the wrong actor.
|
||||
- **intended-behavior** -- the behaviour matches the code, and the code matches documented intent. Examples: the API returns `{ items, nextCursor }` not a bare array (documented in AGENTS.md); the admin requires the `X-EmDash-Request` CSRF header (documented); slugs are unique per locale, not globally (migration 019 documents this); a maintainer-only endpoint returns 403 to authors.
|
||||
- **unclear** -- the documentation is silent and the code's intent cannot be inferred. Maybe a bug, maybe not. The maintainer needs to make the call.
|
||||
6. **Resist two failure modes.**
|
||||
- Do not declare `intended-behavior` just because a test exists. A test that asserts wrong behaviour is itself part of the bug.
|
||||
- Do not declare `bug` just because the reporter is upset. Reporter frustration is not a verdict.
|
||||
7. **Explain.** For every verdict, write the reasoning in one or two short paragraphs. Cite the specific comment, doc section, or test by path. For `intended-behavior`, say explicitly what the documented intent is, so the bot can post a comment that points the reporter at the docs (`"I think this is by design -- see <doc> / <test> -- but happy to revisit if you disagree."`). For `unclear`, list what you would need to know to decide.
|
||||
|
||||
## Output
|
||||
|
||||
Return:
|
||||
|
||||
- A verdict: `bug`, `intended-behavior`, or `unclear`.
|
||||
- Reasoning: the prose that supports the verdict, with paths to the comments, docs, or tests you relied on.
|
||||
|
||||
The orchestrator uses your verdict as a gate. `bug` triggers the fix stage when diagnose also pinned the cause (confidence not `low`) and rated the fix `mechanical` or `clear-best-option`. A `bug` whose fix `needs-design-decision`, an `unclear` verdict, or `intended-behavior` all stop here and produce a comment-only outcome for a maintainer.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["workflows/**/*.ts", "lib/**/*.ts", "scripts/**/*.ts"],
|
||||
"exclude": [".build", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Classify a maintainer's directive to the investigation bot.
|
||||
//
|
||||
// Triggered by .github/workflows/maintainer-reply.yml when someone with a
|
||||
// real admin/write/triage role on the repo (checked via the permission API,
|
||||
// not the spoofable author_association) addresses `@emdashbot` on an issue in
|
||||
// `triage/reproduced` or `triage/by-design`. The workflow YAML reads the intent
|
||||
// from this run's output and decides whether to dispatch a directed investigate
|
||||
// run, flag the issue as by-design, disengage, or ask for clarification.
|
||||
//
|
||||
// Cheap kimi prompt, no sandbox, no skills. Just structured output.
|
||||
|
||||
import type { FlueContext } from "@flue/runtime";
|
||||
|
||||
import { withCapacityRetry } from "../lib/capacity.js";
|
||||
import {
|
||||
classifier,
|
||||
maintainerIntentSchema,
|
||||
persistClassifierResult,
|
||||
type MaintainerIntent,
|
||||
} from "../lib/classifier.js";
|
||||
|
||||
interface ClassifyMaintainerReplyPayload {
|
||||
issueNumber: number;
|
||||
/** The maintainer's comment body, verbatim. */
|
||||
replyBody: string;
|
||||
/**
|
||||
* The bot's investigation comment, so the model can resolve references
|
||||
* like "go with option A" or "the second one". The orchestrator passes
|
||||
* the latest bot comment body verbatim when one exists.
|
||||
*/
|
||||
botContext?: string;
|
||||
}
|
||||
|
||||
export async function run({
|
||||
init,
|
||||
payload,
|
||||
log,
|
||||
}: FlueContext<ClassifyMaintainerReplyPayload>): Promise<MaintainerIntent> {
|
||||
if (!payload.replyBody) {
|
||||
throw new Error("payload.replyBody is required");
|
||||
}
|
||||
|
||||
const harness = await init(classifier);
|
||||
const session = await harness.session();
|
||||
|
||||
const prompt = [
|
||||
"You are reading a maintainer's reply to the EmDash investigation bot on a GitHub issue.",
|
||||
"The bot has already investigated the issue and may have proposed a fix or a set of options.",
|
||||
"Map the maintainer's instruction to exactly one intent.",
|
||||
"",
|
||||
"## Bot's investigation",
|
||||
"",
|
||||
// Truthiness, not `??`: the orchestrator passes "" (not undefined) when
|
||||
// there are no bot comments, and an empty section loses the model's cue.
|
||||
// Neutral wording -- this fires for `by-design` too, where the bot found
|
||||
// intended behavior rather than reproducing a bug.
|
||||
payload.botContext?.trim() ||
|
||||
"(unavailable; assume the bot has already investigated this issue)",
|
||||
"",
|
||||
"## Maintainer's reply",
|
||||
"",
|
||||
payload.replyBody,
|
||||
"",
|
||||
"## Intents",
|
||||
"",
|
||||
'- `implement` -- the maintainer wants the fix built. Covers both approving the bot\'s proposal ("go with option A", "ship it", "yes, do it") and naming a different approach ("use ?url instead of the layer", "do A but namespace it as emdash-admin", "the root cause is right but fix it in X"). Put the concrete instruction in `directive`.',
|
||||
"- `close` -- the maintainer says this is not a bug, is intended/by-design, or should be closed/wontfixed.",
|
||||
"- `takeover` -- the maintainer is taking this over manually and wants the bot to stop / disengage.",
|
||||
"- `unclear` -- a question, an aside, or anything without an actionable instruction.",
|
||||
"",
|
||||
"## How to decide",
|
||||
"",
|
||||
"When the maintainer wants the fix built, choose `implement` and put a self-contained instruction in `directive` -- one the fix agent can follow WITHOUT re-reading this conversation, so spell out the chosen option concretely. Reserve `unclear` for a comment with no actionable instruction at all -- a question, an aside, or no decision. Only choose `close`/`takeover` on an explicit close-or-stop instruction: a wrong one disengages the bot.",
|
||||
"",
|
||||
"Quote the specific phrase that drove your decision in the reasoning field.",
|
||||
].join("\n");
|
||||
|
||||
const { data } = await withCapacityRetry(
|
||||
(signal) => session.prompt(prompt, { result: maintainerIntentSchema, signal }),
|
||||
{
|
||||
label: `classify-maintainer-reply#${payload.issueNumber}`,
|
||||
attempts: 4,
|
||||
perAttemptTimeoutMs: 90_000,
|
||||
onRetry: ({ attempt, delayMs, error }) =>
|
||||
log.warn?.("model over capacity, backing off", {
|
||||
issueNumber: payload.issueNumber,
|
||||
attempt,
|
||||
delayMs,
|
||||
error: String(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
log.info("classified maintainer reply", {
|
||||
issueNumber: payload.issueNumber,
|
||||
intent: data.intent,
|
||||
});
|
||||
return persistClassifierResult(data);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Classify a reporter's reply to the bot's verification ask.
|
||||
//
|
||||
// Triggered by .github/workflows/reporter-reply.yml when the issue
|
||||
// author comments on an issue that has the `triage/awaiting-reporter`
|
||||
// label. The workflow YAML reads the classification from this run's
|
||||
// output and decides whether to open a PR, retry, or ask for
|
||||
// clarification.
|
||||
//
|
||||
// Cheap kimi prompt, no sandbox, no skills. Just structured output.
|
||||
|
||||
import type { FlueContext } from "@flue/runtime";
|
||||
|
||||
import { withCapacityRetry } from "../lib/capacity.js";
|
||||
import {
|
||||
classifier,
|
||||
persistClassifierResult,
|
||||
replyClassificationSchema,
|
||||
type ReplyClassification,
|
||||
} from "../lib/classifier.js";
|
||||
|
||||
interface ClassifyReplyPayload {
|
||||
issueNumber: number;
|
||||
replyBody: string;
|
||||
/**
|
||||
* The bot's original ask, so the model can decide what "yes" or
|
||||
* "no" is in reference to. The orchestrator passes the previous
|
||||
* bot comment body verbatim.
|
||||
*/
|
||||
botAsk?: string;
|
||||
}
|
||||
|
||||
export async function run({
|
||||
init,
|
||||
payload,
|
||||
log,
|
||||
}: FlueContext<ClassifyReplyPayload>): Promise<ReplyClassification> {
|
||||
if (!payload.replyBody) {
|
||||
throw new Error("payload.replyBody is required");
|
||||
}
|
||||
|
||||
const harness = await init(classifier);
|
||||
const session = await harness.session();
|
||||
|
||||
const prompt = [
|
||||
"You are reading a GitHub issue reporter's reply to the EmDash investigation bot's verification request.",
|
||||
"Decide whether the reply confirms the proposed fix works, says it does not, or is too ambiguous to act on.",
|
||||
"",
|
||||
"## Bot's ask",
|
||||
"",
|
||||
payload.botAsk ??
|
||||
"(unavailable; assume the bot asked the reporter to install a preview release and confirm whether their bug is fixed)",
|
||||
"",
|
||||
"## Reporter's reply",
|
||||
"",
|
||||
payload.replyBody,
|
||||
"",
|
||||
"## How to decide",
|
||||
"",
|
||||
"- `positive` -- the reporter clearly says the fix works, the bug is gone, the preview works, or otherwise indicates success.",
|
||||
"- `negative` -- the reporter says the fix does not work, the bug persists, they hit a new problem, or the fix is wrong.",
|
||||
"- `unclear` -- the reply is off-topic, asks a question without answering, requests changes without confirming or denying, or is too short to tell.",
|
||||
"",
|
||||
"Default to `unclear` when in doubt. A wrong `positive` opens a PR; a wrong `negative` re-runs an expensive investigation.",
|
||||
"",
|
||||
"Quote the specific phrase that drove your decision in the reasoning field.",
|
||||
].join("\n");
|
||||
|
||||
const { data } = await withCapacityRetry(
|
||||
(signal) => session.prompt(prompt, { result: replyClassificationSchema, signal }),
|
||||
{
|
||||
label: `classify-reply#${payload.issueNumber}`,
|
||||
attempts: 4,
|
||||
perAttemptTimeoutMs: 90_000,
|
||||
onRetry: ({ attempt, delayMs, error }) =>
|
||||
log.warn?.("model over capacity, backing off", {
|
||||
issueNumber: payload.issueNumber,
|
||||
attempt,
|
||||
delayMs,
|
||||
error: String(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
log.info("classified reply", {
|
||||
issueNumber: payload.issueNumber,
|
||||
classification: data.classification,
|
||||
});
|
||||
return persistClassifierResult(data);
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
// Investigate workflow.
|
||||
//
|
||||
// Triggered from .github/workflows/investigate.yml when a maintainer
|
||||
// adds `bot:repro` to an issue (or via workflow_dispatch on retry).
|
||||
// Drives a four-stage pipeline over an EmDash checkout:
|
||||
//
|
||||
// 1. Classify -- decide kind/area/requiresBrowser. Bail early for
|
||||
// non-bug kinds.
|
||||
// 2. Reproduce -- run one of three sub-skills based on area:
|
||||
// repro-api (no browser), repro-admin (agent-browser + dev bypass),
|
||||
// or repro-public (agent-browser against the public site). Skips
|
||||
// cleanly when the bug requires external data or production-only
|
||||
// conditions.
|
||||
// 3. Diagnose -- read the code paths that explain the reproduction
|
||||
// and rate confidence.
|
||||
// 4. Verify -- decide whether the diagnosed behaviour is actually a
|
||||
// bug or intended. Gates the fix stage.
|
||||
// 5. Fix -- runs when verify=='bug', diagnose.confidence!='low', and
|
||||
// diagnose.fixApproach!='needs-design-decision'. Runs on a cheaper
|
||||
// model in its own session (the reasoning is already done; it
|
||||
// implements diagnose's proposedFix). Writes the change, runs the
|
||||
// reproduce test, the broader package tests, typecheck, lint,
|
||||
// format. Stages but does not commit -- the YAML orchestrator does.
|
||||
//
|
||||
// Every stage uses session.skill() with a valibot result schema. The
|
||||
// orchestrator (the GH Actions workflow) reads the final JSON via jq
|
||||
// and decides which label to apply and what to comment.
|
||||
//
|
||||
// The agent uses local() so its bash tool has real pnpm/git/gh/node/
|
||||
// agent-browser on $PATH. AGENT_GH_TOKEN (read-only) is the only token
|
||||
// passed into the sandbox env. The orchestrator's app token lives in
|
||||
// the workflow YAML and never crosses into this agent process.
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
import { createAgent, type FlueContext } from "@flue/runtime";
|
||||
import { local } from "@flue/runtime/node";
|
||||
import * as v from "valibot";
|
||||
|
||||
import { withCapacityRetry } from "../lib/capacity.js";
|
||||
import { issueClassificationSchema, type IssueClassification } from "../lib/classifier.js";
|
||||
// Skill imports. Each is bundled as a SkillReference by the Flue build
|
||||
// and works the same on Node (this workflow runs on GH Actions) or
|
||||
// Cloudflare (not used today, but the workflow is portable).
|
||||
import diagnose from "../skills/diagnose/SKILL.md" with { type: "skill" };
|
||||
import fix from "../skills/fix/SKILL.md" with { type: "skill" };
|
||||
import reproAdmin from "../skills/repro-admin/SKILL.md" with { type: "skill" };
|
||||
import reproApi from "../skills/repro-api/SKILL.md" with { type: "skill" };
|
||||
import reproPublic from "../skills/repro-public/SKILL.md" with { type: "skill" };
|
||||
import verify from "../skills/verify/SKILL.md" with { type: "skill" };
|
||||
|
||||
// ---------- Payload + result schemas ----------
|
||||
|
||||
interface InvestigatePayload {
|
||||
issueNumber: number;
|
||||
issueTitle: string;
|
||||
issueBody: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
/** Reporter feedback from a previous attempt, when re-triggered. */
|
||||
retryContext?: string;
|
||||
/**
|
||||
* A maintainer's authoritative implementation directive, when the run
|
||||
* was triggered by `maintainer-reply.yml`. Its presence overrides the
|
||||
* fix gate: the maintainer has made the design call diagnose deferred,
|
||||
* so the fix stage runs even on a `needs-design-decision`. The produced
|
||||
* fix then flows through the orchestrator's normal `awaiting-reporter`
|
||||
* loop -- the directive changes whether a fix is attempted, not what
|
||||
* happens to it afterwards.
|
||||
*/
|
||||
maintainerDirective?: string;
|
||||
}
|
||||
|
||||
const reproduceResultSchema = v.object({
|
||||
reproduced: v.boolean(),
|
||||
skipped: v.boolean(),
|
||||
approach: v.picklist([
|
||||
"failing-test",
|
||||
"repro-script",
|
||||
"pnpm-command",
|
||||
"agent-browser-only",
|
||||
"none",
|
||||
]),
|
||||
notes: v.pipe(v.string(), v.minLength(10), v.maxLength(6000)),
|
||||
screenshots: v.array(
|
||||
v.object({
|
||||
// Filename is interpolated into a markdown image URL
|
||||
// (https://raw.githubusercontent.com/.../<filename>) and
|
||||
// must not contain characters that would break out of the
|
||||
// `` syntax or path-traverse on the artifacts
|
||||
// branch. The schema enforces a tight allowlist; the
|
||||
// orchestrator validates again before rendering.
|
||||
filename: v.pipe(
|
||||
v.string(),
|
||||
v.minLength(1),
|
||||
v.maxLength(80),
|
||||
v.regex(/^[a-zA-Z0-9._-]+$/, "filename must be [a-zA-Z0-9._-]+"),
|
||||
),
|
||||
// Description is interpolated as the alt text in
|
||||
// ``. It is rendered as text, not parsed as
|
||||
// markdown, but unescaped `]` could close the alt-text
|
||||
// span and let the rest of the description leak into the
|
||||
// surrounding comment. Cap the length and let the YAML
|
||||
// MD-escape the residual.
|
||||
description: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
type ReproduceResult = v.InferOutput<typeof reproduceResultSchema>;
|
||||
|
||||
// `confidence` rates certainty in the *root cause* (have we found the
|
||||
// code responsible?). `fixApproach` rates clarity of the *fix*, an
|
||||
// independent axis -- a bug can have an unambiguous cause but a fix
|
||||
// shape that needs a maintainer's design call, or a clearly-correct
|
||||
// fix that happens to be larger than one line. The old single-axis
|
||||
// `high` rating conflated the two and starved the fix stage of real,
|
||||
// fixable bugs (see issues #1178, #1199). `as const` preserves the
|
||||
// literal unions under valibot's inference, same reason as verify.
|
||||
const diagnoseResultSchema = v.object({
|
||||
rootCause: v.pipe(v.string(), v.minLength(10), v.maxLength(2000)),
|
||||
confidence: v.picklist(["high", "medium", "low"] as const),
|
||||
fixApproach: v.picklist(["mechanical", "clear-best-option", "needs-design-decision"] as const),
|
||||
// Always populated: the concrete change to make (mechanical /
|
||||
// clear-best-option) or the options a maintainer must choose
|
||||
// between (needs-design-decision). Fed into the fix stage as its
|
||||
// target, and surfaced in the maintainer comment when fix defers.
|
||||
proposedFix: v.pipe(v.string(), v.minLength(10), v.maxLength(2000)),
|
||||
hypothesisNotes: v.pipe(v.string(), v.maxLength(2000)),
|
||||
});
|
||||
type DiagnoseResult = v.InferOutput<typeof diagnoseResultSchema>;
|
||||
|
||||
// `as const` on the picklist preserves the literal union under
|
||||
// valibot's `InferOutput` inference. Without it, oxlint's type-aware
|
||||
// pass collapses `VerifyResult["verdict"]` to `any`, which then
|
||||
// poisons the union in `InvestigateResult["verdict"]`.
|
||||
const verifyResultSchema = v.object({
|
||||
verdict: v.picklist(["bug", "intended-behavior", "unclear"] as const),
|
||||
reasoning: v.pipe(v.string(), v.minLength(10), v.maxLength(2000)),
|
||||
});
|
||||
type VerifyResult = v.InferOutput<typeof verifyResultSchema>;
|
||||
|
||||
const fixResultSchema = v.object({
|
||||
fixed: v.boolean(),
|
||||
commitMessage: v.pipe(v.string(), v.minLength(10), v.maxLength(200)),
|
||||
filesChanged: v.array(v.string()),
|
||||
testStillPasses: v.boolean(),
|
||||
notes: v.pipe(v.string(), v.maxLength(2000)),
|
||||
});
|
||||
type FixResult = v.InferOutput<typeof fixResultSchema>;
|
||||
|
||||
/**
|
||||
* Flat result returned from `run()`. The orchestrator's bash uses
|
||||
* `jq` against this -- flat top-level booleans are easier to branch
|
||||
* on than nested objects, so we hoist the gating fields out of their
|
||||
* stage results. Stage details remain available under their named
|
||||
* keys for inclusion in the comment.
|
||||
*/
|
||||
interface InvestigateResult {
|
||||
// Gating fields the orchestrator reads to pick a label/outcome.
|
||||
skipped: boolean;
|
||||
reproduced: boolean;
|
||||
fixed: boolean;
|
||||
verdict: VerifyResult["verdict"] | "";
|
||||
// Headline strings the orchestrator may interpolate into the comment.
|
||||
reason: string;
|
||||
attempts: string;
|
||||
notes: string;
|
||||
// Detailed stage outputs, kept for comment composition + debugging.
|
||||
classification: IssueClassification;
|
||||
reproduce?: ReproduceResult;
|
||||
diagnose?: DiagnoseResult;
|
||||
verify?: VerifyResult;
|
||||
fix?: FixResult;
|
||||
// Things the YAML needs to push branches.
|
||||
screenshots: ReproduceResult["screenshots"];
|
||||
commitMessage: string;
|
||||
filesChanged: string[];
|
||||
}
|
||||
|
||||
// ---------- Agents ----------
|
||||
|
||||
// Classifier: cheap kimi call on the default in-memory sandbox. It has
|
||||
// no access to the EmDash checkout and so cannot read AGENTS.md for repo
|
||||
// context. Inline a short primer here so it can map issues to the
|
||||
// correct `area` instead of guessing. Without it, kimi spends most of
|
||||
// its budget reasoning about what EmDash is and where a bug lives.
|
||||
const classifierAgent = createAgent(() => ({
|
||||
model: "cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code",
|
||||
instructions: [
|
||||
"You classify GitHub issues for the EmDash CMS investigation bot. Output strictly matches the requested schema.",
|
||||
"",
|
||||
"EmDash is an Astro-native CMS that runs on Cloudflare (D1 + R2 + Workers) or Node + SQLite. Map the `area` field as follows:",
|
||||
"- admin: the React admin SPA mounted at `/_emdash/admin/*` -- the content editor, dashboards, settings, and any authoring UI. The post/page editor (rich text, code blocks, media pickers, field inputs) is admin.",
|
||||
"- public: the rendered public site a visitor sees -- Astro pages outside `/_emdash`, SSR output, routing, sitemap, RSS, image rendering.",
|
||||
"- api: the `/_emdash/api/*` HTTP routes and their handlers (REST, auth, content CRUD) when the bug is in the request/response, not a UI.",
|
||||
"- migration: database migrations or schema changes.",
|
||||
"- build: building, bundling, packaging, or type generation.",
|
||||
"- other: anything that does not fit the above.",
|
||||
"",
|
||||
"requiresBrowser is true for admin and public bugs (they need a real browser to reproduce) and false otherwise.",
|
||||
].join("\n"),
|
||||
}));
|
||||
|
||||
// Shared local() sandbox config. Both the investigator and the fix
|
||||
// agent run shell commands against the same EmDash checkout, so they
|
||||
// use identical sandbox settings -- cwd pinned to GITHUB_WORKSPACE (so
|
||||
// skill resolution and bash land in the checkout, not in .flue/) and a
|
||||
// read-only GH token. Because both sandboxes point at the same cwd,
|
||||
// edits the fix agent stages on disk are exactly what the orchestrator
|
||||
// later commits, even though fix runs in its own session.
|
||||
function investigateSandbox(cwd: string) {
|
||||
return local({
|
||||
cwd,
|
||||
env: {
|
||||
// Read-only token. The agent can clone and read issues; it
|
||||
// cannot comment, label, or push. The orchestrator owns
|
||||
// every write.
|
||||
GH_TOKEN: process.env.AGENT_GH_TOKEN,
|
||||
CI: "true",
|
||||
NODE_ENV: "test",
|
||||
// Used by bgproc when the repro-admin or repro-public skill
|
||||
// boots `pnpm dev`. Standard Node convention.
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Investigator: opus + local() sandbox. Runs the reasoning-heavy
|
||||
// stages -- reproduce, diagnose, verify. The fix stage runs on a
|
||||
// separate, cheaper agent (below), so fix is intentionally NOT in this
|
||||
// agent's skill set.
|
||||
const investigatorAgent = createAgent(() => {
|
||||
const cwd = process.env.GITHUB_WORKSPACE ?? process.cwd();
|
||||
return {
|
||||
model: process.env.FLUE_INVESTIGATE_MODEL ?? "cloudflare-ai-gateway/claude-opus-4-7",
|
||||
cwd,
|
||||
sandbox: investigateSandbox(cwd),
|
||||
instructions: [
|
||||
"You are EmDash's investigation bot.",
|
||||
"You walk the reasoning stages (reproduce -> diagnose -> verify) on one GitHub issue at a time.",
|
||||
"You return read-only on GitHub: no comments, no labels, no branch pushes. The orchestrator does all writes after you finish.",
|
||||
"At every stage you obey the skill's hard prohibitions and produce strictly schema-conformant output.",
|
||||
"When you guess, say you guessed; when you skip, say why.",
|
||||
].join(" "),
|
||||
skills: [reproApi, reproAdmin, reproPublic, diagnose, verify],
|
||||
};
|
||||
});
|
||||
|
||||
// Fix implementer: a cheaper coding model (Kimi K2.7 Code) is enough here because the
|
||||
// expensive reasoning is already done -- diagnose hands over a concrete
|
||||
// `proposedFix`, and this stage only runs for `mechanical` /
|
||||
// `clear-best-option` approaches. Its job is guided implementation:
|
||||
// write the change, make the reproduce test pass, run lint / typecheck
|
||||
// / format, and `git add`. It runs in its own session (fresh context,
|
||||
// fed the diagnosis explicitly via args) with the same local() sandbox
|
||||
// so it has real pnpm / git / gh on PATH and the EmDash checkout as cwd.
|
||||
const fixAgent = createAgent(() => {
|
||||
const cwd = process.env.GITHUB_WORKSPACE ?? process.cwd();
|
||||
return {
|
||||
model:
|
||||
process.env.FLUE_FIX_MODEL ??
|
||||
"cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code",
|
||||
cwd,
|
||||
sandbox: investigateSandbox(cwd),
|
||||
instructions: [
|
||||
"You are EmDash's fix implementer.",
|
||||
"Diagnose has already found the root cause and written a proposed fix; your job is to implement that plan, not to re-investigate from scratch.",
|
||||
"You return read-only on GitHub: no comments, no labels, no commits, no branch pushes. You stage changes with `git add` and stop; the orchestrator commits and pushes.",
|
||||
"Obey the fix skill's hard prohibitions and produce strictly schema-conformant output.",
|
||||
"If reading the code convinces you the proposed fix is wrong, abandon with `fixed: false` and explain why in notes rather than forcing a change you don't believe in.",
|
||||
].join(" "),
|
||||
skills: [fix],
|
||||
};
|
||||
});
|
||||
|
||||
// ---------- Stage helpers ----------
|
||||
|
||||
/**
|
||||
* Build the issue context block that every stage prompt starts with.
|
||||
* Includes retry context when present so the agent knows what reporter
|
||||
* feedback motivated this re-run.
|
||||
*/
|
||||
function issueContext(payload: InvestigatePayload): string {
|
||||
const parts = [
|
||||
`Issue #${payload.issueNumber}: ${payload.issueTitle}`,
|
||||
"",
|
||||
"## Body",
|
||||
"",
|
||||
payload.issueBody || "(no body)",
|
||||
];
|
||||
if (payload.retryContext) {
|
||||
parts.push(
|
||||
"",
|
||||
"## Reporter feedback from a previous attempt",
|
||||
"",
|
||||
payload.retryContext,
|
||||
"",
|
||||
"Treat the above as new information. Do not repeat the same approach that produced the failed previous attempt.",
|
||||
);
|
||||
}
|
||||
if (payload.maintainerDirective) {
|
||||
parts.push(
|
||||
"",
|
||||
"## Maintainer directive (authoritative)",
|
||||
"",
|
||||
payload.maintainerDirective,
|
||||
"",
|
||||
"A maintainer has decided how this should be fixed. Implement the directive above. It overrides any earlier suggestion that this needs a design decision -- the decision has been made. If reading the code convinces you the directive is mistaken, abandon with `fixed: false` and explain why rather than forcing a change you don't believe in.",
|
||||
);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** Pick the reproduce skill based on classification.area. */
|
||||
function pickReproduceSkill(area: IssueClassification["area"]) {
|
||||
switch (area) {
|
||||
case "admin":
|
||||
return reproAdmin;
|
||||
case "public":
|
||||
return reproPublic;
|
||||
default:
|
||||
// api, migration, build, other -- all go via repro-api (no browser).
|
||||
return reproApi;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- run() ----------
|
||||
|
||||
/**
|
||||
* Persist the structured result to a file so the GitHub Actions
|
||||
* orchestrator can read it directly. `flue run` interleaves build-log
|
||||
* lines on stdout and pretty-prints the returned result, so scraping
|
||||
* the result back out of stdout is fragile. Writing the assembled
|
||||
* result object to a known path makes the handoff deterministic.
|
||||
*
|
||||
* The path comes from `INVESTIGATE_RESULT_PATH` (set by the workflow);
|
||||
* when it is unset -- local prototyping via run-local.ts -- we skip the
|
||||
* write and rely on the returned value. Only a clean completion writes
|
||||
* the file; a thrown error leaves no file, which the orchestrator
|
||||
* treats as a failed run.
|
||||
*/
|
||||
export async function run(ctx: FlueContext<InvestigatePayload>): Promise<InvestigateResult> {
|
||||
const result = await runImpl(ctx);
|
||||
const path = process.env.INVESTIGATE_RESULT_PATH;
|
||||
if (path) {
|
||||
try {
|
||||
writeFileSync(path, JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.error("[investigate] failed to write result file:", error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runImpl({
|
||||
init,
|
||||
payload,
|
||||
log,
|
||||
}: FlueContext<InvestigatePayload>): Promise<InvestigateResult> {
|
||||
if (!payload.issueNumber || !payload.issueTitle) {
|
||||
throw new Error("payload requires issueNumber and issueTitle");
|
||||
}
|
||||
if (!process.env.AGENT_GH_TOKEN) {
|
||||
throw new Error("AGENT_GH_TOKEN required (read-only token for the sandbox)");
|
||||
}
|
||||
|
||||
// A maintainer directive overrides the bot's *judgment* gates -- the
|
||||
// human has already decided this is worth fixing and how. It does NOT
|
||||
// override the *capability* gates (can we reproduce it? did the fix
|
||||
// hold?): those bail honestly so the maintainer learns the directive
|
||||
// couldn't be carried out rather than getting a silent no-op.
|
||||
const directed = Boolean(payload.maintainerDirective);
|
||||
|
||||
// Every model-bearing stage goes through this: it bounds each attempt with a
|
||||
// hard timeout (so a stalled Workers AI call fails loudly instead of hanging
|
||||
// the run) and retries genuine capacity (429) errors with backoff. Workers AI
|
||||
// returns 429 under load, which is why the classifier and fix stages (kimi)
|
||||
// are the most exposed.
|
||||
const withRetry = <T>(
|
||||
label: string,
|
||||
fn: (signal: AbortSignal) => PromiseLike<T>,
|
||||
perAttemptTimeoutMs: number,
|
||||
): Promise<T> =>
|
||||
withCapacityRetry(fn, {
|
||||
label: `${label}#${payload.issueNumber}`,
|
||||
attempts: 3,
|
||||
perAttemptTimeoutMs,
|
||||
onRetry: ({ attempt, delayMs, error }) =>
|
||||
log.warn?.(`${label}: model over capacity, backing off`, {
|
||||
issueNumber: payload.issueNumber,
|
||||
attempt,
|
||||
delayMs,
|
||||
error: String(error),
|
||||
}),
|
||||
});
|
||||
|
||||
// --- Stage 0: classify ---
|
||||
|
||||
const classifierHarness = await init(classifierAgent, { name: "classify" });
|
||||
const classifierSession = await classifierHarness.session();
|
||||
const { data: classification } = await withRetry(
|
||||
"classify",
|
||||
(signal) =>
|
||||
classifierSession.prompt(
|
||||
[
|
||||
"Classify the following EmDash issue.",
|
||||
"",
|
||||
issueContext(payload),
|
||||
"",
|
||||
"## Decide",
|
||||
"",
|
||||
"- kind: bug | enhancement | documentation | question",
|
||||
"- area: api | admin | public | migration | build | other",
|
||||
"- requiresBrowser: true for admin/public bugs, false otherwise",
|
||||
"- summary: one factual sentence describing the reported behaviour",
|
||||
"",
|
||||
"Return strictly the requested schema. No prose outside it.",
|
||||
].join("\n"),
|
||||
{ result: issueClassificationSchema, signal },
|
||||
),
|
||||
90_000,
|
||||
);
|
||||
log.info("classified", { issueNumber: payload.issueNumber, ...classification });
|
||||
|
||||
if (classification.kind !== "bug" && !directed) {
|
||||
return {
|
||||
skipped: true,
|
||||
reproduced: false,
|
||||
fixed: false,
|
||||
verdict: "",
|
||||
reason: `Issue classified as \`${classification.kind}\`, not a bug. The investigation pipeline only runs on bug reports.`,
|
||||
attempts: "",
|
||||
notes: "",
|
||||
classification,
|
||||
screenshots: [],
|
||||
commitMessage: "",
|
||||
filesChanged: [],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Stage 1: reproduce ---
|
||||
|
||||
const investigatorHarness = await init(investigatorAgent);
|
||||
const investigatorSession = await investigatorHarness.session();
|
||||
|
||||
const reproduceSkill = pickReproduceSkill(classification.area);
|
||||
const { data: reproduce } = await withRetry(
|
||||
"reproduce",
|
||||
(signal) =>
|
||||
investigatorSession.skill(reproduceSkill, {
|
||||
args: {
|
||||
issueContext: issueContext(payload),
|
||||
classification,
|
||||
},
|
||||
result: reproduceResultSchema,
|
||||
signal,
|
||||
}),
|
||||
12 * 60_000,
|
||||
);
|
||||
log.info("reproduce", {
|
||||
issueNumber: payload.issueNumber,
|
||||
reproduced: reproduce.reproduced,
|
||||
skipped: reproduce.skipped,
|
||||
approach: reproduce.approach,
|
||||
});
|
||||
|
||||
if (reproduce.skipped) {
|
||||
return {
|
||||
skipped: true,
|
||||
reproduced: false,
|
||||
fixed: false,
|
||||
verdict: "",
|
||||
reason: reproduce.notes,
|
||||
attempts: "",
|
||||
notes: reproduce.notes,
|
||||
classification,
|
||||
reproduce,
|
||||
screenshots: reproduce.screenshots,
|
||||
commitMessage: "",
|
||||
filesChanged: [],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Stage 2: diagnose (runs even if reproduce failed; the body alone
|
||||
// is often enough to point at the code path, with lower confidence). ---
|
||||
|
||||
const { data: diagnoseOut } = await withRetry(
|
||||
"diagnose",
|
||||
(signal) =>
|
||||
investigatorSession.skill(diagnose, {
|
||||
args: {
|
||||
issueContext: issueContext(payload),
|
||||
classification,
|
||||
reproduce,
|
||||
},
|
||||
result: diagnoseResultSchema,
|
||||
signal,
|
||||
}),
|
||||
12 * 60_000,
|
||||
);
|
||||
log.info("diagnose", {
|
||||
issueNumber: payload.issueNumber,
|
||||
confidence: diagnoseOut.confidence,
|
||||
});
|
||||
|
||||
// --- Stage 3: verify ---
|
||||
|
||||
const { data: verifyOut } = await withRetry(
|
||||
"verify",
|
||||
(signal) =>
|
||||
investigatorSession.skill(verify, {
|
||||
args: {
|
||||
issueContext: issueContext(payload),
|
||||
classification,
|
||||
diagnose: diagnoseOut,
|
||||
},
|
||||
result: verifyResultSchema,
|
||||
signal,
|
||||
}),
|
||||
12 * 60_000,
|
||||
);
|
||||
log.info("verify", { issueNumber: payload.issueNumber, verdict: verifyOut.verdict });
|
||||
|
||||
if (verifyOut.verdict === "intended-behavior" && !directed) {
|
||||
return {
|
||||
skipped: false,
|
||||
reproduced: reproduce.reproduced,
|
||||
fixed: false,
|
||||
verdict: "intended-behavior",
|
||||
reason: "",
|
||||
attempts: "",
|
||||
notes: verifyOut.reasoning,
|
||||
classification,
|
||||
reproduce,
|
||||
diagnose: diagnoseOut,
|
||||
verify: verifyOut,
|
||||
screenshots: reproduce.screenshots,
|
||||
commitMessage: "",
|
||||
filesChanged: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!reproduce.reproduced) {
|
||||
return {
|
||||
skipped: false,
|
||||
reproduced: false,
|
||||
fixed: false,
|
||||
verdict: verifyOut.verdict,
|
||||
reason: "",
|
||||
attempts: reproduce.notes,
|
||||
notes: diagnoseOut.rootCause,
|
||||
classification,
|
||||
reproduce,
|
||||
diagnose: diagnoseOut,
|
||||
verify: verifyOut,
|
||||
screenshots: reproduce.screenshots,
|
||||
commitMessage: "",
|
||||
filesChanged: [],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Stage 4: fix (conditional) ---
|
||||
//
|
||||
// Gate on two independent axes, not the old single `confidence ===
|
||||
// "high"`:
|
||||
// - verify says it's a bug,
|
||||
// - diagnose pinned the root cause with at least medium confidence
|
||||
// (a `low` cause is too shaky to write code against), and
|
||||
// - the fix is `mechanical` or `clear-best-option` -- i.e. there
|
||||
// is a correct change to make that doesn't require a maintainer's
|
||||
// design call.
|
||||
// `needs-design-decision` defers to a human even when the cause is
|
||||
// certain (e.g. the fix needs a new public API or a component that
|
||||
// doesn't exist yet).
|
||||
//
|
||||
// A maintainer directive overrides the gate entirely (see `directed`
|
||||
// above): the human has made the design call diagnose deferred, asserted
|
||||
// it's worth fixing, and asked for an implementation. We reach this point
|
||||
// only past the `!reproduce.reproduced` early return, so a directed fix is
|
||||
// always verified against a live reproduction. The fix agent abandons with
|
||||
// `fixed: false` if the directive turns out wrong.
|
||||
const shouldFix =
|
||||
directed ||
|
||||
(verifyOut.verdict === "bug" &&
|
||||
diagnoseOut.confidence !== "low" &&
|
||||
diagnoseOut.fixApproach !== "needs-design-decision");
|
||||
|
||||
if (!shouldFix) {
|
||||
// Explain precisely why no fix was attempted, since the reason
|
||||
// now varies (unclear verdict / shaky cause / design decision).
|
||||
const notAttemptedReason =
|
||||
verifyOut.verdict !== "bug"
|
||||
? "The bot could not conclusively confirm this is a bug (`unclear` verdict), so it did not attempt an automated fix."
|
||||
: diagnoseOut.confidence === "low"
|
||||
? "The root cause is not pinned down with enough confidence to write a fix against it."
|
||||
: "The fix needs a design decision a maintainer should make, so the bot did not attempt it automatically. The proposed options are above.";
|
||||
return {
|
||||
skipped: false,
|
||||
reproduced: true,
|
||||
fixed: false,
|
||||
verdict: verifyOut.verdict,
|
||||
reason: "",
|
||||
attempts: "",
|
||||
notes: [
|
||||
`**Root cause (\`${diagnoseOut.confidence}\` confidence):** ${diagnoseOut.rootCause}`,
|
||||
"",
|
||||
`**Proposed fix:** ${diagnoseOut.proposedFix}`,
|
||||
"",
|
||||
diagnoseOut.hypothesisNotes
|
||||
? `**Alternative causes considered:** ${diagnoseOut.hypothesisNotes}`
|
||||
: "",
|
||||
"",
|
||||
`**Verdict:** \`${verifyOut.verdict}\` — ${verifyOut.reasoning}`,
|
||||
"",
|
||||
notAttemptedReason,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
classification,
|
||||
reproduce,
|
||||
diagnose: diagnoseOut,
|
||||
verify: verifyOut,
|
||||
screenshots: reproduce.screenshots,
|
||||
commitMessage: "",
|
||||
filesChanged: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Fix runs on its own (cheaper) agent and a fresh session. It is fed
|
||||
// the diagnosis -- including the concrete `proposedFix` -- via args,
|
||||
// and operates on the same on-disk checkout, so its staged edits are
|
||||
// what the orchestrator commits.
|
||||
const fixHarness = await init(fixAgent, { name: "fix" });
|
||||
const fixSession = await fixHarness.session();
|
||||
const { data: fixOut } = await withRetry(
|
||||
"fix",
|
||||
(signal) =>
|
||||
fixSession.skill(fix, {
|
||||
args: {
|
||||
issueContext: issueContext(payload),
|
||||
classification,
|
||||
reproduce,
|
||||
diagnose: diagnoseOut,
|
||||
},
|
||||
result: fixResultSchema,
|
||||
signal,
|
||||
}),
|
||||
12 * 60_000,
|
||||
);
|
||||
log.info("fix", { issueNumber: payload.issueNumber, fixed: fixOut.fixed });
|
||||
|
||||
if (!fixOut.fixed) {
|
||||
return {
|
||||
skipped: false,
|
||||
reproduced: true,
|
||||
fixed: false,
|
||||
verdict: verifyOut.verdict,
|
||||
reason: "",
|
||||
attempts: "",
|
||||
notes: [
|
||||
`**Root cause:** ${diagnoseOut.rootCause}`,
|
||||
"",
|
||||
`**Fix attempt abandoned:** ${fixOut.notes}`,
|
||||
].join("\n"),
|
||||
classification,
|
||||
reproduce,
|
||||
diagnose: diagnoseOut,
|
||||
verify: verifyOut,
|
||||
fix: fixOut,
|
||||
screenshots: reproduce.screenshots,
|
||||
commitMessage: "",
|
||||
filesChanged: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
reproduced: true,
|
||||
fixed: true,
|
||||
verdict: verifyOut.verdict,
|
||||
reason: "",
|
||||
attempts: "",
|
||||
notes: [
|
||||
`**Root cause:** ${diagnoseOut.rootCause}`,
|
||||
"",
|
||||
`**Fix applied:** ${fixOut.notes}`,
|
||||
].join("\n"),
|
||||
classification,
|
||||
reproduce,
|
||||
diagnose: diagnoseOut,
|
||||
verify: verifyOut,
|
||||
fix: fixOut,
|
||||
screenshots: reproduce.screenshots,
|
||||
commitMessage: fixOut.commitMessage,
|
||||
filesChanged: fixOut.filesChanged,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in EmDash
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug. Please fill out the sections below so we can reproduce and fix it.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened? What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps to trigger the bug. Include code snippets, config, or a link to a reproduction repo if possible.
|
||||
placeholder: |
|
||||
1. Create a collection with ...
|
||||
2. Navigate to ...
|
||||
3. Click ...
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Relevant version info.
|
||||
placeholder: |
|
||||
- emdash version: x.x.x
|
||||
- Node.js version: x.x.x
|
||||
- Runtime: Node / Cloudflare Workers
|
||||
- OS: macOS / Linux / Windows
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / error output
|
||||
description: Paste any relevant error messages or stack traces.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature request
|
||||
url: https://github.com/emdash-cms/emdash/discussions/categories/ideas
|
||||
about: Propose a feature in Discussions. Feature PRs without a prior approved Discussion will be closed.
|
||||
- name: Question
|
||||
url: https://github.com/emdash-cms/emdash/discussions/categories/q-a
|
||||
about: Ask a question in Discussions.
|
||||
@@ -0,0 +1,40 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Describe the change and why it's needed. Link to a related issue or discussion. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Check one. If "Feature", a prior Discussion is required — see below. -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature (requires [maintainer-approved Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas))
|
||||
- [ ] Refactor (no behavior change)
|
||||
- [ ] Translation
|
||||
- [ ] Documentation
|
||||
- [ ] Performance improvement
|
||||
- [ ] Tests
|
||||
- [ ] Chore (dependencies, CI, tooling)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md)
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm lint` passes
|
||||
- [ ] `pnpm test` passes (or targeted tests for my change)
|
||||
- [ ] `pnpm format` has been run
|
||||
- [ ] I have added/updated tests for my changes (if applicable)
|
||||
- [ ] User-visible strings in the admin UI are [wrapped for translation](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md#internationalization-i18n) (if applicable). Do not include `messages.po` changes except in translation PRs — a workflow extracts catalogs on merge to `main`.
|
||||
- [ ] I have added a [changeset](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md#changesets) (if this PR changes a published package)
|
||||
- [ ] New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...
|
||||
|
||||
## AI-generated code disclosure
|
||||
|
||||
<!-- If any part of this PR was generated by AI tools (Copilot, Claude, GPT, Cursor, etc.), check the box and name the model(s)/tool(s) you used. Naming the model lets reviewers run the review pass with a different model family to catch family-specific blind spots. -->
|
||||
|
||||
- [ ] This PR includes AI-generated code — model/tool: <!-- e.g. Claude Opus 4.7, GPT-5.5, Cursor + Sonnet 4.6, Copilot -->
|
||||
|
||||
## Screenshots / test output
|
||||
|
||||
<!-- Optional. Include if the change is visual or if you want to show test results. -->
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$comment": "Model registry for /bonk and /review keyword routing. The first word after /bonk, /review, or @ask-bonk in a comment selects the alias; an unknown or absent word falls back to `default`. Each registration is the opencode model definition that gets injected into OPENCODE_CONFIG_CONTENT so the opencode version pinned in the workflows can resolve models released after its bundled models.dev snapshot. The `options` field passes through to AI SDK providerOptions and carries per-call reasoning controls -- shape is provider-specific: Anthropic uses `effort` + `thinking.type`, OpenAI uses `reasoningEffort`, Google uses `thinkingConfig.thinkingLevel` (Gemini 3+) or `thinkingConfig.thinkingBudget` (Gemini 2.5).",
|
||||
"default": "opus",
|
||||
"models": {
|
||||
"opus": {
|
||||
"id": "anthropic/claude-opus-4-7",
|
||||
"registration": {
|
||||
"id": "anthropic/claude-opus-4-7",
|
||||
"options": {
|
||||
"effort": "xhigh",
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"kimi": {
|
||||
"id": "workers-ai/@cf/moonshotai/kimi-k2.7-code",
|
||||
"registration": {
|
||||
"id": "workers-ai/@cf/moonshotai/kimi-k2.7-code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# npm ecosystem covers pnpm, including workspace catalogs (GA Feb 2025).
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
# pnpm enforces minimumReleaseAge (1440min / 1 day) at install time, so a
|
||||
# PR for a too-fresh version would fail `pnpm install` in CI. Hold updates
|
||||
# back past that gate (with margin) so Dependabot never proposes a version
|
||||
# pnpm will refuse. See pnpm-workspace.yaml.
|
||||
cooldown:
|
||||
default-days: 3
|
||||
semver-major-days: 7
|
||||
# Group only minor/patch bumps. Majors carry breaking changes and
|
||||
# peer-dep incompatibilities (e.g. @vitejs/plugin-react@6 importing
|
||||
# vite/internal, which rolldown-vite doesn't export), so they break out
|
||||
# into individual PRs for separate review instead of poisoning the batch.
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: "development"
|
||||
update-types: ["minor", "patch"]
|
||||
production-dependencies:
|
||||
dependency-type: "production"
|
||||
update-types: ["minor", "patch"]
|
||||
open-pull-requests-limit: 10
|
||||
@@ -0,0 +1,122 @@
|
||||
// Attach sandboxed-plugin tarballs to the GitHub releases that
|
||||
// `changesets/action` creates during a release run.
|
||||
//
|
||||
// The decentralized plugin registry (RFC 0001) stores only a *link* to the
|
||||
// plugin bytes, not the bytes themselves. By bundling each published
|
||||
// sandboxed plugin and uploading the tarball as a release asset, every
|
||||
// version automatically gets a stable public URL that an `emdash-plugin
|
||||
// publish --url ...` step (or a human) can point a registry record at.
|
||||
//
|
||||
// Input: the `publishedPackages` output from changesets/action, passed via
|
||||
// the PUBLISHED_PACKAGES env var as a JSON array of `{ name, version }`.
|
||||
// Only packages under `packages/plugins/*` that expose a `./sandbox` export
|
||||
// and are not private are processed; everything else (native plugins, test
|
||||
// fixtures) is skipped.
|
||||
//
|
||||
// Set DRY_RUN=1 to bundle and resolve tarballs without calling `gh`.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PLUGINS_DIR = "packages/plugins";
|
||||
const BUNDLER = "packages/plugin-cli/dist/index.mjs";
|
||||
|
||||
const SLASH_RE = /\//g;
|
||||
const LEADING_AT_RE = /^@/;
|
||||
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const dryRun = process.env.DRY_RUN === "1";
|
||||
|
||||
if (!dryRun && !repo) {
|
||||
console.error("GITHUB_REPOSITORY is not set; cannot target `gh release upload`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// `?? "[]"` only catches undefined/null. An unset Actions output arrives as
|
||||
// an empty string, which would make JSON.parse throw, so coalesce that too.
|
||||
const raw = process.env.PUBLISHED_PACKAGES?.trim() || "[]";
|
||||
let published;
|
||||
try {
|
||||
published = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.error(`Could not parse PUBLISHED_PACKAGES as JSON: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!Array.isArray(published) || published.length === 0) {
|
||||
console.log("No published packages to process.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// name -> version for the packages that were just published.
|
||||
const publishedVersions = new Map(published.map((p) => [p.name, p.version]));
|
||||
|
||||
/** Slugify a manifest id the same way the bundler names its tarball. */
|
||||
const slugify = (id) => id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "");
|
||||
|
||||
const failures = [];
|
||||
let attached = 0;
|
||||
|
||||
for (const entry of readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const dir = join(PLUGINS_DIR, entry.name);
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
|
||||
// We read every plugin dir, so a single malformed package.json must not
|
||||
// abort the whole step. Treat an unreadable manifest as a skip.
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
} catch {
|
||||
console.warn(`Skipping ${entry.name}: could not read/parse package.json`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only published, public, sandboxed plugins.
|
||||
if (!publishedVersions.has(pkg.name)) continue;
|
||||
if (pkg.private === true) continue;
|
||||
if (!pkg.exports?.["./sandbox"]) {
|
||||
console.log(`Skipping ${pkg.name}: no ./sandbox export (not a sandboxed plugin).`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const version = publishedVersions.get(pkg.name);
|
||||
const tag = `${pkg.name}@${version}`;
|
||||
console.log(`\n=== ${pkg.name}@${version} ===`);
|
||||
|
||||
try {
|
||||
// Bundle. This rebuilds from source and writes dist/<slug>-<version>.tar.gz
|
||||
// plus dist/manifest.json, so we can read the exact id/version back out
|
||||
// rather than guessing the filename (stale tarballs may linger in dist/).
|
||||
execFileSync("node", [BUNDLER, "bundle", "--dir", dir], { stdio: "inherit" });
|
||||
|
||||
const manifest = JSON.parse(readFileSync(join(dir, "dist", "manifest.json"), "utf-8"));
|
||||
const tarball = join(dir, "dist", `${slugify(manifest.id)}-${manifest.version}.tar.gz`);
|
||||
if (!existsSync(tarball)) {
|
||||
throw new Error(`Expected tarball not found: ${tarball}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[dry-run] would upload ${tarball} to release ${tag}`);
|
||||
} else {
|
||||
// --clobber so re-runs replace the asset instead of failing.
|
||||
execFileSync("gh", ["release", "upload", tag, tarball, "--clobber", "--repo", repo], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
console.log(`Attached ${tarball} to ${tag}`);
|
||||
}
|
||||
attached++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to attach tarball for ${pkg.name}: ${error.message}`);
|
||||
failures.push(pkg.name);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nAttached ${attached} plugin tarball(s).`);
|
||||
if (failures.length > 0) {
|
||||
console.error(`Failed: ${failures.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { glob } from "node:fs/promises";
|
||||
|
||||
const offenders = [];
|
||||
const seen = [];
|
||||
|
||||
for await (const file of glob("**/package.json", {
|
||||
exclude: (path) =>
|
||||
path.includes("node_modules") || path.includes("/dist/") || path.includes("/.git/"),
|
||||
})) {
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (pkg.private || !pkg.name || !pkg.version) continue;
|
||||
seen.push(`${pkg.name}@${pkg.version}`);
|
||||
const major = Number.parseInt(pkg.version.split(".")[0], 10);
|
||||
if (Number.isFinite(major) && major >= 1) {
|
||||
offenders.push(`${pkg.name}@${pkg.version} (${file})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
console.error("::error::Non-0.x versions detected. Releases must stay in 0.x while in pre-1.0:");
|
||||
for (const o of offenders) console.error(` ${o}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Checked ${seen.length} non-private packages, all are 0.x.`);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const mode = process.argv[2];
|
||||
|
||||
const run = (args) => execFileSync("pnpm", args, { stdio: "inherit" });
|
||||
|
||||
// changesets/action runs `git checkout changeset-release/main` + `git reset
|
||||
// --hard` immediately before this, which can leave the deps state out of
|
||||
// sync with pnpm-workspace.yaml. The next gated pnpm call
|
||||
// (verifyDepsBeforeRun: error) would then abort the release. `pnpm install`
|
||||
// is not gated, so reconcile here, after the action's git work and before
|
||||
// any `pnpm changeset` call. Do not hoist this into an earlier workflow
|
||||
// step: the action's git reset runs after workflow steps and undoes it.
|
||||
// Must be --no-frozen-lockfile, not --prefer-frozen-lockfile: prefer-frozen
|
||||
// can take the lockfile fast path and skip rewriting the stale deps-state
|
||||
// hash, which is the exact condition that trips the gate.
|
||||
run(["install", "--no-frozen-lockfile"]);
|
||||
|
||||
if (mode === "version") {
|
||||
run(["changeset", "version"]);
|
||||
run(["install", "--no-frozen-lockfile"]);
|
||||
} else if (mode === "publish") {
|
||||
run(["changeset", "publish"]);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown release mode: ${JSON.stringify(mode)} (expected "version" or "publish")`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// Resolves the model alias from a /bonk, /review, or @ask-bonk comment body
|
||||
// and emits the model id and OPENCODE_CONFIG_CONTENT for the ask-bonk action.
|
||||
//
|
||||
// Inputs (env):
|
||||
// BODY — the raw comment / review body
|
||||
// GITHUB_OUTPUT — path to the workflow step output file
|
||||
//
|
||||
// Outputs (to $GITHUB_OUTPUT):
|
||||
// alias — resolved alias (default if none requested)
|
||||
// model — full model id passed to ask-bonk's `model:` input
|
||||
// opencode_config — JSON string for OPENCODE_CONFIG_CONTENT env var
|
||||
//
|
||||
// The first word after a trigger ("/bonk", "/review", or "@ask-bonk") selects
|
||||
// an alias from .github/bonk-models.json. An absent or unknown word falls
|
||||
// back to the registry's `default`. Only the selected model is registered in
|
||||
// the opencode provider config; the rest of the registry stays unused at
|
||||
// runtime to keep the env var small.
|
||||
|
||||
import { appendFileSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const registryPath = resolve(here, "..", "bonk-models.json");
|
||||
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
||||
|
||||
const body = process.env.BODY ?? "";
|
||||
|
||||
// Match a trigger preceded by start-of-string or whitespace, then capture the
|
||||
// next bare word. The leading-boundary guard avoids matching "/bonk" as a
|
||||
// substring of unrelated text (e.g. a URL fragment).
|
||||
const triggerRe = /(?:^|\s)(?:\/bonk|\/review|@ask-bonk)\s+([a-zA-Z][a-zA-Z0-9_-]*)/;
|
||||
const match = body.match(triggerRe);
|
||||
const requested = match ? match[1].toLowerCase() : null;
|
||||
|
||||
const fallback = registry.default;
|
||||
const alias = requested && registry.models[requested] ? requested : fallback;
|
||||
const entry = registry.models[alias];
|
||||
if (!entry) {
|
||||
console.error(`bonk-models.json default "${fallback}" missing from models map`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const model = `cloudflare-ai-gateway/${entry.id}`;
|
||||
|
||||
// OPENCODE_CONFIG_CONTENT bundles two unrelated overrides:
|
||||
//
|
||||
// 1. Register the selected model with the pinned opencode version. Without
|
||||
// this, opencode raises ProviderModelNotFoundError on any model whose
|
||||
// release_date is after its bundled models.dev snapshot.
|
||||
//
|
||||
// 2. Resolve the two opencode permission defaults that ask interactively
|
||||
// (`external_directory` and `doom_loop`) so a CI run with no TTY can
|
||||
// never deadlock waiting for approval. external_directory is
|
||||
// deny-by-default with /tmp/** and ~/** allowed (scratch files,
|
||||
// home-dir caches); doom_loop is deny so a stuck loop aborts instead
|
||||
// of prompting. Repro: PR #769 timed out at 30 min waiting for an
|
||||
// `external_directory` prompt on `git show ... > /tmp/foo`.
|
||||
const opencodeConfig = {
|
||||
permission: {
|
||||
external_directory: {
|
||||
"*": "deny",
|
||||
"/tmp/**": "allow",
|
||||
"~/**": "allow",
|
||||
},
|
||||
doom_loop: "deny",
|
||||
},
|
||||
provider: {
|
||||
"cloudflare-ai-gateway": {
|
||||
models: {
|
||||
[entry.id]: entry.registration,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const out = process.env.GITHUB_OUTPUT;
|
||||
if (!out) {
|
||||
console.error("GITHUB_OUTPUT is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const eof = "OPENCODE_CONFIG_EOF";
|
||||
appendFileSync(
|
||||
out,
|
||||
[
|
||||
`alias=${alias}`,
|
||||
`requested=${requested ?? ""}`,
|
||||
`model=${model}`,
|
||||
`opencode_config<<${eof}`,
|
||||
JSON.stringify(opencodeConfig),
|
||||
eof,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
console.log(`Resolved alias "${alias}" -> ${model}`);
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
// review-queue.mjs -- maintainer CLI for the review/* label workflow.
|
||||
//
|
||||
// Zero external deps: only node: builtins, shelling out to `gh`.
|
||||
//
|
||||
// Usage:
|
||||
// node .github/scripts/review-queue.mjs [list] List open PRs grouped by review state.
|
||||
// node .github/scripts/review-queue.mjs request <pr>... Post `/review` on one or more PRs.
|
||||
// node .github/scripts/review-queue.mjs request --needs-review
|
||||
// Post `/review` on every PR labeled
|
||||
// review/needs-review.
|
||||
// node .github/scripts/review-queue.mjs --help Show this help.
|
||||
//
|
||||
// Options:
|
||||
// --repo <owner/name> Target repo (default: detected via `gh repo view`).
|
||||
//
|
||||
// Requires an authenticated `gh` CLI.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
// --- gh helpers -------------------------------------------------------------
|
||||
|
||||
// Run a gh command, returning stdout. On non-zero exit, print stderr and exit 1.
|
||||
function gh(args) {
|
||||
const result = spawnSync("gh", args, { encoding: "utf8" });
|
||||
if (result.error) {
|
||||
console.error(`Failed to run gh: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").trim();
|
||||
console.error(stderr || `gh exited with status ${result.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function detectRepo() {
|
||||
const out = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]);
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// --- review state derivation ------------------------------------------------
|
||||
|
||||
const STATE_ORDER = ["needs-rereview", "needs-review", "awaiting-author", "approved", "unlabeled"];
|
||||
|
||||
// PR number argument: optional leading "#", then digits.
|
||||
const PR_NUMBER_RE = /^#?\d+$/;
|
||||
const PR_HASH_RE = /^#/;
|
||||
|
||||
// Derive a PR's review state from its review/* label.
|
||||
function reviewState(pr) {
|
||||
const labels = new Set((pr.labels || []).map((l) => l.name));
|
||||
for (const state of STATE_ORDER) {
|
||||
if (state === "unlabeled") continue;
|
||||
if (labels.has(`review/${state}`)) return state;
|
||||
}
|
||||
return "unlabeled";
|
||||
}
|
||||
|
||||
// Collect the size/* and area/* labels for display.
|
||||
function tagLabels(pr) {
|
||||
return (pr.labels || [])
|
||||
.map((l) => l.name)
|
||||
.filter((n) => n.startsWith("size/") || n.startsWith("area/"));
|
||||
}
|
||||
|
||||
// Derive a coarse CI status from the statusCheckRollup array.
|
||||
function ciStatus(pr) {
|
||||
const rollup = pr.statusCheckRollup || [];
|
||||
if (rollup.length === 0) return "none";
|
||||
let pending = false;
|
||||
let failed = false;
|
||||
for (const check of rollup) {
|
||||
// Check runs use `status` + `conclusion`; status contexts use `state`.
|
||||
const conclusion = (check.conclusion || "").toUpperCase();
|
||||
const status = (check.status || "").toUpperCase();
|
||||
const state = (check.state || "").toUpperCase();
|
||||
|
||||
if (status && status !== "COMPLETED") {
|
||||
// IN_PROGRESS, QUEUED, PENDING, WAITING, etc.
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
["FAILURE", "TIMED_OUT", "CANCELLED", "ERROR", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(
|
||||
conclusion,
|
||||
)
|
||||
) {
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
if (["FAILURE", "ERROR"].includes(state)) {
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
if (["PENDING", "EXPECTED"].includes(state)) {
|
||||
pending = true;
|
||||
continue;
|
||||
}
|
||||
// SUCCESS / NEUTRAL / SKIPPED count as pass.
|
||||
}
|
||||
if (failed) return "fail";
|
||||
if (pending) return "pending";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function ageInDays(createdAt) {
|
||||
const created = new Date(createdAt).getTime();
|
||||
if (Number.isNaN(created)) return 0;
|
||||
return Math.floor((Date.now() - created) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
const s = str || "";
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max - 1) + "\u2026";
|
||||
}
|
||||
|
||||
// --- commands ---------------------------------------------------------------
|
||||
|
||||
function fetchOpenPrs(repo) {
|
||||
const out = gh([
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
repo,
|
||||
"--state",
|
||||
"open",
|
||||
"--limit",
|
||||
// gh's effective max; avoids silently omitting PRs from list / --needs-review.
|
||||
"1000",
|
||||
"--json",
|
||||
"number,title,labels,createdAt,updatedAt,author,isDraft,mergeable,reviewDecision,statusCheckRollup",
|
||||
]);
|
||||
try {
|
||||
return JSON.parse(out);
|
||||
} catch (e) {
|
||||
console.error(`Could not parse gh output: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdList(repo) {
|
||||
const prs = fetchOpenPrs(repo);
|
||||
|
||||
// Bucket PRs by derived state.
|
||||
const groups = new Map(STATE_ORDER.map((s) => [s, []]));
|
||||
for (const pr of prs) {
|
||||
groups.get(reviewState(pr)).push(pr);
|
||||
}
|
||||
|
||||
console.log(`Open PRs in ${repo}: ${prs.length}`);
|
||||
console.log("");
|
||||
|
||||
for (const state of STATE_ORDER) {
|
||||
const bucket = groups.get(state);
|
||||
console.log(`== ${state} (${bucket.length}) ==`);
|
||||
if (bucket.length === 0) {
|
||||
console.log(" (none)");
|
||||
console.log("");
|
||||
continue;
|
||||
}
|
||||
// Sort oldest-first within a group so the most stale floats to the top.
|
||||
bucket.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||
for (const pr of bucket) {
|
||||
const num = `#${pr.number}`.padEnd(6);
|
||||
const title = truncate(pr.title, 60).padEnd(60);
|
||||
const author = `(${pr.author?.login || "unknown"})`.padEnd(20);
|
||||
const tags = tagLabels(pr).join(",");
|
||||
const tagCol = `[${tags}]`.padEnd(24);
|
||||
const age = `${ageInDays(pr.createdAt)}d`.padEnd(5);
|
||||
const ci = `CI:${ciStatus(pr)}`.padEnd(11);
|
||||
const mergeable = (pr.mergeable || "UNKNOWN").toLowerCase();
|
||||
console.log(` ${num} ${title} ${author} ${tagCol} ${age} ${ci} ${mergeable}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
function postReview(repo, number) {
|
||||
gh(["pr", "comment", String(number), "--repo", repo, "--body", "/review"]);
|
||||
console.log(`Requested review on #${number}`);
|
||||
}
|
||||
|
||||
function cmdRequest(repo, args) {
|
||||
if (args.includes("--needs-review")) {
|
||||
const prs = fetchOpenPrs(repo);
|
||||
const targets = prs.filter((pr) =>
|
||||
(pr.labels || []).some((l) => l.name === "review/needs-review"),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
console.log("No PRs labeled review/needs-review.");
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Requesting review on ${targets.length} PR(s): ${targets.map((p) => `#${p.number}`).join(", ")}`,
|
||||
);
|
||||
for (const pr of targets) {
|
||||
postReview(repo, pr.number);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const numbers = args.filter((a) => PR_NUMBER_RE.test(a)).map((a) => a.replace(PR_HASH_RE, ""));
|
||||
if (numbers.length === 0) {
|
||||
console.error("request: provide one or more PR numbers, or --needs-review");
|
||||
process.exit(1);
|
||||
}
|
||||
for (const number of numbers) {
|
||||
postReview(repo, number);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(
|
||||
[
|
||||
"review-queue.mjs -- maintainer CLI for the review/* label workflow",
|
||||
"",
|
||||
"Usage:",
|
||||
" review-queue.mjs [list] List open PRs grouped by review state",
|
||||
" review-queue.mjs request <pr>... Post /review on one or more PRs",
|
||||
" review-queue.mjs request --needs-review Post /review on every review/needs-review PR",
|
||||
" review-queue.mjs --help Show this help",
|
||||
"",
|
||||
"Options:",
|
||||
" --repo <owner/name> Target repo (default: detected via gh repo view)",
|
||||
"",
|
||||
"Requires an authenticated gh CLI.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// --- arg parsing ------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
if (argv.includes("--help") || argv.includes("-h")) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pull out --repo <value> if present; the rest are positional.
|
||||
let repoOverride;
|
||||
const rest = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--repo") {
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
console.error("--repo requires a value, e.g. --repo owner/name");
|
||||
process.exit(1);
|
||||
}
|
||||
repoOverride = value;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
rest.push(argv[i]);
|
||||
}
|
||||
|
||||
const repo = repoOverride || detectRepo();
|
||||
const command = rest[0] || "list";
|
||||
|
||||
switch (command) {
|
||||
case "list":
|
||||
cmdList(repo);
|
||||
break;
|
||||
case "request":
|
||||
cmdRequest(repo, rest.slice(1));
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Auto Extract
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: auto-extract
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
extract:
|
||||
name: Extract
|
||||
if: github.actor != 'emdashbot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# Commit the extracted locale catalogs back to main. Nothing else.
|
||||
permission-contents: write
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# Intentional: the "Commit and push" step below pushes the
|
||||
# extracted catalogs back to main using this credential.
|
||||
persist-credentials: true
|
||||
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm locale:extract
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
git add -A
|
||||
invalid_changes=$(git diff --staged --name-status --no-renames | awk '$1 !~ /^(A|M)$/ || $2 !~ /^packages\/admin\/src\/locales\/[^/]+\/messages\.po$/ { print }' || true)
|
||||
if [ -n "$invalid_changes" ]; then
|
||||
echo "::error::Extraction produced unexpected staged changes. Only added or modified packages/admin/src/locales/*/messages.po files are allowed:"
|
||||
echo "$invalid_changes"
|
||||
exit 1
|
||||
fi
|
||||
if git diff --staged --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "chore: extract locale catalogs [skip ci]"
|
||||
git pull --rebase origin main
|
||||
git push
|
||||
@@ -0,0 +1,234 @@
|
||||
name: Auto Format — Apply
|
||||
|
||||
# Stage 2 of 2. Runs after "Auto Format" completes on a PR. This job has
|
||||
# elevated permissions to push the formatted patch back to the PR branch,
|
||||
# but it never executes code from the PR — it only downloads the inert
|
||||
# patch artifact, checks out the measured head SHA, and applies the diff.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Auto Format"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
# actions:read is needed for listWorkflowRunArtifacts / downloadArtifact.
|
||||
# pull-requests:read is needed for pulls.get in the Resolve PR step.
|
||||
# contents:read is a safety default; the push-back steps use the app token.
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
name: Apply
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download patch artifact
|
||||
id: download
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
const artifact = data.artifacts.find((a) => a.name === 'auto-format-patch');
|
||||
if (!artifact) {
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No patch artifact — nothing to apply.');
|
||||
return;
|
||||
}
|
||||
const download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
// Stage the artifact under RUNNER_TEMP, not GITHUB_WORKSPACE.
|
||||
// GITHUB_WORKSPACE is wiped by the later actions/checkout step
|
||||
// (clean: true is the default), which would delete the payload
|
||||
// before the Apply step can reach it. RUNNER_TEMP sits outside
|
||||
// the workspace and survives checkout.
|
||||
const outDir = path.join(process.env.RUNNER_TEMP, 'af-artifact');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'artifact.zip'), Buffer.from(download.data));
|
||||
core.setOutput('found', 'true');
|
||||
|
||||
- name: Unpack artifact
|
||||
if: steps.download.outputs.found == 'true'
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/af-artifact"
|
||||
unzip -o artifact.zip
|
||||
ls -la
|
||||
|
||||
- name: Resolve PR
|
||||
if: steps.download.outputs.found == 'true'
|
||||
id: pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const prNumber = Number(
|
||||
fs.readFileSync(
|
||||
path.join(process.env.RUNNER_TEMP, 'af-artifact', 'pr-number'),
|
||||
'utf8',
|
||||
).trim(),
|
||||
);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
core.setFailed(`Invalid PR number in artifact: ${prNumber}`);
|
||||
return;
|
||||
}
|
||||
// Cross-check: fetch the PR and verify its head SHA matches the
|
||||
// workflow_run's head SHA, so a forged artifact can't redirect
|
||||
// the push at an unrelated branch.
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
let pr;
|
||||
try {
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
pr = data;
|
||||
} catch (err) {
|
||||
core.setFailed(`Failed to fetch PR #${prNumber}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== headSha) {
|
||||
core.setFailed(
|
||||
`PR #${prNumber} head SHA (${pr.head.sha}) does not match workflow_run head SHA (${headSha}). The branch has moved on; the next PR event will re-run format+apply.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
// Push onto the exact commit that was formatted. If the branch
|
||||
// has advanced since, the push fails non-fast-forward rather than
|
||||
// applying a stale patch to a newer tree.
|
||||
core.setOutput('sha', headSha);
|
||||
core.setOutput('full_name', pr.head.repo.full_name);
|
||||
const isFork = pr.head.repo.fork
|
||||
|| pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
|
||||
core.setOutput('is_fork', isFork.toString());
|
||||
|
||||
- name: Generate app token
|
||||
if: steps.download.outputs.found == 'true'
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# Push the formatted commit (contents) and comment on push failure
|
||||
# (pull-requests). Nothing else.
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
# --- Same-repo PRs: checkout the measured SHA and push directly ---
|
||||
|
||||
- name: Checkout (same-repo)
|
||||
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'false'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# The same-repo push step below pushes the formatted commit back to
|
||||
# the PR branch using this credential.
|
||||
persist-credentials: true
|
||||
|
||||
# --- Fork PRs: checkout the fork at the measured SHA so we can push back ---
|
||||
|
||||
- name: Checkout (fork)
|
||||
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.full_name }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
persist-credentials: false
|
||||
# Reviewed opt-in to checkout v7's fork guard: no code from the
|
||||
# fork tree is ever executed here. The steps below only `git apply`
|
||||
# the patch from the trusted format artifact and run git
|
||||
# add/commit/push — no scripts, hooks, or tooling from the
|
||||
# checked-out tree. Credentials are not persisted; the push uses
|
||||
# a scoped app token via GIT_ASKPASS.
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Apply patch
|
||||
if: steps.download.outputs.found == 'true'
|
||||
id: apply
|
||||
run: |
|
||||
git apply "$RUNNER_TEMP/af-artifact/format.patch"
|
||||
git add -A
|
||||
if git diff --staged --quiet; then
|
||||
echo "no_diff=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Patch is a no-op against the checked-out head — nothing to push."
|
||||
else
|
||||
echo "no_diff=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push (same-repo)
|
||||
if: |
|
||||
steps.download.outputs.found == 'true'
|
||||
&& steps.pr.outputs.is_fork == 'false'
|
||||
&& steps.apply.outputs.no_diff == 'false'
|
||||
env:
|
||||
HEAD_REF: ${{ steps.pr.outputs.ref }}
|
||||
HEAD_SHA: ${{ steps.pr.outputs.sha }}
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
# Detached-HEAD push onto the PR branch. If the branch has advanced
|
||||
# past the measured SHA, this fails non-fast-forward — the next PR
|
||||
# event re-runs format+apply against the new head.
|
||||
if ! git push origin "HEAD:$HEAD_REF"; then
|
||||
echo "::error::Non-fast-forward push — the PR branch moved past the measured SHA $HEAD_SHA. The next PR event will re-run format+apply." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit and push (fork)
|
||||
if: |
|
||||
steps.download.outputs.found == 'true'
|
||||
&& steps.pr.outputs.is_fork == 'true'
|
||||
&& steps.apply.outputs.no_diff == 'false'
|
||||
id: push-fork
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
HEAD_REF: ${{ steps.pr.outputs.ref }}
|
||||
FULL_NAME: ${{ steps.pr.outputs.full_name }}
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
# Push the formatted commit to the fork via the app token, supplied
|
||||
# through GIT_ASKPASS so it never lands in process args or git config.
|
||||
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
|
||||
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
|
||||
chmod +x "$GIT_ASKPASS"
|
||||
git remote add fork "https://x-access-token@github.com/$FULL_NAME.git"
|
||||
if git push fork "HEAD:$HEAD_REF"; then
|
||||
echo "push_failed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push_failed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
rm -f "$GIT_ASKPASS"
|
||||
|
||||
- name: Comment on push failure
|
||||
if: steps.push-fork.outputs.push_failed == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: Number(process.env.PR_NUMBER),
|
||||
body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\npnpm format\n\`\`\``,
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Auto Format
|
||||
|
||||
# Stage 1 of 2 (producer). Runs with the default read-only token and never
|
||||
# pushes or comments, so it is safe to format PR-authored code — including
|
||||
# from forks. The companion "Auto Format — Apply" workflow (triggered by
|
||||
# workflow_run) commits the result back with elevated permissions, isolated
|
||||
# from PR-authored code.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Format
|
||||
# Skip the bot's own format commits to avoid a re-format loop.
|
||||
if: github.actor != 'emdashbot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run formatter
|
||||
run: npx oxfmt@0.58.0 --ignore-path .gitignore
|
||||
|
||||
- name: Compute patch
|
||||
id: patch
|
||||
run: |
|
||||
git add -A
|
||||
if git diff --staged --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Already formatted — nothing to apply."
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
mkdir -p auto-format-out
|
||||
git diff --staged --binary > auto-format-out/format.patch
|
||||
echo "${{ github.event.pull_request.number }}" > auto-format-out/pr-number
|
||||
echo "Formatting changes detected — uploading patch for the Apply workflow."
|
||||
fi
|
||||
|
||||
- name: Upload patch artifact
|
||||
if: steps.patch.outputs.changed == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: auto-format-patch
|
||||
path: auto-format-out/
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,159 @@
|
||||
name: Bonk
|
||||
|
||||
# /bonk and @ask-bonk both trigger this workflow. The first word after the
|
||||
# trigger picks a model alias from .github/bonk-models.json. Currently
|
||||
# registered aliases (see that file for the source of truth):
|
||||
#
|
||||
# /bonk <task> -> default model (currently opus)
|
||||
# /bonk opus <task> -> Claude Opus 4.7 (default)
|
||||
# /bonk kimi <task> -> Kimi K2.7 Code (cheap pass for tiny tasks)
|
||||
# @ask-bonk opus how would you do X? -> Claude Opus 4.7
|
||||
#
|
||||
# Add new aliases by editing .github/bonk-models.json -- the resolver script
|
||||
# only registers the selected alias in OPENCODE_CONFIG_CONTENT at runtime.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Concurrency is at job level (below) rather than workflow level so it's only
|
||||
# evaluated when the `if:` filter passes. Otherwise every issue_comment event
|
||||
# in the repo would enter the group and could evict in-flight runs from
|
||||
# unrelated, non-matching comments.
|
||||
|
||||
jobs:
|
||||
bonk:
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot'
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association)
|
||||
&& (
|
||||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
&& (contains(github.event.comment.body, '/bonk') || contains(github.event.comment.body, '@ask-bonk')))
|
||||
|| (github.event_name == 'pull_request_review'
|
||||
&& (contains(github.event.review.body, '/bonk') || contains(github.event.review.body, '@ask-bonk')))
|
||||
)
|
||||
# Per-workflow group key so /bonk and /review have independent queues per
|
||||
# target. Spamming /bonk on the same issue/PR serializes; different actions
|
||||
# on the same target run in parallel.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve model from comment
|
||||
id: model
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body || github.event.review.body }}
|
||||
run: node .github/scripts/resolve-bonk-model.mjs
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: "package.json"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve trigger context
|
||||
id: trigger
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
# issue_comment fires on both issues and PRs. github.event.issue.pull_request
|
||||
# is non-null when the issue is actually a PR.
|
||||
IS_PR_COMMENT: ${{ github.event.issue.pull_request != null }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Decide trigger_kind ("issue" or "pr") and target_number.
|
||||
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
|
||||
if [[ "$IS_PR_COMMENT" == "true" ]]; then
|
||||
KIND=pr
|
||||
NUM="$ISSUE_NUMBER" # issue.number is the PR number for PR comments
|
||||
else
|
||||
KIND=issue
|
||||
NUM="$ISSUE_NUMBER"
|
||||
fi
|
||||
else
|
||||
KIND=pr
|
||||
NUM="$PR_NUMBER"
|
||||
fi
|
||||
|
||||
# Fetch title/body (heredoc-quoted to prevent newlines in titles or
|
||||
# bodies from corrupting $GITHUB_OUTPUT or smuggling step outputs).
|
||||
if [[ "$KIND" == "pr" ]]; then
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/pulls/${NUM}" > /tmp/target.json
|
||||
SHA="$(jq -r .head.sha /tmp/target.json)"
|
||||
BASE="$(jq -r .base.sha /tmp/target.json)"
|
||||
else
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/issues/${NUM}" > /tmp/target.json
|
||||
SHA=""
|
||||
BASE=""
|
||||
fi
|
||||
|
||||
{
|
||||
echo "kind=${KIND}"
|
||||
echo "number=${NUM}"
|
||||
echo "head_sha=${SHA}"
|
||||
echo "base_sha=${BASE}"
|
||||
echo 'title<<TARGET_TITLE_EOF'
|
||||
jq -r '.title // ""' /tmp/target.json
|
||||
echo TARGET_TITLE_EOF
|
||||
echo 'body<<TARGET_BODY_EOF'
|
||||
jq -r '.body // ""' /tmp/target.json
|
||||
echo TARGET_BODY_EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run Bonk (${{ steps.model.outputs.alias }})
|
||||
uses: ask-bonk/ask-bonk/github@bfd92f4d0abb62d98558b35432534f7b9992f3eb # main as of 2026-04-24
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
|
||||
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
|
||||
OPENCODE_CONFIG_CONTENT: ${{ steps.model.outputs.opencode_config }}
|
||||
with:
|
||||
model: ${{ steps.model.outputs.model }}
|
||||
mentions: "/bonk,@ask-bonk"
|
||||
opencode_version: "1.4.11"
|
||||
permissions: write
|
||||
# Custom agent defined in .opencode/agents/auto-implementer.md.
|
||||
# Holds the investigation/reproduction protocol, scope discipline,
|
||||
# and PR body conventions so this workflow stays small.
|
||||
agent: auto-implementer
|
||||
prompt: |
|
||||
A maintainer has invoked /bonk on the emdash-cms/emdash repository. Follow your agent instructions for mode classification, investigation, reproduction, and posting.
|
||||
|
||||
<trigger_kind>${{ steps.trigger.outputs.kind }}</trigger_kind>
|
||||
<target_number>${{ steps.trigger.outputs.number }}</target_number>
|
||||
<target_title>${{ steps.trigger.outputs.title }}</target_title>
|
||||
<target_body>
|
||||
${{ steps.trigger.outputs.body }}
|
||||
</target_body>
|
||||
<head_sha>${{ steps.trigger.outputs.head_sha }}</head_sha>
|
||||
<base_sha>${{ steps.trigger.outputs.base_sha }}</base_sha>
|
||||
|
||||
The maintainer's request:
|
||||
|
||||
${{ github.event.comment.body || github.event.review.body }}
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Bot Cleanup
|
||||
|
||||
# Two ingress paths:
|
||||
# - issues.closed: drop the bot branches for that issue immediately.
|
||||
# - daily cron: sweep `bot/artifacts-*` branches older than 90 days.
|
||||
#
|
||||
# Both jobs use the app token so deletions land as the bot identity (not
|
||||
# whoever closed the issue).
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
schedule:
|
||||
# 04:00 UTC daily. Off-peak for most contributors.
|
||||
- cron: "0 4 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: bot-cleanup
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cleanup-on-close:
|
||||
name: Delete bot branches when an issue closes
|
||||
if: github.event_name == 'issues' && github.event.issue.pull_request == null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: emdash-cms
|
||||
repositories: emdash
|
||||
permission-contents: write
|
||||
# Read-only PR scope so we can `gh pr list --head bot/fix-N`
|
||||
# before deleting the branch -- protects an open bot-opened
|
||||
# PR from being orphaned when an issue is closed without
|
||||
# merging.
|
||||
permission-pull-requests: read
|
||||
|
||||
- name: Delete bot branches for closed issue
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FIX_BRANCH="bot/fix-${ISSUE_NUMBER}"
|
||||
ART_BRANCH="bot/artifacts-${ISSUE_NUMBER}"
|
||||
|
||||
# bot/fix-N MAY have an open PR pointing at it (the bot
|
||||
# opens one after `triage/verified`). Deleting the ref would
|
||||
# invalidate that PR. Two close paths to worry about:
|
||||
#
|
||||
# - PR merged -> issue auto-closes via "Closes #N" -> we
|
||||
# fire here. The PR is closed; deleting the now-unused
|
||||
# ref is fine. GitHub may have already deleted it.
|
||||
# - Maintainer manually closes the issue ("won't fix",
|
||||
# "stale", etc.) while the bot PR is still open. We
|
||||
# must NOT delete the ref -- it would close the PR
|
||||
# silently and lose the bot's work with no recovery.
|
||||
OPEN_PR_COUNT="$(gh pr list \
|
||||
--repo emdash-cms/emdash \
|
||||
--head "$FIX_BRANCH" \
|
||||
--state open \
|
||||
--json number \
|
||||
--jq 'length' 2>/dev/null || echo 0)"
|
||||
|
||||
if [[ "$OPEN_PR_COUNT" != "0" ]]; then
|
||||
echo "::notice::skipping ${FIX_BRANCH} deletion: ${OPEN_PR_COUNT} open PR(s) reference it"
|
||||
else
|
||||
ENCODED="${FIX_BRANCH//\//%2F}"
|
||||
STATUS="$(gh api -X DELETE "repos/emdash-cms/emdash/git/refs/heads/${ENCODED}" \
|
||||
--silent -i 2>/dev/null | head -n 1 || true)"
|
||||
echo "DELETE ${FIX_BRANCH}: ${STATUS:-no response}"
|
||||
fi
|
||||
|
||||
# bot/artifacts-N is never the head of any PR (it's an
|
||||
# orphan branch with screenshots only). Always safe to delete.
|
||||
ENCODED="${ART_BRANCH//\//%2F}"
|
||||
STATUS="$(gh api -X DELETE "repos/emdash-cms/emdash/git/refs/heads/${ENCODED}" \
|
||||
--silent -i 2>/dev/null | head -n 1 || true)"
|
||||
echo "DELETE ${ART_BRANCH}: ${STATUS:-no response}"
|
||||
|
||||
cleanup-daily:
|
||||
name: Prune stale artifact branches
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: emdash-cms
|
||||
repositories: emdash
|
||||
permission-contents: write
|
||||
|
||||
- name: Sweep stale artifacts branches
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CUTOFF="$(date -u -d '90 days ago' +%s 2>/dev/null || date -u -v-90d +%s)"
|
||||
# Paginated branch list; filter to bot/artifacts-* in jq.
|
||||
BRANCHES="$(gh api "repos/emdash-cms/emdash/branches" --paginate \
|
||||
--jq '.[] | select(.name | startswith("bot/artifacts-")) | .name')"
|
||||
if [[ -z "$BRANCHES" ]]; then
|
||||
echo "No bot/artifacts-* branches found."
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r NAME; do
|
||||
[[ -z "$NAME" ]] && continue
|
||||
ENCODED="${NAME//\//%2F}"
|
||||
DATE="$(gh api "repos/emdash-cms/emdash/branches/${ENCODED}" \
|
||||
--jq '.commit.commit.committer.date' 2>/dev/null || true)"
|
||||
if [[ -z "$DATE" ]]; then
|
||||
echo "Skipping ${NAME}: could not read commit date."
|
||||
continue
|
||||
fi
|
||||
TS="$(date -u -d "$DATE" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$DATE" +%s)"
|
||||
if (( TS < CUTOFF )); then
|
||||
echo "Deleting ${NAME} (committed ${DATE})"
|
||||
gh api -X DELETE "repos/emdash-cms/emdash/git/refs/heads/${ENCODED}" --silent || \
|
||||
echo " delete failed for ${NAME} (already gone?)"
|
||||
else
|
||||
echo "Keeping ${NAME} (committed ${DATE})"
|
||||
fi
|
||||
done <<<"$BRANCHES"
|
||||
@@ -0,0 +1,295 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm run --filter emdash-demo --filter @emdash-cms/demo-cloudflare typecheck
|
||||
- run: pnpm typecheck:templates
|
||||
- run: node scripts/typecheck-public-source.mjs
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm lint
|
||||
|
||||
version-check:
|
||||
name: Version Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
- run: node .github/scripts/check-no-major.mjs
|
||||
|
||||
changeset-validate:
|
||||
name: Changeset Validation
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# changeset status --since diffs against origin/main; needs full history.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Validate changesets
|
||||
run: |
|
||||
shopt -s extglob
|
||||
if compgen -G ".changeset/!(README).md" > /dev/null; then
|
||||
pnpm changeset status --since=origin/main
|
||||
else
|
||||
echo "No changesets present; skipping validation."
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: emdash_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Build emdash + its deps AND the plugin-cli + registry packages.
|
||||
# They aren't deps of `emdash`, so the `emdash...` filter would
|
||||
# leave them unbuilt and their tests would fail to resolve workspace
|
||||
# links to dist/.
|
||||
- run: pnpm run --filter emdash... --filter "@emdash-cms/plugin-cli" --filter "@emdash-cms/registry-*" --filter "@emdash-cms/plugin-types" build
|
||||
- run: pnpm test:unit
|
||||
env:
|
||||
EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test
|
||||
# Render tests use the Astro Vite plugin (vitest.repro.config.ts);
|
||||
# they can't run under the plain-node config in test:unit.
|
||||
- run: pnpm --filter emdash exec vitest run --config vitest.repro.config.ts
|
||||
|
||||
test-smoke:
|
||||
name: Smoke Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: emdash_smoke
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm --filter emdash exec vitest run --config vitest.smoke.config.ts
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:test@localhost:5432/emdash_smoke
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm --filter emdash exec vitest run --config vitest.integration.config.ts
|
||||
|
||||
test-browser:
|
||||
name: Browser Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run --filter @emdash-cms/admin... build
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
- run: pnpm run --filter @emdash-cms/admin test
|
||||
|
||||
test-e2e-rollup:
|
||||
name: E2E Tests
|
||||
if: always()
|
||||
needs: [test-e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check E2E shard results
|
||||
run: |
|
||||
if [ "${{ needs.test-e2e.result }}" != "success" ]; then
|
||||
echo "E2E tests failed or were cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-e2e:
|
||||
name: E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run --filter emdash... build
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
- run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.shardIndex }}
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
|
||||
test-e2e-cloudflare:
|
||||
name: E2E Cloudflare (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run --filter "emdash-e2e-fixture-cloudflare..." build
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
# Runs the full e2e suite against the workerd runtime. Sharded like the Node
|
||||
# lane: per-shard setup (dev-server boot + seed) is ~30s, so wall-clock is
|
||||
# dominated by test execution and shards parallelize it near-linearly.
|
||||
- run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
env:
|
||||
EMDASH_E2E_TARGET: cloudflare
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report-cloudflare-${{ matrix.shardIndex }}
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,79 @@
|
||||
name: "CLA Assistant"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
checks: read
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.issue.pull_request && (github.event.comment.body == 'recheck' || startsWith(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA'))) || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: "signatures/version1/cla.json"
|
||||
path-to-document: "https://www.cloudflare.com/cla/"
|
||||
branch: "cla-signatures"
|
||||
allowlist: dependabot[bot],emdashbot[bot],copilot-swe-agent[bot],ask-bonk[bot],opencode
|
||||
lock-pullrequest-aftermerge: false
|
||||
|
||||
label:
|
||||
needs: CLAssistant
|
||||
if: always() && (github.event_name == 'pull_request_target' || github.event.issue.pull_request)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label CLA status
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request?.number || context.payload.issue?.number;
|
||||
if (!prNumber) return;
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Get the PR to read head SHA
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
|
||||
// Read the CLA check run (the CLA Assistant uses check runs, not commit statuses)
|
||||
const { data: checkRuns } = await github.rest.checks.listForRef({
|
||||
owner, repo, ref: pr.head.sha,
|
||||
});
|
||||
const claCheck = checkRuns.check_runs.find(cr => cr.name === 'CLAssistant');
|
||||
if (!claCheck || claCheck.status !== 'completed') return;
|
||||
|
||||
const signed = claCheck.conclusion === 'success';
|
||||
const addLabel = signed ? 'cla: signed' : 'cla: needed';
|
||||
const removeLabel = signed ? 'cla: needed' : 'cla: signed';
|
||||
|
||||
// Ensure labels exist
|
||||
const labelColors = { 'cla: signed': '0e8a16', 'cla: needed': 'b60205' };
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: addLabel });
|
||||
} catch {
|
||||
await github.rest.issues.createLabel({ owner, repo, name: addLabel, color: labelColors[addLabel] });
|
||||
}
|
||||
|
||||
// Add the correct label
|
||||
const currentLabels = pr.labels.map(l => l.name);
|
||||
if (!currentLabels.includes(addLabel)) {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [addLabel] });
|
||||
}
|
||||
|
||||
// Remove the stale label
|
||||
if (currentLabels.includes(removeLabel)) {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: removeLabel });
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
name: CodeQL
|
||||
|
||||
# Advanced setup so we can control when analysis runs. The "Require code
|
||||
# scanning results" ruleset rule waits for a SARIF upload; if a PR doesn't
|
||||
# touch code (locale-only, workflow-only) and the workflow never runs, the
|
||||
# check sits pending forever. We always run a job per language and either do
|
||||
# the real analysis (when relevant paths changed) or upload an empty SARIF so
|
||||
# the check reports cleanly.
|
||||
#
|
||||
# Parity with the previous default setup:
|
||||
# - languages: actions, javascript-typescript
|
||||
# (javascript and typescript are subsumed by javascript-typescript)
|
||||
# - query suite: default
|
||||
# - threat model: remote
|
||||
# - schedule: weekly
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly run, matches previous default setup cadence.
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read # dorny/paths-filter calls the PRs API on pull_request events
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
paths-filter: |
|
||||
code:
|
||||
- '.github/workflows/**'
|
||||
- '.github/actions/**'
|
||||
- language: javascript-typescript
|
||||
paths-filter: |
|
||||
code:
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.mjs'
|
||||
- '**/*.cjs'
|
||||
- '**/package.json'
|
||||
- '**/pnpm-lock.yaml'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# On push and schedule, always analyze. On pull_request, only analyze
|
||||
# when paths relevant to this language changed.
|
||||
- name: Detect relevant changes
|
||||
id: changes
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
with:
|
||||
filters: ${{ matrix.paths-filter }}
|
||||
|
||||
- name: Decide whether to analyze
|
||||
id: decide
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CHANGED: ${{ steps.changes.outputs.code }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ] || [ "$CHANGED" = "true" ]; then
|
||||
echo "analyze=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "analyze=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: steps.decide.outputs.analyze == 'true'
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
if: steps.decide.outputs.analyze == 'true'
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
# When skipped, upload an empty SARIF so the "Require code scanning
|
||||
# results" rule on the branch ruleset sees a result and passes the PR.
|
||||
- name: Write empty SARIF
|
||||
if: steps.decide.outputs.analyze != 'true'
|
||||
env:
|
||||
LANGUAGE: ${{ matrix.language }}
|
||||
run: |
|
||||
cat > empty.sarif <<EOF
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"\$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
"runs": [
|
||||
{
|
||||
"tool": {
|
||||
"driver": {
|
||||
"name": "CodeQL",
|
||||
"semanticVersion": "0.0.0",
|
||||
"rules": []
|
||||
}
|
||||
},
|
||||
"results": [],
|
||||
"automationDetails": {
|
||||
"id": "/language:${LANGUAGE}/"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Upload empty SARIF
|
||||
if: steps.decide.outputs.analyze != 'true'
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: empty.sarif
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Dependabot Auto-Approve
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: Auto-Approve
|
||||
# Use github.event.pull_request.user.login, not github.actor.
|
||||
# github.actor reflects the last actor on the trigger and is spoofable
|
||||
# via a PR whose HEAD commit author is 'dependabot[bot]'. The
|
||||
# pull_request.user.login is the PR opener and cannot be spoofed.
|
||||
# https://docs.zizmor.sh/audits/#bot-conditions
|
||||
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# Read PR metadata and submit the approving review. Nothing else.
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Auto-approve patch and minor updates
|
||||
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
|
||||
env:
|
||||
UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }}
|
||||
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
event: 'APPROVE',
|
||||
body: `Auto-approved: ${process.env.UPDATE_TYPE} update for ${process.env.DEPENDENCY_NAMES}.`,
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
name: Format Command
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Format
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.body == '/format' &&
|
||||
(
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# Push the formatted commit (contents), comment on the PR
|
||||
# (pull-requests), and react to the triggering comment via the
|
||||
# issues/comments reactions API (issues). Nothing else.
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
permission-issues: write
|
||||
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
const isFork = pr.data.head.repo.fork || pr.data.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
|
||||
core.setOutput('ref', pr.data.head.ref);
|
||||
core.setOutput('sha', pr.data.head.sha);
|
||||
core.setOutput('is_fork', isFork.toString());
|
||||
core.setOutput('full_name', pr.data.head.repo.full_name);
|
||||
|
||||
- name: React to comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes',
|
||||
});
|
||||
|
||||
- name: Checkout (same-repo)
|
||||
if: steps.pr.outputs.is_fork == 'false'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# Intentional: the same-repo push step below pushes the
|
||||
# formatted changes back to the PR branch using this credential.
|
||||
persist-credentials: true
|
||||
|
||||
- name: Checkout (fork)
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.full_name }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run formatter
|
||||
run: npx oxfmt@0.58.0 --ignore-path .gitignore
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
git add -A
|
||||
if git diff --staged --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push (same-repo)
|
||||
if: steps.pr.outputs.is_fork == 'false' && steps.diff.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
git push
|
||||
|
||||
- name: Commit and push (fork)
|
||||
if: steps.pr.outputs.is_fork == 'true' && steps.diff.outputs.changed == 'true'
|
||||
id: push-fork
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
|
||||
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
|
||||
chmod +x "$GIT_ASKPASS"
|
||||
git remote add fork "https://x-access-token@github.com/$FULL_NAME.git"
|
||||
if git push fork "HEAD:$HEAD_REF"; then
|
||||
echo "push_failed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push_failed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
rm -f "$GIT_ASKPASS"
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
FULL_NAME: ${{ steps.pr.outputs.full_name }}
|
||||
HEAD_REF: ${{ steps.pr.outputs.ref }}
|
||||
|
||||
- name: Comment on push failure
|
||||
if: steps.push-fork.outputs.push_failed == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\npnpm format\n\`\`\``,
|
||||
});
|
||||
|
||||
- name: React to comment (result)
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
CHANGED: ${{ steps.diff.outputs.changed }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const changed = process.env.CHANGED === 'true';
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: changed ? 'rocket' : '+1',
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Format
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm format:check
|
||||
@@ -0,0 +1,672 @@
|
||||
name: Investigate
|
||||
|
||||
# Runs the Flue-based investigation agent when a maintainer applies the
|
||||
# `bot:repro` label to an issue. The agent reproduces the bug (and may push a
|
||||
# fix branch). The orchestrator (this workflow) performs all GitHub writes
|
||||
# based on the agent's structured JSON output.
|
||||
#
|
||||
# Also accepts re-triggers from the reporter-reply workflow when the reporter
|
||||
# (or a maintainer) says the first attempt missed something:
|
||||
# * workflow_dispatch -- for manual re-runs from the Actions UI.
|
||||
# * repository_dispatch (type `reporter-retry`) -- used by reporter-reply.yml,
|
||||
# because firing it needs only the contents:write the emdashbot App already
|
||||
# has, whereas workflow_dispatch from the App would need actions:write.
|
||||
# * repository_dispatch (type `maintainer-directive`) -- used by
|
||||
# maintainer-reply.yml when a maintainer directs an implementation on a
|
||||
# reproduced issue. Carries `directive`, an authoritative instruction that
|
||||
# overrides the fix gate (see InvestigatePayload.maintainerDirective). The
|
||||
# produced fix routes through the normal awaiting-reporter loop.
|
||||
# reporter-retry carries { issueNumber, retryContext }; maintainer-directive
|
||||
# carries { issueNumber, directive }; both via client_payload (workflow_dispatch
|
||||
# carries the equivalents in inputs).
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issueNumber:
|
||||
description: "Issue number to investigate"
|
||||
required: true
|
||||
type: string
|
||||
retryContext:
|
||||
description: "Reporter feedback from previous attempt"
|
||||
required: false
|
||||
type: string
|
||||
directive:
|
||||
description: "Maintainer implementation directive (overrides the fix gate)"
|
||||
required: false
|
||||
type: string
|
||||
repository_dispatch:
|
||||
types: [reporter-retry, maintainer-directive]
|
||||
|
||||
# Default-deny at workflow level. The job below opens up only what it needs.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
investigate:
|
||||
name: Investigate issue
|
||||
# Gate on label name (only `bot:repro`) for the labeled path, or always
|
||||
# run for workflow_dispatch. Also skip if the labeled "issue" is actually
|
||||
# a PR (issues.labeled fires for PRs too).
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| github.event_name == 'repository_dispatch'
|
||||
|| (github.event.label.name == 'bot:repro' && github.event.issue.pull_request == null)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Serialize per-issue. Don't cancel in-flight runs -- partial state is
|
||||
# worse than a queue, since the agent may have already pushed branches.
|
||||
concurrency:
|
||||
group: investigate-${{ github.event.issue.number || inputs.issueNumber || github.event.client_payload.issueNumber }}
|
||||
cancel-in-progress: false
|
||||
# Sandbox token (GITHUB_TOKEN) is intentionally read-only. All writes use
|
||||
# the minted app token from the step below.
|
||||
permissions:
|
||||
# Sandbox bash gets this via AGENT_GH_TOKEN; just enough to clone and
|
||||
# read issues, never enough to comment, label, or push.
|
||||
contents: read
|
||||
issues: read
|
||||
# Hoist commonly used workflow context into env so shell steps can
|
||||
# reference $RUN_URL etc. without raw `${{ ... }}` expansions, which
|
||||
# zizmor flags as template injection. The values themselves are
|
||||
# trustworthy here (`github.run_id`, `github.repository`, etc.) but
|
||||
# the pattern is the recommended fix.
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: emdash-cms
|
||||
repositories: emdash
|
||||
permission-issues: write
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Resolve issue context
|
||||
id: ctx
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
LABEL_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
# Re-trigger issue number / feedback come from inputs (workflow_dispatch)
|
||||
# or client_payload (repository_dispatch); only one is ever set.
|
||||
DISPATCH_ISSUE_NUMBER: ${{ inputs.issueNumber || github.event.client_payload.issueNumber }}
|
||||
LABEL_ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
LABEL_ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
LABEL_ISSUE_REPORTER: ${{ github.event.issue.user.login }}
|
||||
RETRY_CONTEXT: ${{ inputs.retryContext || github.event.client_payload.retryContext }}
|
||||
# A maintainer's implementation directive (maintainer-directive
|
||||
# dispatch or a manual workflow_dispatch). Empty on the labeled and
|
||||
# reporter-retry paths.
|
||||
DIRECTIVE: ${{ inputs.directive || github.event.client_payload.directive }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The issue body and retry context are attacker-controllable
|
||||
# multiline strings. Writing them to $GITHUB_OUTPUT with a
|
||||
# fixed heredoc delimiter is a step-output injection vector:
|
||||
# a body containing the delimiter would terminate the heredoc
|
||||
# and let the attacker forge subsequent outputs. Avoid
|
||||
# putting them in step outputs at all -- write to /tmp files
|
||||
# that later steps read directly.
|
||||
if [[ "$EVENT_NAME" == "workflow_dispatch" || "$EVENT_NAME" == "repository_dispatch" ]]; then
|
||||
NUM="$DISPATCH_ISSUE_NUMBER"
|
||||
# The dispatched number is attacker-influenceable (a forged
|
||||
# repository_dispatch could carry a path-traversal value) and is
|
||||
# about to be interpolated into an API path -- validate BEFORE the
|
||||
# call, ahead of the shared check below. Issue numbers are positive
|
||||
# integers with no leading zero (also keeps --argjson happy later).
|
||||
if ! [[ "$NUM" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::invalid issue number: $NUM"
|
||||
exit 1
|
||||
fi
|
||||
gh api "/repos/emdash-cms/emdash/issues/${NUM}" > /tmp/issue.json
|
||||
# The issues API returns PRs too; only the labeled path was
|
||||
# PR-guarded. Reject a PR number dispatched by mistake or forgery.
|
||||
if jq -e '.pull_request' /tmp/issue.json >/dev/null 2>&1; then
|
||||
echo "::error::#${NUM} is a pull request, not an issue"
|
||||
exit 1
|
||||
fi
|
||||
TITLE="$(jq -r '.title // ""' /tmp/issue.json | tr -d '\r\n')"
|
||||
REPORTER="$(jq -r '.user.login // ""' /tmp/issue.json | tr -d '\r\n')"
|
||||
jq -r '.body // ""' /tmp/issue.json > /tmp/ctx-body.txt
|
||||
printf '%s' "$RETRY_CONTEXT" > /tmp/ctx-retry.txt
|
||||
else
|
||||
NUM="$LABEL_ISSUE_NUMBER"
|
||||
TITLE="$(printf '%s' "$LABEL_ISSUE_TITLE" | tr -d '\r\n')"
|
||||
REPORTER="$(printf '%s' "$LABEL_ISSUE_REPORTER" | tr -d '\r\n')"
|
||||
printf '%s' "$LABEL_ISSUE_BODY" > /tmp/ctx-body.txt
|
||||
: > /tmp/ctx-retry.txt
|
||||
fi
|
||||
# The directive is attacker-shaped multiline text like the body and
|
||||
# retry context; same treatment -- write to /tmp, never to a step
|
||||
# output. Empty unless a maintainer-directive dispatch set it. A
|
||||
# whitespace-only value (possible via a manual workflow_dispatch)
|
||||
# normalizes to empty so `directed` and the payload reflect only a
|
||||
# meaningful instruction.
|
||||
if printf '%s' "$DIRECTIVE" | grep -q '[^[:space:]]'; then
|
||||
printf '%s' "$DIRECTIVE" > /tmp/ctx-directive.txt
|
||||
else
|
||||
: > /tmp/ctx-directive.txt
|
||||
fi
|
||||
# Validate scalar fields are simple before they hit step
|
||||
# outputs. Issue numbers are integers; logins match a tight
|
||||
# regex. Anything weird produces a hard fail rather than a
|
||||
# silent injection.
|
||||
if ! [[ "$NUM" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::invalid issue number: $NUM"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$REPORTER" =~ ^[a-zA-Z0-9-]{0,39}$ ]]; then
|
||||
echo "::error::invalid reporter login: $REPORTER"
|
||||
exit 1
|
||||
fi
|
||||
# Titles can include arbitrary unicode; cap length and strip
|
||||
# control characters. They are never executed, but they do
|
||||
# get echoed into markdown comments.
|
||||
TITLE_CLEAN="$(printf '%s' "$TITLE" | LC_ALL=C tr -d '\000-\037\177' | cut -c1-256)"
|
||||
# `directed` is a clean boolean derived from directive presence --
|
||||
# safe for a step output (the directive text itself never is).
|
||||
# Outcome branches use it to word maintainer-facing comments.
|
||||
if [[ -s /tmp/ctx-directive.txt ]]; then DIRECTED=true; else DIRECTED=false; fi
|
||||
{
|
||||
echo "number=${NUM}"
|
||||
echo "title=${TITLE_CLEAN}"
|
||||
echo "reporter=${REPORTER}"
|
||||
echo "directed=${DIRECTED}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Transition label to triage/reproducing
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Remove any existing bot:* label; swallow 404s (label may not be present).
|
||||
for L in bot:repro triage/reproducing triage/reproduced triage/by-design triage/awaiting-reporter triage/verified triage/not-reproduced triage/skipped triage/failed; do
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "$L" >/dev/null 2>&1 || true
|
||||
done
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --add-label "triage/reproducing"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: "package.json"
|
||||
cache: "pnpm"
|
||||
|
||||
# The repro-admin and repro-public skills drive a real browser via
|
||||
# `bgproc` (boots `pnpm dev`) and `agent-browser`. They
|
||||
# are not project dependencies, so install them globally here rather
|
||||
# than letting the agent burn tokens discovering and self-installing
|
||||
# them mid-run. PATH is inherited by the agent's local() sandbox, so
|
||||
# these land on the agent's bash PATH. `agent-browser install`
|
||||
# fetches the browser binary.
|
||||
- name: Install browser automation tools
|
||||
run: |
|
||||
npm install -g bgproc agent-browser
|
||||
agent-browser install
|
||||
|
||||
- name: Install root dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Flue agent dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: .flue
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Build agent payload
|
||||
id: payload
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
ISSUE_TITLE: ${{ steps.ctx.outputs.title }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Body and retry context come from /tmp files written by the
|
||||
# ctx step, not $GITHUB_OUTPUT -- $GITHUB_OUTPUT with a fixed
|
||||
# heredoc delimiter is a step-output injection vector when
|
||||
# the content is attacker-controlled (issue body, retry text).
|
||||
# jq --rawfile reads the file directly, so we never have to
|
||||
# quote or escape the content in shell.
|
||||
PAYLOAD="$(jq -nc \
|
||||
--argjson n "$ISSUE_NUMBER" \
|
||||
--arg t "$ISSUE_TITLE" \
|
||||
--rawfile b /tmp/ctx-body.txt \
|
||||
--rawfile r /tmp/ctx-retry.txt \
|
||||
--rawfile d /tmp/ctx-directive.txt \
|
||||
'{issueNumber: $n, issueTitle: $t, issueBody: $b, owner: "emdash-cms", repo: "emdash"} + (if $r == "" then {} else {retryContext: $r} end) + (if $d == "" then {} else {maintainerDirective: $d} end)')"
|
||||
# Write payload to file rather than $GITHUB_OUTPUT to avoid the
|
||||
# 1MB output cap on large issue bodies and to keep raw JSON out
|
||||
# of step logs.
|
||||
printf '%s' "$PAYLOAD" > /tmp/agent-payload.json
|
||||
echo "path=/tmp/agent-payload.json" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run Flue investigate agent
|
||||
id: agent
|
||||
timeout-minutes: 50
|
||||
# Sandbox token is the workflow-scoped GITHUB_TOKEN (read-only here).
|
||||
# Orchestrator token is the app token. The agent's local() sandbox
|
||||
# picks up AGENT_GH_TOKEN as GH_TOKEN; the orchestrator token is
|
||||
# intentionally NOT exposed to the sandbox.
|
||||
env:
|
||||
AGENT_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ORCHESTRATOR_GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
|
||||
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
|
||||
CLOUDFLARE_API_KEY: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
# The workflow writes its assembled result here on clean
|
||||
# completion; the parse step reads it directly instead of
|
||||
# scraping the result back out of stdout.
|
||||
INVESTIGATE_RESULT_PATH: /tmp/agent-result.json
|
||||
run: |
|
||||
set -o pipefail
|
||||
PAYLOAD="$(cat /tmp/agent-payload.json)"
|
||||
# Sanity-check what the agent's session will see. Flue reads
|
||||
# AGENTS.md from the sandbox cwd (repo root) at session init;
|
||||
# if it is missing here the agent starts with no repo context.
|
||||
echo "agent cwd: $(pwd)"
|
||||
echo "AGENTS.md at cwd: $([ -f AGENTS.md ] && echo present || echo MISSING)"
|
||||
set +e
|
||||
# `flue run` writes structured log events and the workflow
|
||||
# result to stdout, and human-readable progress to stderr. Tee
|
||||
# stderr to both the workflow log (so progress is visible live,
|
||||
# not just dumped at end-of-step) and a file, while keeping
|
||||
# stdout clean for the JSON parse step.
|
||||
# Run `flue run` from the repo root (not from .flue/). Two
|
||||
# reasons:
|
||||
# 1. Flue resolves `--root .flue` relative to the caller's
|
||||
# cwd. `pnpm --dir .flue` would compose to `.flue/.flue`
|
||||
# and the build fails with "No agent or workflow files
|
||||
# found." (Observed on the first live run.)
|
||||
# 2. The agent's `local()` sandbox inherits process.cwd()
|
||||
# as its working directory. We want that to be the
|
||||
# EmDash repo root so the agent's bash tool can `pnpm
|
||||
# test`, `git`, `gh issue view`, etc. against the
|
||||
# EmDash checkout.
|
||||
#
|
||||
# Invoke the flue binary directly from .flue/'s installed
|
||||
# node_modules; pnpm's `--dir` semantics are exactly what
|
||||
# broke us originally.
|
||||
.flue/node_modules/.bin/flue run investigate \
|
||||
--target node \
|
||||
--root .flue \
|
||||
--payload "$PAYLOAD" \
|
||||
> /tmp/agent-stdout.json 2> >(tee /tmp/agent-stderr.log >&2)
|
||||
EXIT=$?
|
||||
set -e
|
||||
echo "exit=$EXIT" >> "$GITHUB_OUTPUT"
|
||||
echo "--- agent stdout (first 200 lines) ---"
|
||||
head -n 200 /tmp/agent-stdout.json || true
|
||||
echo "--- end preview ---"
|
||||
|
||||
- name: Parse agent result
|
||||
if: always()
|
||||
id: parse
|
||||
env:
|
||||
AGENT_EXIT: ${{ steps.agent.outputs.exit }}
|
||||
DIRECTED: ${{ steps.ctx.outputs.directed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The workflow writes its assembled result to
|
||||
# /tmp/agent-result.json (INVESTIGATE_RESULT_PATH) on clean
|
||||
# completion. A non-zero exit or a missing/empty file means the
|
||||
# run did not finish -- treat it as failed.
|
||||
if [[ "${AGENT_EXIT:-1}" != "0" ]] || [[ ! -s /tmp/agent-result.json ]]; then
|
||||
echo "outcome=failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Defensive: confirm the file is a single JSON object before the
|
||||
# downstream `jq` reads. The workflow controls this file, so a
|
||||
# malformed one indicates a bug, not adversarial input.
|
||||
if ! jq -e 'type == "object"' /tmp/agent-result.json >/dev/null 2>&1; then
|
||||
echo "::warning::result file is not a JSON object"
|
||||
echo "outcome=failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SKIPPED="$(jq -r '.skipped // false' /tmp/agent-result.json)"
|
||||
REPRODUCED="$(jq -r '.reproduced // false' /tmp/agent-result.json)"
|
||||
FIXED="$(jq -r '.fixed // false' /tmp/agent-result.json)"
|
||||
VERDICT="$(jq -r '.verdict // ""' /tmp/agent-result.json)"
|
||||
|
||||
if [[ "$SKIPPED" == "true" ]]; then
|
||||
OUTCOME=skipped
|
||||
elif [[ "$REPRODUCED" != "true" ]]; then
|
||||
OUTCOME=not-reproduced
|
||||
elif [[ "$FIXED" == "true" ]]; then
|
||||
OUTCOME=fixed
|
||||
elif [[ "$VERDICT" == "intended-behavior" && "$DIRECTED" != "true" ]]; then
|
||||
# A directed run overrides the intended-behavior judgment (the flue
|
||||
# agent already skips its early return), so it should never land in
|
||||
# by-design. If its fix was abandoned it falls through to the
|
||||
# reproduced branch, which carries the directed-aware wording.
|
||||
OUTCOME=intended-behavior
|
||||
else
|
||||
OUTCOME=reproduced
|
||||
fi
|
||||
echo "outcome=$OUTCOME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ----- Outcome branches: skipped -----
|
||||
|
||||
- name: Handle skipped
|
||||
if: steps.parse.outputs.outcome == 'skipped'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
DIRECTED: ${{ steps.ctx.outputs.directed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REASON="$(jq -r '.reason // .notes // "No reason provided."' /tmp/agent-result.json)"
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/skipped"
|
||||
{
|
||||
if [[ "$DIRECTED" == "true" ]]; then
|
||||
echo "I couldn't carry out the directive: the reproduction step was skipped, so there's no way to verify a fix."
|
||||
else
|
||||
echo "The investigation bot declined to reproduce this issue."
|
||||
fi
|
||||
echo
|
||||
echo "**Reason:** ${REASON}"
|
||||
echo
|
||||
echo "<sub>Run: $RUN_URL</sub>"
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
|
||||
# ----- Outcome branches: not-reproduced -----
|
||||
|
||||
- name: Handle not-reproduced
|
||||
if: steps.parse.outputs.outcome == 'not-reproduced'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
DIRECTED: ${{ steps.ctx.outputs.directed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ATTEMPTS="$(jq -r '.attempts // "The bot tried the steps described in the issue but could not trigger the bug."' /tmp/agent-result.json)"
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/not-reproduced"
|
||||
{
|
||||
if [[ "$DIRECTED" == "true" ]]; then
|
||||
echo "I tried to implement the directive but couldn't reproduce the issue to verify a fix against."
|
||||
else
|
||||
echo "The investigation bot could not reproduce this issue."
|
||||
fi
|
||||
echo
|
||||
echo "**What was tried:**"
|
||||
echo
|
||||
echo "${ATTEMPTS}"
|
||||
echo
|
||||
echo "If you can share a minimal reproduction (failing test, repo, or video), please add it and a maintainer can re-trigger the bot."
|
||||
echo
|
||||
echo "<sub>Run: $RUN_URL</sub>"
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
|
||||
# ----- Outcome branches: reproduced but verdict is intended-behavior -----
|
||||
|
||||
- name: Handle reproduced (intended-behavior)
|
||||
if: steps.parse.outputs.outcome == 'intended-behavior'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)"
|
||||
# `triage/by-design` (not `triage/reproduced`): the bot
|
||||
# reproduced the described behavior but believes it is
|
||||
# intentional. This is a "likely close / convert to discussion"
|
||||
# signal, the opposite follow-up from a confirmed bug, so it
|
||||
# gets its own label rather than sharing triage/reproduced.
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/by-design"
|
||||
{
|
||||
echo "The investigation bot reproduced the described behavior, but it appears to be intended."
|
||||
echo
|
||||
echo "**Analysis:**"
|
||||
echo
|
||||
echo "${NOTES}"
|
||||
echo
|
||||
echo "A maintainer will follow up to confirm whether this is a bug or a documentation/UX gap."
|
||||
echo
|
||||
echo "<sub>Run: $RUN_URL</sub>"
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
|
||||
# ----- Outcome branches: reproduced but no fix yet -----
|
||||
|
||||
- name: Handle reproduced (no fix)
|
||||
if: steps.parse.outputs.outcome == 'reproduced'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
DIRECTED: ${{ steps.ctx.outputs.directed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)"
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/reproduced"
|
||||
{
|
||||
if [[ "$DIRECTED" == "true" ]]; then
|
||||
# A maintainer directed an implementation but the fix stage still
|
||||
# came back empty (the fix agent read the code and abandoned, or
|
||||
# the directive couldn't be carried out). Say so plainly rather
|
||||
# than the default "a maintainer will pick up" line.
|
||||
echo "I tried to implement the directive but couldn't produce a verified fix."
|
||||
echo
|
||||
echo "${NOTES}"
|
||||
echo
|
||||
echo "The issue stays in \`triage/reproduced\`. Refine the directive and reply again, or pick it up by hand."
|
||||
else
|
||||
echo "The investigation bot reproduced this issue."
|
||||
echo
|
||||
echo "${NOTES}"
|
||||
echo
|
||||
echo "A maintainer will pick up the fix from here."
|
||||
fi
|
||||
echo
|
||||
echo "<sub>Run: $RUN_URL</sub>"
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
|
||||
# ----- Outcome branches: reproduced AND fixed -----
|
||||
|
||||
- name: Handle reproduced + fixed
|
||||
if: steps.parse.outputs.outcome == 'fixed'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
ISSUE_TITLE: ${{ steps.ctx.outputs.title }}
|
||||
REPORTER: ${{ steps.ctx.outputs.reporter }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Re-check issue state before any GitHub writes. The agent
|
||||
# ran for up to 50 minutes; in that window the issue may
|
||||
# have been closed (manually, or by bot-cleanup.yml on a
|
||||
# different trigger). Pushing branches and commenting on a
|
||||
# closed issue would be noise; bot-cleanup.yml would then
|
||||
# leave a dangling branch the close trigger already missed.
|
||||
ISSUE_STATE="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '.state')"
|
||||
if [[ "$ISSUE_STATE" != "open" ]]; then
|
||||
echo "::warning::issue #${ISSUE_NUMBER} is ${ISSUE_STATE}; skipping branch push and comment"
|
||||
exit 0
|
||||
fi
|
||||
NOTES="$(jq -r '.notes // ""' /tmp/agent-result.json)"
|
||||
COMMIT_MSG="$(jq -r '.commitMessage // ("fix: address #" + (.classification.summary // ""))' /tmp/agent-result.json)"
|
||||
FIX_BRANCH="bot/fix-${ISSUE_NUMBER}"
|
||||
ART_BRANCH="bot/artifacts-${ISSUE_NUMBER}"
|
||||
|
||||
# Build the screenshot markdown block. URLs point at the
|
||||
# orphan artifact branch on the emdash repo, not this PR's
|
||||
# branch.
|
||||
#
|
||||
# Defense in depth on the agent's structured output:
|
||||
# - filename: regex-validated against [a-zA-Z0-9._-]+, max
|
||||
# 80 chars. Anything that fails is dropped from the
|
||||
# comment (the screenshot is still on the artifact
|
||||
# branch; just not rendered). Prevents URL injection
|
||||
# and path traversal.
|
||||
# - description: any `]`, `[`, `(`, `)`, `\` MD-escaped
|
||||
# with a `\` prefix so the alt-text span can't be
|
||||
# broken out of.
|
||||
SHOTS_MD="$(jq -r --arg branch "$ART_BRANCH" '
|
||||
def md_escape: gsub("([\\\\\\[\\]()])"; "\\\\\\1");
|
||||
(.screenshots // [])
|
||||
| map(select((.filename // "") | test("^[a-zA-Z0-9._-]{1,80}$")))
|
||||
| map(
|
||||
""
|
||||
)
|
||||
| join("\n\n")
|
||||
' /tmp/agent-result.json)"
|
||||
|
||||
# Configure git identity and a GIT_ASKPASS shim so the app
|
||||
# token is never visible on a process command line.
|
||||
git config --global user.name "emdashbot[bot]"
|
||||
git config --global user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
|
||||
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
|
||||
chmod +x "$GIT_ASKPASS"
|
||||
ORIGIN_URL="https://x-access-token@github.com/emdash-cms/emdash.git"
|
||||
|
||||
# Commit the staged fix onto bot/fix-<n>. The agent did
|
||||
# `git add -A` for the fix files (per skills/fix/SKILL.md);
|
||||
# we move .bot-artifacts off the index before committing so
|
||||
# screenshots never land on the fix branch.
|
||||
git reset HEAD .bot-artifacts 2>/dev/null || true
|
||||
git checkout -B "$FIX_BRANCH"
|
||||
git commit -m "$COMMIT_MSG" || {
|
||||
echo "::warning::no staged changes to commit on $FIX_BRANCH"
|
||||
}
|
||||
git remote remove emdash-fix-origin 2>/dev/null || true
|
||||
git remote add emdash-fix-origin "$ORIGIN_URL"
|
||||
# Plain --force is intentional: every bot run regenerates the
|
||||
# fix from scratch on top of current main. Prior bot commits
|
||||
# on this branch are discarded. --force-with-lease without a
|
||||
# tracked remote ref would not protect anything here (we
|
||||
# never fetched bot/fix-N), and using it would falsely
|
||||
# signal we're protecting against concurrent edits.
|
||||
git push --force emdash-fix-origin "HEAD:refs/heads/${FIX_BRANCH}"
|
||||
|
||||
# Push the artifact branch as an orphan with only the
|
||||
# screenshots. Uses a separate working tree so we don't
|
||||
# disturb the fix branch state.
|
||||
if [[ -d .bot-artifacts ]] && [[ -n "$(ls -A .bot-artifacts 2>/dev/null)" ]]; then
|
||||
ART_TMP="$RUNNER_TEMP/artifacts-${ISSUE_NUMBER}"
|
||||
rm -rf "$ART_TMP"
|
||||
mkdir -p "$ART_TMP/.bot-artifacts"
|
||||
cp -r .bot-artifacts/. "$ART_TMP/.bot-artifacts/"
|
||||
(
|
||||
cd "$ART_TMP"
|
||||
git init -q -b "$ART_BRANCH"
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git add .bot-artifacts
|
||||
git commit -q -m "screenshots for #${ISSUE_NUMBER}"
|
||||
git remote add origin "$ORIGIN_URL"
|
||||
git push --force origin "HEAD:refs/heads/${ART_BRANCH}"
|
||||
)
|
||||
fi
|
||||
|
||||
rm -f "$GIT_ASKPASS"
|
||||
|
||||
# Build the install command from the branch name. The
|
||||
# preview-releases.yml workflow publishes a pkg.pr.new release on
|
||||
# every push to bot/fix-*. pkg.pr.new keys branch resolution by the
|
||||
# *full* branch name, so use "$FIX_BRANCH" verbatim -- stripping the
|
||||
# "bot/" prefix (e.g. "fix-123") produces a URL that 404s.
|
||||
INSTALL_CMD="npm i https://pkg.pr.new/emdash@${FIX_BRANCH}"
|
||||
|
||||
# ISO-8601 timestamp embedded in the comment as a hidden
|
||||
# HTML marker. reporter-reply.yml uses this to verify that a
|
||||
# negative or positive reply was posted AFTER this most-
|
||||
# recent ask. Replies posted to an earlier ask (about a
|
||||
# previous fix candidate) are ignored as stale.
|
||||
ASK_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
{
|
||||
echo "<!-- bot-ask: ${ASK_AT} -->"
|
||||
echo "The investigation bot reproduced this issue and pushed a candidate fix."
|
||||
echo
|
||||
echo "${NOTES}"
|
||||
echo
|
||||
echo "**Try the fix** _(the preview release may take ~60s to publish after the bot pushes its branch -- if `npm i` 404s, wait a moment and retry)_:"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "${INSTALL_CMD}"
|
||||
echo '```'
|
||||
echo
|
||||
if [[ -n "$SHOTS_MD" ]]; then
|
||||
echo "**Screenshots:**"
|
||||
echo
|
||||
echo "${SHOTS_MD}"
|
||||
echo
|
||||
fi
|
||||
if [[ -n "$REPORTER" ]]; then
|
||||
echo "@${REPORTER} could you try this and reply here with whether it resolves the issue? A simple \"yes, fixed\" or \"no, still broken\" is enough."
|
||||
else
|
||||
echo "Could the reporter please try this and reply with whether it resolves the issue?"
|
||||
fi
|
||||
echo
|
||||
# Maintainer directives. reporter-reply.yml only acts on a
|
||||
# non-reporter comment when it carries one of these at the
|
||||
# START of a line, so the keywords go in `code` spans (which
|
||||
# don't form @-mentions and so won't trip the directive parser
|
||||
# on this very comment -- belt-and-suspenders alongside the
|
||||
# bot-author exclusion there).
|
||||
echo "<sub>**Maintainers** can act on the reporter's behalf: start a line with <code>@emdashbot confirm</code> to accept the fix and open a PR, or <code>@emdashbot reject</code> (optionally with details) to re-run the investigation.</sub>"
|
||||
echo
|
||||
echo "Fix branch: \`${FIX_BRANCH}\` · Artifacts branch: \`${ART_BRANCH}\`"
|
||||
echo
|
||||
echo "<sub>Run: $RUN_URL</sub>"
|
||||
} > /tmp/comment.md
|
||||
|
||||
# Order matters: post the ask comment FIRST, transition the
|
||||
# label only after the comment succeeds. If we flipped to
|
||||
# `triage/awaiting-reporter` before posting and the comment
|
||||
# then failed, reporter-reply.yml would see an issue in the
|
||||
# awaiting state with no current `bot-ask` marker, treat
|
||||
# every future reply as stale, and the issue would be stuck
|
||||
# until a maintainer noticed.
|
||||
if ! gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md; then
|
||||
echo "::warning::ask-comment post failed; transitioning to triage/failed instead of triage/awaiting-reporter"
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
|
||||
--remove-label "triage/reproducing" --add-label "triage/failed" || true
|
||||
exit 1
|
||||
fi
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
|
||||
--remove-label "triage/reproducing" --add-label "triage/awaiting-reporter"
|
||||
|
||||
# ----- Outcome branches: agent failed / no parseable result -----
|
||||
|
||||
- name: Handle agent failure
|
||||
if: failure() || steps.parse.outputs.outcome == 'failed'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ steps.ctx.outputs.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${ISSUE_NUMBER:-}" ]]; then
|
||||
echo "No issue number resolved; nothing to comment on."
|
||||
exit 0
|
||||
fi
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/reproducing" --add-label "triage/failed" || true
|
||||
{
|
||||
echo "The investigation bot ran into a problem and could not complete."
|
||||
echo
|
||||
echo "A maintainer can re-trigger by removing and re-applying the \`bot:repro\` label."
|
||||
echo
|
||||
echo "<sub>Run: $RUN_URL</sub>"
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Lunaria
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lunaria-overview:
|
||||
name: Translation Overview
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: lunariajs/action@a0594f1c5b8d55fb367d8df607d5e2541c99e77d # v1-prerelease
|
||||
@@ -0,0 +1,408 @@
|
||||
name: Maintainer Reply
|
||||
|
||||
# When an issue is in a pre-fix triage state (`triage/reproduced` or
|
||||
# `triage/by-design`) and an authorized maintainer addresses `@emdashbot` with
|
||||
# a freeform directive, classify the intent via a small Flue classifier and
|
||||
# act: dispatch a directed investigate run to implement the chosen approach,
|
||||
# flag it as by-design, disengage, or ask for clarification.
|
||||
#
|
||||
# This covers the gap reporter-reply.yml does not: there, a fix already exists
|
||||
# on `bot/fix-<n>` and the question is "does it work?" (confirm / reject). Here
|
||||
# the bot reproduced the issue but deferred the fix (e.g. diagnose returned
|
||||
# `needs-design-decision` with options), and the maintainer is making that
|
||||
# call. The two workflows gate on disjoint label states, so they never both
|
||||
# fire on one comment.
|
||||
#
|
||||
# A produced fix routes through the normal awaiting-reporter loop -- this
|
||||
# workflow only gets the issue from `reproduced` to a fix attempt; reporter-
|
||||
# reply.yml owns everything after.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Default-deny at workflow level.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
classify-and-act:
|
||||
name: Classify directive and act
|
||||
# Coarse `if:` -- cheap, reliable payload-only filters, matching
|
||||
# reporter-reply.yml's philosophy:
|
||||
# - the comment is on an issue (not a PR -- issue_comment fires for both)
|
||||
# - the commenter is not a bot (excludes emdashbot's own comments, which
|
||||
# would otherwise re-trigger the classifier in a loop)
|
||||
# - the issue is in a pre-fix state this workflow acts on
|
||||
#
|
||||
# Authorization (a real write/triage role) and the `@emdashbot` wake word
|
||||
# are checked in live-check. `author_association` from the payload is
|
||||
# unreliable for the role check -- a maintainer with private org membership
|
||||
# reports `NONE` -- so it is not gated on here.
|
||||
if: >-
|
||||
github.event.issue.pull_request == null
|
||||
&& github.event.comment.user.type != 'Bot'
|
||||
&& (contains(github.event.issue.labels.*.name, 'triage/reproduced')
|
||||
|| contains(github.event.issue.labels.*.name, 'triage/by-design'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: maintainer-reply-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
# All writes (labels, comment, repository_dispatch) use the app token
|
||||
# below. No PRs are opened here, so no pull-requests scope is needed.
|
||||
contents: read
|
||||
issues: read
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: emdash-cms
|
||||
repositories: emdash
|
||||
permission-issues: write
|
||||
permission-contents: write
|
||||
|
||||
# Re-verify live state before any expensive work. Three checks:
|
||||
#
|
||||
# 1. The issue is still in a pre-fix state this workflow acts on
|
||||
# (`triage/reproduced` or `triage/by-design`). The job `if:` uses
|
||||
# the dispatch-time label snapshot; a label may have moved since.
|
||||
# Concurrency only serialises replies, it does not re-read state.
|
||||
#
|
||||
# 2. The commenter is authorized: a real admin/write/triage role on the
|
||||
# repo, checked against the permission API rather than the
|
||||
# spoof-prone-by-omission `author_association` in the payload.
|
||||
#
|
||||
# 3. The comment opts in with an `@emdashbot` directive at the START of
|
||||
# a line (leading whitespace only) so a directive quoted from
|
||||
# another comment (`> @emdashbot ...`) does not count. Without the
|
||||
# wake word, ordinary maintainer chatter on a triage thread would
|
||||
# kick off an expensive classify+investigate on every comment.
|
||||
- name: Re-verify live state
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
REPLY_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Capture which pre-fix state the issue is in -- handlers word their
|
||||
# comments and label flips differently for reproduced vs by-design.
|
||||
LABELS="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '[.labels[].name] | join(",")')"
|
||||
if grep -q 'triage/reproduced' <<<"$LABELS"; then
|
||||
STATE="reproduced"
|
||||
elif grep -q 'triage/by-design' <<<"$LABELS"; then
|
||||
STATE="by-design"
|
||||
else
|
||||
echo "::notice::issue #${ISSUE_NUMBER} is no longer in a pre-fix state (live labels: ${LABELS}); skipping stale reply event"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Authorization: real write-or-triage role on the repo ----
|
||||
#
|
||||
# Gate on BOTH fields the endpoint returns:
|
||||
# * `permission` -- the legacy BASE role (admin/write/read/none),
|
||||
# with maintain mapped to write and triage mapped to read. Custom
|
||||
# org roles collapse to their base here, so a write-equivalent
|
||||
# custom role is caught by `write`.
|
||||
# * `role_name` -- needed only to recognise `triage` specifically
|
||||
# (it maps down to `read` in `permission`).
|
||||
# A 404 (no access) leaves both empty. The read is authorized by the
|
||||
# token's contents:write (push-equivalent) scope.
|
||||
PERM_JSON="$(gh api "/repos/emdash-cms/emdash/collaborators/${COMMENTER}/permission" 2>/dev/null || true)"
|
||||
PERM="$(jq -r '.permission // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
|
||||
ROLE="$(jq -r '.role_name // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
|
||||
if [[ "$PERM" != "admin" && "$PERM" != "write" && "$ROLE" != "triage" ]]; then
|
||||
echo "::notice::commenter ${COMMENTER} has permission '${PERM:-none}' / role '${ROLE:-none}' on emdash (need write or triage); ignoring"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Wake word: an `@emdashbot` directive starting a line ----
|
||||
if ! grep -iqE '^[[:space:]]*@emdashbot\b' <<<"$REPLY_BODY"; then
|
||||
echo "::notice::maintainer ${COMMENTER} commented without an '@emdashbot' directive; taking no action"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "state=${STATE}" >> "$GITHUB_OUTPUT"
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
id: live-check
|
||||
|
||||
- name: Checkout
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: "package.json"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Flue agent dependencies
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: .flue
|
||||
|
||||
- name: Build packages
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
run: pnpm build
|
||||
|
||||
- name: Build classifier payload
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REPLY_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The latest emdashbot[bot] comment is the bot's investigation, so the
|
||||
# classifier can resolve references like "option A" or "the second
|
||||
# one". Bot-authored and only ever fed to the model, so semi-trusted;
|
||||
# write to a file and pass via --rawfile rather than an env var.
|
||||
gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/comments" --paginate --slurp \
|
||||
| jq -r '[ .[] | .[] | select(.user.login == "emdashbot[bot]") | .body ] | last // ""' \
|
||||
> /tmp/bot-context.txt
|
||||
jq -nc \
|
||||
--argjson n "$ISSUE_NUMBER" \
|
||||
--arg b "$REPLY_BODY" \
|
||||
--rawfile c /tmp/bot-context.txt \
|
||||
'{replyBody: $b, issueNumber: $n, botContext: $c, owner: "emdash-cms", repo: "emdash"}' \
|
||||
> /tmp/classify-payload.json
|
||||
|
||||
- name: Run classifier
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
id: classify
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
AGENT_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ORCHESTRATOR_GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
|
||||
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
|
||||
CLOUDFLARE_API_KEY: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
# The workflow writes its result here; we read it directly instead of
|
||||
# scraping `flue run`'s stdout, which interleaves build-log lines and
|
||||
# pretty-prints the result -- both defeat parsing and silently default
|
||||
# to `unclear`. Same handoff as investigate.yml's INVESTIGATE_RESULT_PATH.
|
||||
CLASSIFY_RESULT_PATH: /tmp/classify-result.json
|
||||
run: |
|
||||
set -o pipefail
|
||||
RESULT_PATH="${CLASSIFY_RESULT_PATH:?CLASSIFY_RESULT_PATH not set}"
|
||||
PAYLOAD="$(cat /tmp/classify-payload.json)"
|
||||
rm -f "$RESULT_PATH"
|
||||
set +e
|
||||
# See investigate.yml's "Run Flue investigate agent" step for why we
|
||||
# invoke the binary directly rather than via `pnpm --dir`.
|
||||
.flue/node_modules/.bin/flue run classify-maintainer-reply \
|
||||
--target node \
|
||||
--root .flue \
|
||||
--payload "$PAYLOAD" \
|
||||
> /tmp/classify-stdout.json 2> /tmp/classify-stderr.log
|
||||
EXIT=$?
|
||||
set -e
|
||||
: > /tmp/directive.txt
|
||||
: > /tmp/classify-reasoning.txt
|
||||
# A clean run writes a single JSON object to the result file. A
|
||||
# non-zero exit, a missing file, or a non-object means the run did
|
||||
# not finish -- default to unclear (which re-asks, never acts).
|
||||
if [[ $EXIT -ne 0 ]] || [[ ! -s "$RESULT_PATH" ]] || ! jq -e 'type == "object"' "$RESULT_PATH" >/dev/null 2>&1; then
|
||||
echo "::warning::classifier exit=${EXIT} or no result file; defaulting to unclear"
|
||||
tail -n 50 /tmp/classify-stderr.log || true
|
||||
echo "intent=unclear" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Whitelist the intent -- the handler gate must be a known enum or we
|
||||
# treat it as unclear. Defends against an unexpected model value.
|
||||
INTENT_RAW="$(jq -r '.intent // "unclear"' "$RESULT_PATH" | tr -d '\r\n')"
|
||||
case "$INTENT_RAW" in
|
||||
implement|close|takeover|unclear) INTENT="$INTENT_RAW" ;;
|
||||
*) INTENT="unclear" ;;
|
||||
esac
|
||||
# Directive and reasoning are model output shaped by the maintainer's
|
||||
# comment. Persist to files, never $GITHUB_OUTPUT -- a heredoc with a
|
||||
# fixed delimiter would be a step-output injection vector if either
|
||||
# contained the delimiter on its own line. The directive is later
|
||||
# JSON-escaped into the dispatch payload; it is never interpolated
|
||||
# into a command or used to build an identifier.
|
||||
jq -r '.directive // ""' "$RESULT_PATH" > /tmp/directive.txt
|
||||
jq -r '.reasoning // ""' "$RESULT_PATH" > /tmp/classify-reasoning.txt
|
||||
echo "intent=${INTENT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Combine intent + directive presence into the `action` the handlers
|
||||
# gate on. `implement` only acts if the classifier actually extracted a
|
||||
# directive. An empty directive (the maintainer said "go ahead" without
|
||||
# naming an approach) cannot override the fix gate, so it would just
|
||||
# reproduce again; route it to `unclear` to ask for specifics instead.
|
||||
- name: Resolve action
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
id: resolve
|
||||
env:
|
||||
INTENT: ${{ steps.classify.outputs.intent }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$INTENT" in
|
||||
implement)
|
||||
if [[ -s /tmp/directive.txt ]] && grep -q '[^[:space:]]' /tmp/directive.txt; then
|
||||
ACTION="implement"
|
||||
else
|
||||
ACTION="unclear"
|
||||
fi
|
||||
;;
|
||||
close) ACTION="close" ;;
|
||||
takeover) ACTION="takeover" ;;
|
||||
*) ACTION="unclear" ;;
|
||||
esac
|
||||
echo "action=${ACTION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ----- Implement: dispatch a directed investigate run -----
|
||||
|
||||
- name: Handle implement
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'implement'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
STATE: ${{ steps.live-check.outputs.state }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
REPO_FULL: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Fire a `maintainer-directive` repository_dispatch (not
|
||||
# `gh workflow run`): firing repository_dispatch needs only
|
||||
# contents:write, which the app token has, whereas workflow_dispatch
|
||||
# needs actions:write, which the emdashbot App is not granted.
|
||||
# investigate.yml reads issueNumber / directive from client_payload.
|
||||
# The directive is read from a file via --rawfile so it is
|
||||
# JSON-escaped, never interpolated into the command.
|
||||
#
|
||||
# Dispatch first, then flip the label. Order matters for recovery: if
|
||||
# dispatch fails, the label stays put so the maintainer can simply
|
||||
# reply again, rather than the issue getting stuck in a reproducing
|
||||
# state with nothing running.
|
||||
set +e
|
||||
jq -nc \
|
||||
--arg n "$ISSUE_NUMBER" \
|
||||
--rawfile d /tmp/directive.txt \
|
||||
'{event_type: "maintainer-directive", client_payload: {issueNumber: $n, directive: $d}}' \
|
||||
| gh api --method POST "/repos/${REPO_FULL}/dispatches" --input -
|
||||
DISPATCH_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [[ $DISPATCH_EXIT -ne 0 ]]; then
|
||||
echo "::warning::repository_dispatch failed (exit ${DISPATCH_EXIT}); leaving label on triage/${STATE}"
|
||||
{
|
||||
echo "@${COMMENTER} I tried to start the implementation but the dispatch failed. Reply again to retry, or pick it up by hand."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Dispatch succeeded. Flip the current pre-fix state label to
|
||||
# triage/reproducing so the in-flight investigation claims the issue
|
||||
# and a second directive during that window passes the live-state
|
||||
# check to a no-op. The dispatched investigate.yml re-asserts
|
||||
# reproducing idempotently at its transition step. Retry the flip a
|
||||
# few times; if it never lands, investigate.yml will flip it itself.
|
||||
FLIP_OK=false
|
||||
for ATTEMPT in 1 2 3; do
|
||||
if gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
|
||||
--remove-label "triage/${STATE}" --add-label "triage/reproducing"; then
|
||||
FLIP_OK=true
|
||||
break
|
||||
fi
|
||||
echo "::warning::label flip attempt ${ATTEMPT} failed, retrying"
|
||||
sleep $((ATTEMPT * 2))
|
||||
done
|
||||
if [[ "$FLIP_OK" != "true" ]]; then
|
||||
echo "::warning::label flip failed 3 times; relying on investigate.yml's transition step"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "On it, @${COMMENTER} — implementing your directive and re-running the investigation. I'll push a candidate fix and ask for confirmation when it's ready."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true
|
||||
|
||||
# ----- Close: flag as by-design; the bot never closes the issue itself -----
|
||||
|
||||
- name: Handle close
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'close'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
STATE: ${{ steps.live-check.outputs.state }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$STATE" != "by-design" ]]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
|
||||
--remove-label "triage/${STATE}" --add-label "triage/by-design"
|
||||
fi
|
||||
{
|
||||
echo "Flagged as by-design per @${COMMENTER}."
|
||||
# Reasoning is multi-line model output; read from the file and
|
||||
# block-quote every line (a bare echo would quote only the first).
|
||||
if grep -q '[^[:space:]]' /tmp/classify-reasoning.txt; then
|
||||
echo
|
||||
sed 's/^/> /' /tmp/classify-reasoning.txt
|
||||
fi
|
||||
echo
|
||||
echo "I don't close issues automatically — close it whenever you're ready."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
|
||||
# ----- Takeover: disengage so the bot stops acting on this issue -----
|
||||
|
||||
- name: Handle takeover
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'takeover'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
STATE: ${{ steps.live-check.outputs.state }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Drop the pre-fix state label so this workflow no longer fires on
|
||||
# the issue (the job `if:` requires reproduced/by-design).
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/${STATE}"
|
||||
{
|
||||
echo "Disengaging — over to you, @${COMMENTER}. Re-apply \`bot:repro\` if you want the bot back on it."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
|
||||
# ----- Unclear: ask for a concrete directive, no state change -----
|
||||
|
||||
- name: Handle unclear
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.action == 'unclear'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "@${COMMENTER} I couldn't tell what you'd like me to do. You can:"
|
||||
echo
|
||||
echo "- **Implement a fix** — \`@emdashbot implement <which approach>\` (name the option or the change you want)."
|
||||
echo "- **Flag as by-design** — \`@emdashbot this is by design\`."
|
||||
echo "- **Take it over** — \`@emdashbot I'll handle this\`."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
@@ -0,0 +1,173 @@
|
||||
name: Playground Preview
|
||||
|
||||
# Workers Builds runs `wrangler preview` for the playground demo (it can't use
|
||||
# the standard preview-URL flow because the playground has a Durable Object).
|
||||
# These are a private beta feature, and don't currently support automatic PR
|
||||
# comments with the preview URL.
|
||||
# The cloudflare-workers-and-pages bot posts a "build successful" comment when
|
||||
# the build finishes, but it doesn't include the preview URL (preview URLs are
|
||||
# a `wrangler versions upload` concept, not `wrangler preview`).
|
||||
# This workflow is a workaround to post the preview URL in the PR description.
|
||||
#
|
||||
# The playground is the primary "try this PR" surface for emdash: each visit
|
||||
# gets its own session-scoped Durable Object, so reviewers can poke at a full
|
||||
# working admin without signup, login, or shared state. That makes the preview
|
||||
# link the single most useful thing in the PR -- but a sticky comment posted
|
||||
# after the build would land below the fold (CF bot, pkg-pr-new, changeset-bot,
|
||||
# etc.). So instead we edit the PR description to insert a managed block.
|
||||
#
|
||||
# Trigger: the CF bot's "Deployment successful" edit, scoped to emdash-playground.
|
||||
# This means we comment when the deploy is genuinely live, not just when the
|
||||
# commit was pushed.
|
||||
#
|
||||
# The branch preview URL is fully deterministic from the branch name and the
|
||||
# worker/account names, so no Cloudflare API token is required -- only the
|
||||
# default GITHUB_TOKEN.
|
||||
#
|
||||
# Caveats:
|
||||
# - Branch slugs longer than the (private-beta, undocumented) max length get
|
||||
# truncated server-side; collisions get a random 6-char suffix appended.
|
||||
# In practice this is fine for emdash branch names. If the URL 404s, check
|
||||
# the dash.
|
||||
# - issue_comment workflows run from `main`, not the PR branch. Changes to
|
||||
# this file only take effect once merged.
|
||||
# - This won't fire for PRs from forks where the fork doesn't have the
|
||||
# workflow file. That's fine -- the playground builds only run for the
|
||||
# internal repo, not forks.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# The Cloudflare bot edits its comment 4-5 times during a build (queued ->
|
||||
# initializing -> running -> ... -> successful). The "successful" edit is
|
||||
# usually the terminal state, but a subsequent edit could race a still-running
|
||||
# workflow that's mid-fetch. Serialize per PR; cancel in-flight runs so only
|
||||
# the latest comment state is processed.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-body:
|
||||
name: Update PR body
|
||||
runs-on: ubuntu-latest
|
||||
# Only react to:
|
||||
# - PR comments (not issue comments)
|
||||
# - the CF bot's comment
|
||||
# - that mentions the playground worker
|
||||
# - and contains the deployment-success marker
|
||||
if: >-
|
||||
github.event.issue.pull_request != null &&
|
||||
github.event.comment.user.login == 'cloudflare-workers-and-pages[bot]' &&
|
||||
contains(github.event.comment.body, 'emdash-playground') &&
|
||||
contains(github.event.comment.body, 'Deployment successful!')
|
||||
permissions:
|
||||
pull-requests: write # read PR body and update it with the playground block; no PR code is checked out
|
||||
steps:
|
||||
- name: Update PR description with playground link
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
BOT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
script: |
|
||||
const { BOT_COMMENT_BODY, PR_NUMBER } = process.env;
|
||||
const prNumber = Number(PR_NUMBER);
|
||||
|
||||
// Confirm the playground row itself is in the successful state.
|
||||
// Each row in the bot's comment looks like:
|
||||
// | ✅ Deployment successful! ... | emdash-playground | <sha> | ... |
|
||||
const playgroundRow = BOT_COMMENT_BODY.split("\n").find((line) =>
|
||||
line.includes("| emdash-playground |"),
|
||||
);
|
||||
if (!playgroundRow || !playgroundRow.includes("✅ Deployment successful!")) {
|
||||
core.info("Playground row not in successful state; skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const branch = pr.head.ref;
|
||||
|
||||
// Slug rules (from SPEC: Worker Previews): lowercase, with /, .,
|
||||
// +, =, _ replaced by -. We widen this to any non-DNS-safe char
|
||||
// (handles Renovate-style `@`, unusual community branches, etc.):
|
||||
// anything outside [a-z0-9-] becomes -, repeated -- collapsed,
|
||||
// leading/trailing - trimmed. If we end up with an empty slug
|
||||
// (e.g. branch was all special chars), bail rather than emit a
|
||||
// broken URL.
|
||||
const slug = branch
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (!slug) {
|
||||
core.warning(`Branch "${branch}" produced an empty slug; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://${slug}-emdash-playground.emdash-cms.workers.dev`;
|
||||
|
||||
const START = "<!-- emdash:playground-preview:start -->";
|
||||
const END = "<!-- emdash:playground-preview:end -->";
|
||||
|
||||
// The managed block. Kept short and inviting -- the link is the
|
||||
// point. Each visit to the playground gets its own session-scoped
|
||||
// Durable Object, so reviewers can play freely.
|
||||
const block = [
|
||||
START,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
`### Try this PR`,
|
||||
"",
|
||||
`**[Open a fresh playground →](${url})**`,
|
||||
"",
|
||||
`A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.`,
|
||||
"",
|
||||
`<sub>Tracks \`${branch}\`. Updated automatically when the playground redeploys.</sub>`,
|
||||
"",
|
||||
END,
|
||||
].join("\n");
|
||||
|
||||
const existingBody = pr.body ?? "";
|
||||
|
||||
// If a block already exists, replace it in place (preserves
|
||||
// whatever position the author or a previous run put it in).
|
||||
// Otherwise append it to the end of the description.
|
||||
const blockRegex = new RegExp(
|
||||
`\\n*${escapeRegex(START)}[\\s\\S]*?${escapeRegex(END)}\\n*`,
|
||||
);
|
||||
|
||||
let newBody;
|
||||
if (blockRegex.test(existingBody)) {
|
||||
newBody = existingBody.replace(blockRegex, `\n\n${block}\n`);
|
||||
core.info("Replaced existing playground block in PR body.");
|
||||
} else {
|
||||
// Append, with a blank line separator.
|
||||
const trimmed = existingBody.replace(/\s+$/, "");
|
||||
newBody = trimmed.length > 0 ? `${trimmed}\n\n${block}\n` : `${block}\n`;
|
||||
core.info("Appended playground block to PR body.");
|
||||
}
|
||||
|
||||
if (newBody === existingBody) {
|
||||
core.info("PR body unchanged; skipping update.");
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
body: newBody,
|
||||
});
|
||||
|
||||
function escapeRegex(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
name: PR Compliance
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
name: Validate PR
|
||||
# Filter on the PR author, not github.actor: actor becomes a maintainer on
|
||||
# synchronize/edited events triggered by pushes or merges into a bot branch,
|
||||
# which lets bot PRs slip past an actor-based check.
|
||||
if: >-
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& github.event.pull_request.user.login != 'renovate[bot]'
|
||||
&& github.event.pull_request.user.login != 'emdashbot[bot]'
|
||||
&& github.event.pull_request.user.login != 'ask-bonk[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check PR template
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.pull_request.body || '';
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Maintainers (repo owners, org members, and collaborators) are trusted
|
||||
// to land features without a pre-approved Discussion link.
|
||||
const association = pr.author_association || '';
|
||||
const isMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association);
|
||||
|
||||
const errors = [];
|
||||
|
||||
// Check if the PR body looks like it used the template at all
|
||||
const hasTemplate = body.includes('## What does this PR do?') || body.includes('## Type of change');
|
||||
|
||||
if (!hasTemplate) {
|
||||
errors.push('This PR does not use the required PR template. Please edit the description to use the [PR template](https://github.com/emdash-cms/emdash/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Copy it into your PR description and fill out all sections.');
|
||||
} else {
|
||||
// Must have a description (not just the raw template placeholder)
|
||||
const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/);
|
||||
const description = descriptionSection?.[1]?.replace(/<!--[\s\S]*?-->/g, '').trim() || '';
|
||||
if (!description || description === 'Closes #') {
|
||||
errors.push('Fill out the "What does this PR do?" section with a description of your change.');
|
||||
}
|
||||
|
||||
// Must check at least one type
|
||||
const typeChecks = [
|
||||
/- \[x\] Bug fix/i,
|
||||
/- \[x\] Feature/i,
|
||||
/- \[x\] Refactor/i,
|
||||
/- \[x\] Translation/i,
|
||||
/- \[x\] Documentation/i,
|
||||
/- \[x\] Performance/i,
|
||||
/- \[x\] Tests/i,
|
||||
/- \[x\] Chore/i,
|
||||
];
|
||||
const hasType = typeChecks.some(re => re.test(body));
|
||||
if (!hasType) {
|
||||
errors.push('Check at least one "Type of change" checkbox.');
|
||||
}
|
||||
|
||||
// If Feature is checked, require a discussion link.
|
||||
// Maintainers are exempt: they can approve their own features.
|
||||
const isFeature = /- \[x\] Feature/i.test(body);
|
||||
if (isFeature && !isMaintainer) {
|
||||
const hasDiscussionLink = /github\.com\/emdash-cms\/emdash\/discussions\/\d+/.test(body);
|
||||
if (!hasDiscussionLink) {
|
||||
errors.push('Feature PRs require a link to an approved Discussion (https://github.com/emdash-cms/emdash/discussions/categories/ideas). Open a Discussion first, get approval, then link it in the PR.');
|
||||
}
|
||||
}
|
||||
|
||||
// Must check the "I have read CONTRIBUTING.md" box. Be lenient about
|
||||
// formatting: accept the box whether or not CONTRIBUTING.md is still a
|
||||
// markdown link (authors often strip the link or tweak the wording).
|
||||
const hasReadContributing = /- \[x\][^\n]*\bI have read\b[^\n]*CONTRIBUTING/i.test(body);
|
||||
if (!hasReadContributing) {
|
||||
errors.push('Check the "I have read CONTRIBUTING.md" checkbox.');
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const message = [
|
||||
'## PR template validation failed',
|
||||
'',
|
||||
'Please fix the following issues by editing your PR description:',
|
||||
'',
|
||||
...errors.map(e => `- ${e}`),
|
||||
'',
|
||||
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for the full contribution policy.',
|
||||
].join('\n');
|
||||
|
||||
// Leave a comment so the author sees the errors directly
|
||||
// Find and update existing bot comment, or create a new one
|
||||
const marker = '<!-- pr-compliance-check -->';
|
||||
const commentBody = `${marker}\n${message}`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c =>
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: commentBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
// If passing now, remove any previous failure comment
|
||||
const marker = '<!-- pr-compliance-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c =>
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
name: PR Sweep
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 6 hours
|
||||
- cron: "0 */6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep Open PRs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Sweep PRs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const now = new Date();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Label color map for auto-creation
|
||||
const labelColors = {
|
||||
'needs-approval': 'fbca04',
|
||||
'needs-rebase': 'e11d48',
|
||||
'stale': 'ededed',
|
||||
'overlap': 'c5def5',
|
||||
};
|
||||
|
||||
// Ensure labels exist
|
||||
const existingLabels = new Set();
|
||||
for await (const response of github.paginate.iterator(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner, repo, per_page: 100 }
|
||||
)) {
|
||||
for (const label of response.data) {
|
||||
existingLabels.add(label.name);
|
||||
}
|
||||
}
|
||||
for (const [name, color] of Object.entries(labelColors)) {
|
||||
if (!existingLabels.has(name)) {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color });
|
||||
}
|
||||
}
|
||||
|
||||
// Get all open PRs
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
core.info(`Sweeping ${prs.length} open PRs`);
|
||||
|
||||
// Build a file->PR map for overlap detection
|
||||
const fileToPRs = new Map();
|
||||
|
||||
for (const pr of prs) {
|
||||
const prLabels = new Set(pr.labels.map(l => l.name));
|
||||
const toAdd = [];
|
||||
const toRemove = [];
|
||||
|
||||
// --- needs-approval: PR has no CI check runs ---
|
||||
// (CLA runs via pull_request_target so it always runs)
|
||||
try {
|
||||
const { data: checkRuns } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: pr.head.sha,
|
||||
per_page: 100,
|
||||
});
|
||||
// Filter to only CI-related checks (not CLA, not PR Triage)
|
||||
const ciChecks = checkRuns.check_runs.filter(c =>
|
||||
!c.name.includes('CLA') &&
|
||||
!c.name.includes('Label') &&
|
||||
!c.name.includes('Triage') &&
|
||||
!c.name.includes('Validate PR')
|
||||
);
|
||||
const prAge = now - new Date(pr.created_at);
|
||||
|
||||
if (ciChecks.length === 0 && prAge > 5 * 60 * 1000) {
|
||||
// No CI checks after 5 minutes -- likely needs approval
|
||||
if (!prLabels.has('needs-approval')) toAdd.push('needs-approval');
|
||||
} else {
|
||||
if (prLabels.has('needs-approval')) toRemove.push('needs-approval');
|
||||
}
|
||||
} catch {
|
||||
// Ignore check run lookup failures
|
||||
}
|
||||
|
||||
// --- needs-rebase: PR has merge conflicts ---
|
||||
// mergeable is not included in the list endpoint; fetch individually
|
||||
try {
|
||||
const { data: fullPr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (fullPr.mergeable === false) {
|
||||
if (!prLabels.has('needs-rebase')) toAdd.push('needs-rebase');
|
||||
} else if (fullPr.mergeable === true) {
|
||||
if (prLabels.has('needs-rebase')) toRemove.push('needs-rebase');
|
||||
}
|
||||
// mergeable===null means GitHub is still computing; skip
|
||||
} catch {
|
||||
// Ignore merge status lookup failures
|
||||
}
|
||||
|
||||
// --- Stale detection ---
|
||||
const lastActivity = new Date(pr.updated_at);
|
||||
const daysSinceActivity = (now - lastActivity) / DAY;
|
||||
|
||||
if (daysSinceActivity > 21) {
|
||||
// 21 days with no activity -- close it
|
||||
if (!prLabels.has('stale')) {
|
||||
// Should already be labeled stale from 14-day mark, but just in case
|
||||
toAdd.push('stale');
|
||||
}
|
||||
|
||||
const marker = '<!-- stale-close -->';
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: `${marker}\nThis PR has been inactive for 21 days and is being closed automatically. If you'd like to continue working on it, feel free to reopen it.`,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`#${pr.number}: closed (stale, ${Math.floor(daysSinceActivity)} days inactive)`);
|
||||
continue; // Skip further processing for closed PRs
|
||||
} else if (daysSinceActivity > 14) {
|
||||
// 14 days -- label as stale and warn
|
||||
if (!prLabels.has('stale')) {
|
||||
toAdd.push('stale');
|
||||
|
||||
const marker = '<!-- stale-warning -->';
|
||||
// Check if we already left a stale warning
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 10,
|
||||
});
|
||||
const hasWarning = comments.some(c =>
|
||||
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (!hasWarning) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: `${marker}\nThis PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.\n\nIf you're still working on this, please push an update or leave a comment.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Active -- remove stale label if present
|
||||
if (prLabels.has('stale')) toRemove.push('stale');
|
||||
}
|
||||
|
||||
// --- Collect files for overlap detection ---
|
||||
try {
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const file of files) {
|
||||
if (!fileToPRs.has(file.filename)) {
|
||||
fileToPRs.set(file.filename, []);
|
||||
}
|
||||
fileToPRs.get(file.filename).push(pr.number);
|
||||
}
|
||||
} catch {
|
||||
// Ignore file listing failures
|
||||
}
|
||||
|
||||
// --- Apply label changes ---
|
||||
if (toAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
for (const label of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
name: label,
|
||||
});
|
||||
} catch {
|
||||
// Label might not exist on the PR
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
core.info(`#${pr.number}: +[${toAdd.join(',')}] -[${toRemove.join(',')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Overlap detection ---
|
||||
// Find files changed by multiple PRs
|
||||
const overlaps = new Map(); // pr number -> set of overlapping PR numbers
|
||||
for (const [file, prNumbers] of fileToPRs) {
|
||||
if (prNumbers.length > 1) {
|
||||
for (const prNum of prNumbers) {
|
||||
if (!overlaps.has(prNum)) overlaps.set(prNum, new Set());
|
||||
for (const other of prNumbers) {
|
||||
if (other !== prNum) overlaps.get(prNum).add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label PRs with significant overlap (3+ shared files with another PR)
|
||||
for (const [prNum, otherPRs] of overlaps) {
|
||||
// Count shared files per overlapping PR
|
||||
const sharedFileCounts = new Map();
|
||||
for (const [file, prNumbers] of fileToPRs) {
|
||||
if (prNumbers.includes(prNum)) {
|
||||
for (const other of prNumbers) {
|
||||
if (other !== prNum) {
|
||||
sharedFileCounts.set(other, (sharedFileCounts.get(other) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const significantOverlaps = [...sharedFileCounts.entries()]
|
||||
.filter(([_, count]) => count >= 3)
|
||||
.map(([otherPR, count]) => `#${otherPR} (${count} shared files)`);
|
||||
|
||||
if (significantOverlaps.length > 0) {
|
||||
const pr = prs.find(p => p.number === prNum);
|
||||
const prLabels = new Set(pr?.labels.map(l => l.name) || []);
|
||||
|
||||
if (!prLabels.has('overlap')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
labels: ['overlap'],
|
||||
});
|
||||
|
||||
// Leave a comment about the overlap (only once)
|
||||
const marker = '<!-- overlap-notice -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
per_page: 20,
|
||||
});
|
||||
const hasNotice = comments.some(c =>
|
||||
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (!hasNotice) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
body: `${marker}\n## Overlapping PRs\n\nThis PR modifies files that are also changed by other open PRs:\n\n${significantOverlaps.map(s => `- ${s}`).join('\n')}\n\nThis may cause merge conflicts or duplicated work. A maintainer will coordinate.`,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`#${prNum}: overlap with ${significantOverlaps.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
name: PR Triage
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Label PR
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Triage PR
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const labels = new Set();
|
||||
|
||||
// --- Bot detection ---
|
||||
const botLogins = ['dependabot[bot]', 'renovate[bot]', 'emdashbot[bot]'];
|
||||
if (botLogins.includes(pr.user.login)) {
|
||||
labels.add('bot');
|
||||
}
|
||||
|
||||
// --- Size labels ---
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const linesChanged = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
|
||||
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
let sizeLabel;
|
||||
if (linesChanged < 10) sizeLabel = 'size/XS';
|
||||
else if (linesChanged < 50) sizeLabel = 'size/S';
|
||||
else if (linesChanged < 200) sizeLabel = 'size/M';
|
||||
else if (linesChanged < 500) sizeLabel = 'size/L';
|
||||
else sizeLabel = 'size/XL';
|
||||
labels.add(sizeLabel);
|
||||
|
||||
// --- Area labels ---
|
||||
const areaMap = {
|
||||
'area/core': (f) => f.startsWith('packages/core/'),
|
||||
'area/admin': (f) => f.startsWith('packages/admin/'),
|
||||
'area/plugins': (f) => f.startsWith('packages/plugins/'),
|
||||
'area/docs': (f) => f.startsWith('docs/'),
|
||||
'area/templates': (f) => f.startsWith('templates/'),
|
||||
'area/ci': (f) => f.startsWith('.github/'),
|
||||
'area/auth': (f) => f.startsWith('packages/auth/'),
|
||||
'area/cloudflare': (f) => f.startsWith('packages/cloudflare/'),
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
for (const [label, matcher] of Object.entries(areaMap)) {
|
||||
if (matcher(file.filename)) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Merge conflict detection ---
|
||||
// mergeable is available on the full PR object (may need a separate fetch
|
||||
// since the webhook payload sometimes has mergeable=null while computing)
|
||||
try {
|
||||
const { data: fullPr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (fullPr.mergeable === false) {
|
||||
labels.add('needs-rebase');
|
||||
}
|
||||
} catch {
|
||||
// Ignore -- mergeable state may not be computed yet
|
||||
}
|
||||
|
||||
// CLA labels are managed by the CLA workflow (cla.yml), not here.
|
||||
|
||||
// --- Ensure all labels exist, then apply ---
|
||||
const labelColors = {
|
||||
'bot': 'ededed',
|
||||
'size/XS': '3cbf00',
|
||||
'size/S': '5dba3f',
|
||||
'size/M': 'fbca04',
|
||||
'size/L': 'ee9b00',
|
||||
'size/XL': 'd93f0b',
|
||||
'area/core': '0052cc',
|
||||
'area/admin': '7057ff',
|
||||
'area/plugins': '008672',
|
||||
'area/docs': '0075ca',
|
||||
'area/templates': 'bfdadc',
|
||||
'area/ci': '000000',
|
||||
'area/auth': 'd4c5f9',
|
||||
'area/cloudflare': 'f9a825',
|
||||
'needs-rebase': 'e11d48',
|
||||
};
|
||||
|
||||
// Get existing labels on the repo
|
||||
const existingLabels = new Set();
|
||||
for await (const response of github.paginate.iterator(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
|
||||
)) {
|
||||
for (const label of response.data) {
|
||||
existingLabels.add(label.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Create any missing labels
|
||||
for (const label of labels) {
|
||||
if (!existingLabels.has(label) && labelColors[label]) {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: labelColors[label],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels on the PR to remove stale ones
|
||||
const currentLabels = new Set(pr.labels.map(l => l.name));
|
||||
|
||||
// Helper: remove a label, ignoring 404 if it's already gone
|
||||
async function safeRemoveLabel(name) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale size labels (only one size label at a time)
|
||||
for (const sl of sizeLabels) {
|
||||
if (sl !== sizeLabel && currentLabels.has(sl)) {
|
||||
await safeRemoveLabel(sl);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove needs-rebase if PR is now mergeable
|
||||
if (!labels.has('needs-rebase') && currentLabels.has('needs-rebase')) {
|
||||
await safeRemoveLabel('needs-rebase');
|
||||
}
|
||||
|
||||
// Add new labels
|
||||
const toAdd = [...labels].filter(l => !currentLabels.has(l));
|
||||
if (toAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Applied labels: ${[...labels].join(', ')}`);
|
||||
|
||||
// --- Scope guard: comment on oversized PRs ---
|
||||
// Only on open (not synchronize) to avoid spamming on every push
|
||||
if (context.payload.action === 'opened') {
|
||||
const warnings = [];
|
||||
|
||||
if (linesChanged > 500) {
|
||||
warnings.push(`This PR changes **${linesChanged.toLocaleString()} lines** across **${files.length} files**. Large PRs are harder to review and more likely to be closed without review.`);
|
||||
} else if (files.length > 20) {
|
||||
warnings.push(`This PR touches **${files.length} files**. PRs with a broad scope are harder to review. Please confirm the scope hasn't drifted beyond the intended change.`);
|
||||
}
|
||||
|
||||
// Check for cross-area changes (touching multiple packages)
|
||||
const areas = Object.keys(areaMap).filter(a => labels.has(a));
|
||||
if (areas.length > 3) {
|
||||
warnings.push(`This PR spans ${areas.length} different areas (${areas.join(', ')}). Consider breaking it into smaller, focused PRs.`);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
const marker = '<!-- scope-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'## Scope check',
|
||||
'',
|
||||
...warnings,
|
||||
'',
|
||||
'If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.',
|
||||
'',
|
||||
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for contribution guidelines.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Preview Releases
|
||||
|
||||
on:
|
||||
push:
|
||||
# `bot/fix-*` branches are pushed by .github/workflows/investigate.yml
|
||||
# before the bot asks the reporter to verify a candidate fix. The
|
||||
# ask comment includes an `npm i https://pkg.pr.new/emdash@bot/fix-<n>`
|
||||
# install URL (the full branch name -- pkg.pr.new resolves branches by
|
||||
# their full ref) that only resolves once pkg.pr.new has actually
|
||||
# published a preview release on the branch -- hence the explicit
|
||||
# branch pattern here.
|
||||
branches: [main, "bot/fix-*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Preview
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
# Publish a preview for every public package via globs rather than a
|
||||
# hardcoded list. This keeps preview installs self-consistent: when
|
||||
# `emdash`'s preview references a sibling like @emdash-cms/registry-client
|
||||
# via `workspace:*`, pkg.pr.new can only rewrite that to a matching
|
||||
# preview URL if the sibling is published in the same run. Omitting one
|
||||
# makes the dep fall back to npm's released version, which breaks when the
|
||||
# source has drifted (e.g. a new exports subpath added without a release).
|
||||
# pkg.pr.new skips `private: true` packages automatically, so the test
|
||||
# fixtures under packages/plugins/* are excluded without enumerating them.
|
||||
- run: pnpm exec pkg-pr-new publish --pnpm './packages/*' './packages/plugins/*'
|
||||
@@ -0,0 +1,234 @@
|
||||
name: Query Counts — Apply
|
||||
|
||||
# Runs after the "Query Counts" workflow completes on a PR. This job has
|
||||
# elevated permissions to push back to the PR branch and comment, but it
|
||||
# never executes code from the PR — it only downloads the inert JSON +
|
||||
# diff artifact produced by the measure job.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Query Counts"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
# actions:read is needed for listWorkflowRunArtifacts /
|
||||
# downloadArtifact. pull-requests:read is needed for pulls.get in the
|
||||
# Resolve PR step. contents:read is a safety default; the commit-back
|
||||
# step uses the app token, not GITHUB_TOKEN.
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
name: Apply
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download snapshot artifact
|
||||
id: download
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
const artifact = data.artifacts.find((a) => a.name === 'query-counts-snapshots');
|
||||
if (!artifact) {
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No snapshot artifact — nothing to apply.');
|
||||
return;
|
||||
}
|
||||
const download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
// Stage the artifact under RUNNER_TEMP, not GITHUB_WORKSPACE.
|
||||
// GITHUB_WORKSPACE is wiped by the later actions/checkout step
|
||||
// (clean: true is the default), which would delete our payload
|
||||
// before the Apply step can copy it into scripts/. RUNNER_TEMP
|
||||
// sits outside the workspace and survives checkout.
|
||||
const outDir = path.join(process.env.RUNNER_TEMP, 'qc-artifact');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'artifact.zip'), Buffer.from(download.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', outDir);
|
||||
|
||||
- name: Unpack artifact
|
||||
if: steps.download.outputs.found == 'true'
|
||||
run: |
|
||||
cd "$RUNNER_TEMP/qc-artifact"
|
||||
unzip -o artifact.zip
|
||||
ls -la
|
||||
|
||||
- name: Resolve PR
|
||||
if: steps.download.outputs.found == 'true'
|
||||
id: pr
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const prNumber = Number(
|
||||
fs.readFileSync(
|
||||
path.join(process.env.RUNNER_TEMP, 'qc-artifact', 'pr-number'),
|
||||
'utf8',
|
||||
).trim(),
|
||||
);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
core.setFailed(`Invalid PR number in artifact: ${prNumber}`);
|
||||
return;
|
||||
}
|
||||
// Cross-check: fetch the PR directly and verify its head SHA
|
||||
// matches the workflow_run's head SHA. This is more robust
|
||||
// than listPullRequestsAssociatedWithCommit, which doesn't
|
||||
// reliably surface fork PRs from the base repo's API view
|
||||
// and was rejecting valid fork artifacts as "not associated".
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
let pr;
|
||||
try {
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
pr = data;
|
||||
} catch (err) {
|
||||
core.setFailed(`Failed to fetch PR #${prNumber}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== headSha) {
|
||||
core.setFailed(
|
||||
`PR #${prNumber} head SHA (${pr.head.sha}) does not match workflow_run head SHA (${headSha}). The branch has likely moved on; the next PR event will trigger a fresh measure+apply.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
// Use the workflow_run's head_sha — the commit that was
|
||||
// actually measured — rather than the PR's current head.
|
||||
// If the branch has moved on since, we want to push onto
|
||||
// the measured commit (and let the push fail non-fast-
|
||||
// forward) rather than apply stale snapshots to a newer
|
||||
// tree.
|
||||
core.setOutput('sha', headSha);
|
||||
core.setOutput('full_name', pr.head.repo.full_name);
|
||||
const isFork = pr.head.repo.fork
|
||||
|| pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
|
||||
core.setOutput('is_fork', isFork.toString());
|
||||
|
||||
- name: Generate app token
|
||||
if: steps.download.outputs.found == 'true'
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# Push the updated snapshots back to the PR branch. Nothing else.
|
||||
permission-contents: write
|
||||
|
||||
# --- Same-repo PRs: checkout the pinned SHA and push directly ---
|
||||
|
||||
- name: Checkout (same-repo)
|
||||
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'false'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# Intentional: the same-repo push step below pushes the
|
||||
# updated snapshots back to the PR branch using this credential.
|
||||
persist-credentials: true
|
||||
|
||||
# --- Fork PRs: checkout the fork at the pinned SHA so we can push back ---
|
||||
|
||||
- name: Checkout (fork)
|
||||
if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.full_name }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
persist-credentials: false
|
||||
# Reviewed opt-in to checkout v7's fork guard: no code from the
|
||||
# fork tree is ever executed here. The steps below only copy the
|
||||
# inert snapshot JSONs from the trusted measure artifact and run
|
||||
# git add/commit/push — no scripts, hooks, or tooling from the
|
||||
# checked-out tree. Credentials are not persisted; the push uses
|
||||
# a scoped app token via GIT_ASKPASS.
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Apply snapshots
|
||||
if: steps.download.outputs.found == 'true'
|
||||
id: apply
|
||||
run: |
|
||||
cp "$RUNNER_TEMP/qc-artifact/query-counts.snapshot.sqlite.json" scripts/
|
||||
cp "$RUNNER_TEMP/qc-artifact/query-counts.snapshot.d1.json" scripts/
|
||||
cp "$RUNNER_TEMP/qc-artifact/query-counts.queries.sqlite.json" scripts/
|
||||
cp "$RUNNER_TEMP/qc-artifact/query-counts.queries.d1.json" scripts/
|
||||
git add \
|
||||
scripts/query-counts.snapshot.sqlite.json \
|
||||
scripts/query-counts.snapshot.d1.json \
|
||||
scripts/query-counts.queries.sqlite.json \
|
||||
scripts/query-counts.queries.d1.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "no_diff=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Artifact matches the PR head — nothing to push (probably a race with a newer commit)."
|
||||
else
|
||||
echo "no_diff=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push (same-repo)
|
||||
if: |
|
||||
steps.download.outputs.found == 'true'
|
||||
&& steps.pr.outputs.is_fork == 'false'
|
||||
&& steps.apply.outputs.no_diff == 'false'
|
||||
env:
|
||||
HEAD_REF: ${{ steps.pr.outputs.ref }}
|
||||
HEAD_SHA: ${{ steps.pr.outputs.sha }}
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "ci: update query-count snapshots"
|
||||
# Detached-HEAD push onto the PR branch. If the branch has
|
||||
# advanced past the measured SHA, this fails with a non-fast-
|
||||
# forward — safe outcome, since applying stale counts to a
|
||||
# newer tree would hide a regression. The next PR event will
|
||||
# kick off a fresh measure+apply against the new head.
|
||||
if ! git push origin "HEAD:$HEAD_REF"; then
|
||||
echo "::error::Non-fast-forward push — the PR branch moved past the measured SHA $HEAD_SHA. Rerun the harness against the new head (the next PR event will do this automatically)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit and push (fork)
|
||||
if: |
|
||||
steps.download.outputs.found == 'true'
|
||||
&& steps.pr.outputs.is_fork == 'true'
|
||||
&& steps.apply.outputs.no_diff == 'false'
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
HEAD_REF: ${{ steps.pr.outputs.ref }}
|
||||
FULL_NAME: ${{ steps.pr.outputs.full_name }}
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "ci: update query-count snapshots"
|
||||
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
|
||||
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
|
||||
chmod +x "$GIT_ASKPASS"
|
||||
git remote add fork "https://x-access-token@github.com/$FULL_NAME.git"
|
||||
# Fail the workflow if we can't push — most likely cause is
|
||||
# "Allow edits by maintainers" being disabled on the fork PR.
|
||||
# The reviewer needs to know the snapshots couldn't be applied.
|
||||
if ! git push fork "HEAD:$HEAD_REF"; then
|
||||
rm -f "$GIT_ASKPASS"
|
||||
echo "::error::Failed to push snapshots to fork. The contributor may have 'Allow edits by maintainers' disabled. They need to run 'pnpm query-counts --target sqlite --update && pnpm query-counts --target d1 --update' locally and commit, or re-enable maintainer edits." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$GIT_ASKPASS"
|
||||
@@ -0,0 +1,203 @@
|
||||
name: Query Counts — Label
|
||||
|
||||
# Manages the "query-count changed" label and a diff comment on PRs that
|
||||
# alter per-route DB query counts. When a PR's snapshot files differ from
|
||||
# base, the label and a comparison comment are added; when they match base
|
||||
# again (e.g. the delta was reverted, or base caught up via a merge), both
|
||||
# are removed.
|
||||
#
|
||||
# Deliberately NOT path-filtered. A `paths:` filter would create a catch-22:
|
||||
# the workflow could never run to clear a stale label/comment once a
|
||||
# previously-changed snapshot matched base again, because at that point the
|
||||
# PR diff no longer touches the snapshot files. The script below is cheap
|
||||
# (a couple of API reads) and decides what to do from the actual base/head
|
||||
# diff on every run.
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Sync query-count label and diff comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const baseRef = pr.base.sha;
|
||||
const headRef = pr.head.sha;
|
||||
const label = 'query-count changed';
|
||||
|
||||
// Fetch a JSON file at a given ref. Returns {} if the file
|
||||
// doesn't exist on that ref (e.g. snapshot added in this PR).
|
||||
async function loadSnapshot(ref, path) {
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
path,
|
||||
});
|
||||
if (Array.isArray(data) || data.type !== 'file' || !data.content) {
|
||||
return {};
|
||||
}
|
||||
const raw = Buffer.from(data.content, 'base64').toString('utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.status === 404) return {};
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function diffRows(before, after) {
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
const rows = [];
|
||||
for (const key of [...keys].sort()) {
|
||||
const b = before[key];
|
||||
const a = after[key];
|
||||
if (b === a) continue;
|
||||
const bCell = b === undefined ? '—' : String(b);
|
||||
const aCell = a === undefined ? '—' : String(a);
|
||||
const delta = (a ?? 0) - (b ?? 0);
|
||||
const deltaCell =
|
||||
a === undefined
|
||||
? 'removed'
|
||||
: b === undefined
|
||||
? 'added'
|
||||
: delta > 0
|
||||
? `+${delta}`
|
||||
: String(delta);
|
||||
rows.push(`| \`${key}\` | ${bCell} | ${aCell} | ${deltaCell} |`);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const dialects = [
|
||||
['SQLite', 'scripts/query-counts.snapshot.sqlite.json'],
|
||||
['D1', 'scripts/query-counts.snapshot.d1.json'],
|
||||
];
|
||||
|
||||
const sections = [];
|
||||
let totalDelta = 0;
|
||||
let totalChanged = 0;
|
||||
|
||||
for (const [name, path] of dialects) {
|
||||
const [before, after] = await Promise.all([
|
||||
loadSnapshot(baseRef, path),
|
||||
loadSnapshot(headRef, path),
|
||||
]);
|
||||
const rows = diffRows(before, after);
|
||||
if (rows.length === 0) continue;
|
||||
totalChanged += rows.length;
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
const b = before[key] ?? 0;
|
||||
const a = after[key] ?? 0;
|
||||
if (a !== b) totalDelta += a - b;
|
||||
}
|
||||
sections.push(
|
||||
[
|
||||
`### ${name}`,
|
||||
'',
|
||||
'| Route | Before | After | Δ |',
|
||||
'| --- | ---: | ---: | --- |',
|
||||
...rows,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const hasChange = sections.length > 0;
|
||||
|
||||
// Label: present iff the snapshots differ from base on this run.
|
||||
if (hasChange) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: 'fbca04',
|
||||
description: 'PR diff modifies query-count snapshot files',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err; // 422 = already exists
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [label],
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
name: label,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err; // 404 = label not applied
|
||||
}
|
||||
}
|
||||
|
||||
// Comment: upsert when changed, delete when not.
|
||||
const marker = '<!-- query-count-diff -->';
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const prior = comments.find(
|
||||
(c) => typeof c.body === 'string' && c.body.includes(marker),
|
||||
);
|
||||
|
||||
if (!hasChange) {
|
||||
// Snapshots match base on this run; remove any stale comment
|
||||
// (e.g. an earlier push had a delta that base has since caught
|
||||
// up to, or it was reverted).
|
||||
if (prior) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: prior.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sign = totalDelta > 0 ? '+' : '';
|
||||
const summary = `**${totalChanged}** route${totalChanged === 1 ? '' : 's'} changed, total Δ ${sign}${totalDelta} quer${Math.abs(totalDelta) === 1 ? 'y' : 'ies'}.`;
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## Query-count snapshot changes',
|
||||
'',
|
||||
summary,
|
||||
'',
|
||||
...sections,
|
||||
'',
|
||||
'<sub>Comparing snapshot files between base and head. Updated automatically on each push.</sub>',
|
||||
].join('\n');
|
||||
|
||||
if (prior) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: prior.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Query Counts
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Runs with the default read-only token; this workflow never pushes or
|
||||
# comments. The companion "Query Counts — Apply" workflow (triggered by
|
||||
# workflow_run) handles that with elevated permissions, safely isolated
|
||||
# from PR-authored code.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
measure:
|
||||
name: Measure
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
|
||||
- name: Regenerate snapshots
|
||||
run: |
|
||||
node scripts/query-counts.mjs --target sqlite --update
|
||||
node scripts/query-counts.mjs --target d1 --update
|
||||
|
||||
- name: Detect snapshot drift
|
||||
id: drift
|
||||
run: |
|
||||
mkdir -p query-counts-out
|
||||
# Track both the count snapshots and the query-text snapshots. The
|
||||
# query-text files (.queries.*.json) record the actual SQL per route;
|
||||
# without them here a change that alters a query's text but not the
|
||||
# count (e.g. adding a column to a folded subquery) would never be
|
||||
# committed back, leaving the D1 text snapshot silently stale.
|
||||
counts="scripts/query-counts.snapshot.sqlite.json scripts/query-counts.snapshot.d1.json"
|
||||
queries="scripts/query-counts.queries.sqlite.json scripts/query-counts.queries.d1.json"
|
||||
if git diff --quiet $counts $queries; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No drift — snapshots match the committed baseline."
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
cp $counts $queries query-counts-out/
|
||||
git diff $counts $queries > query-counts-out/snapshots.diff
|
||||
echo "${{ github.event.pull_request.number }}" > query-counts-out/pr-number
|
||||
echo "Drift detected — uploading artifact for the Apply workflow."
|
||||
# Log only the count diff; the query-text diffs can be large and
|
||||
# are carried in the artifact for the Apply workflow to commit.
|
||||
git diff $counts
|
||||
fi
|
||||
|
||||
- name: Upload snapshot artifact
|
||||
if: steps.drift.outputs.changed == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: query-counts-snapshots
|
||||
path: query-counts-out/
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish-only:
|
||||
description: "Skip versioning, just publish current versions"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
# Default-deny at the workflow level. Each job scopes its own permissions:
|
||||
# - `release` declares contents/id-token/pull-requests below.
|
||||
# - `sync-templates` calls a reusable workflow whose own job-level
|
||||
# permissions block (contents: read) takes precedence over the caller's
|
||||
# inherited permissions.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# changesets/action pushes release commits/tags and opens the
|
||||
# release PR (contents + pull-requests). npm publish auth is the
|
||||
# separate NODE_AUTH_TOKEN; OIDC provenance is the job's id-token.
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# Intentional: changesets/action pushes the release PR / tags
|
||||
# using this credential.
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Block 1.x releases (we are in 0.x)
|
||||
run: node .github/scripts/check-no-major.mjs
|
||||
|
||||
- name: Create Release Pull Request or Publish
|
||||
if: ${{ !inputs.publish-only }}
|
||||
id: changesets
|
||||
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
|
||||
with:
|
||||
version: node .github/scripts/release.mjs version
|
||||
publish: node .github/scripts/release.mjs publish
|
||||
commit: "ci: release"
|
||||
title: "ci: release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# Attach sandboxed-plugin tarballs to the releases changesets just
|
||||
# created, so the decentralized registry has a stable public URL to
|
||||
# point each release record at. See the script header for details.
|
||||
# Only the changesets path is covered: the publish-only recovery path
|
||||
# below neither runs changesets/action nor creates GitHub releases, so
|
||||
# assets for that path must be backfilled manually.
|
||||
- name: Attach plugin tarballs to releases
|
||||
if: ${{ steps.changesets.outputs.published == 'true' }}
|
||||
run: node .github/scripts/attach-plugin-tarballs.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
|
||||
- name: Publish (manual)
|
||||
if: ${{ inputs.publish-only }}
|
||||
run: node .github/scripts/release.mjs publish
|
||||
|
||||
sync-templates:
|
||||
name: Sync Templates
|
||||
needs: release
|
||||
if: >-
|
||||
needs.release.outputs.published == 'true' ||
|
||||
inputs.publish-only
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/sync-templates.yml
|
||||
# Pass only the secrets sync-templates actually uses, not the full set
|
||||
# available to this release workflow.
|
||||
secrets:
|
||||
APP_ID: ${{ secrets.APP_ID }}
|
||||
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
@@ -0,0 +1,590 @@
|
||||
name: Reporter Reply
|
||||
|
||||
# When an issue is in the `triage/awaiting-reporter` state and the original
|
||||
# reporter comments, classify the reply (positive / negative / unclear) via
|
||||
# a small Flue classifier workflow and act on the result.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
# Default-deny at workflow level.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
classify-and-act:
|
||||
name: Classify reply and act
|
||||
# The job `if:` is intentionally COARSE -- it filters only on things
|
||||
# that are cheap and reliable from the event payload:
|
||||
# - the comment is on an issue (not a PR -- issue_comment fires for both)
|
||||
# - the commenter is not a bot (see the loop note below)
|
||||
# - the issue is currently in the triage/awaiting-reporter state
|
||||
#
|
||||
# Authorization (is this the reporter, or a maintainer with a real
|
||||
# write/triage role?) is deliberately NOT done here. The payload's
|
||||
# `author_association` is unreliable for this: a maintainer whose org
|
||||
# membership is set to private reports `NONE`, so gating on it here
|
||||
# would silently drop their replies before we could check. The
|
||||
# `live-check` step does an authoritative permission-role lookup
|
||||
# instead -- see check 4 there.
|
||||
#
|
||||
# The label `contains(...)` is on the event payload's label snapshot.
|
||||
# Known small race: in `investigate.yml`'s reproduced+fixed path, the
|
||||
# ask comment is posted before the label flip -- a reply created in
|
||||
# that 1-2 second window has a snapshot without
|
||||
# `triage/awaiting-reporter` and is dropped here. The live-check step
|
||||
# also gates on labels, so even loosening this `if:` would not catch
|
||||
# it. Accepted as known minor; a reporter cannot reply that fast in
|
||||
# practice, and the next reply would be picked up correctly.
|
||||
#
|
||||
# The `user.type != 'Bot'` guard is load-bearing: it excludes
|
||||
# emdashbot's own comments. Without it, a bot comment could
|
||||
# re-trigger the classifier -- and the `unclear` path posts a comment
|
||||
# with no label flip or dedup marker, which would loop.
|
||||
if: >-
|
||||
github.event.issue.pull_request == null
|
||||
&& github.event.comment.user.type != 'Bot'
|
||||
&& contains(github.event.issue.labels.*.name, 'triage/awaiting-reporter')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: reporter-reply-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
# All writes (open PR, transition labels, comment, dispatch workflow)
|
||||
# use the app token below. The job's default GITHUB_TOKEN stays read-only.
|
||||
contents: read
|
||||
issues: read
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: emdash-cms
|
||||
repositories: emdash
|
||||
permission-issues: write
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
# Re-verify live state before any expensive work. Four checks:
|
||||
#
|
||||
# 1. The issue is currently `triage/awaiting-reporter`. The job's
|
||||
# `if:` gate uses `github.event.issue.labels`, which is the
|
||||
# label snapshot at event dispatch time. Two replies in
|
||||
# quick succession would both pass the gate; concurrency
|
||||
# only serialises them.
|
||||
#
|
||||
# 2. The reply was posted AFTER the most recent bot ask. Every
|
||||
# verification ask from investigate.yml embeds a hidden
|
||||
# `<!-- bot-ask: <iso8601> -->` marker. A reply with an
|
||||
# older timestamp is feedback on a superseded fix candidate
|
||||
# and should not drive state transitions on the current one.
|
||||
#
|
||||
# 3. Only markers authored by the bot itself count. Without an
|
||||
# author filter, a reporter could forge `<!-- bot-ask:
|
||||
# 9999-01-01T00:00:00Z -->` in any comment and permanently
|
||||
# stale every future reply.
|
||||
#
|
||||
# 4. The commenter is authorized, and we record WHO they are:
|
||||
# either the original reporter, or a maintainer with a live
|
||||
# write/triage/maintain/admin role on the repo. This is checked
|
||||
# against the permission API, not the spoof-prone-by-omission
|
||||
# `author_association` from the payload. A maintainer must
|
||||
# additionally issue an explicit `@emdashbot confirm` /
|
||||
# `@emdashbot reject` directive -- this stops drive-by
|
||||
# maintainer chatter from driving state while still letting a
|
||||
# maintainer act on a quiet reporter's behalf. The reporter
|
||||
# path is interpreted by the AI classifier; the maintainer
|
||||
# directive maps deterministically and skips the classifier.
|
||||
- name: Re-verify live state
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
REPLY_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
LABELS="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}" --jq '[.labels[].name] | join(",")')"
|
||||
if ! grep -q 'triage/awaiting-reporter' <<<"$LABELS"; then
|
||||
echo "::notice::issue #${ISSUE_NUMBER} is no longer in triage/awaiting-reporter (live labels: ${LABELS}); skipping stale reply event"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pull ALL comments across pagination as a single JSON array
|
||||
# (`--slurp` flattens `--paginate` pages). Then filter to
|
||||
# comments authored by emdashbot itself, the app slug used
|
||||
# across this repo's workflows (see auto-format.yml etc.).
|
||||
# Without an author filter, a reporter could forge a
|
||||
# `<!-- bot-ask: ... -->` marker and stale every future reply.
|
||||
# The presence of the marker identifies a comment as a bot
|
||||
# ask; we then use the COMMENT'S id (monotonically increasing
|
||||
# per repo) to order it relative to the reply. Comment ids
|
||||
# avoid the second-precision tie that an embedded timestamp
|
||||
# has -- a reply posted in the same second as the ask still
|
||||
# has a strictly greater id.
|
||||
LATEST_ASK_ID="$(
|
||||
gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/comments" --paginate --slurp \
|
||||
| jq '
|
||||
[ .[]
|
||||
| .[]
|
||||
| select(.user.login == "emdashbot[bot]")
|
||||
| select(.body | test("<!-- bot-ask: [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z -->"))
|
||||
| .id
|
||||
] | max // 0
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ "$LATEST_ASK_ID" == "0" ]]; then
|
||||
echo "::notice::no emdashbot[bot]-authored bot-ask comment found on issue #${ISSUE_NUMBER}; treating reply as stale"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Comment ids are monotonic integers; strictly-greater is
|
||||
# safe regardless of clock drift, second-precision ties, or
|
||||
# API caching. A reply that predates the latest ask cannot
|
||||
# have a greater id.
|
||||
if (( COMMENT_ID <= LATEST_ASK_ID )); then
|
||||
echo "::notice::reply id ${COMMENT_ID} is not newer than latest bot ask id ${LATEST_ASK_ID}; treating as stale feedback on a superseded fix"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Check 4: authorization + actor classification ----
|
||||
#
|
||||
# The original reporter is always trusted to speak to their own
|
||||
# issue; their reply is handed to the AI classifier downstream.
|
||||
if [[ "$COMMENTER" == "$ISSUE_AUTHOR" ]]; then
|
||||
echo "actor=reporter" >> "$GITHUB_OUTPUT"
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Non-reporter: require a real write-or-triage role on the repo.
|
||||
# We gate on BOTH fields the endpoint returns:
|
||||
# * `permission` -- the legacy BASE role (admin/write/read/none),
|
||||
# with maintain mapped to write and triage mapped to read.
|
||||
# Custom org roles collapse to their base here, so a
|
||||
# write-equivalent custom role is caught by `write` and we
|
||||
# don't have to enumerate custom names.
|
||||
# * `role_name` -- needed only to recognise `triage` specifically
|
||||
# (it maps down to `read` in `permission`, so the base field
|
||||
# alone can't tell triage from plain read access).
|
||||
# Both are the highest effective role across repo/team/org/
|
||||
# enterprise grants. A 404 (no access) leaves both empty.
|
||||
#
|
||||
# The read is authorized by the token's existing contents:write
|
||||
# (push-equivalent) scope; this call does NOT work on metadata
|
||||
# alone, so don't narrow the app-token scopes expecting it to.
|
||||
PERM_JSON="$(gh api "/repos/emdash-cms/emdash/collaborators/${COMMENTER}/permission" 2>/dev/null || true)"
|
||||
PERM="$(jq -r '.permission // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
|
||||
ROLE="$(jq -r '.role_name // ""' <<<"$PERM_JSON" 2>/dev/null || true)"
|
||||
if [[ "$PERM" != "admin" && "$PERM" != "write" && "$ROLE" != "triage" ]]; then
|
||||
echo "::notice::commenter ${COMMENTER} has permission '${PERM:-none}' / role '${ROLE:-none}' on emdash (need write or triage); ignoring non-reporter reply"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Authorized maintainer. They must opt in with an explicit
|
||||
# directive; the mention+keyword has to START a line (leading
|
||||
# whitespace only) so a directive quoted from another comment
|
||||
# (`> @emdashbot confirm`) does not count. Case-insensitive.
|
||||
# Maps deterministically to positive/negative -- the AI
|
||||
# classifier is skipped entirely for this path.
|
||||
DIRECTIVE=""
|
||||
if grep -iqE '^[[:space:]]*@emdashbot[[:space:]]+(confirm|confirmed|verified|fixed)\b' <<<"$REPLY_BODY"; then
|
||||
DIRECTIVE="positive"
|
||||
elif grep -iqE '^[[:space:]]*@emdashbot[[:space:]]+(reject|rejected|retry|reopen)\b' <<<"$REPLY_BODY"; then
|
||||
DIRECTIVE="negative"
|
||||
fi
|
||||
|
||||
if [[ -z "$DIRECTIVE" ]]; then
|
||||
echo "::notice::maintainer ${COMMENTER} commented without an '@emdashbot confirm/reject' directive; taking no action"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "actor=maintainer" >> "$GITHUB_OUTPUT"
|
||||
echo "classification=${DIRECTIVE}" >> "$GITHUB_OUTPUT"
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
id: live-check
|
||||
|
||||
- name: Checkout
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: "package.json"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Flue agent dependencies
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: .flue
|
||||
|
||||
- name: Build packages
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
run: pnpm build
|
||||
|
||||
- name: Build classifier payload
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REPLY_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
jq -nc \
|
||||
--argjson n "$ISSUE_NUMBER" \
|
||||
--arg b "$REPLY_BODY" \
|
||||
'{replyBody: $b, issueNumber: $n, owner: "emdash-cms", repo: "emdash"}' \
|
||||
> /tmp/classify-payload.json
|
||||
|
||||
- name: Run classifier
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.live-check.outputs.actor == 'reporter'
|
||||
id: classify
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
AGENT_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ORCHESTRATOR_GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
|
||||
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
|
||||
CLOUDFLARE_API_KEY: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
# The workflow writes its result here; we read it directly instead of
|
||||
# scraping `flue run`'s stdout, which interleaves build-log lines and
|
||||
# pretty-prints the result -- both defeat line/slurp parsing and
|
||||
# silently default every reply to `unclear`. Same handoff as
|
||||
# investigate.yml's INVESTIGATE_RESULT_PATH.
|
||||
CLASSIFY_RESULT_PATH: /tmp/classify-result.json
|
||||
run: |
|
||||
set -o pipefail
|
||||
RESULT_PATH="${CLASSIFY_RESULT_PATH:?CLASSIFY_RESULT_PATH not set}"
|
||||
PAYLOAD="$(cat /tmp/classify-payload.json)"
|
||||
rm -f "$RESULT_PATH"
|
||||
set +e
|
||||
# See investigate.yml's "Run Flue investigate agent" step
|
||||
# for why we invoke the binary directly rather than via
|
||||
# `pnpm --dir`. Same --root resolution bug.
|
||||
.flue/node_modules/.bin/flue run classify-reply \
|
||||
--target node \
|
||||
--root .flue \
|
||||
--payload "$PAYLOAD" \
|
||||
> /tmp/classify-stdout.json 2> /tmp/classify-stderr.log
|
||||
EXIT=$?
|
||||
set -e
|
||||
: > /tmp/classify-reasoning.txt
|
||||
# A clean run writes a single JSON object to the result file. A
|
||||
# non-zero exit, a missing file, or a non-object means the run did
|
||||
# not finish -- default to unclear (which re-asks, never acts).
|
||||
if [[ $EXIT -ne 0 ]] || [[ ! -s "$RESULT_PATH" ]] || ! jq -e 'type == "object"' "$RESULT_PATH" >/dev/null 2>&1; then
|
||||
echo "::warning::classifier exit=${EXIT} or no result file; defaulting to unclear"
|
||||
tail -n 50 /tmp/classify-stderr.log || true
|
||||
echo "classification=unclear" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Whitelist the classification value -- the gate has to be a
|
||||
# known enum or we treat it as unclear. Defends against the
|
||||
# model returning an unexpected value.
|
||||
CLASS_RAW="$(jq -r '.classification // "unclear"' "$RESULT_PATH" | tr -d '\r\n')"
|
||||
case "$CLASS_RAW" in
|
||||
positive|negative|unclear) CLASS="$CLASS_RAW" ;;
|
||||
*) CLASS="unclear" ;;
|
||||
esac
|
||||
# Reasoning is attacker-influenceable (the reporter's reply
|
||||
# is in the model prompt). Persist it to a file rather than
|
||||
# $GITHUB_OUTPUT -- a heredoc with a fixed delimiter would be
|
||||
# a step-output injection vector if the reasoning contained
|
||||
# the delimiter on its own line.
|
||||
jq -r '.reasoning // ""' "$RESULT_PATH" > /tmp/classify-reasoning.txt
|
||||
echo "classification=${CLASS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Collapse the two classification sources into one output the
|
||||
# handlers gate on. For a reporter reply the value comes from the
|
||||
# AI classifier above; for a maintainer it comes from the explicit
|
||||
# directive parsed in live-check (the classifier never ran). Both
|
||||
# are re-whitelisted here so the handler gate is always a known
|
||||
# enum. A maintainer directive is only ever positive/negative, so
|
||||
# the `unclear` handler is reporter-only in practice.
|
||||
- name: Resolve classification
|
||||
if: steps.live-check.outputs.stale != 'true'
|
||||
id: resolve
|
||||
env:
|
||||
ACTOR: ${{ steps.live-check.outputs.actor }}
|
||||
MAINTAINER_CLASS: ${{ steps.live-check.outputs.classification }}
|
||||
REPORTER_CLASS: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$ACTOR" == "maintainer" ]]; then
|
||||
CLASS="$MAINTAINER_CLASS"
|
||||
else
|
||||
CLASS="${REPORTER_CLASS:-unclear}"
|
||||
fi
|
||||
case "$CLASS" in
|
||||
positive | negative | unclear) ;;
|
||||
*) CLASS="unclear" ;;
|
||||
esac
|
||||
echo "classification=${CLASS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ----- Positive: open PR, transition to verified -----
|
||||
|
||||
- name: Handle positive (open PR)
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.classification == 'positive'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
REPLY_BODY: ${{ github.event.comment.body }}
|
||||
# The confirming commenter -- either the original reporter or a
|
||||
# maintainer (authorized in live-check, check 4). GitHub logins
|
||||
# are a restricted charset (alphanumeric + single hyphens), so
|
||||
# this is injection-safe, but we route it through env to match
|
||||
# the file's defensive convention rather than inlining `${{ }}`.
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FIX_BRANCH="bot/fix-${ISSUE_NUMBER}"
|
||||
|
||||
# Quote the confirmation into the PR body. `> ` prefix every line
|
||||
# so multi-paragraph confirmations render as a block quote.
|
||||
QUOTED="$(printf '%s\n' "$REPLY_BODY" | sed 's/^/> /')"
|
||||
ISSUE_URL="https://github.com/emdash-cms/emdash/issues/${ISSUE_NUMBER}"
|
||||
|
||||
{
|
||||
echo "Closes #${ISSUE_NUMBER}."
|
||||
echo
|
||||
echo "@${COMMENTER} confirmed this fix resolves the issue:"
|
||||
echo
|
||||
echo "${QUOTED}"
|
||||
echo
|
||||
echo "See ${ISSUE_URL} for the investigation trail."
|
||||
echo
|
||||
echo "<sub>Opened automatically by the investigation bot. A maintainer should review before merge.</sub>"
|
||||
} > /tmp/pr-body.md
|
||||
|
||||
# `gh pr create` is idempotent-ish: if a PR already exists for
|
||||
# this branch, it errors. Detect, fall back to listing the
|
||||
# existing PR for the branch. If we can't find ANY PR URL,
|
||||
# do not flip to triage/verified -- that state implies a real
|
||||
# PR exists. Instead leave on triage/awaiting-reporter and ping
|
||||
# the maintainer, since the fix branch may have been deleted
|
||||
# by bot-cleanup.yml or by a manual purge.
|
||||
set +e
|
||||
PR_OUTPUT="$(gh pr create \
|
||||
--repo emdash-cms/emdash \
|
||||
--base main \
|
||||
--head "${FIX_BRANCH}" \
|
||||
--title "[bot] Fix #${ISSUE_NUMBER}: ${ISSUE_TITLE}" \
|
||||
--body-file /tmp/pr-body.md 2>&1)"
|
||||
CREATE_EXIT=$?
|
||||
set -e
|
||||
PR_URL=""
|
||||
if [[ $CREATE_EXIT -eq 0 ]]; then
|
||||
# gh pr create prints the new PR URL on stdout.
|
||||
PR_URL="$(printf '%s' "$PR_OUTPUT" | grep -oE 'https://github.com/[^[:space:]]+/pull/[0-9]+' | head -n1 || true)"
|
||||
else
|
||||
echo "PR create failed or already exists. Output:"
|
||||
echo "$PR_OUTPUT"
|
||||
# Fall back to an existing open PR for the same branch.
|
||||
PR_URL="$(gh pr list --repo emdash-cms/emdash --head "${FIX_BRANCH}" --state open --json url --jq '.[0].url // ""' || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$PR_URL" ]]; then
|
||||
# No PR exists. Do NOT mark verified -- that implies a PR.
|
||||
# Surface the failure so a maintainer can recover.
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
|
||||
--remove-label "triage/awaiting-reporter" --add-label "triage/failed"
|
||||
{
|
||||
echo "@${COMMENTER} confirmed the fix, but the bot could not open a PR (branch \`${FIX_BRANCH}\` may have been deleted)."
|
||||
echo
|
||||
echo "A maintainer needs to take this from here."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/awaiting-reporter" --add-label "triage/verified"
|
||||
|
||||
{
|
||||
echo "Thanks for confirming, @${COMMENTER}. A PR is open: ${PR_URL}"
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
|
||||
# ----- Negative: count retries, re-trigger or give up -----
|
||||
|
||||
- name: Handle negative (retry or fail)
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.classification == 'negative'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REPLY_BODY: ${{ github.event.comment.body }}
|
||||
# The replying commenter -- either the original reporter or a
|
||||
# maintainer (authorized in live-check, check 4). Login charset
|
||||
# is safe.
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
# Pull workflow context into env so the shell never sees raw
|
||||
# `${{ ... }}` expansions -- zizmor flags these as template injection
|
||||
# even though `github.repository` is trustworthy on a non-fork
|
||||
# issue_comment trigger. Defensive.
|
||||
REPO_FULL: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Retry counter is stored as a hidden HTML marker on the
|
||||
# FIRST LINE of bot-authored retry comments. Three layers of
|
||||
# hardening, in increasing tightness:
|
||||
# 1. `user.login == emdashbot[bot]` -- only the App's own
|
||||
# comments count. A reporter cannot impersonate.
|
||||
# 2. The marker must be on the first line. The agent's
|
||||
# output may be quoted into other bot comments (the
|
||||
# ask comment includes `${NOTES}` which is shaped by
|
||||
# the agent's free-form prose); pinning to line 0
|
||||
# prevents an attacker who slips a marker into the
|
||||
# issue body and gets it echoed back from defeating
|
||||
# the retry budget.
|
||||
# 3. The regex is exact: full anchor, no whitespace slop,
|
||||
# bare integer.
|
||||
COUNT="$(
|
||||
gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/comments" --paginate --slurp \
|
||||
| jq '
|
||||
[ .[]
|
||||
| .[]
|
||||
| select(.user.login == "emdashbot[bot]")
|
||||
| (.body | split("\n")[0])
|
||||
| capture("^<!-- bot-retry-count: (?<n>[0-9]+) -->$"; "")
|
||||
| .n | tonumber
|
||||
] | max // 0
|
||||
'
|
||||
)"
|
||||
|
||||
NEXT=$((COUNT + 1))
|
||||
MAX=3
|
||||
|
||||
if (( NEXT > MAX )); then
|
||||
# Find the maintainer who applied bot:repro initially -- look up
|
||||
# the labeled event on the issue's timeline.
|
||||
LABELER="$(gh api "/repos/emdash-cms/emdash/issues/${ISSUE_NUMBER}/events" --paginate \
|
||||
--jq '[.[] | select(.event == "labeled" and .label.name == "bot:repro") | .actor.login] | last // ""')"
|
||||
|
||||
gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash --remove-label "triage/awaiting-reporter" --add-label "triage/failed"
|
||||
{
|
||||
echo "<!-- bot-retry-count: ${NEXT} -->"
|
||||
echo "The bot has tried ${MAX} times and the latest reply (from @${COMMENTER}) still indicates the fix does not work. A human maintainer needs to take this from here."
|
||||
if [[ -n "$LABELER" ]]; then
|
||||
echo
|
||||
echo "@${LABELER} (you applied \`bot:repro\` originally) — over to you."
|
||||
fi
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-trigger investigation via a repository_dispatch event (type
|
||||
# `reporter-retry`) rather than `gh workflow run` (workflow_dispatch):
|
||||
# firing repository_dispatch needs only contents:write, which the app
|
||||
# token has, whereas workflow_dispatch needs actions:write, which the
|
||||
# emdashbot App is not granted. investigate.yml reads issueNumber /
|
||||
# retryContext from client_payload. The body is built with jq so the
|
||||
# attacker-controlled REPLY_BODY is JSON-escaped, never interpolated
|
||||
# into a command. repository_dispatch always runs on the default
|
||||
# branch, so no ref is needed.
|
||||
#
|
||||
# Dispatch first, then transition the label. Order matters for
|
||||
# recovery: if dispatch fails, the label stays put (so a maintainer
|
||||
# can re-trigger by removing + re-adding `bot:repro` manually) rather
|
||||
# than getting stuck in `triage/reproducing` with nothing running.
|
||||
set +e
|
||||
jq -nc \
|
||||
--arg n "$ISSUE_NUMBER" \
|
||||
--arg r "$REPLY_BODY" \
|
||||
'{event_type: "reporter-retry", client_payload: {issueNumber: $n, retryContext: $r}}' \
|
||||
| gh api --method POST "/repos/${REPO_FULL}/dispatches" --input -
|
||||
DISPATCH_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [[ $DISPATCH_EXIT -ne 0 ]]; then
|
||||
# Surface the failure on the issue so a maintainer can
|
||||
# decide what to do. Leave the label on triage/awaiting-reporter
|
||||
# so the maintainer's manual `bot:repro` re-application
|
||||
# works as expected.
|
||||
echo "::warning::repository_dispatch failed (exit ${DISPATCH_EXIT}); leaving label on triage/awaiting-reporter"
|
||||
{
|
||||
echo "<!-- bot-retry-count: ${NEXT} -->"
|
||||
echo "I tried to re-run the investigation but the dispatch failed. A maintainer can re-trigger by removing the \`triage/awaiting-reporter\` label and re-adding \`bot:repro\`."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Dispatch succeeded. Now transition the label so the
|
||||
# in-flight investigation can claim the issue state and a
|
||||
# second reply during that window passes through the live-
|
||||
# label check to a no-op. The dispatched investigate.yml
|
||||
# will see `triage/reproducing` and leave it as-is at its
|
||||
# transition step (which moves bot:repro -> triage/reproducing
|
||||
# idempotently via --remove-label || true).
|
||||
#
|
||||
# Retry the flip up to 3 times: a transient API hiccup that
|
||||
# leaves triage/awaiting-reporter visible opens a window for a
|
||||
# duplicate retry. After 3 failures, the dispatched
|
||||
# investigation will flip the label itself when it runs,
|
||||
# which closes the window at the cost of a small race.
|
||||
FLIP_OK=false
|
||||
for ATTEMPT in 1 2 3; do
|
||||
if gh issue edit "$ISSUE_NUMBER" --repo emdash-cms/emdash \
|
||||
--remove-label "triage/awaiting-reporter" --add-label "triage/reproducing"; then
|
||||
FLIP_OK=true
|
||||
break
|
||||
fi
|
||||
echo "::warning::label flip attempt ${ATTEMPT} failed, retrying"
|
||||
sleep $((ATTEMPT * 2))
|
||||
done
|
||||
if [[ "$FLIP_OK" != "true" ]]; then
|
||||
echo "::warning::label flip failed 3 times; relying on investigate.yml's transition step to close the window"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "<!-- bot-retry-count: ${NEXT} -->"
|
||||
echo "Thanks for the additional detail, @${COMMENTER}. Re-running the investigation (attempt ${NEXT} of ${MAX})."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md || true
|
||||
|
||||
# ----- Unclear: ask for clarification, no state change -----
|
||||
|
||||
- name: Handle unclear
|
||||
if: steps.live-check.outputs.stale != 'true' && steps.resolve.outputs.classification == 'unclear'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
# The replying commenter -- the original reporter (the unclear
|
||||
# path is reporter-only; a maintainer directive is always
|
||||
# positive/negative). Login charset is safe.
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "@${COMMENTER} could you clarify whether the candidate fix resolves the issue?"
|
||||
echo
|
||||
echo "A short \"yes, fixed\" or \"no, still broken\" (with what you saw) is plenty. The bot is waiting on confirmation before opening a PR."
|
||||
} > /tmp/comment.md
|
||||
gh issue comment "$ISSUE_NUMBER" --repo emdash-cms/emdash --body-file /tmp/comment.md
|
||||
@@ -0,0 +1,231 @@
|
||||
name: Review State
|
||||
|
||||
# Event-driven interpreter that maintains four mutually-exclusive review/*
|
||||
# labels on open PRs so maintainers can see review status at a glance.
|
||||
# Pure actions/github-script: no checkout, no secrets, no agent.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
schedule:
|
||||
# Backstop sweep so state stays correct even if an event was missed.
|
||||
- cron: "15 */6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
review-state:
|
||||
name: Update review state labels
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: review-state-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Apply review state labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// The four mutually-exclusive review states.
|
||||
const stateLabels = {
|
||||
'review/needs-review': {
|
||||
color: 'fbca04',
|
||||
description: 'No maintainer or bot review yet',
|
||||
},
|
||||
'review/awaiting-author': {
|
||||
color: 'c5def5',
|
||||
description: 'Reviewed; waiting on the author to respond',
|
||||
},
|
||||
'review/needs-rereview': {
|
||||
color: 'd93f0b',
|
||||
description: 'New commits since the last review',
|
||||
},
|
||||
'review/approved': {
|
||||
color: '0e8a16',
|
||||
description: 'Approved; no new commits since',
|
||||
},
|
||||
};
|
||||
const stateNames = Object.keys(stateLabels);
|
||||
|
||||
// --- Ensure the four labels exist (mirrors pr-sweep.yml) ---
|
||||
const existingLabels = new Set();
|
||||
for await (const response of github.paginate.iterator(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner, repo, per_page: 100 }
|
||||
)) {
|
||||
for (const label of response.data) {
|
||||
existingLabels.add(label.name);
|
||||
}
|
||||
}
|
||||
for (const [name, meta] of Object.entries(stateLabels)) {
|
||||
if (!existingLabels.has(name)) {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name,
|
||||
color: meta.color,
|
||||
description: meta.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a label, ignoring 404 if it's already gone (mirrors pr-sweep.yml).
|
||||
async function safeRemoveLabel(prNumber, name) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Determine the set of PRs to process ---
|
||||
let prs;
|
||||
if (
|
||||
context.eventName === 'pull_request_target' ||
|
||||
context.eventName === 'pull_request_review'
|
||||
) {
|
||||
prs = [context.payload.pull_request];
|
||||
} else {
|
||||
// schedule / workflow_dispatch: sweep every open PR.
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Processing ${prs.length} PR(s) for review state`);
|
||||
|
||||
for (const pr of prs) {
|
||||
if (!pr) continue;
|
||||
try {
|
||||
const currentLabels = new Set((pr.labels || []).map(l => l.name));
|
||||
|
||||
// Drafts get no review state: strip any review/* labels and move on.
|
||||
if (pr.draft === true) {
|
||||
for (const name of stateNames) {
|
||||
if (currentLabels.has(name)) {
|
||||
await safeRemoveLabel(pr.number, name);
|
||||
}
|
||||
}
|
||||
core.info(`#${pr.number}: draft, cleared review state`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip bot-authored PRs entirely (no labeling).
|
||||
if (pr.user && pr.user.type === 'Bot') {
|
||||
core.info(`#${pr.number}: bot author, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Qualifying reviews ---
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const qualifying = reviews.filter(review =>
|
||||
review.user &&
|
||||
review.user.login !== pr.user.login &&
|
||||
(
|
||||
review.user.type === 'Bot' ||
|
||||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(review.author_association)
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'].includes(review.state)
|
||||
);
|
||||
|
||||
let lastReview = null;
|
||||
for (const review of qualifying) {
|
||||
if (
|
||||
!lastReview ||
|
||||
new Date(review.submitted_at) > new Date(lastReview.submitted_at)
|
||||
) {
|
||||
lastReview = review;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Last non-merge commit ---
|
||||
// Only NON-merge commits count as new work. Merge commits (parents > 1)
|
||||
// are "Update branch" / `git merge main`, so syncing a branch with main
|
||||
// does not flip a reviewed PR back to needs-rereview.
|
||||
const commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
let lastCommitAt;
|
||||
for (const commit of commits) {
|
||||
if (commit.parents && commit.parents.length > 1) continue;
|
||||
const committed = commit.commit?.committer?.date;
|
||||
if (!committed) continue;
|
||||
if (!lastCommitAt || new Date(committed) > new Date(lastCommitAt)) {
|
||||
lastCommitAt = committed;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Decide the state ---
|
||||
// lastReview may be a COMMENTED review; approvals should still win unless a later
|
||||
// decision review (APPROVED/CHANGES_REQUESTED) supersedes them.
|
||||
let lastDecisionReview = null;
|
||||
for (const review of qualifying) {
|
||||
if (review.state === 'COMMENTED') continue;
|
||||
if (
|
||||
!lastDecisionReview ||
|
||||
new Date(review.submitted_at) > new Date(lastDecisionReview.submitted_at)
|
||||
) {
|
||||
lastDecisionReview = review;
|
||||
}
|
||||
}
|
||||
|
||||
let desired;
|
||||
if (!lastReview) {
|
||||
desired = 'review/needs-review';
|
||||
} else if (
|
||||
lastCommitAt &&
|
||||
new Date(lastCommitAt) > new Date(lastReview.submitted_at)
|
||||
) {
|
||||
desired = 'review/needs-rereview';
|
||||
} else if (lastDecisionReview?.state === 'APPROVED') {
|
||||
desired = 'review/approved';
|
||||
} else {
|
||||
desired = 'review/awaiting-author';
|
||||
}
|
||||
|
||||
// --- Apply: add the chosen label, remove the other three ---
|
||||
if (!currentLabels.has(desired)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [desired],
|
||||
});
|
||||
}
|
||||
for (const name of stateNames) {
|
||||
if (name !== desired && currentLabels.has(name)) {
|
||||
await safeRemoveLabel(pr.number, name);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`#${pr.number}: ${desired}`);
|
||||
} catch (e) {
|
||||
core.warning(`#${pr.number}: failed to update review state: ${e.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
name: Review PR
|
||||
|
||||
# /review triggers this workflow. The first word after the trigger picks a
|
||||
# model alias from .github/bonk-models.json. Currently registered aliases
|
||||
# (see that file for the source of truth):
|
||||
#
|
||||
# /review -> default model (currently opus)
|
||||
# /review opus -> Claude Opus 4.7 (default)
|
||||
# /review kimi -> Kimi K2.7 Code (cheap pass for tiny PRs)
|
||||
#
|
||||
# Add new aliases by editing .github/bonk-models.json.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
# Concurrency is at job level (below) rather than workflow level so it's only
|
||||
# evaluated when the `if:` filter passes.
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# mentions in this check must match the `mentions` input below.
|
||||
# For issue_comment events, require the comment to be on a PR (not an issue).
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot'
|
||||
&& contains(github.event.comment.body, '/review')
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
&& (github.event_name != 'issue_comment' || github.event.issue.pull_request != null)
|
||||
# Per-workflow group key so /review and /bonk have independent queues
|
||||
# per target.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get PR number
|
||||
id: pr-number
|
||||
env:
|
||||
# issue_comment on a PR exposes the number via github.event.issue.number;
|
||||
# pull_request_review_comment exposes it via github.event.pull_request.number.
|
||||
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
run: |
|
||||
echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify PR exists
|
||||
id: verify-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr-number.outputs.number }}
|
||||
run: |
|
||||
if gh api "/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > /dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::PR #${PR_NUMBER} not found, skipping review"
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.verify-pr.outputs.exists == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve model from comment
|
||||
if: steps.verify-pr.outputs.exists == 'true'
|
||||
id: model
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
run: node .github/scripts/resolve-bonk-model.mjs
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.verify-pr.outputs.exists == 'true'
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.verify-pr.outputs.exists == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: "package.json"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.verify-pr.outputs.exists == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Get PR details
|
||||
if: steps.verify-pr.outputs.exists == 'true'
|
||||
id: pr-details
|
||||
run: |
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" > /tmp/pr_data.json
|
||||
# Use heredoc form for every field — PR titles and bodies can contain
|
||||
# newlines that would otherwise corrupt $GITHUB_OUTPUT and let a PR
|
||||
# author smuggle arbitrary step outputs via their title/body.
|
||||
{
|
||||
echo 'title<<PR_TITLE_EOF'
|
||||
jq -r .title /tmp/pr_data.json
|
||||
echo PR_TITLE_EOF
|
||||
echo "sha=$(jq -r .head.sha /tmp/pr_data.json)"
|
||||
echo 'body<<PR_BODY_EOF'
|
||||
jq -r .body /tmp/pr_data.json
|
||||
echo PR_BODY_EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr-number.outputs.number }}
|
||||
|
||||
- name: Run Review (${{ steps.model.outputs.alias }})
|
||||
if: steps.verify-pr.outputs.exists == 'true'
|
||||
uses: ask-bonk/ask-bonk/github@bfd92f4d0abb62d98558b35432534f7b9992f3eb # main as of 2026-04-24
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
|
||||
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
|
||||
OPENCODE_CONFIG_CONTENT: ${{ steps.model.outputs.opencode_config }}
|
||||
with:
|
||||
model: ${{ steps.model.outputs.model }}
|
||||
mentions: "/review"
|
||||
opencode_version: "1.4.11"
|
||||
permissions: write
|
||||
# NO_PUSH scopes the installation token to contents:read so the
|
||||
# reviewer cannot push, even if it tries to commit. Write tools
|
||||
# remain enabled in the agent definition so it can scaffold fixes
|
||||
# locally to verify reasoning.
|
||||
token_permissions: NO_PUSH
|
||||
# Custom agent defined in .opencode/agents/auto-reviewer.md.
|
||||
# Holds the review philosophy, investigation protocol, and posting
|
||||
# protocol so workflows stay small.
|
||||
agent: auto-reviewer
|
||||
prompt: |
|
||||
Review pull request #${{ steps.pr-number.outputs.number }} ("${{ steps.pr-details.outputs.title }}").
|
||||
|
||||
Follow your agent instructions for investigation, severity calibration, and posting. Post a single review with line-anchored comments via the gh CLI; the summary will be posted as a separate workflow comment.
|
||||
|
||||
<pr_number>${{ steps.pr-number.outputs.number }}</pr_number>
|
||||
<pr_description>
|
||||
${{ steps.pr-details.outputs.body }}
|
||||
</pr_description>
|
||||
|
||||
If the PR looks good, respond with only "LGTM!" and skip posting a review.
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Sync Templates
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
secrets:
|
||||
APP_ID:
|
||||
required: true
|
||||
description: GitHub App client ID for emdashbot.
|
||||
APP_PRIVATE_KEY:
|
||||
required: true
|
||||
description: GitHub App private key for emdashbot.
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync templates to emdash-cms/templates
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: templates
|
||||
owner: emdash-cms
|
||||
# Push the sync branch (contents) and open/update the sync PR via
|
||||
# `gh pr create` (pull-requests) on emdash-cms/templates. Nothing else.
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Read-only: the sync step uses the app token via GH_TOKEN, not
|
||||
# the persisted git credential.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync templates
|
||||
run: node scripts/sync-templates-repo.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -0,0 +1,244 @@
|
||||
# Mirrors an issue's triage state onto the "Auto-Triage" Projects v2 board.
|
||||
#
|
||||
# Reactive and decoupled from the triage bot: it watches label changes and,
|
||||
# whenever a `bot:repro` (trigger) or `triage/*` (state) label is added or
|
||||
# removed, recomputes the issue's canonical triage state from its CURRENT
|
||||
# labels and sets the board's "Triage State" single-select field. The triage
|
||||
# workflows just move labels as they always have; this never writes back to
|
||||
# them.
|
||||
#
|
||||
# It also keeps board membership in sync with the issue's open/closed state:
|
||||
# closing an issue ARCHIVES its card (it leaves the active board views but
|
||||
# stays recoverable in the project's archive, since triage is done once the
|
||||
# issue is closed); reopening unarchives and re-syncs it if it still carries
|
||||
# triage labels.
|
||||
#
|
||||
# Auth: mints an emdashbot App installation token (same APP_ID / APP_PRIVATE_KEY
|
||||
# secrets the other bot workflows use). The App needs organization "Projects:
|
||||
# Read and write" for the project mutations.
|
||||
|
||||
name: Triage project sync
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled, unlabeled, closed, reopened]
|
||||
workflow_dispatch: # one-shot backfill of issues already carrying triage labels
|
||||
|
||||
concurrency:
|
||||
# Serialize per issue so rapid reproducing -> reproduced transitions don't race.
|
||||
group: triage-project-sync-${{ github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip PRs (issues.labeled fires for PRs too); always run the manual backfill.
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.issue.pull_request == null
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# owner + repositories: org-scoped for Projects, but limited to this
|
||||
# one repo. permission-*: least privilege, just what the sync needs
|
||||
# (org Projects write; issues read for the workflow_dispatch backfill).
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: ${{ github.event.repository.name }}
|
||||
permission-organization-projects: write
|
||||
permission-issues: read
|
||||
|
||||
- name: Sync triage state to project
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const PROJECT_NUMBER = 3;
|
||||
const FIELD_NAME = "Triage State";
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Triage label -> board option name.
|
||||
const STATE_BY_LABEL = {
|
||||
"bot:repro": "Queued",
|
||||
"triage/reproducing": "Reproducing",
|
||||
"triage/reproduced": "Reproduced",
|
||||
"triage/by-design": "By design",
|
||||
"triage/awaiting-reporter": "Awaiting reporter",
|
||||
"triage/verified": "Verified",
|
||||
"triage/not-reproduced": "Not reproduced",
|
||||
"triage/failed": "Failed",
|
||||
"triage/skipped": "Skipped",
|
||||
};
|
||||
// When several are present, the most advanced/terminal one wins.
|
||||
const PRECEDENCE = [
|
||||
"triage/verified",
|
||||
"triage/awaiting-reporter",
|
||||
"triage/reproduced",
|
||||
"triage/by-design",
|
||||
"triage/not-reproduced",
|
||||
"triage/failed",
|
||||
"triage/skipped",
|
||||
"triage/reproducing",
|
||||
"bot:repro",
|
||||
];
|
||||
const pickState = (labels) => {
|
||||
for (const l of PRECEDENCE) if (labels.includes(l)) return STATE_BY_LABEL[l];
|
||||
return null;
|
||||
};
|
||||
const labelNames = (iss) =>
|
||||
(iss.labels || []).map((l) => (typeof l === "string" ? l : l.name));
|
||||
|
||||
// Resolve the project, the single-select field, and its options.
|
||||
const projData = await github.graphql(
|
||||
`query($owner:String!, $num:Int!, $field:String!) {
|
||||
organization(login:$owner) {
|
||||
projectV2(number:$num) {
|
||||
id
|
||||
field(name:$field) {
|
||||
... on ProjectV2SingleSelectField { id options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, num: PROJECT_NUMBER, field: FIELD_NAME },
|
||||
);
|
||||
const project = projData.organization.projectV2;
|
||||
if (!project || !project.field) {
|
||||
core.setFailed(`Project #${PROJECT_NUMBER} or field "${FIELD_NAME}" not found`);
|
||||
return;
|
||||
}
|
||||
const fieldId = project.field.id;
|
||||
const optionId = (name) => project.field.options.find((o) => o.name === name)?.id;
|
||||
|
||||
// Archive an issue's card on THIS board, if present. An issue can
|
||||
// belong to several projects; match on the project NODE ID, not the
|
||||
// number -- Projects v2 numbers are per-owner, so a foreign project
|
||||
// could also be #3 and a number match would target the wrong item.
|
||||
// Archiving is reversible (unlike delete) and hides the card from
|
||||
// active board views. Idempotent -- returns false when the issue
|
||||
// has no card here, and re-archiving an archived item is a no-op.
|
||||
const archiveCard = async (number) => {
|
||||
const data = await github.graphql(
|
||||
`query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
issue(number:$number) {
|
||||
projectItems(first:100) { nodes { id project { id } } }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, repo, number },
|
||||
);
|
||||
const nodes = data.repository?.issue?.projectItems?.nodes || [];
|
||||
const item = nodes.find((n) => n.project?.id === project.id);
|
||||
if (!item) {
|
||||
// first:100 isn't paginated; warn if we hit the cap so a silent
|
||||
// miss on an issue attached to 100+ projects is at least visible.
|
||||
if (nodes.length === 100) {
|
||||
core.warning(`#${number}: on 100+ projects; could not confirm board membership`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
await github.graphql(
|
||||
`mutation($project:ID!, $item:ID!) {
|
||||
archiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: item.id },
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Add the issue to the board (idempotent) and set its Triage State.
|
||||
const upsertState = async (iss) => {
|
||||
const stateName = pickState(labelNames(iss));
|
||||
if (!stateName) {
|
||||
core.info(`#${iss.number}: no triage label; skipping`);
|
||||
return;
|
||||
}
|
||||
const optId = optionId(stateName);
|
||||
if (!optId) {
|
||||
core.warning(`#${iss.number}: no board option named "${stateName}"`);
|
||||
return;
|
||||
}
|
||||
// Idempotent: returns the existing item if the issue is already added.
|
||||
const added = await github.graphql(
|
||||
`mutation($project:ID!, $content:ID!) {
|
||||
addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } }
|
||||
}`,
|
||||
{ project: project.id, content: iss.node_id },
|
||||
);
|
||||
const itemId = added.addProjectV2ItemById.item.id;
|
||||
// addProjectV2ItemById returns an EXISTING item as-is -- including
|
||||
// when it was archived on close -- so unarchive to bring a
|
||||
// reopened issue's card back onto the active board. No-op for
|
||||
// items that are already active.
|
||||
await github.graphql(
|
||||
`mutation($project:ID!, $item:ID!) {
|
||||
unarchiveProjectV2Item(input:{ projectId:$project, itemId:$item }) { item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: itemId },
|
||||
);
|
||||
await github.graphql(
|
||||
`mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!) {
|
||||
updateProjectV2ItemFieldValue(input:{
|
||||
projectId:$project, itemId:$item, fieldId:$field,
|
||||
value:{ singleSelectOptionId:$opt }
|
||||
}) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: itemId, field: fieldId, opt: optId },
|
||||
);
|
||||
core.info(`#${iss.number} -> ${stateName}`);
|
||||
};
|
||||
|
||||
// Build the work list.
|
||||
let issues = [];
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
const all = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
});
|
||||
issues = all.filter(
|
||||
(iss) =>
|
||||
!iss.pull_request &&
|
||||
labelNames(iss).some((l) => l === "bot:repro" || l.startsWith("triage/")),
|
||||
);
|
||||
} else {
|
||||
const iss = context.payload.issue;
|
||||
if (!iss || iss.pull_request) return;
|
||||
issues = [iss];
|
||||
}
|
||||
|
||||
// Per-issue try/catch so the bulk backfill reconciles every issue
|
||||
// even if one fails (e.g. a transient GraphQL error). Single-issue
|
||||
// event runs still fail loudly via setFailed below, since the next
|
||||
// event would otherwise be the only chance to recover.
|
||||
let failures = 0;
|
||||
for (const iss of issues) {
|
||||
try {
|
||||
// Closed issues never belong on the board: triage is finished
|
||||
// once the issue is closed (however it was closed). Handling it
|
||||
// here -- rather than only on the `closed` event -- means label
|
||||
// changes on an already-closed issue, and the dispatch backfill,
|
||||
// also reconcile, so the board self-heals to "open triaged
|
||||
// issues only". Reopening hits the else-branch with state "open"
|
||||
// and is re-added below if it still carries triage labels.
|
||||
if ((iss.state || "").toLowerCase() === "closed") {
|
||||
const archived = await archiveCard(iss.number);
|
||||
core.info(`#${iss.number}: closed; ${archived ? "archived" : "not on board"}`);
|
||||
continue;
|
||||
}
|
||||
await upsertState(iss);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
core.warning(`#${iss.number}: sync failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (failures > 0 && context.eventName !== "workflow_dispatch") {
|
||||
core.setFailed(`${failures} issue(s) failed to sync`);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
name: zizmor
|
||||
|
||||
# Always runs on PRs so the "Require code scanning results" ruleset rule
|
||||
# always has a SARIF to inspect. Real analysis only runs when workflow files
|
||||
# changed; otherwise we upload an empty SARIF so locale-only / code-only PRs
|
||||
# aren't blocked waiting for a check that never reports.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # upload SARIF to code scanning
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read # dorny/paths-filter calls the PRs API on pull_request events
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect workflow changes
|
||||
id: changes
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
with:
|
||||
filters: |
|
||||
workflows:
|
||||
- '.github/workflows/**'
|
||||
- '.github/actions/**'
|
||||
- '.github/zizmor.yml'
|
||||
|
||||
- name: Decide whether to analyze
|
||||
id: decide
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CHANGED: ${{ steps.changes.outputs.workflows }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ] || [ "$CHANGED" = "true" ]; then
|
||||
echo "analyze=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "analyze=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Run zizmor
|
||||
if: steps.decide.outputs.analyze == 'true'
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
with:
|
||||
# Pin the tool version (the action defaults to `latest`) so a new
|
||||
# zizmor release can't surface findings that block unrelated PRs.
|
||||
version: "1.26.1"
|
||||
|
||||
# When skipped, upload an empty SARIF so the ruleset rule passes.
|
||||
- name: Write empty SARIF
|
||||
if: steps.decide.outputs.analyze != 'true'
|
||||
run: |
|
||||
cat > zizmor.sarif <<'EOF'
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
"runs": [
|
||||
{
|
||||
"tool": {
|
||||
"driver": {
|
||||
"name": "zizmor",
|
||||
"semanticVersion": "0.0.0",
|
||||
"rules": []
|
||||
}
|
||||
},
|
||||
"results": [],
|
||||
"automationDetails": {
|
||||
"id": "zizmor/"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Upload empty SARIF
|
||||
if: steps.decide.outputs.analyze != 'true'
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
@@ -0,0 +1,75 @@
|
||||
# Configuration for zizmor (static analysis for GitHub Actions).
|
||||
# https://docs.zizmor.sh/configuration/
|
||||
#
|
||||
# Findings ignored here are *deliberate* design choices, not "TODOs we never
|
||||
# got to". Anything new or unexpected should land as a finding in the Security
|
||||
# tab and be triaged before merge. Add an entry here only with a comment
|
||||
# explaining why the finding is acceptable.
|
||||
|
||||
rules:
|
||||
# `pull_request_target` and `workflow_run` are inherently dangerous, but we
|
||||
# use them in workflows that have been written defensively: they either
|
||||
# don't run untrusted code from the PR head, or they isolate untrusted state
|
||||
# into clearly-marked steps. Each entry below has a one-line justification.
|
||||
dangerous-triggers:
|
||||
ignore:
|
||||
# CLA assistant must comment on PRs from forks; runs a pinned third-party
|
||||
# action and does not execute any code from the PR.
|
||||
- cla.yml
|
||||
# auto-format-apply uses workflow_run to commit the formatted patch
|
||||
# computed by the untrusted `auto-format` workflow. The trusted job only
|
||||
# consumes the inert patch artifact, verifies the PR head SHA matches the
|
||||
# workflow_run head SHA, applies the diff, and pushes back via an app
|
||||
# token. It never checks out or executes PR-controlled code.
|
||||
- auto-format-apply.yml
|
||||
# Lunaria only reads localized content and posts a status comment; does
|
||||
# not execute PR-controlled code.
|
||||
- lunaria.yml
|
||||
# pr-compliance and pr-triage only inspect PR metadata (title, labels,
|
||||
# author) via github-script. They never check out PR code.
|
||||
- pr-compliance.yml
|
||||
- pr-triage.yml
|
||||
# review-state only reads PR and review metadata via github-script to set review/* labels; it never checks out or runs PR code.
|
||||
- review-state.yml
|
||||
# query-counts-apply uses workflow_run to apply snapshot updates that
|
||||
# were computed in the untrusted `query-counts` workflow. The trusted
|
||||
# workflow only consumes the artifact JSON, verifies the head SHA, and
|
||||
# pushes back via an app token. See workflow comments for the full
|
||||
# threat model.
|
||||
- query-counts-apply.yml
|
||||
# query-counts-label labels PRs based on path filters; it does not
|
||||
# check out PR code.
|
||||
- query-counts-label.yml
|
||||
|
||||
# The CLA assistant action (contributor-assistant/github-action) requires
|
||||
# write access to actions, contents (cla-signatures branch), issues, PRs,
|
||||
# and statuses. The workflow has only one job that uses these permissions,
|
||||
# so workflow-level vs job-level scoping makes no real difference. A future
|
||||
# PR may split the `label` job to a separate file with narrower permissions.
|
||||
excessive-permissions:
|
||||
ignore:
|
||||
- cla.yml
|
||||
|
||||
# pkg.pr.new is a preview-release service, not npm registry publishing. It
|
||||
# authenticates via its own GitHub OIDC flow (no long-lived token), so npm
|
||||
# trusted publishing does not apply. The real npm publish (release.yml) uses
|
||||
# OIDC provenance via the job's id-token: write.
|
||||
use-trusted-publishing:
|
||||
ignore:
|
||||
- preview-releases.yml
|
||||
|
||||
# investigate.yml is the bot's reproduction runner. It installs the
|
||||
# maintainer-owned bgproc / agent-browser CLIs globally at runtime so the
|
||||
# agent doesn't self-install them mid-run. This is not a production build,
|
||||
# and pinning these dev tools by version would add churn without a real
|
||||
# supply-chain gain here.
|
||||
adhoc-packages:
|
||||
ignore:
|
||||
- investigate.yml
|
||||
|
||||
# contributor-assistant/github-action is archived upstream but pinned by
|
||||
# SHA, and has no maintained drop-in replacement. The CLA flow is critical;
|
||||
# replacing the action is a separate, deliberate migration.
|
||||
archived-uses:
|
||||
ignore:
|
||||
- cla.yml
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
/themes
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.dev.vars
|
||||
.dev.vars.*
|
||||
|
||||
# database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# uploads & EmDash data
|
||||
uploads/
|
||||
.emdash/
|
||||
!packages/core/tests/e2e/fixture/.emdash/
|
||||
!packages/core/tests/integration/fixture/.emdash/
|
||||
!e2e/fixture/.emdash/
|
||||
!e2e/fixture-cloudflare/.emdash/
|
||||
!templates/*/.emdash/seed.json
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
.wrangler
|
||||
|
||||
# Playwright E2E testing
|
||||
playwright-report/
|
||||
test-results/
|
||||
playwright/.cache/
|
||||
|
||||
# Debug screenshots
|
||||
debug-*.png
|
||||
|
||||
# Template screenshots - only keep latest/, ignore dated folders
|
||||
assets/templates/*/[0-9]*
|
||||
|
||||
# Vitest browser-mode failure screenshots
|
||||
__screenshots__/
|
||||
.vitest-attachments/
|
||||
|
||||
# AI review artifacts
|
||||
*-review.md
|
||||
|
||||
.chainlink/
|
||||
|
||||
.emdash-bundle-tmp
|
||||
|
||||
# Downloaded test data (fetched on demand in CI)
|
||||
examples/wp-theme-unit-test/
|
||||
# .opencode is local-only EXCEPT for agents/, which are shared with CI workflows
|
||||
# (review.yml, ultrareview.yml, bonk.yml, ultrabonk.yml). Use a contents-style
|
||||
# pattern so we can re-include the agents subdirectory.
|
||||
.opencode/*
|
||||
!.opencode/agents/
|
||||
|
||||
# .claude is local-only EXCEPT for the symlinks pointing at AGENTS.md and skills/
|
||||
.claude/*
|
||||
!.claude/CLAUDE.md
|
||||
!.claude/skills
|
||||
|
||||
.perf-query-counts
|
||||
query-counts-out/
|
||||
|
||||
# Generated query-dump artefacts; tooling lives in scripts/query-dumps/ but
|
||||
# the per-run JSON and classification reports regenerate from the harness.
|
||||
scripts/query-dumps/sqlite/
|
||||
scripts/query-dumps/d1/
|
||||
scripts/query-dumps/classification.*.md
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
description: Automated implementer for the EmDash CI workflows. Used by the /bonk and @ask-bonk workflows to investigate issues, reproduce bugs, and implement fixes or features. Not intended for interactive local use -- prefer the default `build` or `plan` agents for that.
|
||||
mode: primary
|
||||
temperature: 0.1
|
||||
permission:
|
||||
edit: allow
|
||||
bash:
|
||||
"*": allow
|
||||
"git tag*": deny
|
||||
"git remote *": deny
|
||||
"rm -rf *": deny
|
||||
---
|
||||
|
||||
You are doing real engineering work on the **emdash-cms/emdash** repository on behalf of a maintainer. The CI workflow that invoked you has already gated on author trust -- by definition, any request that reaches you is an authorized maintainer request. You do not need to evaluate whether you're allowed to do the work, whether a Discussion exists for the feature, or whether the requester has authority to ask for the change. Proceed.
|
||||
|
||||
The human maintainers review the PR you open. That is where gating happens; not here. Your job is to do the work _well_.
|
||||
|
||||
The repo's `AGENTS.md` is loaded into your context separately. It already covers build/lint/test commands, code conventions (Lingui, RTL classes, SQL safety, API envelope, RBAC, locale filtering, indexes, imports, changesets), the migration system, and the PR template. **Do not re-derive those rules.** Defer to AGENTS.md and follow it. This file covers what AGENTS.md doesn't: how to investigate and reproduce on this specific codebase.
|
||||
|
||||
## Pick a mode first
|
||||
|
||||
Classify the request before doing anything else:
|
||||
|
||||
- **Question** -- "How does X work?" "Why did Y happen?" "Is Z safe?"
|
||||
- **Bug fix** -- "X is broken." "Y throws on Z." "This regressed."
|
||||
- **Feature** -- "Add X." "Support Y." "Make Z configurable."
|
||||
|
||||
Different modes have different protocols below. If the request is ambiguous, treat it as a question first: investigate, then decide whether the answer requires code changes.
|
||||
|
||||
If at any point you realize you cannot do the work (cannot reproduce a bug, request is contradictory, scope is far larger than initially apparent, the right approach requires a design decision you shouldn't make unilaterally), **stop and respond as a comment** explaining what you found and what you'd need to proceed. Do not guess. An honest "I couldn't reproduce this on main, here's what I tried" is more valuable than a speculative fix.
|
||||
|
||||
## Investigation protocol (all modes)
|
||||
|
||||
Before touching code, ground yourself in what's actually being asked.
|
||||
|
||||
1. **Fetch the trigger context.** The workflow injects PR/issue metadata as XML-tagged blocks in the prompt. Read all of it. If the trigger is on an issue, fetch the issue body and **all comments** (`gh issue view <n> --comments --repo emdash-cms/emdash`), plus any linked issues/PRs referenced in the discussion. If the trigger is on a PR, fetch the PR diff and the conversation (`gh pr view <n> --comments`, `gh pr diff <n>`).
|
||||
|
||||
2. **Read full files, not just diff hunks.** Bugs frequently live in the interaction between a changed line and surrounding unchanged code. When you'll modify a file, read the whole thing. When you cite a function, read its full body and one level of callers.
|
||||
|
||||
3. **Map call sites and siblings before designing.** If you'll change a function/type/route/migration, search for:
|
||||
- All call sites (`rg`, `grep`).
|
||||
- Sibling implementations that follow the same pattern (e.g. `posts` route vs `pages` route, `image` field vs `file` field, byline aggregator vs taxonomy aggregator). **Asymmetries between siblings that aren't justified by intent are usually bugs and should be either fixed or explicitly preserved with a comment explaining why.**
|
||||
- Parallel tests that exercise the same surface.
|
||||
|
||||
4. **Check AGENTS.md compliance proactively.** When your fix touches admin UI, content tables, SQL, API routes, or migrations, the AGENTS.md rules apply -- localization, RTL classes, parameterized SQL, `ApiResponse<T>` envelope, `requirePerm`/`requireOwnerPerm`, `locale` filtering, index discipline, `.js` extensions on internal imports.
|
||||
|
||||
5. **Use scratch notes for long investigations.** For non-trivial work, keep running notes in `/tmp/investigation.md` -- findings, dead ends, the failing-test reproduction, the eventual root cause. Reference it as you go. **Never** write helper notes into the repo working tree; they get swept into commits.
|
||||
|
||||
## Bug reproduction protocol
|
||||
|
||||
AGENTS.md mandates TDD for bugs: a fix without a reproducing test is not a fix. This is not optional and it is not a style preference. Apply it strictly.
|
||||
|
||||
### Reproduce before fixing
|
||||
|
||||
1. **Find the right test surface.** EmDash bug reproductions almost always go in `packages/core/tests/`:
|
||||
- `tests/unit/` -- pure functions, parsers, validators, helpers.
|
||||
- `tests/integration/` -- anything that touches the DB, API handlers, schema registry, migrations. Most bugs land here.
|
||||
- `tests/e2e/` -- Playwright; reserve for browser-only behavior.
|
||||
Mirror the source path under the chosen tree (e.g. a bug in `src/api/handlers/content.ts` gets a test under `tests/integration/api/handlers/content.test.ts`).
|
||||
|
||||
2. **Use real databases via `test-db.ts`.** Tests use real DBs, never mocks. The utilities in `packages/core/tests/utils/test-db.ts` give you:
|
||||
- `setupTestDatabase()` -- fresh in-memory SQLite + migrations.
|
||||
- `setupTestDatabaseWithCollections()` -- migrations + standard `posts`/`pages` collections, which is what you usually want.
|
||||
- `setupForDialect(dialect)` / `describeEachDialect(name, fn)` -- run the same suite against SQLite and Postgres. **Use this for any code that builds queries.** Many EmDash bugs are dialect-sensitive (json_extract, ON CONFLICT, INTEGER vs BOOLEAN, returning clauses) and a SQLite-only test will pass while Postgres breaks.
|
||||
- Per-test teardown is mandatory -- use `beforeEach`/`afterEach`.
|
||||
|
||||
3. **Write the failing test first.** Run it, confirm it fails, and **confirm it fails for the right reason** -- not because of a typo, missing setup, or a different upstream error. A red test that throws "table not found" is not a reproduction of an authorization bug.
|
||||
|
||||
4. **Then write the fix.** Run the test again; it should now pass. Then run the rest of the package's test suite (`pnpm --filter @emdash-cms/core test` or whatever package you touched) to confirm you haven't regressed something else.
|
||||
|
||||
5. **Commit the test separately from the fix.** Land the failing-test commit first, then the fix commit. The PR history then shows the TDD shape and a reviewer can `git checkout <test-commit>` and watch it fail. This is worth the small amount of extra effort.
|
||||
|
||||
### When you cannot reproduce
|
||||
|
||||
If you have genuinely tried and cannot reproduce:
|
||||
|
||||
- Document what you tried (the test file you wrote, the inputs you used, the database state you set up).
|
||||
- Search for prior reports of the same symptom in closed issues/PRs.
|
||||
- Look for environmental variables (Node version, dialect, R2 vs LocalStorage, dev vs prod gate, locale, runtime -- Workers vs Node).
|
||||
- **Stop and post a comment.** Do not guess at a fix. The honest report has more value than a speculative patch.
|
||||
|
||||
### Bug-class clustering
|
||||
|
||||
Bugs cluster. After you fix one, ask:
|
||||
|
||||
- **Does the same class of bug exist in a sibling implementation?** If a `posts` route had a missing `locale` filter, check the `pages` route, and any custom collection routes. If one byline aggregator parsed wrong, check the taxonomy aggregator.
|
||||
- **Does the same root cause affect a different code path?** A bug in `findOne` from an unfiltered query likely affects `findMany` too.
|
||||
- **Is there a missing index, or only a missing query filter?** AGENTS.md's index discipline section lists the standard set; if your fix added a new WHERE clause, the column probably needs an index.
|
||||
|
||||
If you find a sibling bug, fix it in the same PR (it's the same change scope) and call it out in the PR body. If you find an unrelated cluster, mention it in the PR body as a follow-up rather than expanding scope.
|
||||
|
||||
## EmDash-specific diagnostic playbook
|
||||
|
||||
Things that are easy to miss without grounding in this codebase:
|
||||
|
||||
- **Content tables (`ec_*`) are per-locale.** Every query against a content table either filters by `locale` or deliberately operates across locales. A query that returns "the post" without a `locale` filter is almost always a bug. Check `migration 019_i18n.ts` for the model.
|
||||
- **Slug uniqueness is `UNIQUE(slug, locale)`, not global.** Tests that assume slug collisions across locales are wrong.
|
||||
- **Auth middleware checks authentication, not authorization.** Routes do their own permission checks via `requirePerm` / `requireOwnerPerm`. A route that does a state change without one of those is a bug, not just a smell.
|
||||
- **API responses are `{ success, data?, error? }` envelopes; list endpoints put `{ items, nextCursor? }` _inside_ `data`.** The admin client unwraps once. A handler that returns a bare array, or an envelope that puts `items` at the top level, will silently break the admin UI.
|
||||
- **Migrations are forward-only and statically registered.** New migrations go in `packages/core/src/database/migrations/NNN_name.ts`, get a static import in `runner.ts`, and get added to `getMigrations()`. Auto-discovery doesn't work because of the Workers bundler.
|
||||
- **`requestCached` deduplicates within a single render.** If a helper takes stable args (slug, key, id) and is called from templates, it should be wrapped. Missing this is a perf bug, not a correctness bug, but is worth fixing while you're nearby.
|
||||
- **Module-scope singletons must hang off `globalThis`.** Vite duplicates modules across SSR chunks; a plain module-scope `let` becomes two variables. See `bylinesHolder` and `request-context.ts` for the pattern.
|
||||
- **`import.meta.env.DEV`, never `process.env.NODE_ENV`.** Dev-only endpoints check `import.meta.env.DEV` because it's a compile-time constant -- runtime spoofing is impossible.
|
||||
- **`sql.raw` with interpolation is a security finding.** Use Kysely's `sql` template (parameterizes values) and `sql.ref` (quotes identifiers). For dynamic identifiers, validate with `validateIdentifier` first.
|
||||
- **Admin UI: bare English literals in JSX, aria labels, placeholders, alt text are localization regressions.** AGENTS.md is strict about this. Use `t\`...\``or`<Trans>`. RTL: logical Tailwind classes only (`ms-_`, `pe-_`, `start-\*`, `text-end`).
|
||||
|
||||
When the bug or feature touches one of these areas, mention the relevant rule in your PR body so the reviewer can confirm you didn't trip it.
|
||||
|
||||
## Branch management
|
||||
|
||||
**Never create new branches.** The workflow checks out the correct branch before invoking you. When triggered on an existing PR, you are already on that PR's branch. When triggered on an issue, you are on `main` and the workflow handles branch creation and pushing after you finish.
|
||||
|
||||
Your job is to commit to whatever branch you're already on. If you create a new branch, your commits won't be pushed to the PR and your work will be lost. This has happened before and wasted a full run.
|
||||
|
||||
- Do not run `git checkout -b`, `git switch -c`, or any branch-creation command.
|
||||
- Do not run `git push` yourself. The workflow handles pushing.
|
||||
- Commit to the current branch. That's it.
|
||||
|
||||
## Implementation protocol
|
||||
|
||||
Two reminders that apply specifically to CI work:
|
||||
|
||||
1. **Stay in scope.** AGENTS.md forbids drive-by refactors and "while I'm here" changes. Touch only files necessary for your task. Do not reformat unrelated files. Do not fix unrelated lint warnings. Do not add comments to code you didn't change.
|
||||
|
||||
2. **Run the gates yourself before declaring done.**
|
||||
- build the whole monorepo first, before running any tests. When iterating on a fix you can re-build just the affected package, but the first build should be full to catch any unexpected cross-package issues.
|
||||
- `pnpm build` for a full build.
|
||||
- `pnpm --filter <package> build` for an iterative fix.
|
||||
- `pnpm lint:quick` after each round of edits (sub-second).
|
||||
- `pnpm typecheck` (or `pnpm typecheck:demos` if you touched a demo).
|
||||
- The package-level test suite for whatever you changed (`pnpm --filter <package> test`).
|
||||
- `pnpm format` once at the end (oxfmt, tabs).
|
||||
- If your change affects a published package's runtime behavior, add a changeset (`pnpm changeset --empty`, edit the file). Skip changesets for docs/tests/CI/demos.
|
||||
|
||||
If a gate fails on code you didn't touch, AGENTS.md is explicit: "Don't dismiss failures as unrelated. Don't assign blame. Just fix them." Main is always green, so if it's failing then it's caused by your change, even if it's a different file.
|
||||
|
||||
## Adversarial self-review before finishing
|
||||
|
||||
When you think the work is done, but before opening the PR, iterate on adversarial reviews. Use the adversarial review skill with a sub-agent. Fix any issues it finds or push back if you disagree, then dispatch another review. Repeat until the review finds no substantive issues.
|
||||
|
||||
## Posting the result
|
||||
|
||||
The workflow's behavior depends on whether you produced code changes:
|
||||
|
||||
- **Code changes pushed to a new branch** -- the workflow auto-opens a PR using your final response as the body. Structure your final response as a PR body following `.github/PULL_REQUEST_TEMPLATE.md`. Tick the AI-generated code disclosure box and name the model. Tick checkboxes that apply; leave the rest unchecked. The Summary should describe **why**, not the line-by-line **what** of the diff.
|
||||
- **Code changes on an existing PR branch** -- the workflow pushes commits to that branch; respond with a normal comment summarizing what changed and why.
|
||||
- **No code changes** (you're answering a question, leaving feedback, or you decided not to proceed) -- respond as a normal comment. State what you found, what you tried, and what would need to happen next.
|
||||
|
||||
## What "good" looks like
|
||||
|
||||
- Reproduces the bug with a failing test before fixing it.
|
||||
- Fix is minimal and confined to the bug's actual scope.
|
||||
- Catches a sibling bug in the same diff when the root cause affects multiple code paths.
|
||||
- AGENTS.md-compliant from the first commit -- no need for a follow-up "fix lint" pass.
|
||||
- PR body explains the **why** clearly enough that a reviewer doesn't need to ask.
|
||||
- When the agent couldn't reproduce, says so honestly and asks for what it would need.
|
||||
|
||||
## What "bad" looks like
|
||||
|
||||
- Fix without a reproducing test.
|
||||
- "Fixed" something that was never broken because the agent guessed instead of verifying.
|
||||
- Drive-by formatting or lint changes in unrelated files.
|
||||
- New WHERE clause without an index, or new content-table query without a `locale` filter.
|
||||
- Bare English literal added to admin JSX.
|
||||
- `sql.raw` with interpolated values.
|
||||
- PR body that just restates the diff line-by-line.
|
||||
- A speculative fix posted as a PR when the right answer was a comment saying "I can't reproduce this."
|
||||
|
||||
Read carefully. Reproduce before fixing. Stay in scope. Be honest about what you don't know.
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
description: Automated PR reviewer for the EmDash CI workflows. Used by the /review and /ultrareview workflows to leave structured review feedback on pull requests. Not intended for interactive local use -- prefer the default `build` or `plan` agents for that.
|
||||
mode: primary
|
||||
temperature: 0.1
|
||||
permission:
|
||||
edit: allow
|
||||
bash:
|
||||
"*": allow
|
||||
"git push*": deny
|
||||
"git commit*": deny
|
||||
"git tag*": deny
|
||||
"git remote *": deny
|
||||
"rm -rf *": deny
|
||||
---
|
||||
|
||||
You are reviewing a pull request on the **emdash-cms/emdash** repository. Your job is to find real bugs, real regressions, and real gaps. You leave structured feedback as GitHub PR review comments. You do not need to duplicate work that the CI checks already do: there is no need to run the test suite or linter.
|
||||
|
||||
You do not commit code. You do not push. The token your shell uses is scoped read-only on `contents`, so any push will fail at the git layer; do not waste turns trying. Write tools are enabled because scaffolding a fix locally to verify your reasoning is often valuable -- but never `git add`, `git commit`, or `git push` it.
|
||||
|
||||
The repo's AGENTS.md is loaded into your context separately. **Read it carefully and check for compliance** -- AGENTS.md violations are first-class findings. The repo's conventions on Lingui localization, RTL-safe Tailwind classes, SQL safety, API envelope shape, role-based authorization, locale filtering on content tables, index discipline, import patterns, and changesets are all documented there. Don't re-derive these rules from the codebase; check that the PR follows them. If AGENTS.md says "every user-facing string in the admin must use Lingui," a bare English literal in admin JSX is a finding.
|
||||
|
||||
## How to investigate
|
||||
|
||||
1. **Start with author intent.** Read the PR description and changeset. What is this PR claiming to fix or change? Verify the description matches the diff. If the description overstates the impact (e.g. claims a function "would have stripped data" when that function has zero production call sites), that's a finding.
|
||||
|
||||
2. **Read the full PR diff.** `gh pr diff <PR> --repo emdash-cms/emdash` and `gh api repos/emdash-cms/emdash/pulls/<PR>/files` for the file list with addition/deletion counts.
|
||||
|
||||
3. **For every changed file, read the FULL file, not just the diff hunks.** Bugs frequently hide in the interaction between changed lines and surrounding unchanged code. Reviewers who only look at the diff window miss this category entirely. Use the `read` tool on each changed file.
|
||||
|
||||
4. **Trace consumers and parallel implementations.** When a type, component, function, or schema is modified, ask:
|
||||
- What else uses it? `grep` for call sites and adjust your understanding of impact.
|
||||
- Does it have a sibling that follows a similar pattern (e.g. `FooRenderer` vs `BarRenderer`, `posts` route vs `pages` route, image field vs file field)? Diff the sibling against the change. **Asymmetries between siblings that aren't justified by intent are usually bugs.**
|
||||
- For schema/type changes: are the corresponding tests updated? Migrations correct? Generated types in sync?
|
||||
|
||||
5. **Look at every file in the diff, not just the most-changed ones.** Includes:
|
||||
- Schema/type generators
|
||||
- Tests -- do they actually exercise the new behavior, or just assert surface details (UI labels, snapshot equality)?
|
||||
- Mocks in tests -- a mock that returns `null` for the very thing the test claims to verify is a false-confidence pattern.
|
||||
- Changeset (does the description match what changes? is the bump type correct?)
|
||||
- Locale catalogs (drift, mass renumbering, untranslated keys)
|
||||
- Any "incidental" file changes the author may not have meant to include.
|
||||
|
||||
6. **Test coverage is a first-class concern.** AGENTS.md mandates TDD for bugs: a fix without a reproducing test is not fixed. If production code changes but tests are missing, weak, or dependent on mocks that defeat the test, that's "Needs fixing." Don't accept tests that just check rendered labels.
|
||||
|
||||
7. **Verify cross-cutting claims.** If the PR description names a function as the cause of a bug, search for that function's call sites and verify the claim. Authors sometimes assume a helper is hot when it's actually only invoked from tests.
|
||||
|
||||
## How to format findings
|
||||
|
||||
Output structure (post via the GitHub API, see "Posting" below):
|
||||
|
||||
For each finding:
|
||||
|
||||
- **Severity:** "Needs fixing" (logic bugs, regressions, security issues, broken contracts, missing required tests, AGENTS.md convention violations) or "Suggestion" (style, minor refactor, nice-to-have, low-confidence observations).
|
||||
- **Path** and **line number** (or line range).
|
||||
- **What's wrong** -- 1-3 sentences, concrete. Cite the line. State what the code currently does and why it's wrong, not what the fix is yet.
|
||||
- **Proposed fix** -- a code snippet where practical. Use GitHub's \`\`\`suggestion blocks when the fix is a clean inline replacement -- the human applies it with one click.
|
||||
|
||||
Severity calibration matters. Don't tag things "Needs fixing" to look thorough. A misleading docstring is "Suggestion." A bug that silently drops data is "Needs fixing." A failing test is "Needs fixing." A missing test for a fixed bug is "Needs fixing." A tiny refactor opportunity is "Suggestion."
|
||||
|
||||
**Be willing to find nothing.** If the PR is well-written and you found no real issues, say so explicitly: "I reviewed all changed files and found no issues that need fixing." Don't manufacture findings.
|
||||
|
||||
## Posting the review
|
||||
|
||||
Post a single review with all comments anchored to lines. Use `gh` CLI:
|
||||
|
||||
```bash
|
||||
gh api repos/<owner>/<repo>/pulls/<PR>/reviews \
|
||||
-X POST \
|
||||
--input - <<JSON
|
||||
{
|
||||
"event": "COMMENT",
|
||||
"body": "",
|
||||
"comments": [
|
||||
{ "path": "...", "line": 123, "side": "RIGHT", "body": "..." }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Build the JSON payload with a heredoc and pipe via `--input -`. If you need a temp file, write it under `/tmp/`. **Never write helper files to the repository working directory.** They get swept into accidental commits.
|
||||
- **Default to `event: "COMMENT"`.** This is the right choice for almost every review. The human maintainer will read your comments and decide what to act on -- they don't need a procedural block on the merge button. Comments are a conversation, not a gate.
|
||||
- **Use `event: "REQUEST_CHANGES"` only for true blockers**: a security vulnerability, a data-loss bug, a build/test break that the PR introduces, a backwards-incompatibility that violates the post-pre-release stability rule in AGENTS.md, or something equivalently serious. A "Needs fixing" finding is _not_ by itself a reason to request changes -- a docstring drift or a truthiness bug is "Needs fixing" but should still ride as a `COMMENT`. If you're unsure whether something rises to blocker level, it doesn't. Use `COMMENT`.
|
||||
- Leave the top-level review body empty. The summary will be posted as a separate comment by the workflow.
|
||||
- Anchor comments to the right line on the right side. `side: "RIGHT"` for additions/changes; `side: "LEFT"` only for comments on deleted lines.
|
||||
- For multi-line ranges, use `start_line` and `line` together (line is the end). Both must be on the same `side`.
|
||||
|
||||
## What "good" looks like
|
||||
|
||||
The best review:
|
||||
|
||||
- Catches a non-obvious bug that requires reading beyond the diff.
|
||||
- Notices an asymmetry between sibling implementations.
|
||||
- Identifies a missing test for a behavior the PR claims to fix.
|
||||
- Flags a misleading PR description or comment.
|
||||
- Stays calibrated -- doesn't pad.
|
||||
|
||||
The worst review:
|
||||
|
||||
- Repeats what the diff already says ("This adds a new function `foo`").
|
||||
- Manufactures findings to look thorough.
|
||||
- Misses cross-file consequences because it only read the hunks.
|
||||
- Edits files in the working tree and gets them swept into a commit.
|
||||
- Gets severity wrong (every finding "Needs fixing" or every finding "Suggestion").
|
||||
|
||||
Read carefully. Cite line numbers. Be specific. Be kind to the author -- they're a human, your feedback is public, and the goal is to ship better code, not to win.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"experimentalSortImports": {},
|
||||
"ignorePatterns": [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.mdx",
|
||||
"**/package.json",
|
||||
"**/emdash-env.d.ts",
|
||||
"**/worker-configuration.d.ts",
|
||||
"packages/registry-lexicons/src/generated/**",
|
||||
"packages/plugin-cli/schemas/**"
|
||||
]
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "import", "unicorn", "promise"],
|
||||
"jsPlugins": ["@e18e/eslint-plugin"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"no-await-in-loop": "off",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
"typescript/no-unsafe-type-assertion": "warn",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-unnecessary-boolean-literal-compare": "off",
|
||||
// Noisy/stylistic rules added in newer oxlint. Disabled while we triage them
|
||||
// (see https://github.com/emdash-cms/emdash/issues — track follow-ups before
|
||||
// re-enabling).
|
||||
"no-underscore-dangle": "off",
|
||||
"typescript/consistent-return": "off",
|
||||
"typescript/no-unnecessary-type-conversion": "off",
|
||||
"typescript/no-unnecessary-type-parameters": "off",
|
||||
// The rule degrades to an unactionable error on configs without
|
||||
// strictNullChecks (the test plugins under packages/plugins/*-test/).
|
||||
"typescript/no-useless-default-assignment": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-unassigned-import": [
|
||||
"warn",
|
||||
{
|
||||
"allow": ["**/*.css", "@testing-library/react", "vitest-browser-react"]
|
||||
}
|
||||
],
|
||||
"e18e/prefer-array-at": "error",
|
||||
"e18e/prefer-array-fill": "error",
|
||||
"e18e/prefer-includes": "error",
|
||||
"e18e/prefer-array-to-reversed": "error",
|
||||
"e18e/prefer-array-to-sorted": "error",
|
||||
"e18e/prefer-array-to-spliced": "error",
|
||||
"e18e/prefer-nullish-coalescing": "error",
|
||||
"e18e/prefer-object-has-own": "error",
|
||||
"e18e/prefer-spread-syntax": "error",
|
||||
"e18e/prefer-url-canparse": "error",
|
||||
"e18e/ban-dependencies": "error",
|
||||
"e18e/prefer-array-from-map": "error",
|
||||
"e18e/prefer-timer-args": "error",
|
||||
"e18e/prefer-date-now": "error",
|
||||
"e18e/prefer-regex-test": "error",
|
||||
"e18e/prefer-array-some": "error",
|
||||
"e18e/prefer-static-regex": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/tests/**/*.ts",
|
||||
"**/tests/**/*.tsx",
|
||||
"packages/atproto-test-utils/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"typescript/no-unnecessary-type-assertion": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"e18e/prefer-static-regex": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/database/repositories/content.ts",
|
||||
"**/database/repositories/comment.ts",
|
||||
"**/database/repositories/user.ts",
|
||||
"**/mcp/server.ts",
|
||||
"**/client/index.ts",
|
||||
"**/client/transport.ts",
|
||||
"**/client/portable-text.ts",
|
||||
"**/cli/**/*.ts",
|
||||
"packages/plugin-cli/src/bundle/api.ts",
|
||||
"packages/plugin-cli/src/bundle/utils.ts",
|
||||
"packages/plugin-cli/src/bundle/command.ts",
|
||||
"packages/plugin-cli/src/bundle/types.ts",
|
||||
"packages/plugin-cli/src/oauth.ts",
|
||||
"packages/plugin-cli/src/publish/api.ts",
|
||||
"packages/plugin-cli/src/commands/publish.ts",
|
||||
"packages/registry-client/src/publishing/index.ts",
|
||||
"**/api/handlers/api-tokens.ts",
|
||||
"**/api/handlers/device-flow.ts",
|
||||
"**/api/handlers/oauth-authorization.ts",
|
||||
"**/api/handlers/comments.ts",
|
||||
"**/routes/api/oauth/token.ts",
|
||||
"**/routes/api/comments/**/*.ts",
|
||||
"**/routes/api/admin/comments/**/*.ts",
|
||||
"**/routes/api/plugins/**/*.ts",
|
||||
"**/plugins/hooks.ts",
|
||||
"**/plugins/context.ts",
|
||||
"**/plugins/cron.ts",
|
||||
"**/plugins/define-plugin.ts",
|
||||
"**/plugins/request-meta.ts",
|
||||
"**/seed/load.ts",
|
||||
"**/comments/notifications.ts",
|
||||
"**/astro/integration/index.ts",
|
||||
"packages/plugins/**/*.ts",
|
||||
"packages/plugins/**/*.tsx",
|
||||
"packages/blocks/**/*.tsx",
|
||||
"packages/admin/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-unsafe-type-assertion": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.d.ts",
|
||||
"**/src/generated/**",
|
||||
"skills/**/scaffold/**",
|
||||
".agents/skills/**/scaffold/**",
|
||||
".claude/skills/**/scaffold/**",
|
||||
"scripts/query-dumps/**",
|
||||
".flue/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Prettier is only used for .astro files (oxfmt handles everything else)
|
||||
*
|
||||
!**/*.astro
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"plugins": ["prettier-plugin-astro"]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
This file provides guidance to agentic coding tools working in this repository.
|
||||
|
||||
For human-facing contributor info (setup, repo layout, PR policy, changesets, i18n), see [CONTRIBUTING.md](CONTRIBUTING.md). This file focuses on the patterns and gotchas an agent needs to write correct code.
|
||||
|
||||
`CLAUDE.md` is a symlink to this file. `.opencode/skills` and `.claude/skills` are symlinks to `skills/`. Don't try to sync between them.
|
||||
|
||||
# Rules
|
||||
|
||||
**Backwards compatibility matters.** EmDash is published and in active use, pre-1.0. Prefer additive changes (new fields, new routes, new options with defaults). Breaking changes need an explicit decision, a package bump, and a changeset that calls the break out clearly. Database migrations are forward-only -- never write one that leaves existing content inaccessible. When in doubt, open a Discussion.
|
||||
|
||||
**TDD for bugs.** Failing test -> fix -> verify. A bug without a reproducing test is not fixed.
|
||||
|
||||
**Localize everything user-facing.** All admin UI strings, aria labels, and toast messages go through Lingui. All admin layout uses RTL-safe logical Tailwind classes. See [Localization](#admin-ui-localization-lingui) and [RTL](#admin-ui-rtl-safe-tailwind).
|
||||
|
||||
**Scope discipline.** No drive-by refactors, no bulk lint/type cleanups, no "while I'm here" edits in unrelated files. If you see a systemic issue, open a Discussion. See [CONTRIBUTING.md § Contribution Policy](CONTRIBUTING.md#contribution-policy).
|
||||
|
||||
## Workflow
|
||||
|
||||
Run `pnpm lint:json | jq '.diagnostics | length'` before starting and confirm it's clean -- if it's failing after your edits, your changes caused it.
|
||||
|
||||
During work:
|
||||
|
||||
- `pnpm lint:quick` after every edit (sub-second)
|
||||
- `pnpm typecheck` (packages) or `pnpm typecheck:demos` (Astro demos) after each round of edits
|
||||
- `pnpm format` regularly (oxfmt, tabs)
|
||||
|
||||
Before opening a PR: tests pass, lint clean, formatted, changeset added if a published package changed. See [CONTRIBUTING.md § Changesets](CONTRIBUTING.md#changesets).
|
||||
|
||||
A changeset is release notes a user reads while upgrading -- **not** a commit message, PR description, or summary of your diff. Do not paste your PR prose into it. Write for someone who will run the new version and wants to know what changed for them: lead with a present-tense verb (`Fixes`, `Adds`, `Updates`, `Removes`), describe the observable effect, and leave out internal mechanics (file names, refactors, how you implemented it). For a breaking change, include the migration step. One sentence is often enough.
|
||||
|
||||
When opening a PR with `gh`/the API, copy `.github/PULL_REQUEST_TEMPLATE.md` into the body and fill every section -- the GitHub UI injects it automatically but the CLI does not, and PRs missing it are auto-closed. Check the AI-generated code disclosure box and name the model. Tick checklist items only for what you actually verified; for test-only/docs/CI PRs, note why changeset/i18n/Discussion items are n/a.
|
||||
|
||||
## Architecture
|
||||
|
||||
EmDash is an Astro-native CMS on Cloudflare (D1 + R2 + Workers) or Node + SQLite.
|
||||
|
||||
- **Schema in the database.** `_emdash_collections` and `_emdash_fields` are the source of truth. Each collection gets a real SQL table (`ec_posts`, `ec_products`) with typed columns -- not EAV.
|
||||
- **Middleware chain:** runtime init -> setup check -> auth -> request context (ALS). Auth middleware checks authentication only; routes check authorization.
|
||||
- **Handler layer** (`packages/core/src/api/handlers/*.ts`) holds business logic and returns `ApiResult<T>` (`{ success: true, data } | { success: false, error: { code, message, details? } }`). Route files are thin wrappers.
|
||||
- **Storage abstraction:** `Storage` interface with `upload/download/delete/exists/list/getSignedUploadUrl`. `LocalStorage` for dev, `S3Storage` for R2/AWS. Access via `emdash.storage` from locals.
|
||||
|
||||
Key files:
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `packages/core/src/emdash-runtime.ts` | Central runtime; orchestrates DB, plugins, storage |
|
||||
| `packages/core/src/schema/registry.ts` | Manages `ec_*` table creation/modification |
|
||||
| `packages/core/src/database/migrations/runner.ts` | StaticMigrationProvider; register new migrations here |
|
||||
| `packages/core/src/plugins/manager.ts` | Loads and orchestrates plugins |
|
||||
|
||||
# Code Patterns
|
||||
|
||||
## Database: Never Interpolate Into SQL
|
||||
|
||||
Kysely is the query builder.
|
||||
|
||||
- **Never** use `sql.raw()` with string interpolation or template literals containing variables.
|
||||
- For **values**, use Kysely's `sql` tagged template -- interpolated values are automatically parameterized.
|
||||
- For **identifiers** (table/column names), use `sql.ref()`.
|
||||
- If you must use `sql.raw()` for dynamic identifiers, validate with `validateIdentifier()` from `database/validate.ts` first (asserts `/^[a-z][a-z0-9_]*$/`).
|
||||
- The `json_extract(data, '$.${field}')` pattern is particularly dangerous -- always validate `field`.
|
||||
|
||||
```typescript
|
||||
// WRONG -- SQL injection
|
||||
const query = `SELECT * FROM ${table} WHERE name = '${name}'`;
|
||||
await sql.raw(query).execute(db);
|
||||
|
||||
// RIGHT -- parameterized value, safe identifier
|
||||
await sql`SELECT * FROM ${sql.ref(table)} WHERE name = ${name}`.execute(db);
|
||||
|
||||
// RIGHT -- validated identifier in raw SQL
|
||||
validateIdentifier(field);
|
||||
return sql.raw(`json_extract(data, '$.${field}')`);
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
Routes live in `packages/core/src/astro/routes/api/`. Conventions:
|
||||
|
||||
- Every route file starts with `export const prerender = false;`.
|
||||
- Named exports: `export const GET: APIRoute`, etc. Destructure from the Astro context.
|
||||
- Access runtime via `const { emdash } = locals;`, user via `locals.user`.
|
||||
- File structure mirrors URLs: `content/[collection]/index.ts` for list/create, `[id].ts` for get/update/delete, sub-actions as siblings.
|
||||
- **Never** add GET handlers for state-changing operations.
|
||||
|
||||
Use the shared utilities -- don't roll your own:
|
||||
|
||||
| Need | Use |
|
||||
| --------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Error response | `apiError(code, message, status)` from `#api/error.js` |
|
||||
| Catch block | `handleError(error, message, code)` -- never expose `error.message` to clients |
|
||||
| Body validation | `parseBody(request, zodSchema)` from `#api/parse.js` -- never `as` cast `request.json()` |
|
||||
| Unwrap handler | `unwrapResult(result)` -- maps error codes to HTTP statuses automatically |
|
||||
| Init check | `if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);` |
|
||||
|
||||
The error helper is `mapErrorStatus`, not `mapErrorToStatus`.
|
||||
|
||||
### Authorization
|
||||
|
||||
Every state-changing route must check authorization. Authorization is permission-based, not role-based -- the `Permissions` map in `packages/auth/src/rbac.ts` is authoritative. Never invent permission strings in route files; add them to `rbac.ts` with a sensible minimum role.
|
||||
|
||||
```typescript
|
||||
import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
|
||||
|
||||
// Any-actor capability (settings, schema)
|
||||
const denied = requirePerm(user, "schema:manage");
|
||||
if (denied) return denied;
|
||||
|
||||
// Ownership-aware (authors edit their own; editors edit anyone's)
|
||||
const denied = requireOwnerPerm(user, post.authorId, "content:edit_own", "content:edit_any");
|
||||
if (denied) return denied;
|
||||
```
|
||||
|
||||
Both helpers return `null` on success or a `Response` (401/403) to return directly.
|
||||
|
||||
### CSRF
|
||||
|
||||
All state-changing endpoints require the `X-EmDash-Request: 1` header, enforced by auth middleware. The admin UI and visual editing client send it automatically.
|
||||
|
||||
### Pagination
|
||||
|
||||
List endpoints return `{ items, nextCursor? }` -- never a bare array. Use `encodeCursor(orderValue, id)` / `decodeCursor(cursor)`. Default limit 50, max 100, always clamp. The repository-level shape is `FindManyResult<T>`.
|
||||
|
||||
### URL/Redirect handling
|
||||
|
||||
When accepting redirect URLs from query params or bodies: require leading `/`, reject `//`, HTML-escape before interpolation, prefer `Response.redirect()` over `<meta http-equiv="refresh">`.
|
||||
|
||||
## Handler Layer
|
||||
|
||||
Handlers in `api/handlers/*.ts` are standalone async functions, not class methods.
|
||||
|
||||
- First parameter is always `db: Kysely<Database>`, followed by route-specific params.
|
||||
- Return `ApiResult<T>`.
|
||||
- Wrap the body in try/catch. Errors return `{ success: false, error: { code, message } }`.
|
||||
- Error codes are `SCREAMING_SNAKE_CASE` (`NOT_FOUND`, `VALIDATION_ERROR`, `CONTENT_CREATE_ERROR`).
|
||||
|
||||
## Migrations
|
||||
|
||||
Migrations live in `packages/core/src/database/migrations/`.
|
||||
|
||||
- **Naming:** `NNN_descriptive_name.ts`, zero-padded.
|
||||
- **Exports:** `up(db: Kysely<unknown>)` and `down(db: Kysely<unknown>)`.
|
||||
- **System tables:** Kysely schema builder.
|
||||
- **Dynamic content tables (`ec_*`):** `sql` tagged templates with `sql.ref()` for identifiers.
|
||||
- **Column types:** SQLite -- `text`, `integer`, `real`, `blob`. Booleans are `integer` defaulting to 0. Timestamps are `text` with ``defaultTo(sql`(datetime('now'))`)``. IDs are `text` primary keys (ULIDs from `ulidx`).
|
||||
- **Registration:** Migrations are statically imported in `runner.ts` and added to `StaticMigrationProvider`. Not auto-discovered (Workers bundler compatibility). When adding: create the file, add a static import in `runner.ts`, add it to `getMigrations()`.
|
||||
- **Multi-table migrations:** When altering all content tables, query `_emdash_collections` and loop. See `013_scheduled_publishing.ts`.
|
||||
|
||||
## Indexes
|
||||
|
||||
Every content table gets indexes on: `status`, `slug`, `created_at`, `deleted_at`, `scheduled_at` (partial, `WHERE scheduled_at IS NOT NULL`), `live_revision_id`, `draft_revision_id`, `author_id`, `primary_byline_id`, `updated_at`, `locale`, `translation_group`. Foreign key columns always get an index.
|
||||
|
||||
Naming: `idx_{table}_{column}` for single-column, `idx_{table}_{purpose}` for multi-column.
|
||||
|
||||
## Content Tables
|
||||
|
||||
Managed by `SchemaRegistry` in `schema/registry.ts`:
|
||||
|
||||
- **Names:** `ec_{collection_slug}`. System tables: `_emdash_{name}`.
|
||||
- **Slugs:** `/^[a-z][a-z0-9_]*$/`, max 63 chars, checked against `RESERVED_COLLECTION_SLUGS` / `RESERVED_FIELD_SLUGS`.
|
||||
- **Standard columns:** `id`, `slug`, `status`, `author_id`, `created_at`, `updated_at`, `published_at`, `scheduled_at`, `deleted_at`, `version`, `live_revision_id`, `draft_revision_id`. Field columns added via `ALTER TABLE`.
|
||||
- **Field type -> column mapping:** `FIELD_TYPE_TO_COLUMN` in `schema/types.ts`. Most string-shaped types -> TEXT; number -> REAL; integer/boolean -> INTEGER; portableText/json/multiSelect -> JSON.
|
||||
- **Orphan discovery:** `discoverOrphanedTables()` finds `ec_*` tables without a matching `_emdash_collections` row.
|
||||
|
||||
## Content Localization
|
||||
|
||||
Content tables use a row-per-locale model (migration `019_i18n.ts`):
|
||||
|
||||
- Every `ec_*` table has `locale` (defaults to `'en'`) and `translation_group` (ULID shared across translations).
|
||||
- Slug uniqueness is `UNIQUE(slug, locale)`, not global.
|
||||
- Any new query against a content table must filter by `locale` -- forgetting this is a correctness bug.
|
||||
- Fetch all translations via `GET /_emdash/api/content/{collection}/{id}/translations`.
|
||||
|
||||
When adding content-table features, ask: per-locale (display fields) or per-translation-group (anything identifying "the same thing" across languages)?
|
||||
|
||||
## Performance: Caching and Query Patterns
|
||||
|
||||
EmDash runs on D1 with the Sessions API. Anonymous reads go to the nearest replica; writes and authenticated reads route to the primary. Every round-trip matters.
|
||||
|
||||
**Wrap query helpers in `requestCached`.** Per-request cache (`src/request-cache.ts`) dedupes identical calls within a render. If a helper takes stable args (slug, key, id) and may be called from multiple components, wrap it. The cache key must include every argument that changes the result. The promise is cached, so concurrent callers share the in-flight query.
|
||||
|
||||
```typescript
|
||||
export function getSiteSetting(key: string) {
|
||||
return requestCached(`siteSetting:${key}`, async () => {
|
||||
const db = await getDb();
|
||||
return ...;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Module-scope singletons must live on `globalThis`.** Vite duplicates modules across SSR chunks; a plain `let cache = null` becomes two variables. Use a `Symbol.for` key on `globalThis`. See `packages/core/src/settings/index.ts` (versioned) and `packages/core/src/request-context.ts` / `request-cache.ts` (per-request).
|
||||
|
||||
**Prefer the batch query to a "has any" probe.** Don't add a `SELECT id FROM foo LIMIT 1` to skip work on empty sites -- on live sites you pay the extra query every request for no gain. Handle missing tables with `isMissingTableError`.
|
||||
|
||||
**Defer bookkeeping with `after(fn)`.** Maintenance writes don't need to block TTFB. `after()` uses workerd's `waitUntil` when available, fire-and-forgets on Node. Wrap your function body in try/catch with a module-specific log prefix.
|
||||
|
||||
```typescript
|
||||
import { after } from "emdash";
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
await recoverStaleLocks();
|
||||
} catch (error) {
|
||||
console.error("[cron] recovery failed:", error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**One query beats two.** Use `LEFT JOIN` for parent+children. Batch with `WHERE id IN (...)`, chunked at `SQL_BATCH_SIZE` (from `utils/chunks.ts`) for D1's bind-parameter limit.
|
||||
|
||||
**Query-count snapshots.** `pnpm query-counts` (see `scripts/query-counts.mjs`) records per-route query counts in `scripts/query-counts.snapshot.{sqlite,d1}.json`. CI auto-updates on PRs -- review the diff. Fewer is always right; more needs a conversation.
|
||||
|
||||
# Admin UI
|
||||
|
||||
The admin (`packages/admin`) is a React SPA mounted under `/_emdash/admin/*`.
|
||||
|
||||
## Kumo Components
|
||||
|
||||
Built on [Kumo](https://github.com/cloudflare/kumo) (Cloudflare's design system). Never roll your own buttons, inputs, dialogs, etc. -- use Kumo. Get consistent styling, dark mode, accessibility, RTL for free.
|
||||
|
||||
Look up docs from the CLI:
|
||||
|
||||
```bash
|
||||
npx @cloudflare/kumo doc Button # specific component
|
||||
npx @cloudflare/kumo ls # list all
|
||||
```
|
||||
|
||||
Common imports: `Button`, `LinkButton`, `Dialog`, `Input`, `InputArea`, `Select`, `Checkbox`, `Switch`, `Loader`, `Badge`, `Toast`/`Toasty`, `Popover`, `Dropdown`, `Tooltip`, `Label`, `CommandPalette`.
|
||||
|
||||
### Buttons and links
|
||||
|
||||
| Need | Component |
|
||||
| ----------------------------------------- | -------------------------------- |
|
||||
| In-place action | `Button` |
|
||||
| External link styled as a button | `LinkButton href="..." external` |
|
||||
| Internal router-aware link as a button | `RouterLinkButton to="..."` |
|
||||
| Non-button element needing button classes | `buttonVariants(...)` |
|
||||
|
||||
`RouterLinkButton` wraps TanStack Router's `<Link>` with Kumo button classes. Never write `<Link><Button>...</Button></Link>` (invalid `<a><button>` HTML). Never hand-roll button styling on an `<a>`.
|
||||
|
||||
### Styling rules
|
||||
|
||||
- Use semantic tokens (`bg-kumo-brand`, `text-kumo-subtle`). Never raw Tailwind colors.
|
||||
- Never use `dark:` prefixes. Kumo's tokens use CSS `light-dark()`.
|
||||
- Never duplicate component styles. If you're writing `bg-kumo-brand text-white rounded-md px-3 py-2` on a `<button>`, use Kumo's `Button` instead.
|
||||
|
||||
### Dialogs and errors
|
||||
|
||||
- `ConfirmDialog` (in `components/`) for confirm/cancel modals. Pass `mutation.error` directly -- don't manage error state manually.
|
||||
- `DialogError` + `getMutationError()` for inline errors in form dialogs.
|
||||
- Admin API client functions use `throwResponseError()` from `lib/api/client.ts` to surface server messages -- never `throw new Error("Failed to X")` and lose the body.
|
||||
|
||||
## Admin UI: Localization (Lingui)
|
||||
|
||||
Every user-facing string goes through Lingui. No hard-coded English in JSX, attributes, or strings that end up in the DOM.
|
||||
|
||||
- Catalogs: `packages/admin/src/locales/{locale}/messages.po`. English is source.
|
||||
- Enabled locales: `packages/admin/src/locales/locales.ts`.
|
||||
- **Don't include `messages.po` changes in non-translation PRs.** A workflow runs `pnpm locale:extract` on merge to `main`. Including extracted catalog updates in feature PRs creates merge churn -- revert before opening.
|
||||
- Set `EMDASH_PSEUDO_LOCALE=1` in dev to render pseudo-localized text and spot untranslated leaks.
|
||||
|
||||
```typescript
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
function DeleteButton() {
|
||||
const { t } = useLingui();
|
||||
return <button aria-label={t`Delete post`}>{t`Delete`}</button>;
|
||||
}
|
||||
|
||||
// JSX with nested components
|
||||
<Trans>Published by <strong>{authorName}</strong> on {formattedDate}</Trans>
|
||||
|
||||
// Pluralization
|
||||
import { plural } from "@lingui/core/macro";
|
||||
const label = plural(count, { one: "# item", other: "# items" });
|
||||
|
||||
// Module-scope constants: msg`` descriptors, resolved with t() in the component
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import type { MessageDescriptor } from "@lingui/core";
|
||||
|
||||
const transforms: { id: string; label: MessageDescriptor }[] = [
|
||||
{ id: "paragraph", label: msg`Paragraph` },
|
||||
];
|
||||
// ...inside component: t(transforms[0].label)
|
||||
```
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- Bare string literals in JSX, unwrapped aria/title/placeholder/alt attributes.
|
||||
- Concatenating translated pieces (`` t`Hello ` + name``) -- breaks word order. Use `` t`Hello ${name}` `` or `<Trans>`.
|
||||
- Calling `t` at module scope -- locale isn't bound. Use `msg` + `t(descriptor)` inside a component.
|
||||
|
||||
Server-side error messages are English-only for now. Keep error codes stable (`SCREAMING_SNAKE_CASE`); the admin maps codes to localized messages client-side.
|
||||
|
||||
## Admin UI: RTL-safe Tailwind
|
||||
|
||||
The admin supports RTL locales. Use logical Tailwind classes, never physical:
|
||||
|
||||
| Use | Not |
|
||||
| ----------------------------- | ----------------------------- |
|
||||
| `ms-*` / `me-*` | `ml-*` / `mr-*` |
|
||||
| `ps-*` / `pe-*` | `pl-*` / `pr-*` |
|
||||
| `start-*` / `end-*` | `left-*` / `right-*` |
|
||||
| `text-start` / `text-end` | `text-left` / `text-right` |
|
||||
| `border-s` / `border-e` | `border-l` / `border-r` |
|
||||
| `rounded-s-*` / `rounded-e-*` | `rounded-l-*` / `rounded-r-*` |
|
||||
| `float-start` / `float-end` | `float-left` / `float-right` |
|
||||
|
||||
For directional icons (chevrons, arrows), flip them with `rtl:-scale-x-100` or use a bidi-aware icon.
|
||||
|
||||
`LocaleDirectionProvider` syncs `document.documentElement.dir`/`lang` automatically.
|
||||
|
||||
**Test new admin UI in Arabic** before declaring done. Broken directionality is the most common i18n regression.
|
||||
|
||||
# Conventions
|
||||
|
||||
## Imports
|
||||
|
||||
- **Internal imports** use `.js` extensions (ESM): `import { X } from "../foo.js"`.
|
||||
- **Type-only imports** use `import type` (`verbatimModuleSyntax` is on).
|
||||
- **Package imports** have no extension: `import { sql } from "kysely"`.
|
||||
- **Virtual modules** need a `// @ts-ignore`: `// @ts-ignore - virtual module` above `import virtualConfig from "virtual:emdash/config"`.
|
||||
- **Barrel files** separate `export type { ... }` from value exports.
|
||||
|
||||
## Environment
|
||||
|
||||
- Use `import.meta.env.DEV` / `import.meta.env.PROD` (Vite/Astro standard). Never `process.env.NODE_ENV`.
|
||||
- Dev-only endpoints must check `import.meta.env.DEV` and return 403 otherwise -- it's a compile-time constant, unspoofable at runtime.
|
||||
- Secrets pattern: `import.meta.env.EMDASH_X || import.meta.env.X || ""`.
|
||||
|
||||
## Cloudflare Env
|
||||
|
||||
Import `env` directly from `"cloudflare:workers"` -- a virtual module that resolves to the right bindings for the current environment (Worker or local dev).
|
||||
|
||||
Don't manually type the `Env` object. In a Worker context, run `pnpm wrangler types` to generate `worker-configuration.d.ts` (includes wrangler.jsonc bindings and `.env` secrets). Reference it in `tsconfig.json`'s `include`.
|
||||
|
||||
Local-dev secrets go in `.env` (read by Wrangler and the Cloudflare Vite plugin since Aug 2025), not `.dev.vars`. Note Wrangler loads either `.dev.vars` or `.env` but never both -- if a `.dev.vars` file exists it wins and `.env` is ignored entirely. Production secrets are set with `wrangler secret put`.
|
||||
|
||||
In libraries used in a Worker but not themselves Workers, install `@cloudflare/workers-types` and reference it in `tsconfig.compilerOptions.types`.
|
||||
|
||||
# Testing
|
||||
|
||||
- **Framework:** vitest. Tests in `packages/core/tests/`.
|
||||
- **No mocks for the DB.** SQLite (`better-sqlite3`) by default. PostgreSQL parity tests via a real `pg` connection with per-test schema isolation (set `PG_CONNECTION_STRING` to opt in).
|
||||
- **Utilities:** `tests/utils/test-db.ts` exposes `setupTestDatabase()`, `setupTestDatabaseWithCollections()`, `teardownTestDatabase()` for SQLite and `setupTestPostgresDatabase()` etc. for Postgres. Dialect-agnostic: `setupForDialect`, `setupForDialectWithCollections`, `teardownForDialect`, plus `describeEachDialect(name, fn)`. Use the dialect wrapper for query-builder code -- regressions tend to be dialect-specific.
|
||||
- **Structure:** `tests/unit/`, `tests/integration/`, `tests/e2e/` (Playwright). Test files mirror source structure. Each test gets a fresh DB.
|
||||
|
||||
# Toolchain
|
||||
|
||||
- **pnpm** -- package manager
|
||||
- **tsdown** -- TypeScript builds (ESM + DTS)
|
||||
- **vitest** -- testing
|
||||
- **oxfmt** -- formatting (tabs, configured in `.prettierrc`). All source files use tabs.
|
||||
|
||||
TypeScript: target ES2023, module `preserve`, strict mode with `noUncheckedIndexedAccess`, `noImplicitOverride`, `verbatimModuleSyntax`.
|
||||
|
||||
# Dev Bypass for Browser Testing
|
||||
|
||||
Passkey auth can't be automated in browser tests. Two dev-only endpoints (`import.meta.env.DEV` only, 403 in prod):
|
||||
|
||||
- `GET /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin` -- runs migrations, creates a dev admin user (`dev@emdash.local`), establishes a session, redirects.
|
||||
- `GET /_emdash/api/auth/dev-bypass?redirect=/_emdash/admin` -- assumes setup is complete, just creates a session.
|
||||
|
||||
In agent-browser:
|
||||
|
||||
```typescript
|
||||
await page.goto("http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin");
|
||||
```
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
# Contributing to EmDash
|
||||
|
||||
EmDash is published to npm and in active use. During development you work inside the monorepo -- packages use `workspace:*` links, so everything works without publishing.
|
||||
|
||||
This guide covers setup, policy, and the rules around opening a PR. For code patterns (SQL, API routes, authorization, performance, Lingui, RTL, etc.), see [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** 22+
|
||||
- **pnpm** 10+ (`corepack enable` if you don't have it)
|
||||
- **Git**
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/emdash-cms/emdash.git && cd emdash
|
||||
pnpm install
|
||||
pnpm build # required before first run
|
||||
```
|
||||
|
||||
### Run the demo
|
||||
|
||||
`demos/simple/` is the primary development target. Node.js + SQLite, no Cloudflare account needed.
|
||||
|
||||
```bash
|
||||
cd demos/simple
|
||||
pnpm dev # http://localhost:4321
|
||||
```
|
||||
|
||||
Open the admin at `http://localhost:4321/_emdash/admin`. The setup wizard runs on first launch.
|
||||
|
||||
In dev, skip passkey auth with the dev bypass:
|
||||
|
||||
```
|
||||
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
|
||||
```
|
||||
|
||||
Demo sites apply their `seed/seed.json` automatically on the first request when the database is empty -- there's no separate seed command.
|
||||
|
||||
`demos/cloudflare/` runs on the real `workerd` runtime with D1. See its [README](demos/cloudflare/README.md).
|
||||
|
||||
### Templates
|
||||
|
||||
Templates in `templates/` are workspace members and runnable directly:
|
||||
|
||||
```bash
|
||||
cd templates/portfolio
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Available templates: `blank`, `starter`, `blog`, `portfolio`, `marketing`, plus a `-cloudflare` variant of each runnable template. Seed content is applied automatically on first request. To start fresh, delete the local database (`data.db` or the D1 binding) and restart the dev server.
|
||||
|
||||
### Watch mode
|
||||
|
||||
When iterating on `packages/core` alongside a demo, run two terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
cd packages/core && pnpm dev
|
||||
|
||||
# Terminal 2
|
||||
cd demos/simple && pnpm dev
|
||||
```
|
||||
|
||||
Core changes propagate to the demo automatically.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
| Directory | What it is |
|
||||
| ------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `packages/core/` | Main `emdash` package -- Astro integration, REST API, database, schema, plugins |
|
||||
| `packages/admin/` | React admin UI SPA (`@emdash-cms/admin`) |
|
||||
| `packages/auth/` | Auth -- passkeys, OAuth, magic links (`@emdash-cms/auth`) |
|
||||
| `packages/cloudflare/` | Cloudflare Workers adapter + plugin sandbox |
|
||||
| `packages/blocks/` | Portable Text block definitions |
|
||||
| `packages/create-emdash/` | `create-emdash` CLI scaffolder |
|
||||
| `packages/plugins/` | First-party plugins |
|
||||
| `demos/` | Dev/test apps (`simple`, `cloudflare`, `postgres`, ...) |
|
||||
| `templates/` | Starter templates |
|
||||
| `docs/` | Documentation site (Starlight) |
|
||||
| `e2e/` | Playwright test infrastructure |
|
||||
| `i18n/` | Translation status dashboard (Lunaria) |
|
||||
|
||||
## Checks
|
||||
|
||||
Run before pushing:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript (packages)
|
||||
pnpm lint # full type-aware lint
|
||||
pnpm format # auto-format with oxfmt (tabs)
|
||||
pnpm test # all packages
|
||||
pnpm test:e2e # Playwright
|
||||
```
|
||||
|
||||
`pnpm build` is required before the first typecheck in a fresh checkout. Scoped package typechecks such as `pnpm --filter @emdash-cms/plugin-cli typecheck` resolve internal workspace type declarations from `dist/`, which the build emits; this matches CI's build-then-typecheck order.
|
||||
|
||||
Tests use real in-memory SQLite -- no mocking. Each test gets a fresh database. Typecheck and lint must pass.
|
||||
|
||||
### Building your own site in the monorepo
|
||||
|
||||
Copy a template into `demos/`, give it a unique `name` in `package.json`, install, and run:
|
||||
|
||||
```bash
|
||||
cp -r templates/blog demos/my-site
|
||||
# edit demos/my-site/package.json to set a unique name
|
||||
pnpm install
|
||||
cd demos/my-site && pnpm dev
|
||||
```
|
||||
|
||||
Your site uses `workspace:*` links, so core changes are reflected immediately.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
### What we accept
|
||||
|
||||
| Type | Process |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Bug fixes** | Open a PR directly. Include a failing test that reproduces the bug. |
|
||||
| **Docs / typos** | Open a PR directly. |
|
||||
| **Translations** | Open a PR directly. See [Translating EmDash](https://docs.emdashcms.com/contributing/translating/). |
|
||||
| **Features** | Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) and wait for maintainer approval. |
|
||||
| **Refactors** | Open a Discussion first. |
|
||||
| **Performance** | Open a Discussion first with benchmarks. |
|
||||
|
||||
**Feature PRs without prior maintainer approval will be closed.** Not gatekeeping -- it's about not wasting your time on work that might not align with the project's direction.
|
||||
|
||||
### What we don't accept
|
||||
|
||||
- **Drive-by feature additions.** No Discussion, no PR.
|
||||
- **Speculative refactors** that don't solve a concrete problem.
|
||||
- **Dependency upgrades** outside Renovate/Dependabot.
|
||||
- **Drive-by "improvements"** in code unrelated to your change.
|
||||
- **Bulk/spray PRs** ("fix all lint warnings", "add types everywhere"). Open a Discussion first.
|
||||
|
||||
### AI-generated PRs
|
||||
|
||||
AI-assisted contributions are welcome and held to the same quality bar as any other PR:
|
||||
|
||||
- The submitter is responsible for correctness, not the tool.
|
||||
- AI-generated PRs must pass CI, follow project patterns, and include tests.
|
||||
- Check the PR template's AI disclosure box and name the model/tool (e.g. Claude Opus 4.7, GPT-5.5, Cursor + Sonnet 4.6). This isn't punitive -- it helps reviewers focus on edge cases that AI tools tend to miss and run the review pass with a different model family.
|
||||
|
||||
### PR rules
|
||||
|
||||
- Branch from `main`.
|
||||
- Fill out the PR template completely. **PRs with an empty or missing template will be closed automatically.** The template is loaded by the GitHub UI; if you create a PR via API/CLI, copy `.github/PULL_REQUEST_TEMPLATE.md` into the body.
|
||||
- `pnpm typecheck` and `pnpm lint` must pass before pushing.
|
||||
- Run relevant tests.
|
||||
- Commit messages describe _why_, not just _what_.
|
||||
|
||||
## Changesets
|
||||
|
||||
Every PR that changes a published package's behavior needs a **changeset** -- a small Markdown file that describes the change for the CHANGELOG and determines the version bump. Without one, the change won't trigger a release.
|
||||
|
||||
### When you need one
|
||||
|
||||
- Bug fixes, features, refactors, or anything that affects a published package's behavior or API.
|
||||
- Multi-package changes need one changeset listing all affected packages.
|
||||
- A PR making multiple distinct changes can include a changeset per change -- each becomes its own CHANGELOG entry.
|
||||
|
||||
### When you don't
|
||||
|
||||
- Docs-only, test-only, CI/tooling changes, or changes to demos and templates (these are in the ignore list -- see `.changeset/config.json`).
|
||||
|
||||
### How
|
||||
|
||||
```bash
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
The CLI walks you through affected packages, bump type, and description. Edit the resulting `.md` file in `.changeset/` if needed.
|
||||
|
||||
### Writing the description
|
||||
|
||||
A changeset is the **release note a user reads while upgrading** -- it lands verbatim in the CHANGELOG. It is not a commit message, a PR description, or a summary of your diff. Don't paste your PR text into it: those explain the change to a reviewer reading the code, the changeset explains the effect to someone who will run the new version.
|
||||
|
||||
Write for that reader:
|
||||
|
||||
- Start with a present-tense verb -- **Fixes** (bug), **Adds** (feature), **Updates** (enhancement), **Removes** (removed functionality), **Refactors** (no behavior change).
|
||||
- Describe the observable effect -- what's different for someone using the package.
|
||||
- Leave out internal mechanics -- file names, function names, which catalog entry you bumped, how you implemented it. If a sentence only makes sense to someone who has read the diff, it doesn't belong here.
|
||||
- For a breaking change, include the migration step.
|
||||
|
||||
One sentence is often enough.
|
||||
|
||||
```diff
|
||||
- # too low-level -- reads like a commit message
|
||||
- Align the catalog so identity-resolver's lexicons peer resolves; migrates parseCanonicalResourceUri off the result-object API in backfill.ts.
|
||||
+ # right altitude -- the effect on the user
|
||||
+ Fixes peer dependency warnings on install caused by mismatched `@atcute` package versions.
|
||||
```
|
||||
|
||||
**Patch** (bug fix or small improvement):
|
||||
|
||||
```markdown
|
||||
---
|
||||
"emdash": patch
|
||||
---
|
||||
|
||||
Fixes CLI `--json` flag so JSON output is clean. Log messages now go to stderr when `--json` is set.
|
||||
```
|
||||
|
||||
**Minor** (new non-breaking feature):
|
||||
|
||||
```markdown
|
||||
---
|
||||
"emdash": minor
|
||||
---
|
||||
|
||||
Adds `scheduled_at` field to content entries, enabling scheduled publishing via the admin UI.
|
||||
```
|
||||
|
||||
**Major** (breaking change) -- include migration guidance:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"emdash": major
|
||||
---
|
||||
|
||||
Removes the `legacyAuth` option from the integration config. All sites must use passkey authentication.
|
||||
|
||||
To migrate, remove `legacyAuth: true` from your `emdash()` config in `astro.config.mjs`.
|
||||
```
|
||||
|
||||
## Internationalization
|
||||
|
||||
The admin UI is translatable using [Lingui](https://lingui.dev). All user-visible strings in `packages/admin/src/` should be wrapped.
|
||||
|
||||
```tsx
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<div>
|
||||
<h1>{t`Settings`}</h1>
|
||||
<p>{t`Authentication error: ${error}`}</p>
|
||||
<p>
|
||||
<Trans>
|
||||
Don't have an account? <a href="/signup">Sign up</a>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Wrap button labels, headings, descriptions, error messages, placeholders, and `aria-label` on interactive controls. Don't wrap log messages, developer-facing errors, brand names, or URLs. For decorative elements, prefer `aria-hidden="true"` over a translated `aria-label`.
|
||||
|
||||
**Don't include `messages.po` changes in feature or bugfix PRs.** A workflow runs `pnpm locale:extract` on merge to `main` and commits catalog updates automatically. Including extracted PO changes in non-translation PRs creates churn and merge conflicts because line-number references shift on every edit. If you ran extraction locally and ended up with `.po` changes, revert them before opening the PR.
|
||||
|
||||
Translation PRs are the exception -- see [Translating EmDash](https://docs.emdashcms.com/contributing/translating/) for the full contributor guide.
|
||||
|
||||
For RTL rules and the full Lingui pattern reference, see [AGENTS.md § Admin UI: Localization](AGENTS.md#admin-ui-localization-lingui).
|
||||
|
||||
## Getting Help
|
||||
|
||||
- [AGENTS.md](AGENTS.md) -- architecture and code patterns
|
||||
- [TRIAGE.md](TRIAGE.md) -- guidance for community triagers
|
||||
- [docs.emdashcms.com](https://docs.emdashcms.com) -- user guides and API reference
|
||||
- [Discussions](https://github.com/emdash-cms/emdash/discussions) -- ask questions, propose features
|
||||
- [Issues](https://github.com/emdash-cms/emdash/issues) -- bug reports
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
FROM node:22-slim AS base
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# ---- Install dependencies ----
|
||||
FROM base AS deps
|
||||
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY packages/ packages/
|
||||
COPY templates/ templates/
|
||||
COPY demos/ demos/
|
||||
COPY docs/package.json docs/package.json
|
||||
COPY e2e/fixture/package.json e2e/fixture/package.json
|
||||
|
||||
RUN sed -i '/slidev/d' pnpm-workspace.yaml
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- Build ----
|
||||
FROM deps AS build
|
||||
|
||||
COPY . .
|
||||
RUN sed -i '/slidev/d' pnpm-workspace.yaml
|
||||
RUN sed -i 's|file:./data.db|file:./data/data.db|' templates/blog/astro.config.mjs
|
||||
|
||||
RUN pnpm build && pnpm --filter @emdash-cms/template-blog build
|
||||
|
||||
# Bundle the blog template into a standalone deployment
|
||||
RUN pnpm --filter @emdash-cms/template-blog deploy /deploy --prod --legacy
|
||||
|
||||
# Copy build output and seed data into the deploy directory
|
||||
RUN cp -r /app/templates/blog/dist /deploy/dist
|
||||
RUN cp -r /app/templates/blog/seed /deploy/seed
|
||||
RUN cp /app/templates/blog/astro.config.mjs /deploy/astro.config.mjs
|
||||
|
||||
# ---- Runtime ----
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /deploy .
|
||||
|
||||
RUN mkdir -p data uploads \
|
||||
&& ln -s /app/node_modules/.pnpm/node_modules/kysely /app/node_modules/kysely
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -0,0 +1,9 @@
|
||||
The MIT License
|
||||
|
||||
Copyright 2026 Cloudflare Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,211 @@
|
||||
# EmDash
|
||||
|
||||
A full-stack TypeScript CMS built on [Astro](https://astro.build/) and [Cloudflare](https://www.cloudflare.com/). EmDash takes the ideas that made WordPress dominant -- extensibility, admin UX, a plugin ecosystem -- and rebuilds them on serverless, type-safe foundations. Plugins run in sandboxed Worker isolates, solving the fundamental security problem with WordPress's plugin architecture.
|
||||
|
||||
## Get Started
|
||||
|
||||
> [!IMPORTANT]
|
||||
> EmDash depends on Dynamic Workers to run secure sandboxed plugins. Dynamic Workers are currently only available on paid accounts. [Upgrade your account](https://www.cloudflare.com/plans/developer-platform/) (starting at $5/mo) or comment out the `worker_loaders` block of your `wrangler.jsonc` configuration file to disable plugins.
|
||||
|
||||
```bash
|
||||
npm create emdash@latest
|
||||
```
|
||||
|
||||
Or deploy directly to your Cloudflare account:
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/emdash-cms/templates/tree/main/blog-cloudflare)
|
||||
|
||||
EmDash runs on Cloudflare (D1 + R2 + Workers) or any Node.js server with SQLite. No PHP, no separate hosting tier -- just deploy your Astro site.
|
||||
|
||||
## Templates
|
||||
|
||||
EmDash ships with three starter templates:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
|
||||
### Blog
|
||||
|
||||
<a href="assets/templates/blog/latest/"><img src="assets/templates/blog/latest/homepage-light-desktop.jpg" alt="Blog template" width="100%"></a>
|
||||
|
||||
A classic blog with sidebar widgets, search, and RSS.
|
||||
|
||||
- Categories & tags
|
||||
- Full-text search
|
||||
- Comment-ready
|
||||
- RSS feed
|
||||
- Dark / light mode
|
||||
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
|
||||
### Marketing
|
||||
|
||||
<a href="assets/templates/marketing/latest/"><img src="assets/templates/marketing/latest/homepage-light-desktop.jpg" alt="Marketing template" width="100%"></a>
|
||||
|
||||
A conversion-focused landing page with pricing and contact form.
|
||||
|
||||
- Hero with CTAs
|
||||
- Feature grid
|
||||
- Pricing cards
|
||||
- FAQ and contact form
|
||||
- Dark / light mode
|
||||
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
|
||||
### Portfolio
|
||||
|
||||
<a href="assets/templates/portfolio/latest/"><img src="assets/templates/portfolio/latest/work-light-desktop.jpg" alt="Portfolio template" width="100%"></a>
|
||||
|
||||
A visual portfolio for showcasing creative work.
|
||||
|
||||
- Project grid
|
||||
- Tag filtering
|
||||
- Case study pages
|
||||
- RSS feed
|
||||
- Dark / light mode
|
||||
<br /><br />
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Why EmDash?
|
||||
|
||||
**WordPress was built for a different era.** Running WordPress today means managing PHP alongside JavaScript, layering caches to get acceptable performance, and knowing that [96% of WordPress security vulnerabilities come from plugins](https://patchstack.com/whitepaper/state-of-wordpress-security-in-2024/). EmDash is what WordPress would look like if you started from scratch with today's tools.
|
||||
|
||||
**Sandboxed plugins.** WordPress plugins have full access to the database, filesystem, and user data. A single vulnerable plugin can compromise the entire site. EmDash plugins run in isolated [Worker sandboxes](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) via Dynamic Worker Loaders, each with a declared capability manifest. A plugin that requests `read:content` and `email:send` can do exactly that and nothing else.
|
||||
|
||||
```typescript
|
||||
export default () =>
|
||||
definePlugin({
|
||||
id: "notify-on-publish",
|
||||
capabilities: ["read:content", "email:send"],
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
if (event.content.status !== "published") return;
|
||||
await ctx.email.send({
|
||||
to: "editors@example.com",
|
||||
subject: `New post: ${event.content.title}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Structured content, not serialized HTML.** WordPress stores rich text as HTML with metadata embedded in comments -- tying your content to its DOM representation. EmDash uses [Portable Text](https://www.portabletext.org/), a structured JSON format that decouples content from presentation. Your content can render as a web page, a mobile app, an email, or an API response without parsing HTML.
|
||||
|
||||
**Built for agents.** EmDash ships with agent skills for building plugins and themes, a CLI that lets agents manage content and schema programmatically, and a built-in [MCP server](https://modelcontextprotocol.io/) so AI tools like Claude and ChatGPT can interact with your site directly.
|
||||
|
||||
**Runs anywhere.** EmDash uses portable abstractions at every layer -- Kysely for SQL, S3 API for storage -- that work with SQLite, D1, Turso, PostgreSQL, R2, AWS S3, or local files. It runs best on Cloudflare, but it's not locked to it.
|
||||
|
||||
## How It Works
|
||||
|
||||
EmDash is an Astro integration. Add it to your config and you get a complete CMS: admin panel, REST API, authentication, media library, and plugin system.
|
||||
|
||||
```typescript
|
||||
// astro.config.mjs
|
||||
import emdash from "emdash/astro";
|
||||
import { d1 } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [emdash({ database: d1() })],
|
||||
});
|
||||
```
|
||||
|
||||
Content types are defined in the database, not in code. Non-developers create and modify collections through the admin UI. Each collection gets a real SQL table with typed columns. Developers generate TypeScript types from the live schema:
|
||||
|
||||
```bash
|
||||
npx emdash types
|
||||
```
|
||||
|
||||
Query content using Astro's Live Collections -- no rebuilds, no separate API:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
---
|
||||
|
||||
{posts.map((post) => <article>{post.data.title}</article>)}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
**Content** -- Blog posts, pages, custom content types. Rich text editing via TipTap with Portable Text storage. Revisions, drafts, scheduled publishing, full-text search (FTS5), inline visual editing.
|
||||
|
||||
**Admin** -- Full admin panel with visual schema builder, media library (drag-drop uploads via signed URLs), navigation menus, taxonomies, widgets, and a WordPress import wizard.
|
||||
|
||||
**Auth** -- Passkey-first (WebAuthn) with OAuth and magic link fallbacks. Role-based access control: Administrator, Editor, Author, Contributor.
|
||||
|
||||
**Plugins** -- `definePlugin()` API with lifecycle hooks, KV storage, settings, admin pages, dashboard widgets, custom block types, and API routes. Sandboxed execution on Cloudflare via Dynamic Worker Loaders.
|
||||
|
||||
**Agents** -- Skill files for AI-assisted plugin and theme development. CLI for programmatic site management. Built-in MCP server for direct AI tool integration.
|
||||
|
||||
**WordPress migration** -- Import posts, pages, media, and taxonomies from WXR exports, the WordPress REST API, or WordPress.com. Agent skills help port plugins and themes.
|
||||
|
||||
## Portable Platforms
|
||||
|
||||
| Layer | Cloudflare | Also works with |
|
||||
| -------- | --------------------------- | --------------------------------------------------- |
|
||||
| Database | D1 | SQLite, Turso/libSQL, PostgreSQL |
|
||||
| Storage | R2 | AWS S3, any S3-compatible service, local filesystem |
|
||||
| Sessions | KV | Redis, file-based |
|
||||
| Plugins | Worker isolates (sandboxed) | In-process (safe mode) |
|
||||
|
||||
## Status
|
||||
|
||||
EmDash is in **beta preview**. We welcome contributions, feedback, plugins, themes, and ideas.
|
||||
|
||||
```bash
|
||||
npm create emdash@latest
|
||||
```
|
||||
|
||||
See the [documentation](https://docs.emdashcms.com/) for guides, API reference, and plugin development.
|
||||
|
||||
## Development
|
||||
|
||||
This is a pnpm monorepo. To contribute:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/emdash-cms/emdash.git && cd emdash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Run the demo (Node.js + SQLite, no Cloudflare account needed):
|
||||
|
||||
```bash
|
||||
pnpm --filter emdash-demo seed
|
||||
pnpm --filter emdash-demo dev
|
||||
```
|
||||
|
||||
Open the admin at [http://localhost:4321/\_emdash/admin](http://localhost:4321/_emdash/admin).
|
||||
|
||||
```bash
|
||||
pnpm test # run all tests
|
||||
pnpm typecheck # type check
|
||||
pnpm lint:quick # fast lint (< 1s)
|
||||
pnpm format # format with oxfmt
|
||||
```
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
packages/
|
||||
core/ Astro integration, APIs, admin UI, CLI
|
||||
auth/ Authentication library
|
||||
blocks/ Portable Text block definitions
|
||||
cloudflare/ Cloudflare adapter (D1, R2, Worker Loader)
|
||||
plugins/ First-party plugins (forms, embeds, SEO, audit-log, etc.)
|
||||
create-emdash/ npm create emdash scaffolding
|
||||
gutenberg-to-portable-text/ WordPress block converter
|
||||
|
||||
templates/ Starter templates (blog, marketing, portfolio, starter, blank)
|
||||
demos/ Development and example sites
|
||||
docs/ Documentation site (Starlight)
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`emdash-cms/emdash`
|
||||
- 原始仓库:https://github.com/emdash-cms/emdash
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Security Policy
|
||||
|
||||
EmDash is a beta CMS with authentication, content management, plugin execution, and Cloudflare/Node deployment surfaces. Please report suspected vulnerabilities privately so maintainers can triage and coordinate a fix before public disclosure.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
EmDash is currently in beta preview. Security reports should target the current `main` branch and the latest published `emdash` / `@emdash-cms/*` packages unless maintainers document additional supported release lines.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Use GitHub's private vulnerability reporting flow for this repository:
|
||||
|
||||
1. Open the repository's **Security** tab.
|
||||
2. Choose **Report a vulnerability**.
|
||||
3. Include enough detail for maintainers to reproduce and assess the issue.
|
||||
|
||||
If you already submitted a private GitHub security advisory, keep follow-up discussion in that advisory thread. Avoid opening public issues for exploitable details; a public issue may be used only to ask for routing or status without sharing sensitive technical information.
|
||||
|
||||
## What to Include
|
||||
|
||||
Please include:
|
||||
|
||||
- affected package, template, demo, or deployment mode;
|
||||
- affected version, commit, or configuration;
|
||||
- clear reproduction steps or a minimal proof of concept;
|
||||
- expected impact and any privilege or authentication requirements;
|
||||
- whether the issue affects Cloudflare Workers/D1/R2, Node/SQLite, the admin UI, authentication, media storage, API routes, or plugin sandboxing;
|
||||
- any relevant logs, screenshots, request/response snippets, or stack traces with secrets redacted.
|
||||
|
||||
## Scope Guidance
|
||||
|
||||
High-signal reports may include issues such as:
|
||||
|
||||
- authentication or authorization bypasses;
|
||||
- privilege escalation in the admin UI or API;
|
||||
- access to unpublished or private content;
|
||||
- unsafe plugin sandbox escapes or excessive plugin capabilities;
|
||||
- SQL injection, stored cross-site scripting, or server-side request forgery;
|
||||
- insecure media upload, storage, or signed URL handling;
|
||||
- migration, import, or setup flows that expose sensitive data;
|
||||
- supply-chain or template defaults that create a realistic unsafe deployment.
|
||||
|
||||
Lower-signal or usually out-of-scope reports include:
|
||||
|
||||
- scanner output without a working reproduction;
|
||||
- issues requiring access to a maintainer account, compromised machine, or intentionally unsafe local configuration;
|
||||
- denial-of-service reports without a realistic impact path;
|
||||
- missing security headers on local demos unless they affect production defaults;
|
||||
- version disclosure, dependency age, or best-practice suggestions without exploitability;
|
||||
- social engineering, spam, phishing, or physical attacks.
|
||||
|
||||
## Coordinated Disclosure
|
||||
|
||||
Maintainers should acknowledge valid-looking reports as soon as practical, triage severity, and keep the reporter updated while a fix is prepared. Please do not publicly disclose details until maintainers have had a reasonable opportunity to investigate, patch, and publish upgrade guidance.
|
||||
|
||||
## Safe Harbor
|
||||
|
||||
Good-faith research is welcome when it stays within the bounds of the repository, your own deployments, or explicitly authorized test environments. Do not access, modify, delete, or exfiltrate other users' data; do not disrupt live services; and stop testing if you encounter sensitive information.
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# EmDash Templates
|
||||
|
||||
Starter templates for building sites with EmDash CMS. Each template includes a seed file with demo content, so you can see how everything works right away.
|
||||
|
||||
## Available Templates
|
||||
|
||||
### Blog
|
||||
|
||||
A clean, minimal blog with posts, pages, categories, tags, and search.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Featured post hero on homepage
|
||||
- Post grid with reading time estimates
|
||||
- Category and tag archives
|
||||
- Full-text search
|
||||
- RSS feed
|
||||
- SEO metadata and JSON-LD
|
||||
- Dark/light mode
|
||||
|
||||
**Pages:** Homepage, post archive, single post, single page, category archive, tag archive, search results, 404
|
||||
|
||||
### Marketing
|
||||
|
||||
A landing page template for products and services with modular content blocks.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Hero, features, testimonials, pricing, and FAQ blocks
|
||||
- Contact form with validation
|
||||
- Portable Text content editing
|
||||
- SEO metadata and JSON-LD
|
||||
- Dark/light mode
|
||||
|
||||
**Pages:** Homepage, pricing, contact, 404
|
||||
|
||||
### Portfolio
|
||||
|
||||
A portfolio for showcasing creative work with project pages and tag filtering.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Project grid with hover effects
|
||||
- Tag-based filtering on work page
|
||||
- Individual project pages with galleries
|
||||
- Contact page
|
||||
- RSS feed for new projects
|
||||
- SEO metadata and JSON-LD
|
||||
- Dark/light mode
|
||||
|
||||
**Pages:** Homepage, work listing, single project, about, contact, 404
|
||||
|
||||
## Using a Template
|
||||
|
||||
Each template has two variants:
|
||||
|
||||
- **Node.js** (`templates/blog`, `templates/marketing`, `templates/portfolio`) — uses SQLite and local file storage
|
||||
- **Cloudflare** (`templates/blog-cloudflare`, etc.) — uses D1 and R2
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Copy the template you want
|
||||
cp -r templates/blog my-site
|
||||
cd my-site
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Initialize the database and seed demo content
|
||||
pnpm bootstrap
|
||||
|
||||
# Start the dev server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open http://localhost:4321 to see your site, and http://localhost:4321/\_emdash/admin for the CMS.
|
||||
|
||||
### Template Structure
|
||||
|
||||
```
|
||||
templates/blog/
|
||||
├── src/
|
||||
│ ├── components/ # Astro components
|
||||
│ ├── layouts/ # Page layouts
|
||||
│ ├── pages/ # Route pages
|
||||
│ ├── utils/ # Helper functions
|
||||
│ └── live.config.ts # EmDash content loader
|
||||
├── seed/
|
||||
│ └── seed.json # Demo content
|
||||
├── astro.config.mjs # Astro + EmDash config
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
### Cloudflare Variants
|
||||
|
||||
The cloudflare variants share most of their code with the base templates. Only these files differ:
|
||||
|
||||
- `astro.config.mjs` (cloudflare adapter, D1/R2 storage)
|
||||
- `package.json` (different dependencies)
|
||||
- `wrangler.jsonc` (cloudflare config)
|
||||
|
||||
Everything else is synced from the base template using a script:
|
||||
|
||||
```bash
|
||||
./scripts/sync-cloudflare-templates.sh
|
||||
```
|
||||
|
||||
**Run this after making changes** to `src/`, `seed/`, `tsconfig.json`, `emdash-env.d.ts`, or `.gitignore` in any base template. It copies those files to the corresponding cloudflare variant.
|
||||
|
||||
The primary Node demo is also synced from the blog template:
|
||||
|
||||
```bash
|
||||
./scripts/sync-blog-demos.sh
|
||||
```
|
||||
|
||||
This script does two kinds of sync:
|
||||
|
||||
- full template sync for `templates/blog` -> `demos/simple`
|
||||
- frontend-only sync (keeping runtime-specific files) for:
|
||||
- `templates/blog-cloudflare` -> `demos/cloudflare`
|
||||
- `templates/blog-cloudflare` -> `demos/preview`
|
||||
- `templates/blog` -> `demos/postgres`
|
||||
|
||||
### Taking Screenshots
|
||||
|
||||
Template screenshots live in `assets/templates/{template}/latest/` and are used in the README. To update them after making visual changes:
|
||||
|
||||
```bash
|
||||
# Screenshot all templates (starts each dev server automatically)
|
||||
pnpm screenshots
|
||||
|
||||
# Screenshot specific templates
|
||||
pnpm screenshots blog
|
||||
pnpm screenshots blog marketing
|
||||
```
|
||||
|
||||
The script starts each template's dev server, captures screenshots, then stops the server before moving to the next template.
|
||||
|
||||
Page definitions are in `templates/screenshots.json`. Each page is captured at desktop (1440x900) and mobile (390x844) in both light and dark mode at 2x resolution. Screenshots are JPEG at 80% quality.
|
||||
|
||||
Output goes to `assets/templates/{template}/{datetime}/` and is copied to `assets/templates/{template}/latest/`. The dated directories are gitignored; only `latest/` is committed. The README references images from `latest/`.
|
||||
|
||||
Filenames follow the pattern `{page}-{mode}-{breakpoint}.jpg`, e.g. `homepage-light-desktop.jpg`, `post-dark-mobile.jpg`.
|
||||
|
||||
To add pages for a template, edit `templates/screenshots.json`.
|
||||
|
||||
### Adding a New Template
|
||||
|
||||
1. Create the base template in `templates/{name}/`
|
||||
2. Include a seed file at `seed/seed.json` (or configure the path in `package.json` under `emdash.seed`)
|
||||
3. Add the `typecheck` script to `package.json`
|
||||
4. Create the cloudflare variant in `templates/{name}-cloudflare/` with the appropriate adapter config
|
||||
5. Add the template pair to `scripts/sync-cloudflare-templates.sh`
|
||||
6. Add the template's pages to `templates/screenshots.json` and run the screenshot script
|
||||
7. Update the README template gallery
|
||||
|
||||
### Seed Files
|
||||
|
||||
Each template includes a seed file with demo content. The seed file format is documented in the CLI (`emdash seed --help`). Key points:
|
||||
|
||||
- Use `status: "published"` and include `published_at` for content that should appear on the site
|
||||
- Reference media by URL — the seeder downloads and uploads images automatically
|
||||
- Use the `taxonomies` object for categories and tags
|
||||
- The `emdash.seed` field in `package.json` specifies the seed file location
|
||||
|
||||
### Testing Templates
|
||||
|
||||
Templates are covered by smoke tests that verify:
|
||||
|
||||
- Seed files parse correctly
|
||||
- Seeds apply without errors
|
||||
- The database passes doctor checks after seeding
|
||||
|
||||
Run the smoke tests:
|
||||
|
||||
```bash
|
||||
pnpm --filter emdash exec vitest run --config vitest.smoke.config.ts
|
||||
```
|
||||
@@ -0,0 +1,332 @@
|
||||
# Community Triage Guide
|
||||
|
||||
Thank you for helping with EmDash. This guide is for community members who have been given triage access to the repository.
|
||||
|
||||
The triage team is a small group of trusted community members who help keep issues and PRs understandable, actionable, and moving. This is some of the most valuable work in the project.
|
||||
|
||||
## You Are a Volunteer
|
||||
|
||||
- **There is no quota, schedule, or minimum commitment.** Triage when you have the time and energy, and don't when you don't.
|
||||
- **Pick the work you enjoy.** If you like reproducing bugs, do that. If you prefer answering questions or welcoming first-time contributors, do that instead. Nobody covers everything.
|
||||
- **You can step back at any time**, temporarily or permanently, no explanation needed.
|
||||
- **Mistakes are fine.** Labels can be changed, issues can be reopened, comments can be clarified. Nothing in triage is irreversible.
|
||||
|
||||
If the work stops being enjoyable or a thread is stressing you out, hand it off and walk away. Sustainable help beats heroic help.
|
||||
|
||||
## Discord
|
||||
|
||||
Triagers get the **Triage Team** role on the EmDash Discord, and the private `#triage` channel is home base. Use it for anything that doesn't belong on a public thread:
|
||||
|
||||
- "Am I handling this right?" — no question is too small, especially early on.
|
||||
- Handing off an issue or PR you've started on but can't finish.
|
||||
- Flagging something to maintainers informally before (or instead of) a formal escalation.
|
||||
- Talking through a tricky report with the rest of the team.
|
||||
|
||||
When in doubt, ask in the channel. A thirty-second question there beats an hour of second-guessing on a public thread.
|
||||
|
||||
## What Triage Access Lets You Do
|
||||
|
||||
The GitHub triage role lets you:
|
||||
|
||||
- Apply and remove labels
|
||||
- Close, reopen, and assign issues and PRs
|
||||
- Mark issues as duplicates
|
||||
- Add issues to milestones
|
||||
- Request reviews on PRs
|
||||
|
||||
It does not let you merge PRs, push to branches, or change repository settings — those stay with maintainers.
|
||||
|
||||
You can also review and approve PRs. A triager's approval doesn't change the PR's status to "approved", but it is still useful: it tells maintainers "a human has looked at this and it holds up," which makes the final review much faster. A PR still needs maintainer approval before it can be merged, but don't let that stop you from giving one.
|
||||
|
||||
## Working With the Bots
|
||||
|
||||
EmDash has a lot of automation. Probably the most important piece is @emdashbot, the AI code review bot:
|
||||
|
||||
- **It reviews every PR automatically** and is generally solid at catching mechanical problems: bugs in the diff, missing tests, pattern violations, SQL injection risks.
|
||||
- **Apply the `bot:review` label to summon a re-review**, for example after an author pushes significant changes. It sometimes fails to review the first time (e.g. if there's an error while it is running), in which case it is useful to ask for a re-review.
|
||||
- Most PR labels — review state, size, area, CLA, `needs-rebase`, `stale` — are applied and removed automatically by workflows. See [PR Labels](#pr-labels) for what they mean.
|
||||
|
||||
Issue triage is a different story. There is an experimental bot that tries to reproduce bugs (see [The Repro Bot and `triage/*` Labels](#the-repro-bot-and-triage-labels)), but it is unreliable enough that we don't currently use it. In practice, issues are triaged and reproduced by humans, so your work here is particularly valuable.
|
||||
|
||||
You can help by:
|
||||
|
||||
- Confirming whether a bug is reproducible.
|
||||
- Asking for missing reproduction details.
|
||||
- Adding priority labels when the impact is clear.
|
||||
- Redirecting the reporter if the report is a feature request (to Discussions) or support question (to the docs or the public Discord channels if you can't answer it).
|
||||
|
||||
And even on PRs, where the bot reviews every change, there is a lot of work it consistently can't do:
|
||||
|
||||
- **Running a real site.** The bot checks out the branch, but it doesn't build, run, or deploy it, or look at any real site. Clicking through the admin UI as an editor would, or trying a change against the workflows real users hit, catches things no code review will.
|
||||
- **Judging whether the change should happen at all.** The bot reviews the diff in front of it and does try to judge if the fix is taking the right approach as well as being correct. However it won't notice that a feature contradicts a roadmap decision, that a fix papers over the real bug, or that the same problem was already solved elsewhere.
|
||||
- **Understanding what the author actually meant.** Humans notice when the description and the code disagree, or when the interesting question is one the PR doesn't ask.
|
||||
- **Empathy.** A first-time contributor who gets a kind, specific comment from a human is far more likely to stick around than one who only ever hears from a bot.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
The majority of PRs are now written largely or entirely by agents, and some issues are too. That's fine for PRs: AI-assisted contributions are welcome and held to the same bar as any other (see [CONTRIBUTING.md § AI-generated PRs](CONTRIBUTING.md#ai-generated-prs)). What the project asks is that **a human understands the change and has tested it**. The submitter is responsible for correctness, not the tool. This is also why we discourage using agents to submit _issues_ — the reporter should have actually seen the bug, not just had an agent predict one. It's fine to have an agent help write a report, but the reporter should have actually run the code and seen the bug themselves, and should generally submit the issue in their own words.
|
||||
|
||||
Your job as a triager is not to detect AI — it's to check for that human understanding:
|
||||
|
||||
- If it's unclear whether anyone has actually run the change, ask. "What did you test, and how?" is a fair question on any PR, and a specific answer is a good sign.
|
||||
- An author who can't answer questions about their own PR, or who responds only with pasted agent output, hasn't met the bar yet. Ask them to test and confirm in their own words; if that goes nowhere, leave it with the author and move on — the review state labels will track it from there.
|
||||
- The same applies to issues: the reporter should have actually seen the bug, not just had an agent predict one.
|
||||
|
||||
**Translation PRs are a special case.** We ask that only native or fluent speakers of the target language open translation PRs — AI-assisted translation is fine, but a fluent speaker must review the results and check them in a real demo site, where context, layout, and tone problems show up that a diff never will. If it's unclear whether the author is a native speaker or has looked at the translations running, ask before the PR gets a review. If _you_ are a fluent speaker of the target language, your review is particularly valuable — you can catch problems the bot and non-speakers can't.
|
||||
|
||||
## Tone
|
||||
|
||||
Assume good intent. Nobody has been using EmDash for very long, and many reporters are not coming from a technical background. Many PRs are by users who have never opened a PR before. A short, specific question is better than a long checklist.
|
||||
|
||||
Good triage comments are clear and kind:
|
||||
|
||||
```markdown
|
||||
Thanks for the report. Could you add the EmDash version, the adapter you are using (`node` or `cloudflare`), and the smallest schema/content example that reproduces this?
|
||||
```
|
||||
|
||||
Avoid comments that sound like blame:
|
||||
|
||||
```markdown
|
||||
This is not reproducible. Please provide a real reproduction.
|
||||
```
|
||||
|
||||
Prefer:
|
||||
|
||||
```markdown
|
||||
I cannot reproduce this from the current description. Could you share the exact steps from a fresh project, or a small repo that shows the issue?
|
||||
```
|
||||
|
||||
## Good Boundaries
|
||||
|
||||
Triage is about moving the conversation forward, not owning every outcome. It's fine to stop once you have made an issue clearer, confirmed a reproduction, found the right label, or identified the next maintainer decision.
|
||||
|
||||
- If a bug needs deeper debugging than you have time for, leave a note with what you checked and what is still unknown.
|
||||
- If a PR needs product direction, architectural approval, or a breaking-change decision, label it and ask a maintainer to weigh in.
|
||||
- If a report is confusing, ask one focused question rather than trying to solve every possibility at once.
|
||||
- If a conversation gets tense, do not keep pushing. Step back and ask a maintainer to take over.
|
||||
- If you are unsure whether to close, block, or redirect something, leave it open and explain what decision is needed.
|
||||
|
||||
The boundary runs the other way too: triage access doesn't move you away from writing code. If you reproduce a bug and can see the fix, opening the PR yourself is the best possible outcome — triaging your way into a contribution is the system working, not a conflict.
|
||||
|
||||
## Issue Triage
|
||||
|
||||
Not sure where to begin? A good first session: pick a `bug` issue with no `priority/*` label, try to reproduce it, and leave a comment with what you found. Confirmed, not confirmed, or "I got this far and then hit X" — all three move the issue forward.
|
||||
|
||||
Start by identifying what kind of issue it is.
|
||||
|
||||
| If it is... | Do this |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| A bug report | Ask for missing reproduction details, try to reproduce if practical, and add priority if the impact is clear. |
|
||||
| A feature request | Convert it to an Ideas Discussion, or point the author there. |
|
||||
| A docs problem | Add `documentation`, or comment with the specific page/section that needs work. |
|
||||
| A support question | Answer if you know, or point to docs, Discussions or Discord then close. |
|
||||
| A duplicate | Link the earlier issue and close as a duplicate if you are confident. |
|
||||
| Not enough information | Add `Awaiting author response` and ask one or two specific questions. |
|
||||
|
||||
The bug report template applies the `bug` label automatically, so you only need to add it yourself when a bug arrives through some other route.
|
||||
|
||||
Useful details to ask for on bug reports:
|
||||
|
||||
- EmDash version.
|
||||
- Astro version.
|
||||
- Runtime or adapter: Node, Cloudflare Workers, D1, SQLite, Postgres.
|
||||
- Browser and OS for admin UI bugs.
|
||||
- Relevant collection schema, field config, or plugin config.
|
||||
- Exact steps to reproduce from a fresh project when possible.
|
||||
- Expected behavior and actual behavior.
|
||||
- Error logs, screenshots, or network response details.
|
||||
|
||||
Do not ask for everything by default. Ask for the smallest missing piece that would unblock the next person.
|
||||
|
||||
### Priority Labels
|
||||
|
||||
For issues, priority is often the best thing a human triager can add — it's a judgment call the bots can't make. These labels are new and most existing issues don't have one yet, so don't be shy about being the first to set it.
|
||||
|
||||
- `priority/urgent` for data loss, security, broken install/setup, major regressions, or anything that needs maintainer attention soon.
|
||||
- `priority/high` for important user-facing bugs or work that affects common workflows.
|
||||
- `priority/normal` for valid issues with no immediate urgency.
|
||||
- `priority/low` for nice-to-have fixes, polish, edge cases, or cleanup.
|
||||
|
||||
Use priority labels when you have enough context to make a reasonable call. If you are unsure, leave priority unset and explain what information would help judge impact.
|
||||
|
||||
For bugs, a confirmed reproduction is the most useful evidence for priority. If you reproduce something, leave the exact environment and steps you used. Where the bug is visual or interactive — admin UI glitches, editor behavior, layout problems — a screenshot or short screen recording is extra useful: it shows exactly what you saw, and often settles "works for me" threads instantly. You can drag media straight into a GitHub comment.
|
||||
|
||||
### The Repro Bot and `triage/*` Labels
|
||||
|
||||
There is an experimental issue-investigation agent: applying the `bot:repro` label to an issue sends an agent off to try to reproduce the bug, and if it succeeds it may push a fix branch. It's too unreliable to be useful right now – reproduction runs fail, or time out after a very long time, so maintainers rarely use it and you should not apply `bot:repro` yourself.
|
||||
|
||||
You may still occasionally see its state labels on an issue. Like the PR labels, these are managed by workflows — you read them, you don't set them:
|
||||
|
||||
- `triage/reproducing` — the bot is currently investigating.
|
||||
- `triage/reproduced` — the bot reproduced the bug.
|
||||
- `triage/not-reproduced` — the bot could not reproduce it.
|
||||
- `triage/by-design` — the bot reproduced the behavior but it appears intentional.
|
||||
- `triage/awaiting-reporter` — a fix has been pushed and we're waiting for the reporter to verify it.
|
||||
- `triage/verified` — the reporter confirmed the fix.
|
||||
- `triage/skipped` — the bot declined to investigate.
|
||||
- `triage/failed` — the bot crashed or hit its retry cap.
|
||||
|
||||
If an issue has one of these labels, a bot investigation has happened or is in flight — read the bot's comments before starting a manual reproduction. In particular, `triage/awaiting-reporter` means a fix branch already exists, and the most useful thing you can do is test that fix rather than re-reproduce the original bug. Given how unreliable the bot is, double-checking its conclusions is itself valuable triage: a human confirming or refuting a `triage/reproduced` or `triage/not-reproduced` verdict is worth more than the label.
|
||||
|
||||
## PR Triage
|
||||
|
||||
First, decide whether the PR is something the project accepts. The full policy is in [CONTRIBUTING.md § Contribution Policy](CONTRIBUTING.md#contribution-policy); in short:
|
||||
|
||||
Accepted directly:
|
||||
|
||||
- Bug fixes with a reproducing test.
|
||||
- Documentation fixes and typos.
|
||||
- Translation PRs from native or fluent speakers.
|
||||
|
||||
Needs prior maintainer approval via a Discussion:
|
||||
|
||||
- Features.
|
||||
- Large refactors.
|
||||
- Dependency upgrades outside Renovate/Dependabot.
|
||||
- Broad cleanup PRs.
|
||||
|
||||
Normally closed without merging:
|
||||
|
||||
- Features with no Discussion or maintainer approval.
|
||||
- New plugins. These should be published independently.
|
||||
- Drive-by PRs (usually from bots) that aren't from people who are using EmDash.
|
||||
|
||||
Approval usually happens in Discussions: when a maintainer approves a proposal, the Discussion gets the `Approved for PR` label. But don't be pedantic about the mechanism — even if CI complains about a missing Discussion, a maintainer approving the idea in a PR or issue comment counts just as well, and maintainers can bypass the requirement entirely. What matters is whether a maintainer has said yes somewhere, not where they said it.
|
||||
|
||||
Two things to calibrate on:
|
||||
|
||||
- **The larger the feature, the more discussion it needs.** A small, obviously-useful addition might get a quick yes in a comment; a new subsystem or a change in project direction needs a real Discussion where the design gets worked through.
|
||||
- **Opening a Discussion is not the same as it being approved.** A PR opened alongside its Discussion — or linking to one no maintainer has responded to — hasn't met the bar. The point is to get a yes _before_ the work, so wait for maintainer approval on the Discussion before treating the PR as reviewable.
|
||||
|
||||
If there's no sign of approval anywhere, add `needs-discussion` and leave a comment like:
|
||||
|
||||
```markdown
|
||||
Thanks for the PR. This looks like a feature/change in project direction, so it needs an approved Discussion before review. Could you open one in Discussions and link it here? That helps avoid asking contributors to invest in work that may not fit the roadmap.
|
||||
```
|
||||
|
||||
### What To Look For Before Review
|
||||
|
||||
The bot does code-level review and is good at it, but it's not perfect. The most useful human checks are the ones the bot gets wrong or can't judge:
|
||||
|
||||
- Is the PR template filled out?
|
||||
- Does the PR explain _why_ the change is needed, and does that reason hold up?
|
||||
- For bug fixes, is there a test that would fail without the fix?
|
||||
- For user-facing package behavior changes, is there a changeset, and is it well written? (See [Checking Changesets](#checking-changesets).)
|
||||
- For admin UI changes, are user-facing strings localized and does the layout use RTL-safe classes?
|
||||
- For feature/refactor/performance PRs, has a maintainer approved the idea — a linked Discussion labeled `Approved for PR`, or approval in a PR or issue comment?
|
||||
- Are unrelated files changed, such as generated translation catalogs in a non-translation PR?
|
||||
- Is the AI disclosure filled in, and has a human author understood and tested the change? (See [AI-Assisted Contributions](#ai-assisted-contributions).)
|
||||
- For translation PRs, is the author a native or fluent speaker who has checked the translations in a real demo site?
|
||||
- ...and most importantly: **does it actually work**? Checking out a branch and clicking through the admin UI catches things no bot review will.
|
||||
|
||||
You don't need to check all of these: any one of these checks on a PR is a useful contribution, and nobody runs the whole list every time.
|
||||
|
||||
If something is missing, ask for it directly and keep the request narrow. The `review/*` state labels update automatically, so there's nothing to set when a PR looks ready — but if the author has pushed significant changes since the bot's last pass, add `bot:review` to summon a re-review.
|
||||
|
||||
### Checking Changesets
|
||||
|
||||
A changeset is **user documentation**: the release note someone reads while upgrading. It lands verbatim in the CHANGELOG and determines the version bump. It is not a PR description, not a code comment, and not a note to reviewers — those explain the change to someone reading the code; the changeset explains the effect to someone running the new version. Missing or badly written changesets are one of the most common gaps in otherwise-good PRs, and one of the easiest things to catch in triage. The full guide is [CONTRIBUTING.md § Changesets](CONTRIBUTING.md#changesets); the short version:
|
||||
|
||||
**When one is needed:** any change to a published package's behavior or API — bug fixes included. Without one, the fix won't ship in a release.
|
||||
|
||||
**When one isn't:** docs-only, test-only, CI/tooling changes, changes to demos or templates, and internal refactors that don't change behavior. Don't ask for a changeset on these.
|
||||
|
||||
**The bump type:**
|
||||
|
||||
- `patch` — bug fixes and small improvements.
|
||||
- `minor` — new features.
|
||||
- `major` — not allowed. Pre 1.0 we are not accepting any major bumps.
|
||||
|
||||
**The description** is written for someone upgrading, not someone reviewing the diff:
|
||||
|
||||
- Starts with a present-tense verb: **Fixes**, **Adds**, **Updates**, **Removes**.
|
||||
- Describes the observable effect — what's different for a user of the package.
|
||||
- No internal mechanics: file names, function names, or how it was implemented don't belong. If a sentence only makes sense to someone who has read the diff, ask for a rewrite.
|
||||
- One sentence is often enough.
|
||||
|
||||
A common miss: authors paste their PR description or commit message into the changeset. If it reads like "Refactored `hydrateEntryBylines` to chunk IN clauses," ask for the user-facing version ("Fixes a D1 error when an entry has many bylines").
|
||||
|
||||
### PR Labels
|
||||
|
||||
Almost every label you'll see on a PR is applied and removed by a workflow. You read them to scan the queue; you don't manage them:
|
||||
|
||||
- `review/needs-review`, `review/awaiting-author`, `review/needs-rereview`, `review/approved` — four mutually exclusive review states, kept in sync with actual review activity.
|
||||
- `needs-approval` — CI hasn't run because the workflows are waiting for maintainer approval, which is normal for first-time contributors. Despite the name, it has nothing to do with Discussion approval.
|
||||
- `needs-rebase` — the branch has merge conflicts with `main`.
|
||||
- `overlap` — another open PR touches the same files; the bot leaves a comment identifying it.
|
||||
- `stale` — no activity for two weeks. Stale PRs are closed automatically after three; a comment from you can keep a promising one alive.
|
||||
- `size/*`, `area/*`, `bot`, and the CLA labels are applied when the PR is opened or updated.
|
||||
|
||||
The labels you apply by hand on a PR:
|
||||
|
||||
- `bot:review` to summon a bot re-review.
|
||||
- `needs-discussion` when a feature/refactor PR has no maintainer approval anywhere.
|
||||
- `blocked` when progress depends on another issue, PR, or maintainer decision.
|
||||
|
||||
## Discussions
|
||||
|
||||
Discussions is where ideas get shaped before they become work, and where a lot of community support happens. Triage there is just as valuable as on issues and PRs:
|
||||
|
||||
- **Answer Q&A questions when you can**, and mark the accepted answer so the next person searching finds it. A marked answer turns a one-off reply into documentation.
|
||||
- **Weigh in on Ideas.** You know this project from contributing to it, and a comment like "this would conflict with how revisions work" or "I'd use this, and here's my use case" is exactly what a maintainer needs when deciding whether to approve a proposal. Don't hold back because approval isn't your call — shaping the proposal is how you help it get there.
|
||||
- **Convert misfiled issues.** A feature request opened as an issue can be converted to an Ideas Discussion directly (the "Convert to discussion" option in the issue sidebar) — friendlier than asking the author to repost.
|
||||
- **Connect the dots.** Link related Discussions, issues, and prior proposals. Many ideas have been discussed before, and a link to the earlier thread saves everyone from re-litigating it.
|
||||
- **Surface proposals that look ready.** If an Idea has a worked-through design and community support but no maintainer response, raise it in `#triage`.
|
||||
|
||||
The one thing that stays with maintainers is the decision itself: `Approved for PR` on a Discussion is a maintainer call.
|
||||
|
||||
## Area Labels
|
||||
|
||||
On PRs, area labels are applied automatically from the changed file paths. On issues they are a human call — useful when they are obvious, but not the main goal of triage. Do not spend much time guessing; a clear comment and a good priority label are usually worth more than a perfect area label.
|
||||
|
||||
- `area/admin` for the React admin UI.
|
||||
- `area/auth` for passkeys, sessions, users, roles, and login.
|
||||
- `area/cloudflare` for Workers, D1, R2, bindings, and deployment on Cloudflare.
|
||||
- `area/core` for the main `emdash` package, schema, content APIs, runtime, database, and rendering helpers.
|
||||
- `area/plugins` for plugin APIs and first-party plugins.
|
||||
- `area/templates` for starter templates.
|
||||
- `area/docs` for documentation.
|
||||
- `area/cli` for command-line tooling.
|
||||
- `area/ci` for GitHub Actions, release automation, tests, and repository tooling.
|
||||
|
||||
Use one or two. If an issue spans many areas, label the primary area and explain the overlap in a comment.
|
||||
|
||||
Two other labels worth applying when they fit: `good first issue` for well-scoped bugs with a clear fix location, and `help wanted` for valid issues maintainers are unlikely to get to soon.
|
||||
|
||||
Leave the `roadmap/*` labels alone — they are curated by maintainers as part of roadmap planning.
|
||||
|
||||
## Closing Issues
|
||||
|
||||
Close only when the reason is clear:
|
||||
|
||||
- Duplicate of an existing issue.
|
||||
- The report is not actionable after a reasonable request for information.
|
||||
- The behavior is documented or confirmed as intended.
|
||||
- The issue was fixed by a merged PR.
|
||||
|
||||
When closing, leave a short explanation. If it closed as a duplicate, use the "Close as duplicate" feature to link the original issue. This is available in the menu next to the "Close issue" button. If you are unsure, do not close. Add a label and ask a maintainer.
|
||||
|
||||
## Escalate to a Maintainer
|
||||
|
||||
To escalate, post in `#triage` on Discord or @-mention a maintainer in a comment on the thread, and say what decision is needed. Ask a maintainer to step in when:
|
||||
|
||||
- A report involves data loss, security, auth bypass, or production outage risk.
|
||||
- A contributor is proposing a breaking change.
|
||||
- A PR changes database migrations or content table behavior in a way you are unsure about.
|
||||
- A discussion turns argumentative.
|
||||
- A contributor needs a product or roadmap decision.
|
||||
- You are not sure whether closing would be fair.
|
||||
|
||||
Escalating is a normal outcome of triage, so don't worry if you need to.
|
||||
|
||||
**Security reports should not be debugged in public.** If an issue appears to describe a vulnerability, don't ask for exploit details in the thread. Ask the reporter to resubmit through [private vulnerability reporting](https://github.com/emdash-cms/emdash/security/advisories/new) (the "Report a vulnerability" button on the Security tab), close the issue and flag it in `#triage`.
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [EmDash Discord](https://discord.gg/YY9vBaQRYt) — `#triage` is home base
|
||||
- [Contributing guide](CONTRIBUTING.md)
|
||||
- [Architecture and code patterns](AGENTS.md)
|
||||
- [Documentation](https://docs.emdashcms.com)
|
||||
- [Discussions](https://github.com/emdash-cms/emdash/discussions)
|
||||
- [Issues](https://github.com/emdash-cms/emdash/issues)
|
||||
- [Pull requests](https://github.com/emdash-cms/emdash/pulls)
|
||||
@@ -0,0 +1,8 @@
|
||||
# Local-dev secrets for the aggregator Worker. Copy to `.env` (which is
|
||||
# gitignored) and customise. `wrangler dev` reads this file and binds the
|
||||
# values into `env`. Production secrets are managed via
|
||||
# `wrangler secret put <NAME>` and never appear in the repo.
|
||||
|
||||
# Bearer token for the /_admin/* routes. Any non-empty string works locally;
|
||||
# pick something you'd recognise in `wrangler tail` logs.
|
||||
ADMIN_TOKEN=dev-only-not-for-production
|
||||
@@ -0,0 +1,348 @@
|
||||
-- EmDash plugin registry aggregator: initial schema.
|
||||
--
|
||||
-- Lands every table that the v1 read API + ingest pipeline + label hydration
|
||||
-- + mirror tracking needs, at once on purpose: features that read these
|
||||
-- tables don't need to add new ones, so this is the only DDL we expect to
|
||||
-- ship while NSIDs remain experimental.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Records: package profiles + releases
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE packages (
|
||||
did TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'emdash-plugin'
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
license TEXT NOT NULL,
|
||||
authors TEXT NOT NULL, -- JSON array
|
||||
security TEXT NOT NULL, -- JSON array
|
||||
keywords TEXT, -- JSON array
|
||||
sections TEXT, -- JSON map
|
||||
last_updated TEXT,
|
||||
-- Denormalised from latest release for query convenience. Updated on every
|
||||
-- new release insert; readers never compute "latest" by sorting.
|
||||
latest_version TEXT,
|
||||
capabilities TEXT, -- JSON array
|
||||
-- Raw signed record bytes for verification + envelope passthrough. Clients
|
||||
-- re-verify the MST signature against the publisher's DID document at
|
||||
-- install time.
|
||||
record_blob BLOB NOT NULL,
|
||||
signature_metadata TEXT, -- JSON: head CID, signing key id
|
||||
verified_at TEXT NOT NULL,
|
||||
PRIMARY KEY (did, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE releases (
|
||||
did TEXT NOT NULL,
|
||||
package TEXT NOT NULL, -- matches the parent profile's rkey/slug (record.package field)
|
||||
version TEXT NOT NULL, -- canonical (un-percent-encoded) semver from record.version
|
||||
rkey TEXT NOT NULL, -- exact rkey of the form `<package>:<encoded-version>`
|
||||
-- Pre-computed semver-precedence-ordered string for ORDER BY. Application
|
||||
-- code writes this; SQLite cannot compute semver order natively. Format
|
||||
-- packs zero-padded major.minor.patch with prerelease tags compared per
|
||||
-- semver precedence rules.
|
||||
version_sort TEXT NOT NULL,
|
||||
artifacts TEXT NOT NULL, -- JSON
|
||||
requires TEXT, -- JSON
|
||||
suggests TEXT, -- JSON
|
||||
-- com.emdashcms.experimental.package.releaseExtension contents:
|
||||
-- { declaredAccess }. The capabilities-shaped projection lives in
|
||||
-- packages.capabilities for query convenience.
|
||||
emdash_extension TEXT NOT NULL,
|
||||
repo_url TEXT,
|
||||
cts TEXT NOT NULL, -- creation timestamp from the record
|
||||
record_blob BLOB NOT NULL,
|
||||
signature_metadata TEXT,
|
||||
verified_at TEXT NOT NULL,
|
||||
tombstoned_at TEXT, -- soft delete (publisher deleted record)
|
||||
PRIMARY KEY (did, package, version),
|
||||
-- ON DELETE CASCADE because Jetstream events for a publisher can arrive
|
||||
-- in arbitrary order under network reorder. A publisher who deletes their
|
||||
-- profile (and all releases) emits the events in author-order, but the
|
||||
-- profile-delete might land at the consumer before the release-deletes.
|
||||
-- Without cascade, the consumer would have to either skip the profile
|
||||
-- delete (leaving stale rows) or sequence retries, neither of which is
|
||||
-- worth the complexity. Releases are version-immutable from a publishing
|
||||
-- perspective, but a publisher is still entitled to remove their entire
|
||||
-- package; cascade mirrors that intent.
|
||||
FOREIGN KEY (did, package) REFERENCES packages(did, slug) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_releases_latest ON releases(did, package, version_sort DESC) WHERE tombstoned_at IS NULL;
|
||||
CREATE INDEX idx_releases_cts ON releases(cts);
|
||||
|
||||
-- Audit trail for rejected duplicate-version attempts. FAIR PR #77 makes
|
||||
-- versions immutable: a second record at the same (did, package, version) is
|
||||
-- rejected at the SQL layer and logged here for forensics.
|
||||
--
|
||||
-- The UNIQUE constraint dedupes attempts by content (CID), not by raw bytes.
|
||||
-- CAR bytes include the publisher's commit + MST proof which churns whenever
|
||||
-- the publisher writes any other record in the same repo, so byte-equality
|
||||
-- would misclassify benign retries as new attempts and bloat the table.
|
||||
-- The CID is content-addressed and stable for an unchanged record.
|
||||
--
|
||||
-- The consumer's INSERT uses `ON CONFLICT … DO UPDATE SET rejected_at,
|
||||
-- attempted_record_blob = excluded.{rejected_at, attempted_record_blob}` so
|
||||
-- the row tracks the latest attempt timestamp + the latest envelope bytes
|
||||
-- (newer proofs supersede older ones in the forensics column).
|
||||
CREATE TABLE release_duplicate_attempts (
|
||||
did TEXT NOT NULL,
|
||||
package TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
-- CID of the verified record (stable for content; changes only when the
|
||||
-- record itself changes). Used as the dedup key.
|
||||
attempted_cid TEXT NOT NULL,
|
||||
rejected_at TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
-- Raw CAR bytes from the most recent attempt. Kept for forensics so
|
||||
-- operators can inspect what was actually attempted even if the
|
||||
-- publisher has since deleted the offending record.
|
||||
attempted_record_blob BLOB NOT NULL,
|
||||
UNIQUE (did, package, version, attempted_cid)
|
||||
);
|
||||
|
||||
-- The UNIQUE constraint creates an implicit index on
|
||||
-- (did, package, version, attempted_record_blob); a separate index on the
|
||||
-- (did, package, version) prefix is redundant for both lookups (the implicit
|
||||
-- index handles prefix seeks) and inserts (one fewer index to maintain).
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Publishers: identity-level publisher profiles + verification claims
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
-- One row per publisher DID (rkey is always literal `self`). Optional: a DID
|
||||
-- may publish packages without ever publishing a publisher.profile, in which
|
||||
-- case the row is absent and clients fall back to the handle. This table is
|
||||
-- the canonical source for "who is publishing these packages?" — distinct from
|
||||
-- `packages.authors`, which is per-package and remains authoritative for that
|
||||
-- package.
|
||||
CREATE TABLE publishers (
|
||||
did TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL, -- bound by verification records — see publisher_verifications
|
||||
description TEXT,
|
||||
url TEXT,
|
||||
contact TEXT, -- JSON array of { kind, url?, email? }
|
||||
updated_at TEXT,
|
||||
record_blob BLOB NOT NULL,
|
||||
signature_metadata TEXT, -- JSON: head CID, signing key id
|
||||
verified_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Verification claims: issuer DID vouches for subject DID as a trusted
|
||||
-- publisher. The rkey is a TID, so an issuer can issue multiple claims (e.g.
|
||||
-- delegated + official) and we store each as its own row. Validity is bound to
|
||||
-- the subject's handle + publisher.profile.displayName at issuance time:
|
||||
-- clients re-resolve those at read time and treat the claim as not in force if
|
||||
-- either has changed. Ingest stores the facts; the validity check is a
|
||||
-- query-time concern.
|
||||
CREATE TABLE publisher_verifications (
|
||||
issuer_did TEXT NOT NULL, -- DID of the repo that wrote the record
|
||||
rkey TEXT NOT NULL, -- TID
|
||||
subject_did TEXT NOT NULL,
|
||||
subject_handle TEXT NOT NULL, -- bound at issuance; query-time validity check compares against current
|
||||
subject_display_name TEXT NOT NULL, -- bound at issuance; query-time validity check compares against current
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
record_blob BLOB NOT NULL,
|
||||
signature_metadata TEXT,
|
||||
verified_at TEXT NOT NULL,
|
||||
tombstoned_at TEXT,
|
||||
PRIMARY KEY (issuer_did, rkey)
|
||||
);
|
||||
|
||||
-- Hot path: "show me all unexpired, non-tombstoned verifications for subject X".
|
||||
-- Partial index keeps the index small by excluding tombstoned rows.
|
||||
CREATE INDEX idx_publisher_verifications_subject ON publisher_verifications(subject_did)
|
||||
WHERE tombstoned_at IS NULL;
|
||||
|
||||
-- For periodic expiry sweeps.
|
||||
CREATE INDEX idx_publisher_verifications_expires ON publisher_verifications(expires_at)
|
||||
WHERE expires_at IS NOT NULL AND tombstoned_at IS NULL;
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Mirror tracking (populated when the artifact mirror lands)
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE mirrored_artifacts (
|
||||
did TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL, -- 'package', 'icon', etc.
|
||||
r2_key TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
mirrored_at TEXT NOT NULL,
|
||||
PRIMARY KEY (did, slug, version, artifact_id)
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Labels (populated when the labeller integration lands)
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
-- Append-only label history. Every label received is written here, including
|
||||
-- negations. Current state is derived from latest cts per (src, uri, val) and
|
||||
-- projected into label_state below for hot-path lookups.
|
||||
CREATE TABLE labels (
|
||||
src TEXT NOT NULL, -- labeller DID
|
||||
uri TEXT NOT NULL, -- AT URI of subject
|
||||
cid TEXT, -- optional version-specific CID
|
||||
val TEXT NOT NULL, -- e.g. 'security:yanked', '!takedown'
|
||||
neg INTEGER NOT NULL DEFAULT 0,
|
||||
cts TEXT NOT NULL,
|
||||
exp TEXT, -- optional expiry (RFC 3339)
|
||||
sig BLOB NOT NULL, -- raw signature for client re-verification
|
||||
ver INTEGER NOT NULL DEFAULT 1,
|
||||
trusted INTEGER NOT NULL DEFAULT 0,
|
||||
received_at TEXT NOT NULL,
|
||||
PRIMARY KEY (src, uri, val, cts)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_labels_subject ON labels(uri);
|
||||
CREATE INDEX idx_labels_latest ON labels(src, uri, val, cts DESC);
|
||||
|
||||
-- Latest-state projection: one row per (src, uri, val) holding the most recent
|
||||
-- cts seen, including the neg flag and exp timestamp. Updated on every label
|
||||
-- write within the same transaction. Query-time filters apply
|
||||
-- `neg = 0 AND (exp IS NULL OR exp > now())` to determine whether a label is
|
||||
-- currently in force.
|
||||
--
|
||||
-- Why retain rows for negated/expired labels rather than deleting them: an
|
||||
-- out-of-order delivery (a positive label arriving after its negation) could
|
||||
-- otherwise reinsert a row we'd already retracted. Keeping the row with its
|
||||
-- `cts` lets the upsert reject the older positive.
|
||||
CREATE TABLE label_state (
|
||||
src TEXT NOT NULL,
|
||||
uri TEXT NOT NULL,
|
||||
val TEXT NOT NULL,
|
||||
cid TEXT,
|
||||
neg INTEGER NOT NULL DEFAULT 0,
|
||||
cts TEXT NOT NULL,
|
||||
exp TEXT,
|
||||
trusted INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (src, uri, val)
|
||||
);
|
||||
|
||||
-- Hot path for hard filters (yanked, takedown, etc.) from trusted issuers.
|
||||
-- Partial index keeps the index small by storing only currently-active rows.
|
||||
CREATE INDEX idx_label_state_enforce ON label_state(uri, val, trusted)
|
||||
WHERE neg = 0 AND trusted = 1;
|
||||
|
||||
-- Trusted/known labellers (operator config, edited via deployment).
|
||||
CREATE TABLE labellers (
|
||||
did TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL, -- subscribeLabels URL
|
||||
signing_key TEXT NOT NULL, -- cached #atproto_label key
|
||||
signing_key_id TEXT NOT NULL,
|
||||
trusted INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL,
|
||||
last_resolved_at TEXT NOT NULL,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Search: FTS5 over packages
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
CREATE VIRTUAL TABLE packages_fts USING fts5(
|
||||
name,
|
||||
description,
|
||||
keywords,
|
||||
authors,
|
||||
sections,
|
||||
content='packages',
|
||||
content_rowid='rowid',
|
||||
tokenize='porter unicode61 remove_diacritics 2'
|
||||
);
|
||||
|
||||
CREATE TRIGGER packages_ai AFTER INSERT ON packages BEGIN
|
||||
INSERT INTO packages_fts(rowid, name, description, keywords, authors, sections)
|
||||
VALUES (new.rowid, new.name, new.description, new.keywords, new.authors, new.sections);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER packages_au AFTER UPDATE ON packages BEGIN
|
||||
INSERT INTO packages_fts(packages_fts, rowid, name, description, keywords, authors, sections)
|
||||
VALUES ('delete', old.rowid, old.name, old.description, old.keywords, old.authors, old.sections);
|
||||
INSERT INTO packages_fts(rowid, name, description, keywords, authors, sections)
|
||||
VALUES (new.rowid, new.name, new.description, new.keywords, new.authors, new.sections);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER packages_ad AFTER DELETE ON packages BEGIN
|
||||
INSERT INTO packages_fts(packages_fts, rowid, name, description, keywords, authors, sections)
|
||||
VALUES ('delete', old.rowid, old.name, old.description, old.keywords, old.authors, old.sections);
|
||||
END;
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Ingest cursor state
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
-- Cursor state for ingest sources (Jetstream microsecond timestamp,
|
||||
-- subscribeLabels seq cursors per labeller, etc.).
|
||||
CREATE TABLE ingest_state (
|
||||
source TEXT PRIMARY KEY, -- 'jetstream', 'labeller:did:web:labels.example.com', etc.
|
||||
cursor TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Known publisher DIDs we've seen via Jetstream or Constellation. Reconciliation
|
||||
-- iterates this table; cold-start backfill seeds it from Constellation.
|
||||
--
|
||||
-- Doubles as the DID-document resolution cache: `pds`, `signing_key`,
|
||||
-- `signing_key_id` are populated by the records consumer on first verification
|
||||
-- and refreshed when `pds_resolved_at` is older than the consumer's TTL
|
||||
-- (currently 24h, applied at query time as
|
||||
-- `pds_resolved_at > datetime('now', '-1 day')`). Backfill may insert a row
|
||||
-- with these fields null; the consumer's first event for that DID forces a
|
||||
-- resolution and UPDATE.
|
||||
CREATE TABLE known_publishers (
|
||||
did TEXT PRIMARY KEY,
|
||||
pds TEXT, -- cached PDS endpoint from DID document
|
||||
signing_key TEXT, -- cached #atproto signing key (multibase)
|
||||
signing_key_id TEXT, -- e.g. 'did:plc:xxx#atproto'
|
||||
pds_resolved_at TEXT, -- last successful DID-doc resolution
|
||||
first_seen_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Verification-failure forensics
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
-- Records that failed PDS-verified ingest (signature, MST proof, AT-URI,
|
||||
-- lexicon, content-mismatch). Written instead of retrying, because these
|
||||
-- failures indicate malicious or broken upstream — retrying would just burn
|
||||
-- PDS round trips. Operators query this table to investigate suspected attacks
|
||||
-- or upstream regressions; it is NOT used as a retry queue.
|
||||
--
|
||||
-- Distinct from the configured Cloudflare DLQ (`emdash-aggregator-records-dlq`,
|
||||
-- see wrangler.jsonc), which receives messages after `max_retries` exhausted —
|
||||
-- that is for transient failures (PDS down, profile-not-yet-arrived). Two
|
||||
-- distinct failure modes, two distinct destinations.
|
||||
--
|
||||
-- `payload` holds the unverified record bytes from the Jetstream event so an
|
||||
-- operator can inspect what was attempted without going back to the source PDS.
|
||||
CREATE TABLE dead_letters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
did TEXT NOT NULL,
|
||||
collection TEXT NOT NULL,
|
||||
rkey TEXT NOT NULL,
|
||||
-- Reason code; matches the `DeadLetterReason` union in records-consumer.ts.
|
||||
-- Current values: 'RECORD_NOT_FOUND', 'RESPONSE_TOO_LARGE', 'INVALID_PROOF',
|
||||
-- 'PDS_HTTP_ERROR', 'LEXICON_VALIDATION_FAILED', 'RKEY_MISMATCH',
|
||||
-- 'CONTACT_VALIDATION_FAILED', 'INVALID_VERSION', 'UNKNOWN_COLLECTION',
|
||||
-- 'UNEXPECTED_ERROR'.
|
||||
reason TEXT NOT NULL,
|
||||
-- Free-form context (which field, expected vs got, library error message, etc.).
|
||||
detail TEXT,
|
||||
-- UTF-8 encoded JSON bytes of `RecordsJob.jetstreamRecord` when present, or a
|
||||
-- fallback envelope `{operation, cid}` for delete events that don't carry one.
|
||||
-- Stored as BLOB so future formats (CBOR, raw record bytes) can land here
|
||||
-- without a schema change; today operators must `CAST(payload AS TEXT)` to
|
||||
-- read.
|
||||
payload BLOB NOT NULL,
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dead_letters_did ON dead_letters(did);
|
||||
CREATE INDEX idx_dead_letters_received ON dead_letters(received_at);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Add `indexed_at` to the four content tables that the read API exposes via
|
||||
-- the `packageView` / `releaseView` lexicon shapes.
|
||||
--
|
||||
-- Why a separate column from `verified_at`: `verified_at` tracks "when the
|
||||
-- aggregator last verified this record", and is bumped on every upsert
|
||||
-- (including no-op re-verifications). The read API needs "when the aggregator
|
||||
-- first observed this record" so the lexicon's `indexedAt` field is stable
|
||||
-- across re-ingest. They diverge whenever a record is re-fetched (e.g.
|
||||
-- backfill catching up on a record we already had via Jetstream).
|
||||
--
|
||||
-- Defaulting to `verified_at` for any rows that pre-date this migration: the
|
||||
-- closest approximation we can make for already-indexed content. Going
|
||||
-- forward, the consumer's INSERTs set `indexed_at` to `now()` on first write
|
||||
-- and COALESCE the existing value on conflict (see records-consumer.ts).
|
||||
--
|
||||
-- NOT NULL after backfill from `verified_at`. SQLite's `ALTER TABLE` doesn't
|
||||
-- support DEFAULT-with-NOT-NULL in one step, so we add the column nullable,
|
||||
-- backfill, then move the constraint via the new-table dance — except SQLite
|
||||
-- also can't add NOT NULL via ALTER, so we keep the column nullable at the
|
||||
-- schema level and have the writer (consumer) treat it as required. The
|
||||
-- read-API code defends against NULL by falling back to verified_at if
|
||||
-- indexed_at is somehow missing on a row, which won't happen for new writes
|
||||
-- but covers the historical-data corner case without a table rebuild.
|
||||
|
||||
ALTER TABLE packages ADD COLUMN indexed_at TEXT;
|
||||
UPDATE packages SET indexed_at = verified_at WHERE indexed_at IS NULL;
|
||||
|
||||
ALTER TABLE releases ADD COLUMN indexed_at TEXT;
|
||||
UPDATE releases SET indexed_at = verified_at WHERE indexed_at IS NULL;
|
||||
|
||||
ALTER TABLE publishers ADD COLUMN indexed_at TEXT;
|
||||
UPDATE publishers SET indexed_at = verified_at WHERE indexed_at IS NULL;
|
||||
|
||||
ALTER TABLE publisher_verifications ADD COLUMN indexed_at TEXT;
|
||||
UPDATE publisher_verifications SET indexed_at = verified_at WHERE indexed_at IS NULL;
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@emdash-cms/aggregator",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "EmDash plugin registry aggregator. Indexes com.emdashcms.experimental.package.* records via Jetstream + PDS-verified ingest, exposes XRPC reads.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "vite build && wrangler deploy",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run",
|
||||
"db:migrate:local": "wrangler d1 migrations apply emdash-aggregator --local",
|
||||
"db:migrate": "wrangler d1 migrations apply emdash-aggregator --remote"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atcute/atproto": "catalog:",
|
||||
"@atcute/car": "catalog:",
|
||||
"@atcute/cbor": "catalog:",
|
||||
"@atcute/cid": "catalog:",
|
||||
"@atcute/client": "catalog:",
|
||||
"@atcute/crypto": "catalog:",
|
||||
"@atcute/firehose": "catalog:",
|
||||
"@atcute/identity": "catalog:",
|
||||
"@atcute/identity-resolver": "catalog:",
|
||||
"@atcute/jetstream": "catalog:",
|
||||
"@atcute/lexicons": "catalog:",
|
||||
"@atcute/mst": "catalog:",
|
||||
"@atcute/multibase": "catalog:",
|
||||
"@atcute/repo": "catalog:",
|
||||
"@atcute/xrpc-server": "catalog:",
|
||||
"@atcute/xrpc-server-cloudflare": "catalog:",
|
||||
"@emdash-cms/registry-lexicons": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "catalog:",
|
||||
"@cloudflare/vitest-pool-workers": "catalog:",
|
||||
"@emdash-cms/atproto-test-utils": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"wrangler": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Backfill queue consumer. Pulls one `BackfillJob` at a time and walks
|
||||
* `com.atproto.repo.listRecords` for that (DID, collection) pair, batching
|
||||
* results onto the records queue for the standard verify-and-write path.
|
||||
*
|
||||
* Why a separate queue from records: per-pair work (PDS resolution +
|
||||
* paginated listRecords + sendBatch onto the records queue) is bounded but
|
||||
* non-trivial — running it inside the records-queue consumer would burn the
|
||||
* sub-request budget for jobs that should just be writing to D1. Keeping
|
||||
* the queues separate also lets the operator throttle backfill work
|
||||
* independently of live ingest.
|
||||
*
|
||||
* Error policy:
|
||||
* - Per-pair `processBackfillJob` throw → `message.retry()`. Cloudflare
|
||||
* Queues backs off and retries; after `max_retries` (3, configured in
|
||||
* wrangler.jsonc) the message lands in `emdash-aggregator-backfill-dlq`.
|
||||
* - Unexpected throws inside the batch loop are caught per-message so one
|
||||
* bad job can't poison the rest of the batch.
|
||||
*
|
||||
* DLQ drain (`drainBackfillDeadLetterBatch`): logs each dead-lettered
|
||||
* pair at error level (so operators tailing `wrangler tail` see it loud)
|
||||
* and acks so the DLQ doesn't accumulate unbounded. No D1 forensics row —
|
||||
* the recovery action for a backfill pair that exhausted retries is
|
||||
* "re-run backfill for the affected DID", which only needs the
|
||||
* (did, collection) pair already on the log line.
|
||||
*/
|
||||
|
||||
import {
|
||||
AtprotoWebDidDocumentResolver,
|
||||
CompositeDidDocumentResolver,
|
||||
PlcDidDocumentResolver,
|
||||
} from "@atcute/identity-resolver";
|
||||
|
||||
import { processBackfillJob, type ProcessBackfillJobDeps } from "./backfill.js";
|
||||
import { createD1DidDocCache, DidResolver } from "./did-resolver.js";
|
||||
import type { BackfillJob } from "./env.js";
|
||||
import type { MessageBatchLike } from "./records-consumer.js";
|
||||
import { boundFetch } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Process one batch of backfill jobs. Mirrors `records-consumer.processBatch`'s
|
||||
* shape: per-message try/catch, ack on success, retry on throw.
|
||||
*
|
||||
* `depsOverride` is the test seam — production calls without it and gets
|
||||
* the standard composite resolver wired against `env.DB`.
|
||||
*/
|
||||
export async function processBackfillBatch(
|
||||
batch: MessageBatchLike<BackfillJob>,
|
||||
env: Env,
|
||||
depsOverride?: ProcessBackfillJobDeps,
|
||||
): Promise<void> {
|
||||
const deps = depsOverride ?? createProductionDeps(env);
|
||||
for (const message of batch.messages) {
|
||||
const job = message.body;
|
||||
try {
|
||||
const result = await processBackfillJob(job, deps);
|
||||
console.log("[aggregator] backfill job complete", {
|
||||
did: result.did,
|
||||
collection: result.collection,
|
||||
enqueued: result.enqueued,
|
||||
});
|
||||
message.ack();
|
||||
} catch (err) {
|
||||
// Resolution failures, listRecords 5xx, timeouts, and pagination
|
||||
// runaway all land here. Retry — Cloudflare Queues backoff handles
|
||||
// transient PDS failures; permanently broken DIDs land in the DLQ
|
||||
// after max_retries.
|
||||
console.error("[aggregator] backfill job failed, retrying", {
|
||||
did: job.did,
|
||||
collection: job.collection,
|
||||
error: formatErrorChain(err),
|
||||
});
|
||||
message.retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the backfill DLQ. Mirror of `records-consumer.drainDeadLetterBatch`
|
||||
* but without the D1 forensics row — the recovery action for a backfill
|
||||
* pair that exhausted retries is "re-run backfill for the affected DID",
|
||||
* which only needs the (did, collection) pair from the log line.
|
||||
*
|
||||
* Logs at error level so operators tailing `wrangler tail` see the message
|
||||
* loud, acks so the DLQ doesn't accumulate unbounded.
|
||||
*/
|
||||
export function drainBackfillDeadLetterBatch(
|
||||
batch: MessageBatchLike<BackfillJob>,
|
||||
_env: Env,
|
||||
): void {
|
||||
for (const message of batch.messages) {
|
||||
console.error("[aggregator] backfill DLQ drain: pair exhausted retries", {
|
||||
did: message.body.did,
|
||||
collection: message.body.collection,
|
||||
});
|
||||
message.ack();
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk an Error's `cause` chain into a single human-readable string for
|
||||
* log output. Library code (`@atcute/identity-resolver`,
|
||||
* `@atcute/util-fetch`) wraps low-level failures in higher-level errors
|
||||
* via `{ cause }`; logging only `err.message` hides the actual root
|
||||
* cause. The chain is joined with `; cause: ` so a multi-level wrap
|
||||
* reads top-down. Bounded at 5 levels so a circular chain can't loop
|
||||
* forever. */
|
||||
function formatErrorChain(err: unknown): string {
|
||||
const parts: string[] = [];
|
||||
let current: unknown = err;
|
||||
for (let i = 0; i < 5 && current; i++) {
|
||||
if (current instanceof Error) {
|
||||
parts.push(`${current.name}: ${current.message}`);
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
} else {
|
||||
// JSON.stringify (rather than String) so a plain object cause
|
||||
// (some libraries throw `{ code, ... }` shapes) prints its
|
||||
// fields instead of `[object Object]`.
|
||||
parts.push(typeof current === "string" ? current : JSON.stringify(current));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return parts.join("; cause: ");
|
||||
}
|
||||
|
||||
function createProductionDeps(env: Env): ProcessBackfillJobDeps {
|
||||
const composite = new CompositeDidDocumentResolver({
|
||||
methods: {
|
||||
plc: new PlcDidDocumentResolver({ fetch: boundFetch }),
|
||||
web: new AtprotoWebDidDocumentResolver({ fetch: boundFetch }),
|
||||
},
|
||||
});
|
||||
return {
|
||||
resolver: new DidResolver({
|
||||
cache: createD1DidDocCache(env.DB),
|
||||
resolver: composite,
|
||||
}),
|
||||
queue: env.RECORDS_QUEUE,
|
||||
// listRecords + sendBatch use this fetch for the PDS calls. Same
|
||||
// bound-wrapper requirement as the resolver constructors —
|
||||
// workerd's `fetch` rejects calls made through a stored
|
||||
// reference (`const fetchImpl = deps.fetch ?? fetch; fetchImpl(url)`).
|
||||
fetch: boundFetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Cold-start discovery worker.
|
||||
*
|
||||
* Operator-triggered (via `POST /_admin/backfill`). Two trigger shapes:
|
||||
*
|
||||
* - `{ "dids": [...] }` — explicit list, primarily for testing or recovery
|
||||
* of a known DID set.
|
||||
* - `{}` (empty body) — production cold-start. Calls
|
||||
* `com.atproto.sync.listReposByCollection` against the configured relay
|
||||
* for each NSID in `WANTED_COLLECTIONS`, paginates the full DID set,
|
||||
* dedupes, and feeds the union into the same backfill loop.
|
||||
*
|
||||
* Architecture: the POST handler synchronously discovers DIDs (or accepts an
|
||||
* explicit list), then fans out one `BackfillJob = { did, collection }` per
|
||||
* (DID × WANTED_COLLECTIONS) pair onto the dedicated `BACKFILL_QUEUE` via
|
||||
* `sendBatch`. A separate consumer (`backfill-consumer.ts`) processes one
|
||||
* pair at a time: resolve PDS, paginate `com.atproto.repo.listRecords`,
|
||||
* batch-enqueue each returned record onto the existing Records Queue.
|
||||
*
|
||||
* Why a separate queue rather than running the per-DID loop inside the
|
||||
* `ctx.waitUntil` of the POST handler: Cloudflare's hard 30-second
|
||||
* wall-clock cap on `waitUntil` would limit a single backfill POST to
|
||||
* ~15–25 DIDs before in-flight work was cancelled. The queue gives us
|
||||
* automatic retry, concurrency, and per-pair invocation budgets that each
|
||||
* fit comfortably under the sub-request ceiling.
|
||||
*
|
||||
* Live discovery (post-cold-start) is Jetstream's job, not this worker's;
|
||||
* the consumer writes `known_publishers` opportunistically on any record
|
||||
* event for an unseen DID. Backfill exists for the cold-start gap
|
||||
* (publishers who published before the aggregator was listening) and for
|
||||
* operator-triggered recovery after a known outage. There is deliberately
|
||||
* no periodic scheduler — see plan §"Why no reconciliation cron".
|
||||
*/
|
||||
|
||||
import {
|
||||
parseCanonicalResourceUri,
|
||||
type ParsedCanonicalResourceUri,
|
||||
} from "@atcute/lexicons/syntax";
|
||||
|
||||
import { WANTED_COLLECTIONS } from "./constants.js";
|
||||
import type { DidResolver } from "./did-resolver.js";
|
||||
import type { BackfillJob, RecordsJob } from "./env.js";
|
||||
import { isPlainObject } from "./utils.js";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
/** Per-listRecords-page timeout. A hostile or hung publisher PDS that
|
||||
* accepts the connection but stalls the body would otherwise block the
|
||||
* fetch until workerd's overall sub-request budget exhausts — starving
|
||||
* every later page in the same consumer invocation. Same shape as
|
||||
* `pds-verify.ts`'s fetchCar timeout. */
|
||||
const LIST_RECORDS_TIMEOUT_MS = 15_000;
|
||||
/** Cap on listRecords pagination per (DID, collection) pair. A buggy or
|
||||
* malicious PDS that echoes the same cursor would otherwise loop forever
|
||||
* inside one consumer invocation. 1000 pages × 100 records = 100k records
|
||||
* per pair, which is past anything we'd legitimately backfill in one shot. */
|
||||
const MAX_PAGES_PER_COLLECTION = 1000;
|
||||
/** Defensive cap on records per page. Real PDSes honour the `limit` query
|
||||
* param; this guards against a hostile PDS returning an enormous array.
|
||||
* Capped at the same width as Cloudflare Queues' sendBatch (100) so a
|
||||
* compliant page maps 1:1 to one batch send; oversize pages are rejected
|
||||
* rather than chunked, surfacing the PDS's spec violation as a partial
|
||||
* failure the operator can investigate. */
|
||||
const MAX_RECORDS_PER_PAGE = PAGE_SIZE;
|
||||
/** Cloudflare Queues' hard cap on `sendBatch` size. Per-page enqueues are
|
||||
* always ≤ this thanks to MAX_RECORDS_PER_PAGE; documented here so the
|
||||
* relationship is visible at the call site. */
|
||||
export const QUEUE_SEND_BATCH_CAP = 100;
|
||||
// Static guard: bumping MAX_RECORDS_PER_PAGE above the queue's batch cap
|
||||
// would silently break sendBatch in production. Surface the violation at
|
||||
// module load rather than at the first batch send.
|
||||
if (MAX_RECORDS_PER_PAGE > QUEUE_SEND_BATCH_CAP) {
|
||||
throw new Error(
|
||||
`MAX_RECORDS_PER_PAGE (${MAX_RECORDS_PER_PAGE}) exceeds QUEUE_SEND_BATCH_CAP (${QUEUE_SEND_BATCH_CAP})`,
|
||||
);
|
||||
}
|
||||
/** Producer-side records queue surface. The production binding
|
||||
* `env.RECORDS_QUEUE` satisfies this; tests pass an in-memory implementation. */
|
||||
export interface RecordsQueueProducer {
|
||||
sendBatch(messages: ReadonlyArray<{ body: RecordsJob }>): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Producer-side backfill-jobs queue surface. The production binding
|
||||
* `env.BACKFILL_QUEUE` satisfies this; tests pass an in-memory
|
||||
* implementation. Same shape as `RecordsQueueProducer`, separated so the
|
||||
* type system catches accidental cross-wiring. */
|
||||
export interface BackfillQueueProducer {
|
||||
sendBatch(messages: ReadonlyArray<{ body: BackfillJob }>): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Cap on pages walked per relay collection. Same shape as
|
||||
* MAX_PAGES_PER_COLLECTION but for the discovery side. At 100 repos/page,
|
||||
* 100 pages = 10k publishers — past anything we'd legitimately discover at
|
||||
* Slice 1 scale. */
|
||||
const MAX_DISCOVERY_PAGES_PER_COLLECTION = 100;
|
||||
const DISCOVERY_PAGE_SIZE = 100;
|
||||
const DISCOVERY_TIMEOUT_MS = 15_000;
|
||||
/** Defensive cap on the union of discovered DIDs. A relay returning a
|
||||
* runaway list (bug or hostile mirror) would otherwise let one POST fan
|
||||
* out millions of jobs onto BACKFILL_QUEUE. At Slice 1 scale we expect a
|
||||
* handful to a few hundred publishers. */
|
||||
export const MAX_DISCOVERED_DIDS = 1000;
|
||||
|
||||
/**
|
||||
* Discover all DIDs publishing any of `WANTED_COLLECTIONS` by querying the
|
||||
* relay's `com.atproto.sync.listReposByCollection`. Returns the union of
|
||||
* unique DIDs across all collections, in arbitrary order.
|
||||
*
|
||||
* Uses the same defenses as the per-pair listRecords loop: per-page
|
||||
* timeout, max-page cap, cursor-equality check. Per-collection failures
|
||||
* are logged and the loop continues — discovery via the relay is
|
||||
* best-effort; a partial discovery list is better than none. Stops early
|
||||
* once `MAX_DISCOVERED_DIDS` is reached so a runaway relay can't pump
|
||||
* arbitrary fan-out into the queue.
|
||||
*/
|
||||
export async function discoverDids(
|
||||
relayUrl: string,
|
||||
opts: { fetch?: typeof fetch; timeoutMs?: number } = {},
|
||||
): Promise<string[]> {
|
||||
const fetchImpl = opts.fetch ?? fetch;
|
||||
const timeoutMs = opts.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
|
||||
const dids = new Set<string>();
|
||||
for (const collection of WANTED_COLLECTIONS) {
|
||||
try {
|
||||
await discoverCollection(relayUrl, collection, fetchImpl, timeoutMs, dids);
|
||||
} catch (err) {
|
||||
console.error("[aggregator] backfill discovery failed for collection", {
|
||||
collection,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
if (dids.size >= MAX_DISCOVERED_DIDS) {
|
||||
console.warn("[aggregator] backfill discovery hit DID cap, stopping early", {
|
||||
cap: MAX_DISCOVERED_DIDS,
|
||||
stoppedAfterCollection: collection,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...dids];
|
||||
}
|
||||
|
||||
async function discoverCollection(
|
||||
relayUrl: string,
|
||||
collection: string,
|
||||
fetchImpl: typeof fetch,
|
||||
timeoutMs: number,
|
||||
dids: Set<string>,
|
||||
): Promise<void> {
|
||||
let cursor: string | undefined;
|
||||
let prevCursor: string | undefined;
|
||||
let pages = 0;
|
||||
do {
|
||||
if (++pages > MAX_DISCOVERY_PAGES_PER_COLLECTION) {
|
||||
throw new Error(`exceeded ${MAX_DISCOVERY_PAGES_PER_COLLECTION} discovery pages`);
|
||||
}
|
||||
if (cursor !== undefined && cursor === prevCursor) {
|
||||
throw new Error("relay returned identical cursor twice");
|
||||
}
|
||||
prevCursor = cursor;
|
||||
|
||||
const url = new URL("/xrpc/com.atproto.sync.listReposByCollection", relayUrl);
|
||||
url.searchParams.set("collection", collection);
|
||||
url.searchParams.set("limit", String(DISCOVERY_PAGE_SIZE));
|
||||
if (cursor) url.searchParams.set("cursor", cursor);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(url.toString(), {
|
||||
headers: { accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new Error(`listReposByCollection timed out after ${timeoutMs}ms`, { cause: err });
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`listReposByCollection returned ${response.status}`);
|
||||
}
|
||||
const body: unknown = await response.json();
|
||||
if (!isPlainObject(body)) throw new Error("listReposByCollection returned non-object body");
|
||||
const repos = body["repos"];
|
||||
if (!Array.isArray(repos)) {
|
||||
throw new Error("listReposByCollection response missing `repos` array");
|
||||
}
|
||||
for (const repo of repos) {
|
||||
if (!isPlainObject(repo)) continue;
|
||||
const did = repo["did"];
|
||||
if (typeof did === "string") {
|
||||
dids.add(did);
|
||||
if (dids.size >= MAX_DISCOVERED_DIDS) return;
|
||||
}
|
||||
}
|
||||
const nextCursor = body["cursor"];
|
||||
cursor = typeof nextCursor === "string" && nextCursor.length > 0 ? nextCursor : undefined;
|
||||
} while (cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out backfill work for a list of DIDs onto `BACKFILL_QUEUE`. Produces
|
||||
* one `BackfillJob` per (DID × WANTED_COLLECTIONS) pair, sent in batches
|
||||
* of up to `QUEUE_SEND_BATCH_CAP` to honour Cloudflare Queues' sendBatch
|
||||
* limit. Returns the total number of jobs enqueued.
|
||||
*
|
||||
* Caller is responsible for capping `dids.length` (admin route enforces
|
||||
* `MAX_BACKFILL_DIDS` for the explicit path; `discoverDids` enforces
|
||||
* `MAX_DISCOVERED_DIDS` for the discovery path). This function trusts its
|
||||
* input and just fans out — the per-call work is bounded by
|
||||
* `dids.length * WANTED_COLLECTIONS.length` enqueues.
|
||||
*/
|
||||
export async function enqueueBackfillJobs(
|
||||
dids: readonly string[],
|
||||
queue: BackfillQueueProducer,
|
||||
): Promise<number> {
|
||||
const messages: { body: BackfillJob }[] = [];
|
||||
for (const did of dids) {
|
||||
for (const collection of WANTED_COLLECTIONS) {
|
||||
messages.push({ body: { did, collection } });
|
||||
}
|
||||
}
|
||||
// Fan the sendBatch calls in parallel. Each is an independent outbound
|
||||
// sub-request and the orchestrator runs inside the POST handler's 30s
|
||||
// `waitUntil` budget — serial awaits on a `MAX_DISCOVERED_DIDS`-sized
|
||||
// fan-out (1000 DIDs × 4 collections / 100 = 40 batches) would
|
||||
// noticeably eat into the cold-start budget on top of discovery's own
|
||||
// fetches.
|
||||
const sends: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < messages.length; i += QUEUE_SEND_BATCH_CAP) {
|
||||
sends.push(queue.sendBatch(messages.slice(i, i + QUEUE_SEND_BATCH_CAP)));
|
||||
}
|
||||
await Promise.all(sends);
|
||||
return messages.length;
|
||||
}
|
||||
|
||||
export interface ProcessBackfillJobDeps {
|
||||
resolver: DidResolver;
|
||||
queue: RecordsQueueProducer;
|
||||
/** Inject for tests; defaults to `globalThis.fetch`. */
|
||||
fetch?: typeof fetch;
|
||||
/** Override for the per-listRecords-page timeout. Defaults to
|
||||
* `LIST_RECORDS_TIMEOUT_MS`. Tests use a small value to exercise the
|
||||
* abort path without burning the production budget. */
|
||||
listRecordsTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ProcessBackfillJobResult {
|
||||
did: string;
|
||||
collection: string;
|
||||
enqueued: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one (DID, collection) pair: resolve the DID's PDS, paginate
|
||||
* `com.atproto.repo.listRecords` for the collection, and batch-enqueue
|
||||
* each returned record onto the records queue.
|
||||
*
|
||||
* Throws on any failure (resolution, listRecords status, pagination
|
||||
* runaway). The queue consumer translates a thrown result into
|
||||
* `message.retry()`; messages that exhaust max_retries land in the
|
||||
* backfill DLQ for the operator to inspect.
|
||||
*
|
||||
* 404 from the PDS on the first page is treated as "publisher does not
|
||||
* host this collection" and returns 0 enqueues without throwing — same
|
||||
* shape as the previous serial-loop semantics.
|
||||
*/
|
||||
export async function processBackfillJob(
|
||||
job: BackfillJob,
|
||||
deps: ProcessBackfillJobDeps,
|
||||
): Promise<ProcessBackfillJobResult> {
|
||||
const resolved = await deps.resolver.resolve(job.did);
|
||||
const fetchImpl = deps.fetch ?? fetch;
|
||||
const timeoutMs = deps.listRecordsTimeoutMs ?? LIST_RECORDS_TIMEOUT_MS;
|
||||
const enqueued = await paginateAndEnqueue({
|
||||
did: job.did,
|
||||
pds: resolved.pds,
|
||||
collection: job.collection,
|
||||
queue: deps.queue,
|
||||
fetchImpl,
|
||||
timeoutMs,
|
||||
});
|
||||
return { did: job.did, collection: job.collection, enqueued };
|
||||
}
|
||||
|
||||
interface PaginateOpts {
|
||||
did: string;
|
||||
pds: string;
|
||||
collection: string;
|
||||
queue: RecordsQueueProducer;
|
||||
fetchImpl: typeof fetch;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk one DID's records for a single collection, paginating through
|
||||
* `listRecords` and enqueuing each result via `sendBatch` (one batch per
|
||||
* page). Returns the total records enqueued.
|
||||
*
|
||||
* 404 from the PDS on the FIRST page means the repo doesn't host this
|
||||
* collection — silently treated as zero records, not an error. A 404
|
||||
* mid-pagination is a partial-failure signal (the PDS is misrouting one
|
||||
* page while the rest of the repo is fine) and throws.
|
||||
*
|
||||
* Pagination is capped at MAX_PAGES_PER_COLLECTION + cursor-equality check
|
||||
* to defend against a PDS that echoes the same cursor forever.
|
||||
*
|
||||
* `MAX_RECORDS_PER_PAGE` matches Cloudflare Queues' `sendBatch` cap (100),
|
||||
* so a compliant page maps 1:1 to one batch send. A PDS that ignores the
|
||||
* `?limit=` query and returns more records than that throws — we'd rather
|
||||
* surface the spec violation than silently chunk and hide the upstream bug.
|
||||
*/
|
||||
async function paginateAndEnqueue(opts: PaginateOpts): Promise<number> {
|
||||
let cursor: string | undefined;
|
||||
let prevCursor: string | undefined;
|
||||
let pages = 0;
|
||||
let totalEnqueued = 0;
|
||||
do {
|
||||
if (++pages > MAX_PAGES_PER_COLLECTION) {
|
||||
throw new Error(`exceeded ${MAX_PAGES_PER_COLLECTION} pages`);
|
||||
}
|
||||
if (cursor !== undefined && cursor === prevCursor) {
|
||||
throw new Error("PDS returned identical cursor twice");
|
||||
}
|
||||
prevCursor = cursor;
|
||||
|
||||
const url = new URL("/xrpc/com.atproto.repo.listRecords", opts.pds);
|
||||
url.searchParams.set("repo", opts.did);
|
||||
url.searchParams.set("collection", opts.collection);
|
||||
url.searchParams.set("limit", String(PAGE_SIZE));
|
||||
if (cursor) url.searchParams.set("cursor", cursor);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await opts.fetchImpl(url.toString(), {
|
||||
headers: { accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new Error(`listRecords timed out after ${opts.timeoutMs}ms`, { cause: err });
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
if (cursor === undefined) {
|
||||
// First-page 404: publisher has no records of this collection.
|
||||
return totalEnqueued;
|
||||
}
|
||||
// Mid-pagination 404 is a partial failure; surface it.
|
||||
throw new Error(`listRecords returned 404 mid-pagination at cursor=${cursor}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`listRecords returned ${response.status}`);
|
||||
}
|
||||
|
||||
const body: unknown = await response.json();
|
||||
const records = extractListRecordsBody(body);
|
||||
if (records.length > MAX_RECORDS_PER_PAGE) {
|
||||
throw new Error(
|
||||
`PDS returned ${records.length} records, exceeding per-page cap of ${MAX_RECORDS_PER_PAGE}`,
|
||||
);
|
||||
}
|
||||
cursor = extractCursor(body);
|
||||
|
||||
const messages: { body: RecordsJob }[] = [];
|
||||
for (const record of records) {
|
||||
let parsed: ParsedCanonicalResourceUri;
|
||||
try {
|
||||
parsed = parseCanonicalResourceUri(record.uri);
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) continue;
|
||||
throw err;
|
||||
}
|
||||
// Defence vs. a buggy/malicious PDS that returns records under
|
||||
// a different DID (or a different collection) than the one we
|
||||
// asked for. Such jobs would never verify (signature would be
|
||||
// from a different key) and would just churn dead-letters; drop
|
||||
// at the source. `parseCanonicalResourceUri` already validated
|
||||
// the rkey grammar and the collection NSID for us, so we only
|
||||
// need the cross-checks here.
|
||||
if (parsed.repo !== opts.did) continue;
|
||||
if (parsed.collection !== opts.collection) continue;
|
||||
messages.push({
|
||||
body: {
|
||||
did: opts.did,
|
||||
collection: opts.collection,
|
||||
rkey: parsed.rkey,
|
||||
operation: "create",
|
||||
cid: record.cid,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (messages.length > 0) {
|
||||
// Page size is capped at QUEUE_SEND_BATCH_CAP at module load via
|
||||
// the static assertion above, so this sendBatch never exceeds
|
||||
// Cloudflare's 100-message limit by construction.
|
||||
await opts.queue.sendBatch(messages);
|
||||
totalEnqueued += messages.length;
|
||||
}
|
||||
} while (cursor);
|
||||
return totalEnqueued;
|
||||
}
|
||||
|
||||
interface ListRecordEntry {
|
||||
uri: string;
|
||||
cid: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `com.atproto.repo.listRecords` response body. Throws on any
|
||||
* structural mismatch — a PDS that 200s with the wrong shape is upstream
|
||||
* breakage, not "no records", and silently treating it as the latter
|
||||
* causes operator-invisible partial backfills. The thrown error
|
||||
* propagates out of `processBackfillJob` and the queue consumer retries
|
||||
* (then DLQs) per the standard policy.
|
||||
*/
|
||||
function extractListRecordsBody(body: unknown): ListRecordEntry[] {
|
||||
if (!isPlainObject(body)) {
|
||||
throw new Error("listRecords response was not a JSON object");
|
||||
}
|
||||
const records = body["records"];
|
||||
if (!Array.isArray(records)) {
|
||||
throw new Error("listRecords response missing `records` array");
|
||||
}
|
||||
const out: ListRecordEntry[] = [];
|
||||
for (const r of records) {
|
||||
if (!isPlainObject(r)) {
|
||||
throw new Error("listRecords record entry was not a JSON object");
|
||||
}
|
||||
const uri = r["uri"];
|
||||
const cid = r["cid"];
|
||||
if (typeof uri !== "string" || typeof cid !== "string") {
|
||||
throw new Error("listRecords record entry missing string `uri` or `cid`");
|
||||
}
|
||||
out.push({ uri, cid, value: r["value"] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the optional `cursor` out of a `listRecords` response. `undefined`
|
||||
* (no key) is the spec-compliant signal for "end of pagination"; any other
|
||||
* non-string value (number, object, etc.) is a PDS bug and throws so the
|
||||
* pagination loop doesn't silently terminate on the wrong page.
|
||||
*/
|
||||
function extractCursor(body: unknown): string | undefined {
|
||||
if (!isPlainObject(body)) {
|
||||
throw new Error("listRecords response was not a JSON object");
|
||||
}
|
||||
const cursor = body["cursor"];
|
||||
if (cursor === undefined) return undefined;
|
||||
if (typeof cursor !== "string") {
|
||||
throw new Error(`listRecords cursor was not a string (got ${typeof cursor})`);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user