diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8866b52b..26465ace 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,9 +12,10 @@ on: jobs: claude-review: - # Forks have no CLAUDE_CODE_OAUTH_TOKEN, so the job would fail unfixably there. + # `pull_request` from a fork gets no secrets — not even for a MEMBER author — + # so without the head-repo check the job fails unfixably on fork PRs. if: | - github.repository_owner == 'openstatusHQ' && ( + github.event.pull_request.head.repo.full_name == github.repository && ( github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER' ) @@ -26,10 +27,6 @@ jobs: issues: read id-token: write - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - steps: - name: Checkout repository uses: actions/checkout@v6 @@ -52,8 +49,13 @@ jobs: with: deno-version: 2.9.4 + # Remote-cache secrets stay on this step. The review step below runs an LLM + # with Bash tools, and job-level env would hand them straight to it. - name: 📥 Download deps run: pnpm install + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Run Claude Code Review id: claude-review diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 6d0ee929..a1d385ca 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -50,6 +50,28 @@ jobs: with: fetch-depth: 0 + # Same reason as claude-code-review.yml: without a toolchain, `@claude` + # can only read the diff and every answer is caveated as unverified. + - name: Set up pnpm + uses: pnpm/action-setup@v6 + + - name: ⎔ Setup node + uses: actions/setup-node@v6 + with: + node-version: 24.12.0 + cache: "pnpm" + + - name: 🦕 Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: 2.9.4 + + - name: 📥 Download deps + run: pnpm install + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md index 6ca43f07..b5839e25 100644 --- a/apps/dashboard/AGENTS.md +++ b/apps/dashboard/AGENTS.md @@ -13,7 +13,7 @@ The whole tRPC surface is served from `apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts` on the **Node.js** runtime — several routers pull Node-only SDKs (`@slack/web-api`, email and notification clients). Node-only dependencies inside `@openstatus/services` are -still forbidden, because Edge routes and `apps/workflows` reach the same code. +still forbidden, because `apps/workflows` runs the same code on Deno. ## Client boundary diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md index b6906aea..40c03a34 100644 --- a/apps/server/AGENTS.md +++ b/apps/server/AGENTS.md @@ -20,6 +20,15 @@ New endpoints should call a service verb rather than query Drizzle directly; `oxlint.config.ts` already bans `@openstatus/db` and `drizzle-orm` imports in the handlers that have migrated, and that list grows one domain per PR. +## Route config + +Resolve config once and pass it in; +`createSlackRoute(config)` in `apps/server/src/routes/slack/index.ts` is the +pattern — production calls `slackConfigFromEnv()` at module scope, tests build a +route with explicit config. Reading `env` at request time is what made the slack +route untestable under `deno test --parallel`, since the workers share one +process environment. + ## MCP MCP tools declare `scope: 'read' | 'write'` and register via diff --git a/apps/workflows/AGENTS.md b/apps/workflows/AGENTS.md index 535f209a..b0d6b091 100644 --- a/apps/workflows/AGENTS.md +++ b/apps/workflows/AGENTS.md @@ -11,5 +11,7 @@ Constraints that follow from the runtime: build here — run `pnpm check` in both packages after touching services. - Sentry comes from `@sentry/deno`, not `@sentry/node`. - Tests run `deno test --parallel`, so test files share one process - environment. Never drive a branch by assigning to `process.env` mid-test; - resolve config once and pass it in (`createSlackRoute(config)` is the pattern). + environment. Never drive a branch by assigning to `process.env` mid-test — + the assignment leaks into whatever else is running. Resolve config once at + module scope and pass it in, so "credential missing" is a value a test hands + you rather than a global it mutates. diff --git a/oxlint.config.ts b/oxlint.config.ts index 2da1de05..2f56e4ef 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -119,7 +119,7 @@ export default defineConfig({ excludeFiles: ["**/__tests__/**", "**/*.test.ts"], rules: { "openstatus/services-mutation-guards": "error", - // Reachable from the Next.js Edge runtime and from Deno. + // apps/workflows runs this on Deno, and it stays Edge-safe by design. "no-restricted-imports": [ "error", { @@ -127,7 +127,7 @@ export default defineConfig({ { group: ["node:*"], message: - "@openstatus/services runs on Edge — no node built-ins. Hand-roll the helper (see deepEqual) or move the code to a Node-only package.", + "@openstatus/services must stay runtime-agnostic — no node built-ins. Hand-roll the helper (see deepEqual) or move the code to a Node-only package.", }, ], }, diff --git a/packages/services/AGENTS.md b/packages/services/AGENTS.md index c9920a40..17da257c 100644 --- a/packages/services/AGENTS.md +++ b/packages/services/AGENTS.md @@ -47,9 +47,10 @@ write tools. ## Runtime constraints -- This package is reachable from the Next.js Edge runtime and from Deno - (`apps/workflows`). **No `node:*` imports** — that is why `deepEqual` is - hand-rolled instead of pulled from `node:util`. +- **No `node:*` imports.** `apps/workflows` runs this code on Deno, and the + package is written to stay Edge-safe so a Next.js route can adopt it without a + rewrite — no Edge route imports it today. That is why `deepEqual` is + hand-rolled rather than pulled from `node:util`. - **No logtape here.** It breaks Edge builds; use `console.warn`. ## Query plans diff --git a/scripts/check-doc-refs.mts b/scripts/check-doc-refs.mts index 06d54d46..d0c4cc77 100644 --- a/scripts/check-doc-refs.mts +++ b/scripts/check-doc-refs.mts @@ -45,7 +45,8 @@ const REPO_PATH_PREFIXES = [ ".claude/", ]; -const DOCS_REFERENCE = /(?"; +/** Arrows and function expressions have no `id`; fall back to the const they're assigned to. */ +function functionName(node, assignedName) { + if (node.id?.type === "Identifier") return node.id.name; + return assignedName ?? ""; } const servicesMutationGuards = { @@ -43,16 +51,21 @@ const servicesMutationGuards = { }, }, create(context) { - // Calls are recorded against the outermost function, so a `withTransaction` - // callback's `emitAudit` still counts for the verb that owns it. let depth = 0; let outermost = null; + let outermostName = null; + let pendingName = null; + // Anywhere in the verb, so a `withTransaction` callback's `emitAudit` counts. const calls = new Set(); + // The verb's own body, where `requireScope` belongs. + const bodyCalls = new Set(); function enter(node) { if (depth === 0) { outermost = node; + outermostName = functionName(node, pendingName); calls.clear(); + bodyCalls.clear(); } depth += 1; } @@ -62,27 +75,37 @@ const servicesMutationGuards = { if (depth !== 0 || outermost === null) return; if (calls.has("withTransaction")) { - const missing = ["requireScope", "emitAudit"].filter( - (name) => !calls.has(name), - ); + const missing = []; + if (!bodyCalls.has("requireScope")) missing.push("requireScope"); + if (!calls.has("emitAudit")) missing.push("emitAudit"); + if (missing.length > 0) { context.report({ node: outermost, messageId: "missing", data: { - name: functionName(outermost), + name: outermostName, missing: missing.map((name) => `\`${name}\``).join(" or "), }, }); } } outermost = null; + outermostName = null; + pendingName = null; } const visitor = { + VariableDeclarator(node) { + if (depth === 0 && node.id?.type === "Identifier") { + pendingName = node.id.name; + } + }, CallExpression(node) { const name = calleeName(node); - if (name) calls.add(name); + if (!name) return; + calls.add(name); + if (depth === 1) bodyCalls.add(name); }, }; for (const type of FUNCTION_TYPES) {