From a69d89df65140e1d1171a15618646ccf80bbaa35 Mon Sep 17 00:00:00 2001 From: Asgeir Frimannsson Date: Thu, 16 Jul 2026 22:37:41 +0800 Subject: [PATCH] feat: PR delivery, plan mode, sticky PR comments, gate annotation, self-tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with the GitLab kapi-components (and beyond kapi-action's old commit-only surface): - create-pull-request: deliver as branch + PR (convergence report in the description; labels best-effort so a missing label never fails a run). - plan: dry-run `kapi up --plan` — pending work, TM leverage, token estimate as outputs + job summary; needs no provider keys. - pr-comment: one sticky comment on pull-request events (plan, outcome, or gate result) that re-runs update in place. - command: check gets a distinct exit-3 "gate unmet" annotation and a `gate` output — a failing quality gate reads as "not on-spec yet", never as a generic crash; `|| rc=$?` keeps outputs publishing under errexit. - Convergence report always lands in GITHUB_STEP_SUMMARY. - Marketplace branding; release.yml moves the floating major tag. - Keyless self-test workflow (plan outputs, exit-3 contract, file-mode pass, no-changes path) against the untranslated fixture in test/fixture, plus a daily schedule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NBoxTTZe69Nyssa6QSMxFK --- .github/workflows/release.yml | 35 +++++ .github/workflows/test.yml | 131 +++++++++++++++++ README.md | 120 +++++++++++++--- action.yml | 256 +++++++++++++++++++++++++++++++++- test/fixture/.gitignore | 2 + test/fixture/content/en.json | 5 + test/fixture/fixture.kapi | 34 +++++ 7 files changed, 560 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 test/fixture/.gitignore create mode 100644 test/fixture/content/en.json create mode 100644 test/fixture/fixture.kapi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2cd6f08 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + update-major-tag: + name: Update floating major tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Parse version + id: version + env: + TAG: ${{ github.ref_name }} + run: | + MAJOR=$(echo "${TAG}" | grep -oP '^v\d+') + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "major=${MAJOR}" >> "$GITHUB_OUTPUT" + echo "Tag: ${TAG}, Major: ${MAJOR}" + + - name: Update major tag + env: + TAG: ${{ steps.version.outputs.tag }} + MAJOR: ${{ steps.version.outputs.major }} + run: | + git tag -f "${MAJOR}" "${TAG}" + git push -f origin "${MAJOR}" + echo "Updated ${MAJOR} → ${TAG}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a10c7d0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,131 @@ +name: Test + +# Keyless self-tests against the fixture project in test/fixture — plan mode +# and check need no provider or server credentials. The CLI version is pinned +# to the first release that ships `kapi up` / `check --ship`; bump +# deliberately. + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 5 * * *" + workflow_dispatch: + +env: + KAPI_VERSION: "1.2.0-rc11" + +jobs: + test-plan: + name: "up --plan (dry run + outputs)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: neokapi/setup-kapi@v1 + with: + version: "1.2.0-rc11" + plugins: "" + + - name: Run plan + id: plan + uses: ./ + with: + plan: "true" + project: "test/fixture/fixture.kapi" + commit: "false" + + - name: Verify plan outputs + env: + MISSING: ${{ steps.plan.outputs.plan-missing }} + TOKENS: ${{ steps.plan.outputs.plan-token-estimate }} + run: | + echo "plan: missing=${MISSING} tokens=${TOKENS}" + test "${MISSING}" -gt 0 + test "${TOKENS}" -gt 0 + + test-check-gate: + name: "check --ship (exit-3 contract)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: neokapi/setup-kapi@v1 + with: + version: "1.2.0-rc11" + plugins: "" + + # The fixture is deliberately untranslated: the ship gate must FAIL the + # step (exit 3) while still publishing gate=fail — not die before its + # outputs, and not pass. + - name: Run failing ship gate + id: gate + continue-on-error: true + uses: ./ + with: + command: check + args: "--ship" + project: "test/fixture/fixture.kapi" + commit: "false" + + - name: Verify gate contract + env: + STEP_OUTCOME: ${{ steps.gate.outcome }} + GATE: ${{ steps.gate.outputs.gate }} + run: | + echo "step outcome: ${STEP_OUTCOME}, gate: ${GATE}" + test "${STEP_OUTCOME}" = "failure" + test "${GATE}" = "fail" + + test-check-file: + name: "check (file-mode pass)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: neokapi/setup-kapi@v1 + with: + version: "1.2.0-rc11" + plugins: "" + + - name: Gate the fixture source file + id: check + uses: ./ + with: + command: check + args: "test/fixture/content/en.json" + commit: "false" + + - name: Verify pass + env: + GATE: ${{ steps.check.outputs.gate }} + run: test "${GATE}" = "pass" + + test-run-keyless: + name: "pseudo-translate (general runner)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: neokapi/setup-kapi@v1 + with: + version: "1.2.0-rc11" + plugins: "" + + - name: Pseudo-translate the fixture + id: pseudo + uses: ./ + with: + command: pseudo-translate + args: "test/fixture/content/en.json --target-lang qps" + + # The produced en_qps.json is gitignored by the fixture, so the action + # must report no-changes rather than trying to commit. + - name: Verify no-changes path + env: + STATUS: ${{ steps.pseudo.outputs.status }} + run: | + test -f test/fixture/content/en_qps.json + test "${STATUS}" = "no-changes" diff --git a/README.md b/README.md index d9f84c1..fa2f99f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Kapi Action -A GitHub Action that runs [kapi](https://github.com/neokapi/neokapi) localization commands and commits the results. +A GitHub Action that runs [kapi](https://github.com/neokapi/neokapi) localization commands and delivers the results — as a commit, a pull request, or a report on the PR that caused the work. ## Prerequisites -This action requires the `kapi` CLI to be installed. Use [`neokapi/setup-kapi@v1`](https://github.com/neokapi/setup-kapi) to install it, or add it to `PATH` yourself. +This action requires the `kapi` CLI to be installed. Use [`neokapi/setup-kapi@v1`](https://github.com/neokapi/setup-kapi) to install it (the bowrain plugin is included by default), or add it to `PATH` yourself. ## Usage ### Bring translations up to date -`kapi up` is the convergence verb, and the Action's default. In a server-connected project — a recipe with a `server:` block, plus the `kapi-bowrain` plugin — it pushes, converges on the Bowrain server (org keys, shared TM, team review), and pulls the produced targets back. With no server it runs the same loop locally. +`kapi up` is the convergence verb, and the Action's default. In a server-connected project — a recipe with a `server:` block — it pushes, converges on the Bowrain server (org keys, shared TM, team review), and pulls the produced targets back. With no server it runs the same loop locally. ```yaml name: Translations @@ -39,11 +39,11 @@ A convergence run ends in one of three states, and the Action treats them differ | Run state | What it means | What the Action does | |---|---|---| -| **converged** | Every gated scope cleared its ship gate | Commits the produced translations | -| **parked** | Work remains that the loop could not carry to the gate (a failing check, an unreachable gate) | Commits what *did* converge, and annotates the run with the parked locales. This is normal pending work, not a failure | -| **failed / canceled** | The run broke (a provider outage, a server error, a cancel) | `kapi up` exits non-zero, the step fails, **nothing is committed** | +| **converged** | Every gated scope cleared its ship gate | Delivers the produced translations | +| **parked** | Work remains that the loop could not carry to the gate (a failing check, an unreachable gate) | Delivers what *did* converge, and annotates the run with the parked locales. This is normal pending work, not a failure | +| **failed / canceled** | The run broke (a provider outage, a server error, a cancel) | `kapi up` exits non-zero, the step fails, **nothing is delivered** | -Parked is the interesting one: partial progress is real progress, so the default is to commit it and warn rather than throw it away. To block instead: +Parked is the interesting one: partial progress is real progress, so the default is to deliver it and warn rather than throw it away. To block instead: ```yaml - uses: neokapi/kapi-action@v1 @@ -51,20 +51,81 @@ Parked is the interesting one: partial progress is real progress, so the default fail-on-parked: "true" ``` -Under the hood the Action runs `kapi up --json`, an NDJSON stream — one convergence event per line, closed by a single `{"type":"result", ...}` record. That record is the contract; the events are the log. It becomes the `outcome`, `passes`, and `parked-locales` outputs: +The convergence report (outcome, passes, parked locales) is always written to the job summary. Under the hood the Action runs `kapi up --json`, an NDJSON stream — one convergence event per line, closed by a single `{"type":"result", ...}` record. That record is the contract; the events are the log. It becomes the `outcome`, `passes`, and `parked-locales` outputs. + +### Deliver as a pull request + +By default the Action commits to the current branch. With `create-pull-request: "true"` it delivers a branch + PR instead — the reviewable unit, and the path that works with branch protection on the default branch: ```yaml -- uses: neokapi/kapi-action@v1 - id: kapi - with: - commit: "false" +permissions: + contents: write + pull-requests: write + +steps: + - uses: neokapi/kapi-action@v1 + with: + create-pull-request: "true" +``` + +The created PR carries the convergence report in its description. `pr-title`, `pr-labels`, `pr-base`, and `branch-prefix` tune it; labels are applied best-effort (a label that doesn't exist in the repo never fails the run). The PR URL lands in the `pull-request-url` output. + +### Plan mode: the cost of a change, on its PR + +`plan: "true"` dry-runs the convergence loop — pending work, TM leverage, and a token estimate, with no writes and no provider calls (so it needs no API keys). With `pr-comment: "true"` on a pull-request event, the plan lands as one sticky comment that re-runs update in place: + +```yaml +name: Translation plan +on: + pull_request: + paths: ["src/locales/en/**", "content/**"] + +permissions: + pull-requests: write + +jobs: + plan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: neokapi/setup-kapi@v1 + - uses: neokapi/kapi-action@v1 + with: + plan: "true" + pr-comment: "true" +``` + +> "This change leaves **42 unit(s)** of pending translation work: 30 recoverable from TM, 12 for AI (~450 tokens estimated)." + +### Gate pull requests on content quality + +`command: check` with `--ship` is the release bar: the project's bound quality gates (brand, terminology, QA) plus its ship/source coverage gates. An unmet gate exits `3`, which the Action surfaces as a distinct **"gate unmet"** annotation (not a generic failure) and as the `gate` output: + +```yaml +name: Ship gate +on: + pull_request: + paths: ["content/**", "src/locales/**"] + +permissions: + pull-requests: write -- run: | - echo "outcome: ${{ steps.kapi.outputs.outcome }}" - echo "passes: ${{ steps.kapi.outputs.passes }}" - echo "parked: ${{ steps.kapi.outputs.parked-locales }}" +jobs: + ship-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: neokapi/setup-kapi@v1 + - uses: neokapi/kapi-action@v1 + with: + command: check + args: "--ship" + commit: "false" + pr-comment: "true" ``` +Ordinary builds never fail on target-language drift — a locale that is behind is pending work, not an error. `check --ship` is the explicit, opt-in enforcement point. + ### Run any other kapi command `command` takes any kapi subcommand — the Action stays a general runner. The convergence outputs are only populated for `up`. @@ -81,6 +142,20 @@ Under the hood the Action runs `kapi up --json`, an NDJSON stream — one conver This runs `kapi run -p myproject.kapi translate`. +### Caching + +Convergence is incremental via the project's `.kapi/cache` (block store, extractions), which is gitignored and therefore rebuilt on every fresh runner. Restore it across runs to skip re-extraction: + +```yaml +- uses: actions/cache@v5 + with: + path: .kapi/cache + key: kapi-cache-${{ hashFiles('*.kapi', 'src/locales/en/**') }} + restore-keys: kapi-cache- +``` + +Server-connected projects don't need this — the convergence state lives on the server. + ## Inputs | Input | Default | Description | @@ -88,9 +163,17 @@ This runs `kapi run -p myproject.kapi translate`. | `command` | `up` | Kapi subcommand to execute | | `args` | | Additional arguments | | `project` | | Path to `.kapi` project file (`-p` flag) | +| `plan` | `false` | With `command: up`: dry run — pending work, TM leverage, token estimate; no writes, no provider calls | | `fail-on-parked` | `false` | With `command: up`, fail the workflow when the run parks instead of committing partial progress | | `commit` | `true` | Whether to commit changes | | `commit-message` | `chore: update translations via kapi` | Commit message | +| `create-pull-request` | `false` | Deliver as a branch + PR instead of pushing the current branch | +| `pr-title` | `chore: update translations via kapi` | Title for the created PR | +| `pr-labels` | `translations,kapi` | Comma-separated labels (best-effort) | +| `pr-base` | | Base branch for the PR (empty = repository default) | +| `branch-prefix` | `kapi/up` | Branch prefix for PR delivery | +| `pr-comment` | `false` | Sticky report comment on pull-request events | +| `token` | `${{ github.token }}` | Token for PR creation and comments | | `git-user-name` | `Kapi Bot` | Git committer name | | `git-user-email` | `bot@kapi.dev` | Git committer email | | `paths` | | Space-separated paths to stage for commit (all changes if empty) | @@ -103,12 +186,15 @@ This runs `kapi run -p myproject.kapi translate`. | `outcome` | With `command: up`: `converged` or `parked` (a failed run fails the step, so it never reaches an output) | | `passes` | With `command: up`: how many reconciliation passes the run took | | `parked-locales` | With `command: up`: comma-separated locales still short of their gate | +| `gate` | With `command: check`: `pass` or `fail` | +| `plan-missing` / `plan-tm-exact` / `plan-ai-remaining` / `plan-token-estimate` | With `plan: true`: the plan totals | | `committed` | `true` or `false` | | `commit-sha` | SHA of the created commit (empty if no commit) | +| `pull-request-url` | URL of the created PR (empty if none) | ## Permissions -The workflow must have `permissions: contents: write` for the commit step to push changes. +`permissions: contents: write` for commit/push delivery; add `pull-requests: write` for `create-pull-request` and `pr-comment`. ## License diff --git a/action.yml b/action.yml index a26edc6..31d50a2 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,8 @@ name: "Kapi Run" -description: "Run kapi localization commands and commit results" +description: "Run kapi localization commands — converge, gate, plan — and deliver the results as a commit or pull request" +branding: + icon: "refresh-cw" + color: "blue" inputs: command: @@ -14,6 +17,13 @@ inputs: description: "Path to .kapi project file (-p flag)" required: false default: "" + plan: + description: >- + With `command: up`, dry-run instead: report pending work, TM leverage, + and a token estimate without writing anything or calling a provider. + Pairs with `pr-comment` to post the cost of a change on its PR. + required: false + default: "false" fail-on-parked: description: >- With `command: up`, fail the workflow when the run parks (work remains that @@ -30,6 +40,39 @@ inputs: description: "Commit message" required: false default: "chore: update translations via kapi" + create-pull-request: + description: >- + Deliver changes as a pull request instead of pushing to the current + branch. Needs `permissions: pull-requests: write` (plus contents: write). + required: false + default: "false" + pr-title: + description: "Title for the created pull request" + required: false + default: "chore: update translations via kapi" + pr-labels: + description: "Comma-separated labels for the created pull request" + required: false + default: "translations,kapi" + pr-base: + description: "Base branch for the created pull request (empty = repository default branch)" + required: false + default: "" + branch-prefix: + description: "Branch name prefix for pull-request delivery" + required: false + default: "kapi/up" + pr-comment: + description: >- + On pull-request events, post one sticky comment with the report (plan, + convergence outcome, or gate result) that re-runs update in place. + Needs `permissions: pull-requests: write`. + required: false + default: "false" + token: + description: "GitHub token for PR creation and comments" + required: false + default: ${{ github.token }} git-user-name: description: "Git committer name" required: false @@ -56,12 +99,30 @@ outputs: parked-locales: description: "With `command: up`: comma-separated locales still short of their gate" value: ${{ steps.run-kapi.outputs.parked-locales }} + gate: + description: "With `command: check`: `pass` or `fail`" + value: ${{ steps.run-kapi.outputs.gate }} + plan-missing: + description: "With `plan: true`: pending units across all locales" + value: ${{ steps.run-kapi.outputs.plan-missing }} + plan-tm-exact: + description: "With `plan: true`: units recoverable from TM exact hits" + value: ${{ steps.run-kapi.outputs.plan-tm-exact }} + plan-ai-remaining: + description: "With `plan: true`: units left for AI translation" + value: ${{ steps.run-kapi.outputs.plan-ai-remaining }} + plan-token-estimate: + description: "With `plan: true`: estimated tokens for the remaining units" + value: ${{ steps.run-kapi.outputs.plan-token-estimate }} committed: description: "Whether a commit was created" value: ${{ steps.set-outputs.outputs.committed }} commit-sha: description: "SHA of the created commit (empty if no commit)" value: ${{ steps.set-outputs.outputs.commit-sha }} + pull-request-url: + description: "URL of the created pull request (empty if none)" + value: ${{ steps.deliver.outputs.pr-url }} runs: using: "composite" @@ -76,6 +137,7 @@ runs: COMMAND: ${{ inputs.command }} ARGS: ${{ inputs.args }} PROJECT: ${{ inputs.project }} + PLAN: ${{ inputs.plan }} FAIL_ON_PARKED: ${{ inputs.fail-on-parked }} run: | set -euo pipefail @@ -88,6 +150,68 @@ runs: cmd+=("${extra[@]}") fi + # ---------- plan mode: dry run, no writes, no provider calls ---------- + if [ "$COMMAND" = "up" ] && [ "$PLAN" = "true" ]; then + cmd+=(--plan --json) + printf 'Running:'; printf ' %q' "${cmd[@]}"; echo + "${cmd[@]}" > "${RUNNER_TEMP}/kapi-plan.json" + MISSING=$(jq -r '.totals.missingTarget // 0' "${RUNNER_TEMP}/kapi-plan.json") + TM=$(jq -r '.totals.tmExact // 0' "${RUNNER_TEMP}/kapi-plan.json") + AI=$(jq -r '.totals.aiRemaining // 0' "${RUNNER_TEMP}/kapi-plan.json") + TOKENS=$(jq -r '.totals.tokenEstimate // 0' "${RUNNER_TEMP}/kapi-plan.json") + { + echo "mode=plan" + echo "plan-missing=${MISSING}" + echo "plan-tm-exact=${TM}" + echo "plan-ai-remaining=${AI}" + echo "plan-token-estimate=${TOKENS}" + } >> "$GITHUB_OUTPUT" + { + echo "## kapi up — plan" + echo + echo "This change leaves **${MISSING} unit(s)** of pending translation work: ${TM} recoverable from TM, ${AI} for AI (~${TOKENS} tokens estimated)." + echo + echo "| Locale | Pending | TM exact | AI remaining | Token estimate |" + echo "|---|---|---|---|---|" + jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.tmExact) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" + echo + echo "_Estimate is source-chars/4; no provider calls were made._" + } >> "$GITHUB_STEP_SUMMARY" + echo "Plan: ${MISSING} unit(s) pending, ${TM} from TM, ${AI} for AI (~${TOKENS} tokens)." + exit 0 + fi + + # ---------- check: the quality gate, with a distinct exit-3 signal ---------- + if [ "$COMMAND" = "check" ]; then + printf 'Running:'; printf ' %q' "${cmd[@]}"; echo + # || rc=$? keeps the gate's exit code without tripping errexit — the + # annotation, summary, and outputs must still publish on a failing gate. + rc=0 + "${cmd[@]}" | tee "${RUNNER_TEMP}/kapi-check-output.txt" || rc=$? + if [ "$rc" -eq 0 ]; then + echo "gate=pass" >> "$GITHUB_OUTPUT" + { + echo "## kapi check" + echo + echo "✅ **Gate: pass**" + } >> "$GITHUB_STEP_SUMMARY" + elif [ "$rc" -eq 3 ]; then + echo "gate=fail" >> "$GITHUB_OUTPUT" + { + echo "## kapi check" + echo + echo "❌ **Gate: unmet** — a brand, terminology, QA, or coverage gate failed. See the findings in the job log." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::kapi gate unmet (exit 3) — content quality gates failed; read the findings and fix them. This is 'not on-spec yet', not a crash." + exit 3 + else + echo "run_status=failed" >> "$GITHUB_OUTPUT" + echo "::error::kapi check failed operationally (exit ${rc})" + exit "$rc" + fi + exit 0 + fi + # `kapi up` is the convergence verb: in a server-connected project it # pushes, converges on the Bowrain server, and pulls the produced targets # back; with no server it runs the same loop locally. Under --json it is @@ -146,6 +270,16 @@ runs: echo "parked-locales=${PARKED}" } >> "$GITHUB_OUTPUT" + { + echo "## kapi up — convergence report" + echo + echo "| | |" + echo "|---|---|" + echo "| Outcome | **${OUTCOME}** |" + echo "| Passes | ${PASSES} |" + echo "| Parked locales | ${PARKED:-—} |" + } >> "$GITHUB_STEP_SUMMARY" + echo "Run ${OUTCOME} after ${PASSES} pass(es)." if [ "$OUTCOME" = "parked" ]; then # Parked is pending work, not a broken run: what did converge is real @@ -159,6 +293,7 @@ runs: - name: Check for changes id: check-changes + if: inputs.plan != 'true' shell: bash env: PATHS: ${{ inputs.paths }} @@ -191,19 +326,30 @@ runs: echo "has_changes=true" >> "$GITHUB_OUTPUT" fi - - name: Commit changes - id: commit - if: inputs.commit == 'true' && steps.check-changes.outputs.has_changes == 'true' + - name: Commit and deliver changes + id: deliver + if: inputs.plan != 'true' && inputs.commit == 'true' && steps.check-changes.outputs.has_changes == 'true' shell: bash env: GIT_USER_NAME: ${{ inputs.git-user-name }} GIT_USER_EMAIL: ${{ inputs.git-user-email }} COMMIT_MESSAGE: ${{ inputs.commit-message }} PATHS: ${{ inputs.paths }} + CREATE_PR: ${{ inputs.create-pull-request }} + PR_TITLE: ${{ inputs.pr-title }} + PR_LABELS: ${{ inputs.pr-labels }} + PR_BASE: ${{ inputs.pr-base }} + BRANCH_PREFIX: ${{ inputs.branch-prefix }} + GH_TOKEN: ${{ inputs.token }} + RUN_ID: ${{ github.run_id }} + OUTCOME: ${{ steps.run-kapi.outputs.outcome }} + PASSES: ${{ steps.run-kapi.outputs.passes }} + PARKED: ${{ steps.run-kapi.outputs.parked-locales }} run: | set -euo pipefail git config user.name "$GIT_USER_NAME" git config user.email "$GIT_USER_EMAIL" + if [ -z "$PATHS" ]; then git add -A else @@ -222,16 +368,114 @@ runs: exit 0 fi + if [ "$CREATE_PR" != "true" ]; then + git commit -m "$COMMIT_MESSAGE" + git push + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + exit 0 + fi + + BRANCH="${BRANCH_PREFIX}-${RUN_ID}" + git checkout -b "$BRANCH" git commit -m "$COMMIT_MESSAGE" - git push + git push -u origin "$BRANCH" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + BODY=" + ## kapi up — convergence report + + | | | + |---|---| + | Outcome | **${OUTCOME:-—}** | + | Passes | ${PASSES:-—} | + | Parked locales | ${PARKED:-—} | + + Produced by [kapi-action](https://github.com/neokapi/kapi-action). Parked scopes need a person — see the run log for the gate each one is short of." + PR_ARGS=(--title "$PR_TITLE" --body "$BODY" --head "$BRANCH") + [ -n "$PR_BASE" ] && PR_ARGS+=(--base "$PR_BASE") + PR_URL=$(gh pr create "${PR_ARGS[@]}") + echo "pr-url=${PR_URL}" >> "$GITHUB_OUTPUT" + echo "Opened ${PR_URL}" + # Labels are best-effort: gh fails when a label doesn't exist in the + # repo, and a missing label must never fail a delivered run. + if [ -n "$PR_LABELS" ]; then + gh pr edit "$PR_URL" --add-label "$PR_LABELS" || echo "::notice::could not add labels '${PR_LABELS}' (create them in the repo to label kapi PRs)" + fi + + - name: Sticky PR comment + if: inputs.pr-comment == 'true' && github.event_name == 'pull_request' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MODE: ${{ steps.run-kapi.outputs.mode }} + OUTCOME: ${{ steps.run-kapi.outputs.outcome }} + PASSES: ${{ steps.run-kapi.outputs.passes }} + PARKED: ${{ steps.run-kapi.outputs.parked-locales }} + GATE: ${{ steps.run-kapi.outputs.gate }} + PLAN_MISSING: ${{ steps.run-kapi.outputs.plan-missing }} + PLAN_TM: ${{ steps.run-kapi.outputs.plan-tm-exact }} + PLAN_AI: ${{ steps.run-kapi.outputs.plan-ai-remaining }} + PLAN_TOKENS: ${{ steps.run-kapi.outputs.plan-token-estimate }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + MARKER="" + + if [ "$MODE" = "plan" ]; then + TABLE=$(jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.tmExact) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" 2>/dev/null || true) + BODY="${MARKER} + ## kapi up — plan + + This change leaves **${PLAN_MISSING:-0} unit(s)** of pending translation work: ${PLAN_TM:-0} recoverable from TM, ${PLAN_AI:-0} for AI (~${PLAN_TOKENS:-0} tokens estimated). + + | Locale | Pending | TM exact | AI remaining | Token estimate | + |---|---|---|---|---| + ${TABLE} + + _[Run](${RUN_URL}) · estimate is source-chars/4, no provider calls._" + elif [ -n "$GATE" ]; then + if [ "$GATE" = "pass" ]; then + HEAD="✅ **Gate: pass** — every requested gate passed." + else + HEAD="❌ **Gate: unmet** — a content quality gate failed. See the [run log](${RUN_URL}) for the findings." + fi + BODY="${MARKER} + ## kapi check + + ${HEAD}" + elif [ -n "$OUTCOME" ]; then + BODY="${MARKER} + ## kapi up — convergence report + + | | | + |---|---| + | Outcome | **${OUTCOME}** | + | Passes | ${PASSES:-—} | + | Parked locales | ${PARKED:-—} | + + _[Run](${RUN_URL})_" + else + echo "Nothing to report — skipping comment." + exit 0 + fi + + COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ + --jq "[.[] | select(.body | startswith(\"${MARKER}\"))][0].id // empty") + if [ -n "$COMMENT_ID" ]; then + gh api --method PATCH "repos/${REPO}/issues/comments/${COMMENT_ID}" -f body="$BODY" > /dev/null + else + gh api --method POST "repos/${REPO}/issues/${PR_NUMBER}/comments" -f body="$BODY" > /dev/null + fi + echo "Report posted to PR #${PR_NUMBER}." + - name: Set outputs id: set-outputs shell: bash env: HAS_CHANGES: ${{ steps.check-changes.outputs.has_changes }} - COMMIT_SHA: ${{ steps.commit.outputs.sha }} + COMMIT_SHA: ${{ steps.deliver.outputs.sha }} run: | set -euo pipefail if [ "$HAS_CHANGES" = "true" ]; then diff --git a/test/fixture/.gitignore b/test/fixture/.gitignore new file mode 100644 index 0000000..24c4ff8 --- /dev/null +++ b/test/fixture/.gitignore @@ -0,0 +1,2 @@ +.kapi/ +content/en_*.json diff --git a/test/fixture/content/en.json b/test/fixture/content/en.json new file mode 100644 index 0000000..e087f2f --- /dev/null +++ b/test/fixture/content/en.json @@ -0,0 +1,5 @@ +{ + "app.title": "Acme Translator", + "app.greeting": "Hello, world!", + "app.cta": "Get started" +} diff --git a/test/fixture/fixture.kapi b/test/fixture/fixture.kapi new file mode 100644 index 0000000..cb0b4e5 --- /dev/null +++ b/test/fixture/fixture.kapi @@ -0,0 +1,34 @@ +version: v1 +name: fixture +defaults: + source_language: en + target_languages: + - fr + - de + +# Define content and flows. Each bare content entry maps a source glob to a +# target; kapi tools read the source content and edit, check, or translate it. +# The {lang} placeholder in a target fans output out per language. Runtime block +# state lives in .kapi/cache/blocks.db. +# +# content: +# - path: "src/locales/en/*.json" +# format: json +# target: "src/locales/{lang}/*.json" +# +# flows: +# # Monolingual: check source content against brand voice and terminology. +# brand-check: +# steps: +# - tool: brand-vocab-check +# - tool: brand-voice-check +# # Multilingual: translate the source into each target language. +# translate: +# steps: +# - tool: translate +# +# Tip: 'kapi init --framework ' pre-fills content for a known stack. +content: + - path: "content/en.json" + target: "content/{lang}.json" +flows: {}