diff --git a/openspec/changes/spanish-copy/apply-progress.md b/openspec/changes/spanish-copy/apply-progress.md index 8cc36c1..24034db 100644 --- a/openspec/changes/spanish-copy/apply-progress.md +++ b/openspec/changes/spanish-copy/apply-progress.md @@ -94,6 +94,47 @@ RED run: 51 failed / 76 passed in `test/adapters/telegram`, plus 12 failed in `t - Leftover-English grep over Phase 2 reply paths: only domain exception messages (`NotFoundError`/`UnauthorizedError` args, never shown) and log event names remain. - Size: 6 files, +368 / -128 (src about 129 net lines changed in commands.ts/picker plus 87 catalog; rest tests). +## Phase 3 (PR3): GitHub Alerts — COMPLETE (3.1-3.5) + +Branch: `feat/spanish-copy-github` (from `main` 2152a2d). Not pushed. + +### Commits + +| Hash | Message | +|------|---------| +| ecdbb3b | feat(copy): add Spanish GitHub alert header catalog | +| ce8d7f6 | feat(copy): translate GitHub alerts to Spanish | + +### TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|------|-----------|-------|------------|-----|-------|-------------|----------| +| 3.1 | `test/domain/github.test.ts` | Unit | Full suite green before (71 files, 857 tests) | Exact Spanish output, "Revisor:"/"Por:", `it.each` over all 8 kind:action combos (no raw codes, <=4096); 10 failed | Passed | 8 combos + reviewer present/absent + merged exact output | None needed | +| 3.2 | `test/copy/catalog-language.test.ts` | Unit | Same | `GITHUB_ALERT_HEADERS` and `githubCopy` added to catalogs, 8-key check; 3 failed | Passed | Denylist + non-empty + key set + labels | None needed | +| 3.3 | `src/domain/copy.ts` | Unit | Same | Covered by 3.1/3.2 | Passed | Yes | None needed | +| 3.4 | `src/domain/github.ts` | Unit | Same | Covered by 3.1 | Passed | Yes | None needed | +| 3.5 | full suite | - | - | - | 71 files, 870 tests pass; `npm run typecheck` clean | - | - | + +### Work Unit Evidence + +| Evidence | Result | +|---|---| +| Focused test command | `npm test -- test/domain/github.test.ts test/copy`: RED 10 + 3 failed; GREEN after implementation. Full `npm test`: 870/870 | +| Runtime harness | N/A: copy only | +| Rollback boundary | `src/domain/github.ts` and the GitHub section of `src/domain/copy.ts` | + +### Deviations / notes + +- `test/adapters/github/event-mapper.test.ts` asserts mapped domain events (kind/action codes), never alert text, so it needs no Spanish assertion and was left unchanged. +- `src/domain/copy.ts` imports `GithubEventKind`/`GithubEventAction` as types from `./github` while `github.ts` imports the catalog values: type-only, so no runtime cycle. +- `githubCopy` (`reviewerLabel`, `byLabel`) added next to `GITHUB_ALERT_HEADERS`. +- Size: src +28/-3 lines (2 files), tests +46/-9 (2 files). + +## Phase 4: Final Verification — COMPLETE (4.1-4.2) + +- 4.1 Leftover-English grep over `src/` (comments excluded) across every reply path (`ctx.reply`, `answerCallbackQuery`, `chatPublisher.post`, alert sender, `format*`): no user-visible English copy. Matches remaining are only (a) domain exception messages (`UnauthorizedError`/`NotFoundError`/`ConfigError` args), (b) log event names, (c) import paths containing "topic", (d) the intentional `/profile set` field keys. Verified (a) never reach users: `runCommand` replies only with `errorReplies[err.name]` (Spanish catalog strings) and logs `err.name`/declared reason; unrecognized errors are rethrown, and there is no `bot.catch` sending text. `err.message` is used only in logs (`ConfigError` reason) and internal regexes. HTTP bodies ("Unauthorized", "Internal Server Error", "ok") go to Telegram/GitHub servers, not chat users. +- 4.2 `prompt.test.ts` unchanged (`git diff main` does not touch it). Full `npm test`: 71 files, 870 tests pass. `npm run typecheck` clean. + ### Remaining -Phase 3 (3.1-3.5), Phase 4 (4.1-4.2). +None. All tasks (1.1-4.2) complete; next: sdd-verify. diff --git a/openspec/changes/spanish-copy/tasks.md b/openspec/changes/spanish-copy/tasks.md index 586e66d..4579940 100644 --- a/openspec/changes/spanish-copy/tasks.md +++ b/openspec/changes/spanish-copy/tasks.md @@ -51,13 +51,13 @@ No `npm run harness` needed: fetch, LLM and validation logic are unchanged; fetc ## Phase 3: GitHub Alerts (PR3) -- [ ] 3.1 RED: `test/domain/github.test.ts` — header per combo (`pull_request`: PR abierto/PR cerrado/PR fusionado/Revisión solicitada; `issues`: Issue abierto/Issue cerrado/…), labels "Revisor:"/"Por:", no raw codes, ≤4096. -- [ ] 3.2 RED: extend `test/copy/catalog-language.test.ts` to `GITHUB_ALERT_HEADERS` (all 8 keys non-empty). -- [ ] 3.3 GREEN: `src/domain/copy.ts` — `Record<\`${GithubEventKind}:${GithubEventAction}\`,string>` with comment on unused `issues:merged`/`issues:review_requested`. -- [ ] 3.4 GREEN: `src/domain/github.ts` uses header map and labels. -- [ ] 3.5 Run `npm test` and `npm run typecheck`. +- [x] 3.1 RED: `test/domain/github.test.ts` — header per combo (`pull_request`: PR abierto/PR cerrado/PR fusionado/Revisión solicitada; `issues`: Issue abierto/Issue cerrado/…), labels "Revisor:"/"Por:", no raw codes, ≤4096. +- [x] 3.2 RED: extend `test/copy/catalog-language.test.ts` to `GITHUB_ALERT_HEADERS` (all 8 keys non-empty). +- [x] 3.3 GREEN: `src/domain/copy.ts` — `Record<\`${GithubEventKind}:${GithubEventAction}\`,string>` with comment on unused `issues:merged`/`issues:review_requested`. +- [x] 3.4 GREEN: `src/domain/github.ts` uses header map and labels. +- [x] 3.5 Run `npm test` and `npm run typecheck`. ## Phase 4: Final Verification (after PR3) -- [ ] 4.1 `rg` over `src/` (comments excluded) for former English fragments (e.g. "Only a team admin", "not a member", "Usage:", "Analyzing", "and \{?n\}? more", "Reviewer:", "topic") on all reply paths; expect none. -- [ ] 4.2 Confirm `prompt.test.ts` unchanged; run `npm test` and `npm run typecheck`, both green. +- [x] 4.1 `rg` over `src/` (comments excluded) for former English fragments (e.g. "Only a team admin", "not a member", "Usage:", "Analyzing", "and \{?n\}? more", "Reviewer:", "topic") on all reply paths; expect none. +- [x] 4.2 Confirm `prompt.test.ts` unchanged; run `npm test` and `npm run typecheck`, both green. diff --git a/src/domain/copy.ts b/src/domain/copy.ts index 710fbca..c42bed1 100644 --- a/src/domain/copy.ts +++ b/src/domain/copy.ts @@ -1,4 +1,5 @@ import type { PageFetchFailureKind } from "./errors"; +import type { GithubEventAction, GithubEventKind } from "./github"; import type { ExtractedFields } from "./hackathon/extraction"; // Bot-authored Spanish copy for the text the domain composes (neutral "tú" @@ -59,3 +60,25 @@ export const analysisCopy = { movedLink: "Se movió el vínculo de este análisis desde otro tema.", pinFailed: "No se pudo fijar el mensaje; se publicó sin fijar.", }; + +// One header phrase per kind:action pair; the type makes a missing pair a +// compile error. The mapper never produces `issues:merged` or +// `issues:review_requested`, but the domain type allows them, so they carry +// safe placeholder phrases instead of falling back to raw codes. +export const GITHUB_ALERT_HEADERS: Readonly< + Record<`${GithubEventKind}:${GithubEventAction}`, string> +> = { + "pull_request:opened": "PR abierto", + "pull_request:closed": "PR cerrado", + "pull_request:merged": "PR fusionado", + "pull_request:review_requested": "Revisión solicitada", + "issues:opened": "Issue abierto", + "issues:closed": "Issue cerrado", + "issues:merged": "Issue fusionado", + "issues:review_requested": "Revisión solicitada", +}; + +export const githubCopy = { + reviewerLabel: "Revisor:", + byLabel: "Por:", +}; diff --git a/src/domain/github.ts b/src/domain/github.ts index 9718c50..8838e4f 100644 --- a/src/domain/github.ts +++ b/src/domain/github.ts @@ -1,3 +1,5 @@ +import { GITHUB_ALERT_HEADERS, githubCopy } from "./copy"; + // Domain-only GitHub event model. The adapter mapper (adapters/github) is // the only place that reads raw webhook JSON — the domain never sees // payload shapes (design.md "Architecture Decisions", Event filtering). @@ -58,10 +60,10 @@ const MESSAGE_MAX = 4096; export function formatGithubAlert(event: GithubEvent): string { const title = truncate(event.title, TITLE_MAX); const lines = [ - `${event.repo} — ${event.kind} ${event.action}`, + `${event.repo} — ${GITHUB_ALERT_HEADERS[`${event.kind}:${event.action}`]}`, `#${event.number}: ${title}`, - event.reviewer !== undefined ? `Reviewer: ${event.reviewer}` : null, - `By: ${event.actor}`, + event.reviewer !== undefined ? `${githubCopy.reviewerLabel} ${event.reviewer}` : null, + `${githubCopy.byLabel} ${event.actor}`, event.url, ].filter((line): line is string => line !== null); return truncate(lines.join("\n"), MESSAGE_MAX); diff --git a/test/copy/catalog-language.test.ts b/test/copy/catalog-language.test.ts index 4c55472..2f64547 100644 --- a/test/copy/catalog-language.test.ts +++ b/test/copy/catalog-language.test.ts @@ -3,6 +3,8 @@ import { analysisCopy, FETCH_FAILURE_PHRASES, FIELD_LABELS, + GITHUB_ALERT_HEADERS, + githubCopy, moreItems, } from "../../src/domain/copy"; import { @@ -22,7 +24,7 @@ import { // Guard for the Spanish-copy change: every bot-authored string reachable from // the catalogs must be Spanish (no English words from the denylist) and every // code map must be complete. Scope: the domain catalog and the full Telegram -// adapter catalog (GitHub alert headers join in a later PR). +// adapter catalog plus the GitHub alert headers. const ENGLISH_DENYLIST = /\b(the|you|your|only|could|please|run|usage|team|member|topic|linked|analysis|page|try)\b/i; @@ -66,6 +68,8 @@ const CATALOGS: Array<[string, Catalog]> = [ ["repoCopy", repoCopy as unknown as Catalog], ["pickerCopy", pickerCopy as unknown as Catalog], ["ROLE_LABELS", ROLE_LABELS as unknown as Catalog], + ["GITHUB_ALERT_HEADERS", GITHUB_ALERT_HEADERS as unknown as Catalog], + ["githubCopy", githubCopy as unknown as Catalog], ]; describe("copy catalogs are Spanish", () => { @@ -132,4 +136,21 @@ describe("copy catalogs are Spanish", () => { "No se pudo verificar tu pertenencia al equipo. Vuelve a intentar /datachannel.", ); }); + + it("the GitHub header map covers all 8 kind:action keys with Spanish text", () => { + expect(Object.keys(GITHUB_ALERT_HEADERS).sort()).toEqual([ + "issues:closed", + "issues:merged", + "issues:opened", + "issues:review_requested", + "pull_request:closed", + "pull_request:merged", + "pull_request:opened", + "pull_request:review_requested", + ]); + for (const [key, text] of Object.entries(GITHUB_ALERT_HEADERS)) { + expect(text.trim(), key).not.toBe(""); + } + expect(githubCopy).toEqual({ reviewerLabel: "Revisor:", byLabel: "Por:" }); + }); }); diff --git a/test/domain/github.test.ts b/test/domain/github.test.ts index e77bda7..a2e617e 100644 --- a/test/domain/github.test.ts +++ b/test/domain/github.test.ts @@ -128,7 +128,7 @@ describe("formatGithubAlert", () => { expect(text.length).toBeLessThanOrEqual(4096); }); - it("includes a Reviewer line with the exact output when reviewer is set", () => { + it("includes a Revisor line with the exact output when reviewer is set", () => { const text = formatGithubAlert( makeEvent({ kind: "pull_request", @@ -138,29 +138,45 @@ describe("formatGithubAlert", () => { ); expect(text).toBe( [ - "octocat/hello-world — pull_request review_requested", + "octocat/hello-world — Revisión solicitada", "#42: Fix the thing", - "Reviewer: hubot", - "By: octocat", + "Revisor: hubot", + "Por: octocat", "https://github.com/octocat/hello-world/pull/42", ].join("\n"), ); }); - it("omits the Reviewer line entirely when reviewer is not set", () => { + it("omits the Revisor line entirely when reviewer is not set", () => { const text = formatGithubAlert(makeEvent({ action: "review_requested" })); - expect(text).not.toContain("Reviewer:"); + expect(text).not.toContain("Revisor:"); }); it("formats a merged pull_request alert with the exact output", () => { const text = formatGithubAlert(makeEvent({ kind: "pull_request", action: "merged" })); expect(text).toBe( [ - "octocat/hello-world — pull_request merged", + "octocat/hello-world — PR fusionado", "#42: Fix the thing", - "By: octocat", + "Por: octocat", "https://github.com/octocat/hello-world/pull/42", ].join("\n"), ); }); + + it.each([ + ["pull_request", "opened", "PR abierto"], + ["pull_request", "closed", "PR cerrado"], + ["pull_request", "merged", "PR fusionado"], + ["pull_request", "review_requested", "Revisión solicitada"], + ["issues", "opened", "Issue abierto"], + ["issues", "closed", "Issue cerrado"], + ["issues", "merged", "Issue fusionado"], + ["issues", "review_requested", "Revisión solicitada"], + ] as const)("uses the Spanish header for %s:%s", (kind, action, header) => { + const text = formatGithubAlert(makeEvent({ kind, action })); + expect(text.split("\n")[0]).toBe(`octocat/hello-world — ${header}`); + expect(text).not.toMatch(/pull_request|issues|review_requested|Reviewer:|By:/); + expect(text.length).toBeLessThanOrEqual(4096); + }); });