From 545a9b6b8891a0e4c7fb84abd0006b4e5ec02d1b Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Tue, 20 Oct 2026 02:42:34 +0100 Subject: [PATCH] init --- README.md | 10 + license-bot/.dockerignore | 4 + license-bot/.env.example | 68 ++ license-bot/.gitignore | 4 + license-bot/.license-header.tpl | 14 + license-bot/.licenseignore | 30 + license-bot/Dockerfile | 36 + license-bot/README.md | 204 ++++++ license-bot/SECURITY.md | 302 ++++++++ license-bot/deploy.sh | 251 +++++++ license-bot/docker-compose.yaml | 87 +++ license-bot/go.mod | 3 + license-bot/internal/bot/bot.go | 642 ++++++++++++++++++ license-bot/internal/bot/bot_test.go | 558 +++++++++++++++ license-bot/internal/config/config.go | 133 ++++ license-bot/internal/config/config_test.go | 52 ++ license-bot/internal/gitea/client.go | 241 +++++++ license-bot/internal/gitea/client_test.go | 147 ++++ license-bot/internal/licenser/licenser.go | 355 ++++++++++ .../internal/licenser/licenser_test.go | 460 +++++++++++++ license-bot/main.go | 53 ++ 21 files changed, 3654 insertions(+) create mode 100644 README.md create mode 100644 license-bot/.dockerignore create mode 100644 license-bot/.env.example create mode 100644 license-bot/.gitignore create mode 100644 license-bot/.license-header.tpl create mode 100644 license-bot/.licenseignore create mode 100644 license-bot/Dockerfile create mode 100644 license-bot/README.md create mode 100644 license-bot/SECURITY.md create mode 100755 license-bot/deploy.sh create mode 100644 license-bot/docker-compose.yaml create mode 100644 license-bot/go.mod create mode 100644 license-bot/internal/bot/bot.go create mode 100644 license-bot/internal/bot/bot_test.go create mode 100644 license-bot/internal/config/config.go create mode 100644 license-bot/internal/config/config_test.go create mode 100644 license-bot/internal/gitea/client.go create mode 100644 license-bot/internal/gitea/client_test.go create mode 100644 license-bot/internal/licenser/licenser.go create mode 100644 license-bot/internal/licenser/licenser_test.go create mode 100644 license-bot/main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..c592871 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# infra + +This repo holds all Dcntrlized organization infrastructure that could benefit from having version control (scripts, docker compose files, non-repo-bound actions, etc) + +Note that a cleanup might be necessary to remove dev vm references (tests expect vm to be up and at a specific IP, same applies to core Dcntrlized repos) + +| Directory | What it is | +|---|---| +| `license-bot/` | The AGPL header bot that enforces the branch-protection gate. | + diff --git a/license-bot/.dockerignore b/license-bot/.dockerignore new file mode 100644 index 0000000..a41f42e --- /dev/null +++ b/license-bot/.dockerignore @@ -0,0 +1,4 @@ +.git +.env +license-bot +*.log diff --git a/license-bot/.env.example b/license-bot/.env.example new file mode 100644 index 0000000..aa3a9f6 --- /dev/null +++ b/license-bot/.env.example @@ -0,0 +1,68 @@ +# Copy to .env and fill in the secrets. Never commit .env. +# +# Three values are what a new instance needs: +# 1. GITEA_URL -- the instance the bot manages +# 2. GITEA_TOKEN -- a DEDICATED BOT ACCOUNT's token (never an admin token) +# 3. WEBHOOK_SECRET -- the HMAC secret of the org webhook (webhooks are the +# only trigger) +# plus NETWORK_NAME, the Docker network Gitea already runs on. +# +# On an instance whose Gitea runs on the same Docker daemon, point GITEA_URL at +# the name Gitea's container has ON that network, so the bot needs no egress and +# no published port. The alias is instance-specific -- it is the Gitea +# container's name plus its Compose service name: +# GITEA_URL=http://gitea:3000 # a Compose service named `gitea` +# GITEA_URL=http://gitea_dcntrlized:3000 # prod: /docker/org.dcntrlized.git +# +# NETWORK_NAME is the REAL (project-prefixed) Docker network name, not just the +# key in Gitea's compose file -- a Compose-defined network is named +# `_`. Derive it from the running Gitea container: +# docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' gitea_dcntrlized +# -> orgdcntrlizedgit_gitea_dcntrlized +# deploy.sh does this for you when NETWORK_NAME is empty. +# +# Remote/HTTPS instance (e.g. behind a tunnel or reverse proxy): +# GITEA_URL=https://git.example.org +# NETWORK_NAME=dcntrlized_net +GITEA_URL= + +# The URL a HUMAN follows from the bot's comments (commit/file links). Set this +# whenever GITEA_URL is the internal alias so the links are clickable: +# GITEA_URL=http://gitea:3000 +# GITEA_PUBLIC_URL=https://git.example.org +# Defaults to GITEA_URL when empty. +GITEA_PUBLIC_URL= + +# Bot-account token with write access to the allowlisted repositories. Scopes: +# write:repository (clone/push/commit status) + read:issue + write:issue +# (read the PR file list, post/update the comment). +GITEA_TOKEN= + +# HMAC-SHA256 secret configured on the Gitea webhook. Generate one with: +# openssl rand -hex 32 +# The bot rejects any delivery whose X-Gitea-Signature does not match. +WEBHOOK_SECRET= + +# Docker network that already contains Gitea (see docker-compose.yaml). This is +# the real Docker name, project-prefixed if Gitea's compose defines it. Leave +# empty and deploy.sh derives it from the Gitea container; set it explicitly +# (recommended) once you know it. +NETWORK_NAME= + +# Comma-separated owner/name allowlist. Nothing outside it is ever touched. +REPOS=Dcntrlized/Maestro,Dcntrlized/KeepNote,Dcntrlized/Calendar,Dcntrlized/dcntrlized-auth,Dcntrlized/dcntrlized-db + +# Commit-status context. It must equal the string configured in branch +# protection, byte for byte, or the merge gate silently never matches. +STATUS_CONTEXT="license-bot / addlicense" + +LISTEN=:8080 +DRY_RUN=false +LOG_LEVEL=info + +# Optional host binding for the webhook/health port (loopback by default). +BOT_BIND=127.0.0.1 +BOT_PORT=18080 + +# Optional: pin the image tag (default license-bot:dev, built locally). +# LICENSE_BOT_IMAGE=registry.example/owner/license-bot:1 diff --git a/license-bot/.gitignore b/license-bot/.gitignore new file mode 100644 index 0000000..98870b2 --- /dev/null +++ b/license-bot/.gitignore @@ -0,0 +1,4 @@ +.env +/bin/ +/license-bot +*.log diff --git a/license-bot/.license-header.tpl b/license-bot/.license-header.tpl new file mode 100644 index 0000000..be776d8 --- /dev/null +++ b/license-bot/.license-header.tpl @@ -0,0 +1,14 @@ +Copyright (C) {{.Year}} {{.Holder}} + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/license-bot/.licenseignore b/license-bot/.licenseignore new file mode 100644 index 0000000..591c418 --- /dev/null +++ b/license-bot/.licenseignore @@ -0,0 +1,30 @@ +# addlicense ignore patterns (doublestar globs, relative to the repo root). +# Blank lines and lines starting with '#' are skipped. addlicense validates every +# pattern and skips file types it does not understand on its own, so this file is +# only the escape hatch for files that must not carry an AGPL header. + +# generated / vendored / build output +**/node_modules/** +**/vendor/** +**/tmp/** +**/dist/** +**/build/** + +# non-code and config files +**/*.md +**/*.json +**/*.yaml +**/*.yml +**/*.toml +**/*.lock +**/*.sum +**/*.mod +**/*.log +**/*.txt +**/*.debug + +# repo plumbing +.licenseignore +.gitignore +.gitmodules +.vscode/** diff --git a/license-bot/Dockerfile b/license-bot/Dockerfile new file mode 100644 index 0000000..1d268b1 --- /dev/null +++ b/license-bot/Dockerfile @@ -0,0 +1,36 @@ +# Copyright (C) 2026 Dcntrlized +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# syntax=docker/dockerfile:1 + +FROM golang:1.25-alpine AS build +RUN apk add --no-cache git ca-certificates +# addlicense is deliberately the same binary/version the Gitea workflow uses. +RUN go install github.com/google/addlicense@v1.2.0 +WORKDIR /src +COPY go.mod ./ +COPY . . +ARG VERSION=0.0.1 +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/license-bot . + +FROM alpine:3.22 +RUN apk add --no-cache git ca-certificates \ + && adduser -D -u 10001 -h /home/bot bot +COPY --from=build /out/license-bot /usr/local/bin/license-bot +COPY --from=build /go/bin/addlicense /usr/local/bin/addlicense +USER bot +WORKDIR /home/bot +EXPOSE 8080 +ENTRYPOINT ["license-bot"] diff --git a/license-bot/README.md b/license-bot/README.md new file mode 100644 index 0000000..b77e5f8 --- /dev/null +++ b/license-bot/README.md @@ -0,0 +1,204 @@ +# license-bot + +Adds the AGPL-3.0 license header to files a pull request **adds** without one, +in a separate, revertable commit on the pull request head, comments on the pull +request, and posts the `license-bot / addlicense` commit status that branch +protection requires before merging. Works for same-repository pull requests +**and** for fork pull requests. + +Version v0.0.1 takes no arguments and has one trigger: **Gitea webhooks**. + +## Why a service instead of a Gitea Actions workflow + +- Gitea evaluates a pull request's workflows **from the head branch**, so a + contributor (or a fork) can edit, delete or fake the job — including reporting + a green status. A workflow therefore cannot be an enforcement boundary. +- Actions runs triggered by fork pull requests get no repository secrets and a + read-only token, so a workflow can never hold the credentials needed to push a + commit back to a contributor's fork. +- **Nothing permissive lives in Actions.** The bot-account token exists only in + this service's environment on the Gitea host. An in-repo PR workflow can read + every repo and org Actions secret (measured: the value left the host over HTTP + while the log showed `***`), so the fewer credentials reachable from CI, the + smaller the blast radius. +- The merge gate is the **commit status**, not a workflow run: a required + branch-protection check that was never posted blocks the merge (`405`), while + a required *scoped workflow* that never ran does not (`200`, measured on + 1.27.2). + +## How it works + +1. On startup it reconciles once: every open pull request of every allowlisted + repository whose head does not already carry a successful + `license-bot / addlicense` status is checked. The status is the durable record + of what the bot has already resolved, so the scan is self-healing without a + cursor. +2. While running it receives Gitea `pull_request` webhooks (HMAC-SHA256 + verified against `WEBHOOK_SECRET`, duplicate delivery ids ignored) and + processes `opened`, `synchronize`, `reopened` and `ready_for_review`. +3. It re-reads the pull request from the API (the payload is only a hint) and + lists the files the pull request **changed** that still exist at the head: + `added`, `modified`, `renamed` and `copied` are all in scope, so an existing + unlicensed file gets its header the first time any pull request touches it. + Removals are skipped (there is no content left to license), and a + deletion-only pull request is reported `success` ("no changed files need a + license header") with no comment and no commit. Otherwise it posts `pending` + for those files and logs what it is about to check. +4. It clones the head branch (in-repo or fork), runs `google/addlicense` + v1.2.0 with the repository's own `.license-header.tpl` and `.licenseignore` + over exactly those files. Because the scope includes modified files, a + `.licenseignore` that is missing an entry makes the bot rewrite a file the + contributor only touched — keep vendored and generated paths listed there. +5. If a header was added: it creates **its own commit on top of the pull request + head** — never an amend, never a force-push. The contributor's commit keeps + its sha, message and author; the bot is the committer of the extra commit, + whose body says it can be reverted or dropped freely. +6. It pushes with a plain fast-forward `git push`. If the branch moved meanwhile + the push is rejected; the bot drops its local work, re-reads the tip, + re-applies the headers and retries (up to 5 attempts, then the next event + retries). It never rewrites somebody else's history. +7. It replaces its comment — the previous one is deleted and a fresh one posted, + never edited in place, so the message always sits at the bottom of the + timeline next to the latest status — and resolves the status: `success` when + the head carries the headers, `failure` when the contributor has to add them + by hand (maintainer edits disabled), `error` if the bot itself failed. A + pass that finds nothing left to fix deletes a stale problem comment so a + resolved failure never sits next to a green status. A crash leaves the status + `pending`, which keeps the gate closed until the startup reconcile resolves it. + +Loop safety: the bot's own commit produces one more `synchronize` event; that +run finds nothing to fix and reports success. Processed `(repo, PR, sha)` +triples are cached in memory and already-green heads are skipped. + +## Failure semantics + +Webhooks are the only trigger, so **a check that did not run means the service +was down**, not that some other component failed. The consequences are bounded +by design: + +- If the service is down when a pull request opens, no status is posted; the + gate (if enabled) refuses to merge — nothing unlicensed slips through. +- When the service starts, the reconciliation pass processes every open pull + request that is not green, including ones left `pending` by a crash. +- A failed *check* (push refused, API error) resolves the status to `failure` + or `error` and comments, so a parked pull request is visible rather than + silently green. + +## Configuration + +All configuration is environment driven — the image contains no secrets and the +process takes no arguments. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `GITEA_URL` | dev-VM fallback, required by the compose | Target instance base URL. On an instance whose Gitea runs on the same Docker daemon use the network alias (e.g. `http://gitea:3000`) so the bot needs no egress and no published port. | +| `GITEA_PUBLIC_URL` | `GITEA_URL` | URL used for the links in the bot's comments. Set it when `GITEA_URL` is the internal Docker alias, otherwise a reader's browser cannot resolve `http://gitea:3000`. | +| `GITEA_TOKEN` | — (required) | Bot-account token with write access to the target repositories. Needs `write:repository` (clone/push/commit status) plus `read:issue` + `write:issue` (read the files list, post/update the comment) — a repository-only token authenticates but cannot comment. | +| `WEBHOOK_SECRET` | — (required) | HMAC-SHA256 secret of the org webhook. Every delivery is rejected unless `X-Gitea-Signature` matches. | +| `REPOS` | the five Dcntrlized repos | Comma-separated `owner/name` allowlist. Nothing outside it is ever touched. | +| `STATUS_CONTEXT` | `license-bot / addlicense` | Commit-status context; must equal the branch-protection string byte for byte. | +| `LISTEN` | `:8080` | Webhook and health listen address. | +| `DRY_RUN` | `false` | Inspect and report but never commit or push. | +| `BOT_NAME` / `BOT_EMAIL` | `Dcntrlized Auto CopyLeft Bot` / `copyleft+bot@dcntrlized.org` | Committer identity of the bot's commit. | +| `HOLDER` | `Dcntrlized` | `addlicense -c` value. | +| `ADDLICENSE_BIN` | `addlicense` | `addlicense` executable. | +| `WORK_ROOT` | `$TMPDIR` | Where per-pull-request worktrees are created. | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. | + +Copy `.env.example` and fill in `GITEA_TOKEN` and `WEBHOOK_SECRET`. Never commit +that file. + +Routes: `POST /hook` (webhook, `POST /webhook` is accepted as an alias) and +`GET /healthz` (container healthcheck). + +## Deploying to an instance + +Three values plus one command: + +```sh +cp .env.example .env +# GITEA_URL = the instance (network alias when Gitea shares the daemon) +# GITEA_TOKEN = a DEDICATED BOT ACCOUNT token, never an admin token +# WEBHOOK_SECRET = openssl rand -hex 32 +# NETWORK_NAME = the network the Gitea container already runs on +./deploy.sh +``` + +`deploy.sh` creates the network if missing, builds and starts the container, +waits for the healthcheck, verifies the token against `GET /api/v1/user` and +verifies that `http://license-bot:8080/hook` resolves **from Gitea's own +network** — the exact URL the webhook has to reach. It then prints the two +instance-side steps with ready-to-paste commands: + +0. **Let Gitea reach the service (prerequisite).** With its default + `security.ALLOWED_HOST_LIST`, Gitea refuses to deliver a webhook to an + internal Docker alias: the delivery is recorded as failed with + `webhook can only call allowed HTTP servers … deny 'license-bot()'` even + though the URL is perfectly reachable from inside the container. Add + `[security]` → `ALLOWED_HOST_LIST = license-bot` to Gitea's `app.ini` (or + list the private subnet) and restart Gitea. This is a Gitea-side allowlist, + not a networking problem — check it first if deliveries never arrive. +1. **The webhook (required).** An **organization** webhook to + `http://license-bot:8080/hook`, content type JSON, secret = `WEBHOOK_SECRET`, + events = `pull_request`. Without it nothing is ever checked. +2. **The gate.** Branch protection on each default branch requiring the exact + context `license-bot / addlicense`, plus at least one approval so the author + cannot self-merge. Verify afterwards that the context string matches exactly: + a mismatch silently never blocks anything. + +A safe first run is `DRY_RUN=true docker compose up -d --force-recreate`: the +bot comments and posts statuses but never commits. + +There is no Actions runner dependency anywhere in this path: the bot only needs +to be reachable from Gitea and to reach Gitea. + +## Security notes + +- The token is only ever read from the environment. It is never logged: URLs + embedded in git errors are redacted before they reach a log line or a PR + comment. +- Webhook deliveries are rejected unless the HMAC-SHA256 signature matches, and + duplicate delivery ids are ignored. +- Only repositories in `REPOS` are processed. +- The bot never rewrites history: it adds a commit and uses a plain + fast-forward push, so a push that lost a race against a new contributor commit + is rejected. The bot then discards its local commit, re-reads the branch, + re-runs the check on the new tip and pushes again, repeating until it succeeds + or finds nothing left to change (bounded at 5 attempts, after which the next + event retries). Nothing is ever overwritten. +- If the contributor disabled maintainer edits on the pull request, the push is + refused by the server; the bot then comments that the header must be added by + hand and reports a failing status. It never bypasses that. + +### Threat model (measured on Gitea 1.27.2) + +The full per-test methodology and raw observations are in +[`SECURITY.md`](SECURITY.md). Summary: + +- **Merge gate.** Branch-protection checks are enforced on the merge API: a + normal writer's merge is refused `405 Not all required status checks + successful` when the required context is **absent**, still `405` when it is + `pending`, and allowed (`200`) on `success`. A fork PR run cannot post any + status (`403 user should have a permission to write to a repo`). Requiring the + exact context `license-bot / addlicense` is therefore a fail-closed gate: if + the bot is down and never posts, nothing merges. +- A repository **writer** can forge any context (`201`); write access is the + trust boundary, as it is for any other repository change. +- **Do not use required scoped workflows as the gate on 1.27.2.** With a scoped + workflow marked required and failing, a writer's `POST /pulls/{n}/merge` + returned `200` — the requirement is UI-only. +- **Log masking is not protection.** An in-repo PR workflow exfiltrated a repo + secret and an org secret over HTTP (`201`) although the log showed `***`. The + only real question is reachability plus egress — which is why the bot's token + stays on the host. +- **RESOLVED — job-container host read (T2.3).** The dev runner used to expose + `/var/run/docker.sock` to job containers. It now pins + `container.docker_host: "-"`, `privileged: false`, `valid_volumes: []` and an + `--internal` job network; a real job measured `SOCKET_ABSENT`, no internet, + the host IP unreachable and `gitea:3000` reachable. Configs + deploy script: + `infra/gitea-runner/`. +- **STILL OPEN: the org `TOKEN` Actions secret.** It is the site-admin token + (`sha256` matched, T2.4). The bot no longer has anything to do with it, so it + can be deleted as soon as `release.yaml` gets its own least-privilege + `write:package` token, and the bot account's own token should be rotated onto + a dedicated non-admin account scoped to the five repositories. diff --git a/license-bot/SECURITY.md b/license-bot/SECURITY.md new file mode 100644 index 0000000..62f057f --- /dev/null +++ b/license-bot/SECURITY.md @@ -0,0 +1,302 @@ +# Verification — AGPL license bot, merge gate and key exposure + +Measured on the development VM (Gitea **1.27.2**, runner `alpine-runner`, +label `alpine-latest:docker://node:alpine`) on 2026-10-07. Everything below was +observed, not inferred; each test lists the method, the raw observation and the +verdict. Where a result is version- or host-specific it is called out. + +Two questions are answered: + +1. **Is the bot's `license-bot / addlicense` status authoritative — can a PR that + is not supposed to be trusted fake a green?** +2. **Can anyone get a key out?** Masking in logs is not evidence of safety: the + only question is whether a value is reachable by an actor and can leave the + host. + +--- + +## Test 1 — authoritativeness / "fake greens" + +### T1.1 An untrusted (fork) PR run cannot post a commit status + +- **Hypothesis.** A fork PR run's `secrets.GITHUB_TOKEN` has no write access, so + it cannot create the status the gate depends on. +- **Method.** Workflow on a fork branch (`admin/license-bot-lab:forge-test`) + running as a fork PR: + `POST /api/v1/repos/Dcntrlized/license-bot-lab/statuses/` with + `{"context":"license-bot / addlicense","state":"success"}` using + `${{ secrets.GITHUB_TOKEN }}`; the same call again against the fork's own repo; + then `GET /repos/{repo}/commits/{sha}/statuses` read back by the admin API. +- **Observation.** `BASE_HTTP_403` and `FORK_HTTP_403`, body + `{"message":"user should have a permission to write to a repo"}`; no + `license-bot / addlicense` status ever appeared on the PR head. The same fork + run *can* post a comment (`201`). +- **Verdict.** PASS for the platform mechanism: a fork PR cannot produce the + suffix-free context the bot owns. It can still post *comments*. + +### T1.2 A same-repo (in-repo) PR run can post any context + +- **Hypothesis.** A contributor with repository write access can forge the bot's + exact context through their own workflow. +- **Method.** In-repo branch workflow, `POST .../statuses/` with + `{"context":"license-bot / addlicense","state":"success"}`. +- **Observation.** `FORGE_HTTP_201`; `GET .../statuses` then listed + `success license-bot / addlicense` with the description the workflow set + (`forged by a contributor workflow`). +- **Verdict.** FAIL by design: any writer can forge any context. Write access is + the trust boundary (a writer can equally edit the workflow or push directly); + the gate protects against *untrusted* actors and honest mistakes, not against + collaborators. + +### T1.3 Required branch-protection checks are enforced on the merge API + +- **Hypothesis.** With branch protection requiring a context that nobody posts, a + normal writer's merge is refused — i.e. required checks are a real gate. +- **Method.** `POST /repos/{r}/branch_protections` + (`branch_name=main`, `enable_status_check=true`, + `status_check_contexts=["nobody-posted-this-context"]`), then + `POST /repos/{r}/pulls/{n}/merge` as the non-admin collaborator `gateuser` + (`Authorization: token `), `{"Do":"merge"}`. +- **Observation.** `405 {"message":"Not all required status checks successful"}`. + Removing the requirement and re-merging succeeded (`200`). +- **Verdict.** PASS: branch protection is enforced on the REST merge path for a + normal writer. Caveat: admins can override, and contexts match by exact string. +- **Re-measured 2026-10-08 on a protected throwaway branch** (`gate-target`, + context `license-bot / addlicense`), which pins the fail-closed property the + webhook-only design relies on: + | State of the required context | `POST /pulls/57/merge` | + |---|---| + | **absent** (bot down, never posted) | `405 Not all required status checks successful` | + | `pending` | `405` | + | `success` | `200` | + Cleanup: protection removed, both probe branches deleted, `main` untouched. + So "the bot is down" means "no status" means "no merge" — an unlicensed pull + request cannot be merged by accident. + +### T1.4 A *required scoped workflow* is NOT enforced on the merge API (1.27.2) + +- **Hypothesis.** If a scoped workflow is registered from a source repo and marked + required, a failing run blocks merges — making it the natural public gate. +- **Method.** Source repo `Dcntrlized/ci-workflows` with + `.gitea/scoped_workflows/gate.yml` (a job that always exits 1). Registered on + `/org/Dcntrlized/settings/actions/scoped-workflows` via + `POST .../add` `repo_name=ci-workflows` (bare name only — `owner/name` and the + numeric id are rejected `400 Repository not found.`), then marked required via + `POST .../required` with `repo_id`, `workflow_ids=gate.yml`, + `required_workflow_ids=gate.yml`, + `required_patterns[gate.yml]="Dcntrlized/ci-workflows: Gate / *"`. Consumer: + throwaway `Dcntrlized/gate-lab`, PR with a real failing status + `Dcntrlized/ci-workflows: Gate / check (pull_request) = failure` on the head. +- **Observation.** The PR page showed *"Some required checks failed"*, but + `POST /pulls/{n}/merge` returned **200** — both for the site admin (PR B) and + for the non-admin writer `gateuser` (PR 4, head `7c00336c`). A required pattern + that never posts does not block either. +- **Verdict.** FAIL: on 1.27.2 required scoped workflows are a UI-only signal, + not an API-enforced gate. Do not rely on them. (Upstream 28.1.0 not tested.) + +### T1.5 Scoped workflows do not leak consumer secrets to fork PRs + +- **Hypothesis.** A scoped workflow sourced from another repo might receive the + consumer's repo/org secrets even on a fork PR. +- **Method.** A scoped `secret-probe.yml` registered from `ci-workflows` tested + presence of the consumer's repo secret, an org secret and `secrets.GITHUB_TOKEN` + on a fork PR and on an in-repo PR (presence via `[ -n "$VAR" ]`, so no value is + needed). +- **Observation.** Fork PR: `PROBE_secret=[]`, + `PROBE_org_secret_present=[false]`, `PROBE_token_present=[true]`. In-repo PR: + `PROBE_secret=[***]`, `PROBE_org_secret_present=[true]` (values masked in the + log). +- **Verdict.** PASS: scoped workflows inherit the consumer's secret rules; forks + get none. (Masking of the in-repo log is cosmetic — see Test 2.) + +### T1.6 Positive control — the bot's own status + +- **Method.** The real bot (own account token) posts + `POST /repos/{r}/statuses/` with its context in the same lab and the status + is read back via the API. +- **Observation.** Context is exactly `license-bot / addlicense`, no `(event)` + suffix, description `license headers added by the bot`. +- **Verdict.** The context the gate should require is the one T1.1 shows forks + cannot create. + +### Test 1 conclusions + +| Test | Result | +|---|---| +| T1.1 fork cannot post a status | 403 — PASS | +| T1.2 in-repo writer can forge any context | 201 — FAIL by design (write = trust) | +| T1.3 required branch-protection check blocks a writer's merge | 405 — PASS | +| T1.4 required scoped workflow blocks a merge | 200 — FAIL (UI-only on 1.27.2) | +| T1.5 scoped fork runs get no consumer secrets | no secret present — PASS | +| T1.6 bot context identifier | `license-bot / addlicense` (suffix-free) | + +**Gate to use:** branch protection on the default branch requiring the exact +context `license-bot / addlicense`, plus at least one approval so an author cannot +self-merge. It is enforced on the merge API (T1.3) and unforgeable by fork PRs +(T1.1); it does not stop a collaborator (T1.2) or an admin. + +**Not covered by Test 1:** whether a run *without* write access can reach secrets +by other means — that is Test 2, and the answer changes the conclusion above, +because an attacker who obtains the admin token can forge any status. + +--- + +## Test 2 — key exposure (reachability and egress, not masking) + +Methodology: unique canary values are planted as a repo secret and an org secret, +a workflow prints their **lengths** and posts them **base64-encoded to the PR as a +comment** through the run token (a real outbound HTTP POST, the "malicious site" +model). The same probe runs from an in-repo PR and from a fork PR. A control step +proves the network path and the runner's capabilities, so a "nothing received" +result means "nothing to send", not "no channel". + +### T2.1 An in-repo PR exfiltrates a repo secret and an org secret + +- **Observation.** `REPO_CANARY_LEN=23 ORG_CANARY_LEN=26 BOT_TOKEN_LEN=0 + RUN_TOKEN_LEN=40`; the log lines `REPO_CANARY_B64=***` / + `ORG_CANARY_B64=***` (masked) but `EXFIL_POST_HTTP=201`; the created comment + body decoded to exactly `CANARY-61ca3f58dd2d9ae3` and + `ORGCANARY-d0c7e3ad5b14af5d`. +- **Verdict.** FAIL (severe). Log masking protects nothing; the values left the + host. Any collaborator who can open an in-repo PR reads every repo/org secret. + +### T2.2 A fork PR gets no repo/org secrets + +- **Observation.** `LEN repo=0 org=0 run_token=40`, + `EXFIL_POST_HTTP=201`, comment + `FORK EXFIL probe: repo_canary_len=0 org_canary_len=0 run_token_len=40`. +- **Verdict.** PASS for direct secret access: the platform withholds repo/org + secrets from fork runs; the run token present is base-repo read-only. + +### T2.3 CRITICAL — a fork PR escapes the runner sandbox through the Docker socket + +- **Hypothesis.** Untrusted builds might still reach host resources through the + runner. +- **Method.** From the *fork* PR job: `ls -l /var/run/docker.sock`; + `apk add --no-cache docker-cli`; `docker ps --format '{{.Names}}'`; then + `docker run --rm -v /:/host alpine sh -c 'echo HOST_READ_OK; + cat /host/etc/hostname; ls /host | head -5'`. +- **Observation.** Socket present: `srw-rw---- 1 root 969 /var/run/docker.sock`. + `docker ps` listed `GITEA-ACTIONS-TASK-…` (the job), `gitea_runner-runner-1` + and **`gitea`**. The host-root bind mount worked: `HOST_READ_OK`, + `dcntrlized_dev`, `bin boot dev etc home`. +- **Verdict.** CRITICAL FAIL. A fork PR — no write access, no secrets — has + root-equivalent access to the runner host: read/write the host filesystem + (including Gitea's data directory and database, i.e. every org/repo secret and + admin token), `docker exec` any container (including `gitea`) and start + privileged containers. This defeats T2.2 in practice: secrets are reachable + indirectly, and the T1.1 "forks cannot fake a status" property is moot once the + attacker steals an admin token. +- **Remediation (required before opening the repos).** In the runner config + (`/home/admin/gitea_runner/config.yaml` on the dev VM) set + `container.docker_host: "-"`. Upstream documents this exact value as "runner + will find an available docker host automatically, but won't mount it into the + job and action containers" (gitea runner `config.example.yaml`, and + `run_context.go`: the socket bind is skipped when `containerDaemonSocket()` + returns `-`). Keep `container.valid_volumes: []` (an empty list means no + workflow-declared volume can be mounted) and leave `container.privileged: + false`. Then restart the runner and re-run T2.3: `ls /var/run/docker.sock` must + fail and a `docker` CLI in the job must not reach a daemon. Until that holds, + no other mitigation matters. A rootless/DIND or VM-isolated runner is the + stronger option for public repositories. +- **Status: FIXED and verified 2026-10-07.** The hardened runner configs are + versioned in the root repo at `infra/gitea-runner/` and deployed on the dev VM + (`container.docker_host: "-"`, an `--internal` job network with Gitea as its + only attached service, `privileged: false`, `valid_volumes: []`, and a job + image with the tools baked in so no workflow needs egress). Measured from a + job after the change: `SOCKET_ABSENT`; `example.com` does not even resolve; + the host (`192.168.122.183:3000`) is `Network unreachable`; `gitea:3000` + answers `200`; the job route is only `172.31.240.0/24 dev eth0 scope link`; + and workflows declaring `--network host`, `--privileged` or `--volume /etc/...` + are ignored by the runner. The runner was re-registered so the job context URL + is `gitea:3000` (the host address is unreachable by design). The fork probe + that previously read the host now finds neither the socket nor a network path. + Verification runs on the lab repo: isolation probe run 179; scoped + `license-check.yml` run 180 and repo `.gitea/workflows/license.yml` run 201 + both succeed with `SERVER: http://gitea:3000`. The two advisory files share + `concurrency.group: license-headers-`, so exactly one of them runs per PR + (the scoped one normally wins; the repo file takes over if the scoped workflow + is unregistered) — a `cancelled` repo run is the expected dedup, not a + failure. + +### T2.4 CRITICAL — the org `TOKEN` secret is the admin/bot token + +- **Hypothesis.** The token the bot uses might also sit in Actions secrets, where + any PR workflow can read it. +- **Method.** An in-repo PR workflow printed `len` and `sha256` of + `secrets.TOKEN` (no plaintext); the same hash was computed locally for the + token in `Maestro/.env` that drives the bot. +- **Observation.** `ORG_TOKEN_LEN=40 SHA=fd539c2ffd50376f6747a05424d4e5fa6e02c67da35cebf3001a2a19524418ad`; + local bot token: `len=40 + sha=fd539c2ffd50376f6747a05424d4e5fa6e02c67da35cebf3001a2a19524418ad` — + identical. (`BOT_TOKEN_LEN=0`: it is not duplicated under another name.) +- **Verdict.** CRITICAL FAIL. The site-admin token that authenticates the bot is + readable by every in-repo PR workflow, and by any fork PR through T2.3. +- **Remediation.** Rotate it; give the bot a dedicated least-privilege account + (`write:repository` + `read:issue` + `write:issue`, scoped to the five repos); + stop storing that token as an org/repo Actions secret (release publishing needs + a separate, approval-gated or push-only path). +- **Update 2026-10-08.** The bot no longer has any Actions dependency: it is the + webhook-only service described below, and its token lives only in the + container's environment on the Gitea host. The org `TOKEN` secret is now used + by nothing except `release.yaml`'s registry auth, so it can be deleted once + that workflow gets its own `write:package` token. The remaining work is the + **rotation** of the bot token onto a dedicated non-admin account. + +### T2.5 Egress and runner capability control + +- **Observation.** From the job: `NET_GITEA=200`, `NET_INTERNET=200` + (`http://example.com` reachable), `HAS_DOCKER=none` before installing the CLI. +- **Verdict.** A working outbound channel exists, so the negative result in T2.2 + is meaningful, and the positive results in T2.1/T2.4 are exfiltration, not a + logging artifact. + +### Test 2 conclusions + +| Test | Reachable | Left the host | Result | +|---|---|---|---| +| T2.1 in-repo PR, repo+org secrets | yes | yes (HTTP 201, comment) | FAIL — masking is cosmetic | +| T2.2 fork PR, repo/org secrets | no | n/a (channel worked, 201) | PASS for direct access | +| T2.3 fork PR, Docker socket / host root | yes | yes (host FS read) | CRITICAL — runner sandbox escape | +| T2.4 org `TOKEN` == bot/admin token | yes (in-repo) | yes | CRITICAL — rotate and relocate | +| T2.5 egress control | yes | — | confirms the tests above | + +**Net:** the bot's design is right (separate revertable commit, exact suffix-free +status context, no token in the repo), but on this host an untrusted pull request +can read the whole machine, so no claim of "no key leakage" or "unforgeable gate" +holds until the runner is fixed. Order of work: **T2.3 remediation → token +rotation/T2.4 → branch protection on `license-bot / addlicense` → re-test.** + +--- + +## Update 2026-10-08 — webhook-only v0.0.1 + +The service was folded down to one trigger and one process, which removes the +last piece of the exposure story that lived in Actions: + +- **Webhooks are the only trigger.** Gitea delivers HMAC-signed `pull_request` + events to `http://license-bot:8080/hook`; the bot verifies + `X-Gitea-Signature` and ignores duplicate `X-Gitea-Delivery` ids. A check that + did not run therefore means the service was down, not that some other + component failed. +- **Startup reconciliation replaces polling.** On boot the bot scans the open + pull requests of every allowlisted repository and processes those whose head + does not already carry a successful `license-bot / addlicense` status. The + commit status is the durable record of what the bot has resolved, so a + delivery lost while it was down, or a `pending` left by a crash, is repaired + without a cursor file and without a polling loop. +- **No arguments.** The binary takes no flags; everything is environment driven + (`v0.0.1`). +- **Reachability, measured on this instance.** A job/pod on the runner's + `--internal` job network reached a sidecar published under a name alias + (`HEALTH=ok`) and an unauthenticated request was rejected (`401`), while the + host IP and the internet stayed unreachable — so a sidecar/service on Gitea's + own Docker network is reachable by Gitea without widening the job network's + egress. +- **Advisory workflows are gone.** The read-only per-repository + `.gitea/workflows/license.yml` and the scoped `license-check.yml` were + removed: they were never the gate (T1.4) and the status is what branch + protection enforces (T1.3). +- **The gate** is still branch protection requiring the exact context + `license-bot / addlicense`; the absent-status case measured above is what makes + it fail-closed. Required approvals are configured separately by the operator. diff --git a/license-bot/deploy.sh b/license-bot/deploy.sh new file mode 100755 index 0000000..a574e78 --- /dev/null +++ b/license-bot/deploy.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Dcntrlized +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# Deploy the license bot to any Gitea instance. +# +# cp .env.example .env # set GITEA_URL, GITEA_TOKEN, WEBHOOK_SECRET +# ./deploy.sh +# +# Idempotent: builds the image, starts the container and verifies it from +# inside Gitea's own network. There is deliberately no admin credential +# anywhere in this script. +# +# The bot is useless unless it lands on the Docker network Gitea already runs +# on: Gitea must resolve `$CONTAINER_NAME` and open `$CONTAINER_NAME:8080`. +# NETWORK_NAME is the REAL (project-prefixed) network name -- a network defined +# inside Gitea's own compose project is named `_`, e.g. prod's +# /docker/org.dcntrlized.git defines `gitea_dcntrlized`, which Docker names +# `orgdcntrlizedgit_gitea_dcntrlized`. When NETWORK_NAME is unset the script +# derives it from the Gitea container; when it points somewhere Gitea is not, +# the deployment FAILS loudly instead of silently printing a webhook URL that +# nothing can reach. Escape hatches: CREATE_NETWORK=true creates a missing +# network (fresh bootstrap), SKIP_BUILD=true uses a pre-built image, and +# SKIP_VERIFY=true skips the reachability checks (remote/HTTPS instance). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$HERE" + +ENV_FILE="${ENV_FILE:-$HERE/.env}" +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + set -a; . "$ENV_FILE"; set +a +else + echo "no $ENV_FILE; copy .env.example and fill it in" >&2 + exit 2 +fi + +GITEA_URL="${GITEA_URL:?set GITEA_URL to the target instance -- the Gitea container name on NETWORK_NAME, e.g. http://gitea_dcntrlized:3000}" +GITEA_TOKEN="${GITEA_TOKEN:?set GITEA_TOKEN to a bot-account token}" +WEBHOOK_SECRET="${WEBHOOK_SECRET:?set WEBHOOK_SECRET: webhooks are the only trigger}" +CONTAINER_NAME="${CONTAINER_NAME:-license-bot}" +STATUS_CONTEXT="${STATUS_CONTEXT:-license-bot / addlicense}" +GITEA_PUBLIC_URL="${GITEA_PUBLIC_URL:-$GITEA_URL}" +BOT_IMAGE="${LICENSE_BOT_IMAGE:-license-bot:dev}" +BOT_PORT="${BOT_PORT:-18080}" + +# gitea_networks prints every Docker network attached to a running container +# whose name contains "gitea", one per line. +gitea_networks() { + local ids + ids="$(docker ps -q --filter 'name=gitea' 2>/dev/null)" + [[ -z "$ids" ]] && return 0 + # shellcheck disable=SC2086 # ids is a deliberate word list of container ids + docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' $ids 2>/dev/null \ + | sed '/^[[:space:]]*$/d' | sort -u +} + +if [[ -z "${NETWORK_NAME:-}" ]]; then + NETWORK_NAME="$(gitea_networks | head -n1)" + if [[ -z "$NETWORK_NAME" ]]; then + echo "no NETWORK_NAME in .env, and no running container with 'gitea' in its" >&2 + echo "name to derive it from. Set NETWORK_NAME to the Docker network Gitea" >&2 + echo "runs on (see 'docker network ls')." >&2 + exit 2 + fi + echo "==> NETWORK_NAME unset; using '$NETWORK_NAME' (from the Gitea container)" +fi +# Export so Compose interpolates the detected value too (set -a is off here). +export NETWORK_NAME + +echo "==> instance: $GITEA_URL" +echo "==> links: $GITEA_PUBLIC_URL (URL used in the bot's comments)" +echo "==> network: $NETWORK_NAME" +echo "==> container: $CONTAINER_NAME (webhook URL http://$CONTAINER_NAME:8080/hook)" +echo "==> webhook: use the CONTAINER port 8080, never the host publish ($BOT_PORT)" +echo " -- Gitea delivers over $NETWORK_NAME, not through the host." +echo "==> gate: $STATUS_CONTEXT" +echo "==> allowlist: ${REPOS:-}" + +if ! docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then + if [[ "${CREATE_NETWORK:-false}" == "true" ]]; then + echo "==> network $NETWORK_NAME does not exist; creating it (CREATE_NETWORK=true)" + docker network create "$NETWORK_NAME" >/dev/null + else + echo "FATAL: network '$NETWORK_NAME' does not exist." >&2 + echo " It must be the network Gitea already runs on, so 'external: true'" >&2 + echo " means Compose will not create it. Find the real name with:" >&2 + echo " docker ps --filter name=gitea --format '{{.Names}}'" >&2 + echo " docker inspect -f '{{range \$k,\$v := .NetworkSettings.Networks}}{{\$k}} {{end}}' " >&2 + echo " then set NETWORK_NAME in .env. (CREATE_NETWORK=true only for a" >&2 + echo " fresh bootstrap where Gitea does not exist yet.)" >&2 + exit 1 + fi +fi + +# A network that exists but does not carry Gitea is the failure mode this script +# used to hide: the bot joins an empty network and every delivery dies with +# "no such host" / "connection refused". Warn here, fail after the probe. +if ! docker network inspect -f '{{range .Containers}}{{.Name}} {{end}}' "$NETWORK_NAME" 2>/dev/null \ + | grep -qi gitea; then + echo "==> WARNING: no container with 'gitea' in its name is on $NETWORK_NAME." >&2 + nets="$(gitea_networks)" + if [[ -n "$nets" ]]; then + echo " Gitea is attached to: $(echo "$nets" | tr '\n' ' ')" >&2 + echo " Set NETWORK_NAME to that value." >&2 + fi +fi + +if [[ "${SKIP_BUILD:-false}" == "true" ]]; then + echo "==> start (SKIP_BUILD=true; using the existing $BOT_IMAGE)" + docker compose up -d --no-build +else + echo "==> build + start" + docker compose up -d --build +fi +docker compose ps + +echo "==> wait for the healthcheck" +cid="$(docker compose ps -q license-bot)" +for _ in $(seq 1 20); do + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid")" + [[ "$status" == "healthy" || "$status" == "running" ]] && break + sleep 2 +done +echo "container state: $status" +docker logs --tail 5 "$cid" 2>&1 | sed 's/^/ /' + +if [[ "${SKIP_VERIFY:-false}" == "true" ]]; then + echo "==> SKIP_VERIFY=true; not probing Gitea from $NETWORK_NAME" +else + echo "==> verify the instance is reachable and the token is accepted" + # --entrypoint: the image's entrypoint is the bot, so `docker run image wget …` + # would run `license-bot wget …` and fail. + if docker run --rm --network "$NETWORK_NAME" --entrypoint wget "$BOT_IMAGE" \ + -q -T5 -O- --header="Authorization: token $GITEA_TOKEN" \ + "${GITEA_URL%/}/api/v1/user" 2>/dev/null | grep -q '"login"'; then + echo "TOKEN_OK $GITEA_URL accepted the bot token from $NETWORK_NAME" + else + echo "TOKEN_FAIL could not authenticate against $GITEA_URL from $NETWORK_NAME" >&2 + echo " NETWORK_NAME=$NETWORK_NAME is attached to:" >&2 + docker network inspect -f '{{range .Containers}} {{.Name}}{{"\n"}}{{end}}' \ + "$NETWORK_NAME" >&2 2>/dev/null || true + echo " No Gitea above means NETWORK_NAME is the wrong network" >&2 + echo " (a network defined in Gitea's own compose is named" >&2 + echo " '_', not just the key). Otherwise check" >&2 + echo " GITEA_URL (Gitea's container name on that network) and" >&2 + echo " the token scopes. Re-run with SKIP_VERIFY=true only if" >&2 + echo " Gitea genuinely lives elsewhere." >&2 + exit 1 + fi + + echo "==> verify Gitea can reach the webhook endpoint by its DNS name" + if docker run --rm --network "$NETWORK_NAME" --entrypoint wget "$BOT_IMAGE" \ + -q -T5 -O- "http://$CONTAINER_NAME:8080/healthz" 2>/dev/null | grep -q ok; then + echo "WEBHOOK_OK http://$CONTAINER_NAME:8080/hook resolves from $NETWORK_NAME" + else + echo "WEBHOOK_FAIL Gitea would not reach the webhook: is the container on" >&2 + echo " $NETWORK_NAME? The org webhook must point at" >&2 + echo " http://$CONTAINER_NAME:8080/hook (container port, not $BOT_PORT)." >&2 + exit 1 + fi +fi + +cat <)' + even though the URL is reachable. Set it in the instance's environment (no + app.ini edit needed) and restart Gitea: + + GITEA__security__ALLOWED_HOST_LIST=external,$CONTAINER_NAME + + (or the private subnet). Keep 'external' if the instance delivers other + webhooks to public hosts. The older GITEA__webhook__ALLOWED_HOST_LIST / + [webhook] key still works on 1.27 because Gitea reads it as a deprecated + fallback, but it is REMOVED in v28 -- move it to [security] before + upgrading. Names are matched before IPs, so the container name covers the + private Docker address. + + 1. THE WEBHOOK (required -- webhooks are the only trigger). Register an + ORGANIZATION webhook pointing at http://$CONTAINER_NAME:8080/hook, + content type application/json, secret = the WEBHOOK_SECRET from .env, with + the pull-request events. As an org admin (substitute the secret): + + ADMIN_TOKEN=... WEBHOOK_SECRET=... + curl -fsS -X POST "${GITEA_URL%/}/api/v1/orgs/Dcntrlized/hooks" \ + -H "Authorization: token \$ADMIN_TOKEN" -H 'Content-Type: application/json' \ + -d "{\"type\":\"gitea\",\"active\":true,\"config\":{\"url\":\"http://$CONTAINER_NAME:8080/hook\",\"content_type\":\"json\",\"secret\":\"\$WEBHOOK_SECRET\"},\"events\":[\"pull_request\"]}" + + Fixing an existing hook whose URL is wrong (e.g. the host publish port): + GET /api/v1/orgs/Dcntrlized/hooks to find the id, then PATCH it. Pass BOTH + config.url and events -- omitting "events" rewrites the hook to push-only: + + curl -fsS -X PATCH "${GITEA_URL%/}/api/v1/orgs/Dcntrlized/hooks/" \ + -H "Authorization: token \$ADMIN_TOKEN" -H 'Content-Type: application/json' \ + -d '{"config":{"url":"http://$CONTAINER_NAME:8080/hook","content_type":"json"},"events":["pull_request"]}' + + 2. THE GATE. Branch protection on each default branch requiring the exact + context '$STATUS_CONTEXT' -- nothing merges without it, so a pull request + parked while the bot is down stays parked -- AND at least one human + approval, so neither the author nor the bot alone can merge. As an org + admin, per repository: + + ADMIN_TOKEN=... + curl -fsS -X PATCH "${GITEA_URL%/}/api/v1/repos/Dcntrlized//branch_protections/main" \ + -H "Authorization: token \$ADMIN_TOKEN" -H 'Content-Type: application/json' \ + -d '{"branch_name":"main","enable_status_check":true,"status_check_contexts":["$STATUS_CONTEXT"],"required_approvals":1,"dismiss_stale_approvals":true,"block_on_outdated_branch":true}' + + Gitea rejects self-approval outright ("approve your own pull is not + allowed", 422), so required_approvals=1 forces a second account with write + access to approve. dismiss_stale_approvals is what makes the approval cover + the tree the bot produced: the bot's own commit invalidates an approval + given before it. Use POST .../branch_protections instead of PATCH to create + protection on a branch that has none. + + The context string must match byte for byte or the gate silently never + matches; verify with: + curl -fsS "${GITEA_URL%/}/api/v1/repos/Dcntrlized//branch_protections" \ + -H "Authorization: token \$ADMIN_TOKEN" + + 3. DRY RUN before trusting it: + DRY_RUN=true docker compose up -d --force-recreate + then open a test pull request, and remember only these payload actions + start a check: opened, synchronize/synchronized, reopened, + ready_for_review. A review request or a comment is ignored by design. + + 4. IPv6-only / no-egress host (the image cannot be built by Compose there): + docker build --network=host -t $BOT_IMAGE . + SKIP_BUILD=true ./deploy.sh + Compose cannot grant the network.host build entitlement. + +Do not use an administrator token for the bot; give it a dedicated account with +write:repository + read:issue + write:issue on the allowlisted repositories. +NEXT diff --git a/license-bot/docker-compose.yaml b/license-bot/docker-compose.yaml new file mode 100644 index 0000000..082d129 --- /dev/null +++ b/license-bot/docker-compose.yaml @@ -0,0 +1,87 @@ +# Copyright (C) 2026 Dcntrlized +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# Deployment compose for the license bot. Instance-agnostic: every value that +# differs between instances comes from the environment (see .env.example); +# ./deploy.sh fills in the common defaults and verifies the result. +# +# cp .env.example .env # set GITEA_URL, GITEA_TOKEN, WEBHOOK_SECRET +# ./deploy.sh +# +# The bot is webhook-only, so it must share a Docker network with Gitea: the +# org webhook points at http://license-bot:8080/hook, using the CONTAINER port +# 8080 (the `ports:` publish below is the host binding and is never the webhook +# target). The token belongs to a bot account with write access to the +# allowlisted repositories and lives only in this container's environment -- +# never in a workflow, never in this file. +services: + license-bot: + build: + context: . + args: + VERSION: ${VERSION:-0.0.1} + image: ${LICENSE_BOT_IMAGE:-license-bot:dev} + # The container name is the DNS name Gitea's webhook has to resolve. + container_name: ${CONTAINER_NAME:-license-bot} + restart: unless-stopped + environment: + # The instance base URL, addressed by the name Gitea's container has ON + # NETWORK_NAME. The alias is instance-specific -- it is the Gitea + # container name plus its Compose service name: + # http://gitea:3000 a Compose service named `gitea` + # http://gitea_dcntrlized:3000 prod (/docker/org.dcntrlized.git) + # Use the internal alias when Gitea runs on this Docker daemon, so the bot + # needs neither egress nor a published port. + GITEA_URL: ${GITEA_URL:?set GITEA_URL to the target instance -- the Gitea container name on NETWORK_NAME} + GITEA_TOKEN: ${GITEA_TOKEN:?set GITEA_TOKEN to a bot-account token} + # The name a human follows from the bot's PR comments. When GITEA_URL is + # the internal Docker alias (`http://gitea:3000`), set this to the public + # URL (`https://git.example.org`) or the links in the comment are dead. + GITEA_PUBLIC_URL: ${GITEA_PUBLIC_URL:-} + # HMAC secret of the org webhook. Required: webhooks are the only trigger. + WEBHOOK_SECRET: ${WEBHOOK_SECRET:?set WEBHOOK_SECRET to the org webhook secret} + REPOS: ${REPOS:-Dcntrlized/Maestro,Dcntrlized/KeepNote,Dcntrlized/Calendar,Dcntrlized/dcntrlized-auth,Dcntrlized/dcntrlized-db} + STATUS_CONTEXT: "${STATUS_CONTEXT:-license-bot / addlicense}" + LISTEN: ${LISTEN:-:8080} + DRY_RUN: ${DRY_RUN:-false} + LOG_LEVEL: ${LOG_LEVEL:-info} + ports: + # Optional: only for a manual curl or a health probe from the host. Gitea + # delivers webhooks over the shared Docker network, not through this. + - "${BOT_BIND:-127.0.0.1}:${BOT_PORT:-18080}:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - instance + +networks: + # The REAL Docker network that already contains Gitea -- NOT necessarily the + # key in Gitea's own compose file. A network defined inside a Compose project + # is named `_`: prod's /docker/org.dcntrlized.git defines + # `gitea_dcntrlized`, which Docker names + # `orgdcntrlizedgit_gitea_dcntrlized`. `external: true` means Compose never + # creates or renames it, so a wrong value silently lands the bot on an empty + # network and every delivery fails with "no such host" / "connection + # refused". Find it with: + # docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' gitea_dcntrlized + # deploy.sh derives it from the Gitea container when unset, and fails loudly + # when the two are not on the same network. + instance: + external: true + name: ${NETWORK_NAME:?set NETWORK_NAME to the Docker network Gitea runs on} diff --git a/license-bot/go.mod b/license-bot/go.mod new file mode 100644 index 0000000..5992704 --- /dev/null +++ b/license-bot/go.mod @@ -0,0 +1,3 @@ +module git.dcntrlized.org/Dcntrlized/license-bot + +go 1.24 diff --git a/license-bot/internal/bot/bot.go b/license-bot/internal/bot/bot.go new file mode 100644 index 0000000..4c0fd7a --- /dev/null +++ b/license-bot/internal/bot/bot.go @@ -0,0 +1,642 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package bot wires Gitea pull-request webhooks to the license fixer. +// +// The service is webhook-only: every run is caused by an HMAC-verified +// delivery, so a run that does not happen can only mean the service itself is +// down. On startup it reconciles every open pull request of the allowlisted +// repositories once, so a delivery lost while the service was down -- or a +// check left pending by a crash -- is replaired without a polling loop. +package bot + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "git.dcntrlized.org/Dcntrlized/license-bot/internal/config" + "git.dcntrlized.org/Dcntrlized/license-bot/internal/gitea" + "git.dcntrlized.org/Dcntrlized/license-bot/internal/licenser" +) + +const ( + // deliveryTTL bounds the replay cache for webhook delivery ids. + deliveryTTL = 5 * time.Minute + // maxDeliveries caps the replay cache; expired entries are pruned first. + maxDeliveries = 4096 + // reconcileTimeout bounds the startup scan. + reconcileTimeout = 15 * time.Minute + // handleTimeout bounds one pull-request processing run. + handleTimeout = 5 * time.Minute + // problemMarker tags a comment that reports a problem the contributor has + // to act on. A pass that finds nothing left to fix deletes such a comment, + // so a resolved failure never sits next to a green status. The + // informational "the bot added the headers" comment carries no marker and + // is left in place. + problemMarker = "" +) + +// Bot reacts to pull requests by adding missing AGPL headers in a separate, +// revertable commit on the pull request head. +type Bot struct { + cfg config.Config + api *gitea.Client + log *slog.Logger + + mu sync.Mutex + handled map[string]bool // repo#pr@sha already processed (or in progress) + seen map[string]time.Time // webhook delivery ids already accepted +} + +// New builds a bot from configuration. +func New(cfg config.Config, log *slog.Logger) *Bot { + return &Bot{ + cfg: cfg, + api: gitea.New(cfg.GiteaURL, cfg.Token), + log: log, + handled: make(map[string]bool), + seen: make(map[string]time.Time), + } +} + +type webhookPayload struct { + Action string `json:"action"` + Number int `json:"number"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + PullRequest *struct { + Number int `json:"number"` + } `json:"pull_request"` +} + +// allowedActions are the pull_request payload actions that start a check. +// Gitea 1.27.2 spells a head-branch push "synchronized" (measured: its +// X-Gitea-Event is pull_request with action synchronized), while the API/docs +// usually say "synchronize" -- accept both spellings. +var allowedActions = map[string]bool{ + "opened": true, + "synchronize": true, + "synchronized": true, + "reopened": true, + "ready_for_review": true, +} + +// SignatureValid verifies Gitea's X-Gitea-Signature (hex HMAC-SHA256 of the raw body). +func (b *Bot) SignatureValid(sig string, body []byte) bool { + sig = strings.TrimPrefix(strings.TrimSpace(sig), "sha256=") + if sig == "" || b.cfg.WebhookSecret == "" { + return false + } + mac := hmac.New(sha256.New, []byte(b.cfg.WebhookSecret)) + mac.Write(body) + want := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(strings.ToLower(sig)), []byte(want)) +} + +// ServeHTTP routes the webhook endpoint and the health route. +func (b *Bot) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/hook", "/webhook": + b.handleHook(w, r) + case "/healthz": + b.handleHealth(w, r) + default: + http.NotFound(w, r) + } +} + +// handleHealth is the container health route: it answers as soon as the process +// is accepting connections. +func (b *Bot) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + io.WriteString(w, "ok\n") +} + +func (b *Bot) handleHook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20)) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + if !b.SignatureValid(r.Header.Get("X-Gitea-Signature"), body) { + b.log.Warn("rejected webhook with bad signature", "remote", r.RemoteAddr) + http.Error(w, "invalid signature", http.StatusForbidden) + return + } + if id := r.Header.Get("X-Gitea-Delivery"); b.duplicateDelivery(id) { + b.log.Info("ignoring duplicate webhook delivery", "delivery", id) + w.WriteHeader(http.StatusAccepted) + return + } + // Gitea's X-Gitea-Event carries the concrete sub-event, not the family: + // a push to a pull request head arrives as `pull_request_sync`, a review + // comment as `pull_request_comment`. Match the whole family and let the + // payload's `action` decide -- that is the field Gitea actually varies + // (opened/synchronize/reopened/ready_for_review), and every other + // sub-event (comment, review, label, milestone, assign) carries an action + // outside the allowlist. Measured on 1.27.2: a synchronize delivery has + // X-Gitea-Event: pull_request_sync with action: synchronize. + event := r.Header.Get("X-Gitea-Event") + if event != "" && !strings.HasPrefix(event, "pull_request") { + w.WriteHeader(http.StatusAccepted) + return + } + var p webhookPayload + if err := json.Unmarshal(body, &p); err != nil { + http.Error(w, "bad payload", http.StatusBadRequest) + return + } + if !allowedActions[p.Action] { + b.log.Debug("ignoring webhook", "event", event, "action", p.Action, + "delivery", r.Header.Get("X-Gitea-Delivery")) + w.WriteHeader(http.StatusAccepted) + return + } + b.log.Debug("webhook accepted", "event", event, "action", p.Action, + "delivery", r.Header.Get("X-Gitea-Delivery")) + number := p.Number + if p.PullRequest != nil && p.PullRequest.Number != 0 { + number = p.PullRequest.Number + } + repo := p.Repository.FullName + if number == 0 || !b.cfg.Allows(repo) { + w.WriteHeader(http.StatusAccepted) + return + } + // Acknowledge immediately: Gitea should not wait for git or API round-trips. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), handleTimeout) + defer cancel() + if err := b.HandlePull(ctx, repo, number); err != nil { + b.log.Error("handle pull failed", "repo", repo, "pr", number, "err", err) + } + }() + w.WriteHeader(http.StatusAccepted) +} + +// duplicateDelivery reports whether a delivery id was already accepted inside +// the replay window, recording it otherwise. An empty id is never a duplicate. +func (b *Bot) duplicateDelivery(id string) bool { + if id == "" { + return false + } + now := time.Now() + b.mu.Lock() + defer b.mu.Unlock() + if t, ok := b.seen[id]; ok && now.Sub(t) < deliveryTTL { + return true + } + if len(b.seen) >= maxDeliveries { + for k, t := range b.seen { + if now.Sub(t) >= deliveryTTL { + delete(b.seen, k) + } + } + } + b.seen[id] = now + return false +} + +// Run serves the webhook endpoint and reconciles every allowlisted repository +// once at startup, until ctx is done. +func (b *Bot) Run(ctx context.Context) error { + srv := &http.Server{Addr: b.cfg.Listen, Handler: b, ReadHeaderTimeout: 10 * time.Second} + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + recCtx, recCancel := context.WithTimeout(runCtx, reconcileTimeout) + defer recCancel() + b.log.Info("startup reconciliation: scanning allowlisted repositories", "repos", len(b.cfg.Repos)) + if err := b.Reconcile(recCtx); err != nil { + b.log.Error("startup reconciliation finished with errors", "err", err) + return + } + b.log.Info("startup reconciliation complete") + }() + + go func() { + <-runCtx.Done() + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutCancel() + _ = srv.Shutdown(shutCtx) + }() + + b.log.Info("webhook listening", "addr", b.cfg.Listen) + serveErr := srv.ListenAndServe() + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + cancel() + } + wg.Wait() + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + return serveErr + } + return nil +} + +// Reconcile scans every allowlisted repository for open pull requests that do +// not yet carry a successful status from this bot and processes them. The +// commit status is the durable record of what the bot is already aware of, so +// the scan is self-healing without a separate cursor: a PR left pending by a +// crash, or whose check failed while the service was down, is picked up again. +func (b *Bot) Reconcile(ctx context.Context) error { + var firstErr error + for _, repo := range b.cfg.Repos { + if err := ctx.Err(); err != nil { + return err + } + pulls, err := b.api.ListOpenPulls(ctx, repo) + if err != nil { + b.log.Error("list pulls failed", "repo", repo, "err", err) + if firstErr == nil { + firstErr = err + } + continue + } + for _, pr := range pulls { + green, err := b.hasSuccess(ctx, repo, pr.Head.SHA) + if err != nil { + // Reading statuses failed; processing is idempotent, so it is + // safer to check the pull request than to skip it. + b.log.Warn("read statuses failed; checking anyway", "repo", repo, "pr", pr.Number, "err", err) + } else if green { + b.log.Debug("already green; skipping", "repo", repo, "pr", pr.Number, "sha", short(pr.Head.SHA)) + continue + } + if err := b.HandlePull(ctx, repo, pr.Number); err != nil { + b.log.Error("handle pull failed", "repo", repo, "pr", pr.Number, "err", err) + if firstErr == nil { + firstErr = err + } + } + } + } + return firstErr +} + +// hasSuccess reports whether sha already carries a successful status with the +// bot's context. +func (b *Bot) hasSuccess(ctx context.Context, repo, sha string) (bool, error) { + if sha == "" { + return false, nil + } + statuses, err := b.api.ListStatuses(ctx, repo, sha) + if err != nil { + return false, err + } + for _, s := range statuses { + if s.Context == b.cfg.StatusContext && s.Status == "success" { + return true, nil + } + } + return false, nil +} + +// HandlePull re-reads the pull request and processes it. +func (b *Bot) HandlePull(ctx context.Context, repo string, number int) error { + if !b.cfg.Allows(repo) { + return nil + } + pr, err := b.api.GetPull(ctx, repo, number) + if err != nil { + return fmt.Errorf("get pull %s#%d: %w", repo, number, err) + } + return b.ProcessPull(ctx, repo, pr) +} + +// fixableFiles returns the paths the bot may touch: every file a pull request +// changed that still exists at the head, so an existing unlicensed file gets the +// header the first time a pull request touches it. "removed" has no content at +// the head to license (addlicense would fail on the path) and "unchanged" is not +// part of the delta. Unknown statuses are kept on purpose: Gitea only reports a +// path here if it changed, and if it turns out not to exist the bot fails +// visibly (error status + comment) instead of silently skipping the file. +func fixableFiles(files []gitea.File) []string { + out := make([]string, 0, len(files)) + for _, f := range files { + switch f.Status { + case "removed", "unchanged": + continue + } + out = append(out, f.Filename) + } + return out +} + +// ProcessPull is the whole per-PR flow: detect changed files, fix headers, add a +// separate revertable commit, comment and report a commit status. +func (b *Bot) ProcessPull(ctx context.Context, repo string, pr *gitea.Pull) error { + if pr == nil || pr.Number == 0 { + return errors.New("invalid pull request") + } + if pr.State != "open" { + return nil + } + key := fmt.Sprintf("%s#%d@%s", repo, pr.Number, pr.Head.SHA) + if !b.claim(key) { + return nil + } + + files, err := b.api.ListPullFiles(ctx, repo, pr.Number) + if err != nil { + b.release(key) + return fmt.Errorf("list files %s#%d: %w", repo, pr.Number, err) + } + targets := fixableFiles(files) + if len(targets) == 0 { + // Only deletions (or no files at all), so there is nothing to license. + // Log it: with no comment, no pending status and no error, this path is + // otherwise invisible. + b.log.Info("no changed files to check (removals only)", + "repo", repo, "pr", pr.Number, "sha", pr.Head.SHA, "files", len(files)) + b.status(ctx, repo, pr.Head.SHA, "success", "no changed files need a license header") + b.clearProblemComment(ctx, repo, pr) + return nil + } + + b.status(ctx, repo, pr.Head.SHA, "pending", "checking changed files for AGPL headers") + b.log.Info("processing pull request", "repo", repo, "pr", pr.Number, "sha", pr.Head.SHA, + "files", len(files), "targets", len(targets)) + + res, err := licenser.EnsureHeaders(ctx, licenser.Options{ + RemoteURL: b.headRemoteURL(pr), + Ref: pr.Head.Ref, + SHA: pr.Head.SHA, + Files: targets, + AddlicenseBin: b.cfg.AddlicenseBin, + Holder: b.cfg.Holder, + BotName: b.cfg.BotName, + BotEmail: b.cfg.BotEmail, + WorkRoot: b.cfg.WorkRoot, + DryRun: b.cfg.DryRun, + Logger: b.log, + }) + if err != nil { + b.release(key) + if errors.Is(err, licenser.ErrRemoteMoved) { + b.log.Warn("branch kept moving after the retry budget; will retry on the next event", "repo", repo, "pr", pr.Number) + return err + } + b.status(ctx, repo, pr.Head.SHA, "error", "license bot failed, see the pull request comment") + b.comment(ctx, repo, pr, failureComment(err), true) + return err + } + + switch { + case res.Blocked: + b.status(ctx, repo, pr.Head.SHA, "failure", "could not add the header: maintainer edits are disabled") + b.comment(ctx, repo, pr, b.blockedComment(pr, targets, res.BlockedReason), true) + case len(res.Changed) == 0: + b.status(ctx, repo, pr.Head.SHA, "success", "all changed files carry a license header") + b.clearProblemComment(ctx, repo, pr) + case b.cfg.DryRun: + b.log.Info("dry run: would have committed", "repo", repo, "pr", pr.Number, "files", res.Changed) + default: + if res.Pushed { + b.comment(ctx, repo, pr, b.caughtComment(pr, res), false) + b.status(ctx, repo, res.NewSHA, "success", "license headers added by the bot") + b.claim(fmt.Sprintf("%s#%d@%s", repo, pr.Number, res.NewSHA)) + b.log.Info("committed and pushed", "repo", repo, "pr", pr.Number, "head", short(pr.Head.SHA), "commit", short(res.NewSHA)) + } else { + b.status(ctx, repo, pr.Head.SHA, "failure", "license headers are missing") + b.comment(ctx, repo, pr, b.missingComment(pr, targets), true) + } + } + return nil +} + +func (b *Bot) headRemoteURL(pr *gitea.Pull) string { + full := pr.Head.Repo.FullName + if full == "" { + full = pr.Base.Repo.FullName + } + host := strings.TrimPrefix(strings.TrimPrefix(b.cfg.GiteaURL, "https://"), "http://") + scheme := "http" + if strings.HasPrefix(b.cfg.GiteaURL, "https://") { + scheme = "https" + } + return fmt.Sprintf("%s://oauth2:%s@%s/%s.git", scheme, b.cfg.Token, host, full) +} + +func (b *Bot) status(ctx context.Context, repo, sha, state, description string) { + if sha == "" { + return + } + if err := b.api.CreateStatus(ctx, repo, sha, state, b.cfg.StatusContext, description, ""); err != nil { + b.log.Warn("post status failed", "repo", repo, "sha", short(sha), "state", state, "err", err) + } +} + +// comment replaces the bot's comment on the pull request. The previous comment +// is deleted and a new one is posted rather than edited in place: Gitea keeps a +// comment's position, so an edited message stayed buried near the top of the +// timeline while the pull request moved on (a resolved failure kept sitting +// next to a green status). Posting fresh puts the message where it belongs, at +// the bottom, next to the latest status. +func (b *Bot) comment(ctx context.Context, repo string, pr *gitea.Pull, body string, problem bool) { + body += "\n\n" + b.cfg.CommentMarker + if problem { + body += " " + problemMarker + } + b.deleteComments(ctx, repo, pr, func(gitea.Comment) bool { return true }) + if _, err := b.api.CreateIssueComment(ctx, repo, pr.Number, body); err != nil { + b.log.Error("post comment failed", "repo", repo, "pr", pr.Number, "err", err) + } +} + +// clearProblemComment deletes the bot's own problem comments without posting +// anything. Called when a pass finds nothing left to fix: the failure comment +// is stale, and the commit status already tells the real story. +func (b *Bot) clearProblemComment(ctx context.Context, repo string, pr *gitea.Pull) { + b.deleteComments(ctx, repo, pr, func(c gitea.Comment) bool { + return strings.Contains(c.Body, problemMarker) + }) +} + +// deleteComments removes the bot's own comments for which del returns true. +// Failures are logged and never fatal: the check result does not depend on +// comment bookkeeping. +func (b *Bot) deleteComments(ctx context.Context, repo string, pr *gitea.Pull, del func(gitea.Comment) bool) { + comments, err := b.api.ListIssueComments(ctx, repo, pr.Number) + if err != nil { + b.log.Warn("list comments failed", "repo", repo, "pr", pr.Number, "err", err) + return + } + for _, c := range comments { + if !strings.Contains(c.Body, b.cfg.CommentMarker) || !del(c) { + continue + } + if err := b.api.DeleteIssueComment(ctx, repo, c.ID); err != nil { + b.log.Warn("delete comment failed", "repo", repo, "pr", pr.Number, "comment", c.ID, "err", err) + } + } +} + +// caughtComment is the message the contributor sees when the bot fixed things. +// Body text is the wording this project already used in the Gitea workflow, +// kept verbatim except for the clause that used to say the bot amended the +// commit — the bot now adds a separate, revertable commit and never rewrites +// the contributor's own. +func (b *Bot) caughtComment(pr *gitea.Pull, res *licenser.Result) string { + return fmt.Sprintf(`## Uh oh! Unlicensed code detected! + +You just tried to push **unlicensed code**! This repository is covered by the +[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code +requires a license header. + +Thankfully, our helpful bot caught it and added the missing licensing in a separate +commit ([`+"`%s`"+`](%s)) — your own commit was left untouched, so you can +revert or drop the bot's commit if you would rather add the headers yourself. + +If this looks wrong, tag **@Admin** so we can take a look!`, + short(res.NewSHA), b.commitURL(pr, res.NewSHA)) +} + +// blockedComment is used when the server refuses the bot's push (maintainer +// edits disabled for the pull request, protected branch, missing permission): +// the bot must not bypass that, so it asks the contributor to add the header. +func (b *Bot) blockedComment(pr *gitea.Pull, targets []string, reason string) string { + msg := fmt.Sprintf(`## Uh oh! Unlicensed code detected! + +You just tried to push **unlicensed code**! This repository is covered by the +[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code +requires a license header. + +The bot could not add the header for you: maintainer edits are disabled for this +pull request (or the fork does not allow them). These files still need one: + +%s +Copy the header from [`+"`.license-header.tpl`"+`](%s) — or enable +"allow edits by maintainers" on this pull request — and push again. + +If this looks wrong, tag **@Admin** so we can take a look!`, + fileList(targets), b.treeURL(pr, ".license-header.tpl")) + if reason != "" { + msg += "\n\nserver said: " + reason + "" + } + return msg +} + +// missingComment is the fallback when the bot produced a commit it did not +// publish (dry run, or a push that neither succeeded nor reported a refusal). +func (b *Bot) missingComment(pr *gitea.Pull, targets []string) string { + return fmt.Sprintf(`## Uh oh! Unlicensed code detected! + +You just tried to push **unlicensed code**! This repository is covered by the +[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code +requires a license header. + +These files still need one: + +%s +Copy the header from [`+"`.license-header.tpl`"+`](%s) and push again. + +If this looks wrong, tag **@Admin** so we can take a look!`, + fileList(targets), b.treeURL(pr, ".license-header.tpl")) +} + +func fileList(files []string) string { + var sb strings.Builder + for _, f := range files { + sb.WriteString(fmt.Sprintf("- `%s`\n", f)) + } + return sb.String() +} + +func failureComment(err error) string { + return "The license bot could not finish checking this pull request: `" + licenser.Redact(err.Error()) + "`.\n\n" + + "A maintainer should look at the bot logs." +} + +// commitURL links the bot's commit on the repository that actually holds it +// (the fork, for cross-repository pull requests). +func (b *Bot) commitURL(pr *gitea.Pull, sha string) string { + return fmt.Sprintf("%s/%s/commit/%s", b.instanceURL(), b.headRepo(pr), sha) +} + +// treeURL links a file on the head repository. +func (b *Bot) treeURL(pr *gitea.Pull, path string) string { + return fmt.Sprintf("%s/%s/src/commit/%s/%s", b.instanceURL(), b.headRepo(pr), pr.Head.SHA, path) +} + +// instanceURL is the URL used for links a human clicks, so it may differ from +// the URL the bot calls (the internal Docker alias is not resolvable from a +// browser). +func (b *Bot) instanceURL() string { + if b.cfg.PublicURL != "" { + return strings.TrimSuffix(b.cfg.PublicURL, "/") + } + return strings.TrimSuffix(b.cfg.GiteaURL, "/") +} + +func (b *Bot) headRepo(pr *gitea.Pull) string { + if pr.Head.Repo.FullName != "" { + return pr.Head.Repo.FullName + } + return pr.Base.Repo.FullName +} + +func short(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} + +// claim marks key as handled, returning false when it already was. It +// serialises concurrent work on one head sha and stops the bot from re-running +// the check for a head it has already resolved. +func (b *Bot) claim(key string) bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.handled[key] { + return false + } + b.handled[key] = true + return true +} + +// release forgets key so a failed run can be retried on the next event. +func (b *Bot) release(key string) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.handled, key) +} diff --git a/license-bot/internal/bot/bot_test.go b/license-bot/internal/bot/bot_test.go new file mode 100644 index 0000000..8e92db5 --- /dev/null +++ b/license-bot/internal/bot/bot_test.go @@ -0,0 +1,558 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package bot + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "git.dcntrlized.org/Dcntrlized/license-bot/internal/config" + "git.dcntrlized.org/Dcntrlized/license-bot/internal/gitea" + "git.dcntrlized.org/Dcntrlized/license-bot/internal/licenser" +) + +func newTestBot(t *testing.T, apiURL string) *Bot { + t.Helper() + return New(config.Config{ + GiteaURL: apiURL, + Token: "tok", + WebhookSecret: "s3cret", + Repos: []string{"Dcntrlized/Maestro"}, + BotName: "Bot", + BotEmail: "bot@example.local", + Holder: "Dcntrlized", + CommentMarker: "", + StatusContext: "license-bot / addlicense", + AddlicenseBin: "addlicense", + WorkRoot: t.TempDir(), + }, slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +func sign(secret string, body []byte) string { + m := hmac.New(sha256.New, []byte(secret)) + m.Write(body) + return hex.EncodeToString(m.Sum(nil)) +} + +func TestSignatureValid(t *testing.T) { + b := newTestBot(t, "http://127.0.0.1:1") + body := []byte(`{"action":"opened"}`) + if !b.SignatureValid(sign("s3cret", body), body) { + t.Fatal("correct signature rejected") + } + if !b.SignatureValid("sha256="+sign("s3cret", body), body) { + t.Fatal("sha256= prefixed signature rejected") + } + if b.SignatureValid(sign("other", body), body) { + t.Fatal("signature from the wrong secret accepted") + } + if b.SignatureValid("", body) { + t.Fatal("empty signature accepted") + } +} + +func TestWebhookRejectsBadSignature(t *testing.T) { + b := newTestBot(t, "http://127.0.0.1:1") + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(`{}`)) + req.Header.Set("X-Gitea-Signature", "deadbeef") + rec := httptest.NewRecorder() + b.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +// TestWebhookIgnores covers repositories outside the allowlist and non +// pull-request actions: neither may touch the Gitea API. +func TestWebhookIgnores(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + })) + defer srv.Close() + b := newTestBot(t, srv.URL) + + cases := []string{ + `{"action":"opened","number":1,"repository":{"full_name":"Dcntrlized/Other"},"pull_request":{"number":1}}`, + `{"action":"closed","number":1,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":1}}`, + } + for _, body := range cases { + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) + req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) + req.Header.Set("X-Gitea-Event", "pull_request") + rec := httptest.NewRecorder() + b.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202", rec.Code) + } + } + time.Sleep(150 * time.Millisecond) + if n := atomic.LoadInt32(&hits); n != 0 { + t.Fatalf("API was called %d times for ignored events", n) + } +} + +// TestWebhookDispatches verifies the full hop: valid signature, allowlisted +// repo, pull_request/opened with a PR number, dispatched to the API. +func TestWebhookDispatches(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"number":7,"state":"closed","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) + })) + defer srv.Close() + b := newTestBot(t, srv.URL) + + body := `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}` + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) + req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) + req.Header.Set("X-Gitea-Event", "pull_request") + rec := httptest.NewRecorder() + b.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202", rec.Code) + } + + deadline := time.Now().Add(2 * time.Second) + for atomic.LoadInt32(&hits) == 0 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if n := atomic.LoadInt32(&hits); n == 0 { + t.Fatal("webhook was not dispatched to the API") + } +} + +func TestShortSHA(t *testing.T) { + if got := short("0123456789abcdef"); got != "0123456789ab" { + t.Fatalf("short = %q", got) + } + if got := short("abc"); got != "abc" { + t.Fatalf("short = %q", got) + } +} + +// TestCaughtCommentWording pins the comment text this project has always used, +// so it cannot silently drift again. It must keep the original pretty markdown +// (heading, bold warning, AGPL link, @Admin tag) and link the bot's commit. +func TestCaughtCommentWording(t *testing.T) { + b := newTestBot(t, "http://192.168.122.183:3000") + pr := &gitea.Pull{ + Number: 4, + HTMLURL: "http://192.168.122.183:3000/Dcntrlized/license-bot-lab/pulls/4", + } + pr.Head.SHA = "78a4a2e1ad20ed316eaa58399901e3e37d7496b0" + pr.Head.Repo.FullName = "admin/license-bot-lab" + pr.Base.Repo.FullName = "Dcntrlized/license-bot-lab" + + got := b.caughtComment(pr, &licenser.Result{ + Changed: []string{"src/adv-unlicensed.go"}, + NewSHA: "78a4a2e1ad20ed316eaa58399901e3e37d7496b0", + Pushed: true, + }) + + for _, want := range []string{ + "## Uh oh! Unlicensed code detected!", + "You just tried to push **unlicensed code**!", + "[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html)", + "Thankfully, our helpful bot caught it and added the missing licensing", + "([`78a4a2e1ad20`](http://192.168.122.183:3000/admin/license-bot-lab/commit/78a4a2e1ad20ed316eaa58399901e3e37d7496b0))", + "tag **@Admin**", + } { + if !strings.Contains(got, want) { + t.Fatalf("comment is missing %q:\n%s", want, got) + } + } + // It must not claim the contributor's commit was rewritten. + if strings.Contains(strings.ToLower(got), "amended") || strings.Contains(got, "force-push") { + t.Fatalf("comment must not mention amending or force-pushing:\n%s", got) + } + if !strings.Contains(got, "your own commit was left untouched") { + t.Fatalf("comment must state the contributor's commit is untouched:\n%s", got) + } +} + +func TestHealthRoute(t *testing.T) { + b := newTestBot(t, "http://127.0.0.1:1") + rec := httptest.NewRecorder() + b.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") { + t.Fatalf("health = %d %q", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + b.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/healthz", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST /healthz = %d, want 405", rec.Code) + } +} + +// TestWebhookDeliveryDedup verifies that Gitea redelivering the same webhook +// (same X-Gitea-Delivery id) is accepted but only dispatched once. +func TestWebhookDeliveryDedup(t *testing.T) { + var fetches int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/pulls/7") { + atomic.AddInt32(&fetches, 1) + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"number":7,"state":"open","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) + })) + defer srv.Close() + b := newTestBot(t, srv.URL) + + body := `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}` + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) + req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) + req.Header.Set("X-Gitea-Event", "pull_request") + req.Header.Set("X-Gitea-Delivery", "delivery-1") + rec := httptest.NewRecorder() + b.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("attempt %d: status = %d, want 202", i, rec.Code) + } + } + time.Sleep(200 * time.Millisecond) + if n := atomic.LoadInt32(&fetches); n != 1 { + t.Fatalf("pull fetched %d times for one delivery id, want 1", n) + } +} + +// TestReconcileSkipsGreenAndProcessesOthers covers the startup scan contract: +// a pull request whose head already carries the bot's success status is left +// alone, while one without it is checked. +func TestReconcileSkipsGreenAndProcessesOthers(t *testing.T) { + var greenFiles, redFiles int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/pulls") && r.URL.Query().Get("state") == "open": + io.WriteString(w, `[ + {"number":1,"state":"open","head":{"ref":"green","sha":"green","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}, + {"number":2,"state":"open","head":{"ref":"red","sha":"red","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}} + ]`) + case strings.Contains(r.URL.Path, "/commits/green/statuses"): + io.WriteString(w, `[{"context":"license-bot / addlicense","status":"success"}]`) + case strings.Contains(r.URL.Path, "/commits/red/statuses"): + io.WriteString(w, `[]`) + case strings.HasSuffix(r.URL.Path, "/pulls/2"): + io.WriteString(w, `{"number":2,"state":"open","head":{"ref":"red","sha":"red","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) + case strings.HasSuffix(r.URL.Path, "/pulls/2/files"): + atomic.AddInt32(&redFiles, 1) + io.WriteString(w, `[]`) + case strings.HasSuffix(r.URL.Path, "/pulls/1/files"): + atomic.AddInt32(&greenFiles, 1) + io.WriteString(w, `[]`) + default: + io.WriteString(w, `[]`) + } + })) + defer srv.Close() + b := newTestBot(t, srv.URL) + + if err := b.Reconcile(context.Background()); err != nil { + t.Fatalf("reconcile: %v", err) + } + if n := atomic.LoadInt32(&greenFiles); n != 0 { + t.Fatalf("green pull request was checked %d times, want 0", n) + } + if n := atomic.LoadInt32(&redFiles); n != 1 { + t.Fatalf("red pull request files listed %d times, want 1", n) + } +} + +// TestWebhookSubEvents pins Gitea's real header/action behavior (measured on +// 1.27.2): a push to a pull request head arrives as X-Gitea-Event: +// pull_request with action "synchronized" -- NOT "synchronize" -- and every +// other pull-request sub-event (comments, reviews, labels) carries an action +// outside the allowlist and must not start a check. +func TestWebhookSubEvents(t *testing.T) { + var fetches int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/pulls/7") { + atomic.AddInt32(&fetches, 1) + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"number":7,"state":"open","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`) + })) + defer srv.Close() + b := newTestBot(t, srv.URL) + + post := func(event, body string) { + req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body)) + req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body))) + req.Header.Set("X-Gitea-Event", event) + req.Header.Set("X-Gitea-Delivery", event+body) + rec := httptest.NewRecorder() + b.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("event %q: status = %d, want 202", event, rec.Code) + } + } + + cases := []struct { + event, body string + checked bool + }{ + {"pull_request", `{"action":"synchronized","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true}, + {"pull_request_sync", `{"action":"synchronize","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true}, + {"pull_request", `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true}, + {"pull_request", `{"action":"created","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, false}, + {"pull_request", `{"action":"closed","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, false}, + {"pull_request_comment", `{"action":"created","number":7,"repository":{"full_name":"Dcntrlized/Maestro"}}`, false}, + {"push", `{"action":"synchronized","number":7,"repository":{"full_name":"Dcntrlized/Maestro"}}`, false}, + } + for _, c := range cases { + before := atomic.LoadInt32(&fetches) + post(c.event, c.body) + time.Sleep(150 * time.Millisecond) + got := atomic.LoadInt32(&fetches) > before + if got != c.checked { + t.Fatalf("event %q action %s: checked = %v, want %v", c.event, c.body, got, c.checked) + } + } +} + +// TestCommentLinksUsePublicURL keeps the browser-facing links in the bot's +// comment on the public name even when the API is reached over the internal +// Docker alias (GITEA_URL=http://gitea:3000 is not resolvable in a browser). +func TestCommentLinksUsePublicURL(t *testing.T) { + b := newTestBot(t, "http://gitea:3000") + b.cfg.PublicURL = "https://git.example.org" + var pr gitea.Pull + if err := json.Unmarshal([]byte(`{"head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`), &pr); err != nil { + t.Fatal(err) + } + if got := b.commitURL(&pr, "abc"); got != "https://git.example.org/Dcntrlized/Maestro/commit/abc" { + t.Fatalf("commitURL = %q", got) + } + b.cfg.PublicURL = "" + if got := b.commitURL(&pr, "abc"); got != "http://gitea:3000/Dcntrlized/Maestro/commit/abc" { + t.Fatalf("fallback commitURL = %q", got) + } +} + +// TestCommentReplacesInsteadOfEditing pins the replacement semantics: the bot's +// previous comment is deleted and a fresh one is posted, never edited in place. +// Gitea keeps a comment's position, so editing left a resolved failure buried +// near the top of the timeline while the status at the head was green. +func TestCommentReplacesInsteadOfEditing(t *testing.T) { + var mu sync.Mutex + var calls []string + var posted string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls = append(calls, r.Method+" "+r.URL.Path) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + io.WriteString(w, `[ + {"id":41,"body":"stale failure\n\n "} + ]`) + case http.MethodPost: + raw, _ := io.ReadAll(r.Body) + mu.Lock() + posted = string(raw) + mu.Unlock() + io.WriteString(w, `{"id":42,"body":"fresh"}`) + default: + w.WriteHeader(http.StatusNoContent) + } + })) + defer srv.Close() + + b := newTestBot(t, srv.URL) + b.comment(context.Background(), "Dcntrlized/Maestro", &gitea.Pull{Number: 7}, "fresh message", false) + + mu.Lock() + got := append([]string(nil), calls...) + raw := posted + mu.Unlock() + + // The client JSON-encodes the request, so decode it before asserting on the + // markdown the server would actually store. + var sent struct { + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(raw), &sent); err != nil { + t.Fatalf("posted body is not JSON: %v (%s)", err, raw) + } + body := sent.Body + + var deletes, posts, patches int + for _, c := range got { + switch { + case strings.HasPrefix(c, http.MethodDelete+" ") && strings.HasSuffix(c, "/issues/comments/41"): + deletes++ + case strings.HasPrefix(c, http.MethodPost+" "): + posts++ + case strings.HasPrefix(c, http.MethodPatch+" "): + patches++ + } + } + if patches != 0 { + t.Fatalf("comment was edited in place; calls: %v", got) + } + if deletes != 1 || posts != 1 { + t.Fatalf("deletes=%d posts=%d, want 1 and 1; calls: %v", deletes, posts, got) + } + if !strings.Contains(body, "") { + t.Fatalf("posted comment lost the marker: %s", body) + } + if strings.Contains(body, problemMarker) { + t.Fatalf("informational comment must not carry the problem marker: %s", body) + } +} + +// TestClearProblemCommentKeepsInformational verifies that a pass finding +// nothing left to fix removes a stale problem comment but leaves the +// informational "the bot added the headers" comment in place. +func TestClearProblemCommentKeepsInformational(t *testing.T) { + var mu sync.Mutex + var deletes []string + var posts int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + io.WriteString(w, `[ + {"id":41,"body":"stale failure\n\n "}, + {"id":42,"body":"the bot added the headers\n\n"}, + {"id":43,"body":"a human's comment"} + ]`) + case http.MethodDelete: + mu.Lock() + deletes = append(deletes, r.URL.Path) + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + case http.MethodPost: + mu.Lock() + posts++ + mu.Unlock() + io.WriteString(w, `{"id":44,"body":""}`) + } + })) + defer srv.Close() + + b := newTestBot(t, srv.URL) + b.clearProblemComment(context.Background(), "Dcntrlized/Maestro", &gitea.Pull{Number: 7}) + + mu.Lock() + got := append([]string(nil), deletes...) + n := posts + mu.Unlock() + + if n != 0 { + t.Fatalf("clearProblemComment posted %d comments, want 0", n) + } + if len(got) != 1 || !strings.HasSuffix(got[0], "/issues/comments/41") { + t.Fatalf("deleted %v, want only the stale problem comment 41", got) + } +} + +// TestFixableFiles pins the scope of the check: every changed file that still +// exists at the head is fair game, so an existing unlicensed file gets its +// header the first time a pull request touches it. A removal has no content left +// to license, and "unchanged" is not part of the delta. +func TestFixableFiles(t *testing.T) { + files := []gitea.File{ + {Filename: "added.go", Status: "added"}, + {Filename: "modified.go", Status: "modified"}, + {Filename: "renamed.go", Status: "renamed"}, + {Filename: "copied.go", Status: "copied"}, + {Filename: "gone.go", Status: "removed"}, + {Filename: "same.go", Status: "unchanged"}, + } + got := strings.Join(fixableFiles(files), ",") + if want := "added.go,modified.go,renamed.go,copied.go"; got != want { + t.Fatalf("fixableFiles = %q, want %q", got, want) + } + if len(fixableFiles(nil)) != 0 { + t.Fatalf("fixableFiles(nil) = %v, want empty", fixableFiles(nil)) + } +} + +// TestProcessPullChecksModifiedFiles is the regression test for "a +3 -1 pull +// request that only edits an existing unlicensed file got no header". A modified +// file is no longer short-circuited to success: the run moves on to clone the +// head, which fails here (the fake API is not a git server), so the flow must +// reach the "pending" status and must never post the "no changed files" success. +func TestProcessPullChecksModifiedFiles(t *testing.T) { + var mu sync.Mutex + var statuses []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/api/v1/") { + http.Error(w, "not a git server", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pulls/2/files"): + io.WriteString(w, `[{"filename":"test.go","status":"modified"}]`) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/issues/2/comments"): + io.WriteString(w, `[]`) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/statuses/"): + raw, _ := io.ReadAll(r.Body) + mu.Lock() + statuses = append(statuses, string(raw)) + mu.Unlock() + io.WriteString(w, `{"id":1}`) + default: + io.WriteString(w, `{}`) + } + })) + defer srv.Close() + + b := newTestBot(t, srv.URL) + var pr gitea.Pull + if err := json.Unmarshal([]byte(`{"number":2,"state":"open",`+ + `"head":{"ref":"licensebot-test","sha":"4ac2f5d","repo":{"full_name":"Dcntrlized/licensebot-test"}},`+ + `"base":{"ref":"licensebot-test","repo":{"full_name":"Dcntrlized/licensebot-test"}}}`), &pr); err != nil { + t.Fatal(err) + } + // The clone against this fake API cannot succeed; that error is expected and + // is not what this test asserts. + _ = b.ProcessPull(context.Background(), "Dcntrlized/licensebot-test", &pr) + + mu.Lock() + defer mu.Unlock() + var pending bool + for _, s := range statuses { + if strings.Contains(s, "no changed files need a license header") { + t.Fatalf("a modified file was short-circuited: %s", s) + } + if strings.Contains(s, `"state":"pending"`) && + strings.Contains(s, "checking changed files for AGPL headers") { + pending = true + } + } + if !pending { + t.Fatalf("never posted the pending status, got %v", statuses) + } +} diff --git a/license-bot/internal/config/config.go b/license-bot/internal/config/config.go new file mode 100644 index 0000000..9fcda9d --- /dev/null +++ b/license-bot/internal/config/config.go @@ -0,0 +1,133 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package config is the complete, environment-driven runtime configuration of +// the license bot. The binary takes no arguments: the container image carries +// no secrets and the deployment decides everything through the environment. +package config + +import ( + "fmt" + "os" + "strings" +) + +// Config is the complete runtime configuration of the bot. +type Config struct { + // GiteaURL is the instance base URL the bot calls, e.g. http://gitea:3000 + // on an instance whose Gitea shares this Docker daemon. Deliberately + // configured here instead of taken from webhook payloads: the payload's + // clone_url follows the instance ROOT_URL, which may be stale. + GiteaURL string + // PublicURL is the instance URL a *human* should follow, used only for the + // links in the bot's PR comments. On an instance reached over the internal + // alias (GITEA_URL=http://gitea:3000) that alias is not resolvable from a + // browser, so the comment must link the public name instead. Defaults to + // GiteaURL. + PublicURL string + // Token is a bot-account token with write access to the target repositories. + // It lives only in this process's environment -- never in a workflow. + Token string + // WebhookSecret verifies X-Gitea-Signature on incoming webhook deliveries. + // Webhooks are the only trigger, so this is required. + WebhookSecret string + // Repos is the allowlist of owner/name repositories the bot may touch. + Repos []string + // Listen is the HTTP listen address (webhook + health route). + Listen string + // BotName/BotEmail identify the licensing commit's committer. + BotName string + BotEmail string + // Holder is passed to addlicense -c. + Holder string + // AddlicenseBin is the addlicense executable (in PATH by default). + AddlicenseBin string + // WorkRoot is where per-PR git worktrees are created. + WorkRoot string + // DryRun inspects and comments but never commits or pushes. + DryRun bool + // CommentMarker identifies the bot's own PR comments so they can be + // replaced (deleted and reposted, never edited in place: Gitea keeps a + // comment's position, so an edited message stays buried in the timeline). + CommentMarker string + // StatusContext is the commit-status context the bot posts and the merge + // gate requires. It must match the branch-protection string byte for byte. + StatusContext string +} + +// FromEnv builds the configuration from the process environment. +func FromEnv() (Config, error) { + c := Config{ + GiteaURL: strings.TrimRight(env("GITEA_URL", ""), "/"), + PublicURL: strings.TrimRight(env("GITEA_PUBLIC_URL", ""), "/"), + Token: os.Getenv("GITEA_TOKEN"), + WebhookSecret: os.Getenv("WEBHOOK_SECRET"), + Repos: splitList(os.Getenv("REPOS")), + Listen: env("LISTEN", ":8080"), + BotName: env("BOT_NAME", "Dcntrlized Auto CopyLeft Bot"), + BotEmail: env("BOT_EMAIL", "copyleft+bot@dcntrlized.org"), + Holder: env("HOLDER", "Dcntrlized"), + AddlicenseBin: env("ADDLICENSE_BIN", "addlicense"), + WorkRoot: env("WORK_ROOT", os.TempDir()), + DryRun: truthy(os.Getenv("DRY_RUN")), + CommentMarker: env("COMMENT_MARKER", ""), + StatusContext: env("STATUS_CONTEXT", "license-bot / addlicense"), + } + + if c.Token == "" { + return c, fmt.Errorf("GITEA_TOKEN is required") + } + if len(c.Repos) == 0 { + return c, fmt.Errorf("REPOS is required (comma-separated owner/name list)") + } + if c.WebhookSecret == "" { + return c, fmt.Errorf("WEBHOOK_SECRET is required: webhooks are the only trigger") + } + if c.PublicURL == "" { + c.PublicURL = c.GiteaURL + } + return c, nil +} + +// Allows reports whether repo (owner/name) is on the allowlist. +func (c Config) Allows(repo string) bool { + for _, r := range c.Repos { + if strings.EqualFold(r, repo) { + return true + } + } + return false +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func truthy(v string) bool { + return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") +} + +func splitList(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/license-bot/internal/config/config_test.go b/license-bot/internal/config/config_test.go new file mode 100644 index 0000000..99fc388 --- /dev/null +++ b/license-bot/internal/config/config_test.go @@ -0,0 +1,52 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import "testing" + +func TestFromEnvRequiresTokenReposAndSecret(t *testing.T) { + t.Setenv("GITEA_TOKEN", "") + t.Setenv("REPOS", "") + t.Setenv("WEBHOOK_SECRET", "") + if _, err := FromEnv(); err == nil { + t.Fatal("empty environment accepted") + } + + t.Setenv("GITEA_TOKEN", "tok") + if _, err := FromEnv(); err == nil { + t.Fatal("missing REPOS accepted") + } + + t.Setenv("REPOS", "Dcntrlized/Maestro") + if _, err := FromEnv(); err == nil { + t.Fatal("missing WEBHOOK_SECRET accepted (webhooks are the only trigger)") + } + + t.Setenv("WEBHOOK_SECRET", "s3cret") + cfg, err := FromEnv() + if err != nil { + t.Fatalf("valid environment rejected: %v", err) + } + if cfg.StatusContext != "license-bot / addlicense" { + t.Fatalf("StatusContext = %q", cfg.StatusContext) + } + if !cfg.Allows("dcntrlized/maestro") { + t.Fatal("allowlist is not case-insensitive") + } + if cfg.Allows("Dcntrlized/Other") { + t.Fatal("allowlist accepted a repository outside it") + } +} diff --git a/license-bot/internal/gitea/client.go b/license-bot/internal/gitea/client.go new file mode 100644 index 0000000..babecd7 --- /dev/null +++ b/license-bot/internal/gitea/client.go @@ -0,0 +1,241 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package gitea is a minimal Gitea REST client covering exactly the endpoints +// the license bot needs. It intentionally has no third-party dependencies. +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Client talks to one Gitea instance. +type Client struct { + Base string + Token string + HTTP *http.Client +} + +// New returns a client for baseURL authenticated with token. +func New(baseURL, token string) *Client { + return &Client{ + Base: strings.TrimRight(baseURL, "/"), + Token: token, + HTTP: &http.Client{Timeout: 60 * time.Second}, + } +} + +// File is one entry of a pull request's changed-file list. +type File struct { + Filename string `json:"filename"` + Status string `json:"status"` +} + +// RepoRef is the subset of a repository object carried by a pull request. +type RepoRef struct { + FullName string `json:"full_name"` +} + +// Pull is the subset of a pull request the bot relies on. +type Pull struct { + Number int `json:"number"` + State string `json:"state"` + HTMLURL string `json:"html_url"` + Title string `json:"title"` + Head struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo RepoRef `json:"repo"` + } `json:"head"` + Base struct { + Ref string `json:"ref"` + Repo RepoRef `json:"repo"` + } `json:"base"` + AllowMaintainerEdit bool `json:"allow_maintainer_edit"` +} + +// Comment is a PR/issue comment. +type Comment struct { + ID int64 `json:"id"` + Body string `json:"body"` +} + +// Status is one commit status entry. +type Status struct { + Context string `json:"context"` + Status string `json:"status"` +} + +// APIError is a non-2xx response from the Gitea API. +type APIError struct { + Status int + Method string + Path string + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.Path, e.Status, e.Body) +} + +func (c *Client) do(ctx context.Context, method, path string, in, out any) error { + var body io.Reader + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.Base+"/api/v1"+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+c.Token) + req.Header.Set("Accept", "application/json") + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return &APIError{Status: resp.StatusCode, Method: method, Path: path, Body: strings.TrimSpace(string(raw))} + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("%s %s: decode response: %w", method, path, err) + } + } + return nil +} + +const pageSize = 50 + +// GetPull returns one pull request. +func (c *Client) GetPull(ctx context.Context, repo string, index int) (*Pull, error) { + var p Pull + if err := c.do(ctx, http.MethodGet, "/repos/"+repo+"/pulls/"+strconv.Itoa(index), nil, &p); err != nil { + return nil, err + } + return &p, nil +} + +// ListOpenPulls returns every open pull request of repo. +func (c *Client) ListOpenPulls(ctx context.Context, repo string) ([]Pull, error) { + var all []Pull + for page := 1; ; page++ { + var batch []Pull + p := fmt.Sprintf("/repos/%s/pulls?state=open&limit=%d&page=%d", repo, pageSize, page) + if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < pageSize { + return all, nil + } + } +} + +// ListPullFiles returns every changed file of a pull request. +func (c *Client) ListPullFiles(ctx context.Context, repo string, index int) ([]File, error) { + var all []File + for page := 1; ; page++ { + var batch []File + p := fmt.Sprintf("/repos/%s/pulls/%d/files?limit=%d&page=%d", repo, index, pageSize, page) + if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < pageSize { + return all, nil + } + } +} + +// ListIssueComments returns all comments of an issue or pull request. +func (c *Client) ListIssueComments(ctx context.Context, repo string, index int) ([]Comment, error) { + var all []Comment + for page := 1; ; page++ { + var batch []Comment + p := fmt.Sprintf("/repos/%s/issues/%d/comments?limit=%d&page=%d", repo, index, pageSize, page) + if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < pageSize { + return all, nil + } + } +} + +// CreateIssueComment posts a comment on an issue or pull request. +func (c *Client) CreateIssueComment(ctx context.Context, repo string, index int, body string) (*Comment, error) { + var out Comment + in := map[string]string{"body": body} + if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/issues/%d/comments", repo, index), in, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditIssueComment updates an existing comment in place. +func (c *Client) EditIssueComment(ctx context.Context, repo string, id int64, body string) error { + in := map[string]string{"body": body} + return c.do(ctx, http.MethodPatch, fmt.Sprintf("/repos/%s/issues/comments/%d", repo, id), in, nil) +} + +// DeleteIssueComment removes a comment. +func (c *Client) DeleteIssueComment(ctx context.Context, repo string, id int64) error { + return c.do(ctx, http.MethodDelete, fmt.Sprintf("/repos/%s/issues/comments/%d", repo, id), nil, nil) +} + +// ListStatuses returns every commit status of one commit. +func (c *Client) ListStatuses(ctx context.Context, repo, sha string) ([]Status, error) { + var all []Status + for page := 1; ; page++ { + var batch []Status + p := fmt.Sprintf("/repos/%s/commits/%s/statuses?limit=%d&page=%d", repo, url.PathEscape(sha), pageSize, page) + if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < pageSize { + return all, nil + } + } +} + +// CreateStatus posts a commit status. state is one of pending, success, +// failure, error, warning. +func (c *Client) CreateStatus(ctx context.Context, repo, sha, state, context, description, targetURL string) error { + in := map[string]string{"state": state, "context": context, "description": description} + if targetURL != "" { + in["target_url"] = targetURL + } + return c.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/statuses/%s", repo, url.PathEscape(sha)), in, nil) +} diff --git a/license-bot/internal/gitea/client_test.go b/license-bot/internal/gitea/client_test.go new file mode 100644 index 0000000..e97e8ae --- /dev/null +++ b/license-bot/internal/gitea/client_test.go @@ -0,0 +1,147 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package gitea + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func TestAuthHeaderAndListPullFilesPagination(t *testing.T) { + var pages []int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/o/r/pulls/3/files" { + t.Errorf("unexpected path %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "token tok" { + t.Errorf("Authorization = %q", got) + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pages = append(pages, page) + n := pageSize + if page == 2 { + n = 3 + } + files := make([]File, 0, n) + for i := 0; i < n; i++ { + files = append(files, File{Filename: fmt.Sprintf("f%d-%d.go", page, i), Status: "added"}) + } + _ = json.NewEncoder(w).Encode(files) + })) + defer srv.Close() + + got, err := New(srv.URL, "tok").ListPullFiles(context.Background(), "o/r", 3) + if err != nil { + t.Fatal(err) + } + if len(got) != pageSize+3 { + t.Fatalf("got %d files, want %d", len(got), pageSize+3) + } + if len(pages) != 2 || pages[0] != 1 || pages[1] != 2 { + t.Fatalf("pages = %v, want [1 2]", pages) + } +} + +func TestCreateStatus(t *testing.T) { + var gotMethod, gotPath, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + err := New(srv.URL, "tok").CreateStatus(context.Background(), "o/r", "abc123", "success", "license-bot / addlicense", "ok", "") + if err != nil { + t.Fatal(err) + } + if gotMethod != http.MethodPost || gotPath != "/api/v1/repos/o/r/statuses/abc123" { + t.Fatalf("%s %s", gotMethod, gotPath) + } + for _, want := range []string{`"state":"success"`, `"context":"license-bot / addlicense"`, `"description":"ok"`} { + if !strings.Contains(gotBody, want) { + t.Fatalf("body %s missing %s", gotBody, want) + } + } +} + +func TestCommentLifecyclePaths(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _ = json.NewEncoder(w).Encode([]Comment{{ID: 42, Body: "hi"}}) + return + } + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Comment{ID: 43}) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(srv.URL, "tok") + ctx := context.Background() + if _, err := c.ListIssueComments(ctx, "o/r", 9); err != nil { + t.Fatal(err) + } + if _, err := c.CreateIssueComment(ctx, "o/r", 9, "body"); err != nil { + t.Fatal(err) + } + if err := c.EditIssueComment(ctx, "o/r", 42, "body"); err != nil { + t.Fatal(err) + } + want := []string{ + "GET /api/v1/repos/o/r/issues/9/comments", + "POST /api/v1/repos/o/r/issues/9/comments", + "PATCH /api/v1/repos/o/r/issues/comments/42", + } + if len(paths) != len(want) { + t.Fatalf("paths = %v", paths) + } + for i := range want { + if paths[i] != want[i] { + t.Fatalf("paths = %v, want %v", paths, want) + } + } +} + +func TestAPIErrorCarriesStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusForbidden) + })) + defer srv.Close() + + _, err := New(srv.URL, "tok").GetPull(context.Background(), "o/r", 1) + if err == nil { + t.Fatal("expected an error") + } + apiErr, ok := err.(*APIError) + if !ok || apiErr.Status != http.StatusForbidden { + t.Fatalf("err = %#v", err) + } +} diff --git a/license-bot/internal/licenser/licenser.go b/license-bot/internal/licenser/licenser.go new file mode 100644 index 0000000..f9c9223 --- /dev/null +++ b/license-bot/internal/licenser/licenser.go @@ -0,0 +1,355 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package licenser applies AGPL headers to a pull request's added files and +// pushes them back as its own commit on top of the head — to the base +// repository for same-repo PRs and to the contributor's fork for cross-repo PRs +// (Gitea's allow_maintainer_edit, on by default). The contributor's commit is +// never rewritten, and a branch that advances while the bot works is re-read and +// re-processed instead of being abandoned. +package licenser + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// Options describes one pull-request head to fix. +type Options struct { + // RemoteURL is the credential-bearing clone/push URL of the HEAD repository + // (the fork, or the base repository for same-repo PRs). + RemoteURL string + // Ref is the head branch name; SHA is the head commit the PR was read at. + // A later attempt works from the branch's current tip instead. + Ref string + SHA string + // Files are repository-relative paths of changed files to check. + Files []string + // AddlicenseBin is the addlicense executable. + AddlicenseBin string + // Holder is the copyright holder passed to addlicense -c. + Holder string + // BotName/BotEmail are the committer identity of the amended commit. + BotName string + BotEmail string + // WorkRoot is the parent directory for the temporary worktree. + WorkRoot string + // DryRun stops before committing/pushing. + DryRun bool + // Logger receives progress; may be nil. + Logger *slog.Logger +} + +// Result reports what happened. +type Result struct { + // Changed lists files that received a header. + Changed []string + // NewSHA is the bot's commit on top of the pull request head, empty when + // nothing changed. + NewSHA string + // Pushed reports whether that commit reached the remote. + Pushed bool + // Blocked reports a push refused by the server (typically + // allow_maintainer_edit disabled on the PR, or a protected branch). + Blocked bool + // BlockedReason carries the redacted server message for Blocked. + BlockedReason string +} + +// ErrRemoteMoved means the remote head kept advancing for every attempt: each +// rejection makes the bot drop its local work, re-read the branch, re-apply the +// headers and push again, and this error is only returned once that loop is +// exhausted. Nothing is ever force-pushed or rewritten, so the caller can simply +// try again later. +var ErrRemoteMoved = errors.New("remote branch kept moving; will retry later") + +// maxPushAttempts bounds the re-read/re-apply/push loop. A normal race is +// resolved on the second attempt; the bound only stops a pathological +// push storm from spinning forever. +const maxPushAttempts = 5 + +const cloneDepth = 50 + +// commitSubject/commitBody describe the bot's own commit. It is deliberately a +// new commit on top of the pull request head — never an amend — so the +// contributor's work and message stay byte-for-byte intact and the bot's change +// can be reverted or dropped with a single `git revert` / `git reset`. +const commitSubject = "chore(license): add AGPL-3.0 headers" + +// EnsureHeaders runs addlicense over opts.Files in a fresh worktree of the PR +// head, records the result in a separate commit and pushes it on top of the +// head branch (fast-forward, no force). +func EnsureHeaders(ctx context.Context, opts Options) (*Result, error) { + log := opts.Logger + if log == nil { + log = slog.Default() + } + if opts.RemoteURL == "" { + return nil, errors.New("RemoteURL is required") + } + if opts.Ref == "" || opts.SHA == "" { + return nil, errors.New("Ref and SHA are required") + } + + var last *Result + for attempt := 1; attempt <= maxPushAttempts; attempt++ { + res, moved, err := ensureHeadersOnce(ctx, opts, log, attempt == 1) + if err != nil { + return res, err + } + if !moved { + return res, nil + } + // The branch advanced under us: drop the local work (the temp clone is + // discarded), re-read the branch, re-run the check and try again. + last = res + log.Warn("branch advanced while working; re-reading it and re-applying the headers", + "ref", opts.Ref, "attempt", attempt, "of", maxPushAttempts) + } + if last != nil { + last.NewSHA = "" // nothing reached the remote + } + return last, fmt.Errorf("%w: %s moved on every one of %d attempts", ErrRemoteMoved, opts.Ref, maxPushAttempts) +} + +// ensureHeadersOnce performs one clone/apply/commit/push round. first selects +// the starting point: the head commit the pull request was read at on the first +// attempt, the branch's current tip afterwards. moved reports a rejected +// non-fast-forward push, which the caller answers by starting over. +func ensureHeadersOnce(ctx context.Context, opts Options, log *slog.Logger, first bool) (res *Result, moved bool, err error) { + dir, err := os.MkdirTemp(opts.WorkRoot, "license-bot-*") + if err != nil { + return nil, false, fmt.Errorf("create worktree: %w", err) + } + defer os.RemoveAll(dir) + + if _, err := git(ctx, dir, "clone", "--quiet", "--depth", strconv.Itoa(cloneDepth), "--single-branch", "--branch", opts.Ref, opts.RemoteURL, dir); err != nil { + return nil, false, fmt.Errorf("clone %s: %w", Redact(opts.RemoteURL), err) + } + + target := opts.SHA + if !first { + // Re-read the branch: the previous attempt lost a race, so the work must + // be redone on top of whatever is there now. + if out, err := git(ctx, dir, "rev-parse", "origin/"+opts.Ref); err == nil { + if tip := strings.TrimSpace(out); tip != "" { + target = tip + } + } + } + if !commitExists(ctx, dir, target) { + if _, err := git(ctx, dir, "fetch", "--quiet", "--unshallow", "origin"); err != nil { + log.Warn("unshallow failed, using fetched history", "err", err) + } + } + if !commitExists(ctx, dir, target) { + return nil, false, fmt.Errorf("head commit %s not present in %s", target, Redact(opts.RemoteURL)) + } + if _, err := git(ctx, dir, "checkout", "--quiet", target); err != nil { + return nil, false, fmt.Errorf("checkout %s: %w", target, err) + } + + tpl := filepath.Join(dir, ".license-header.tpl") + if _, err := os.Stat(tpl); err != nil { + return nil, false, fmt.Errorf(".license-header.tpl missing at %s: %w", target, err) + } + patterns := ReadIgnorePatterns(filepath.Join(dir, ".licenseignore")) + + res = &Result{} + year := strconv.Itoa(time.Now().Year()) + for _, f := range opts.Files { + if f == "" { + continue + } + abs := filepath.Join(dir, filepath.FromSlash(f)) + if st, err := os.Stat(abs); err != nil || st.IsDir() { + continue // removed again or not a regular file + } + args := []string{"-v", "-f", ".license-header.tpl", "-c", opts.Holder, "-y", year} + for _, p := range patterns { + args = append(args, "-ignore", p) + } + args = append(args, f) + out, err := run(ctx, dir, opts.AddlicenseBin, args...) + if err != nil { + return nil, false, fmt.Errorf("addlicense %s: %w: %s", f, err, strings.TrimSpace(out)) + } + } + + porcelain, err := git(ctx, dir, "status", "--porcelain") + if err != nil { + return nil, false, fmt.Errorf("git status: %w", err) + } + res.Changed = ChangedFiles(porcelain) + if len(res.Changed) == 0 { + // Everything this pull request added is already licensed, so there is + // nothing to publish — including on a retry after a concurrent push + // fixed the files itself. + return res, false, nil + } + log.Info("adding license headers", "files", len(res.Changed), "dry_run", opts.DryRun) + if opts.DryRun { + return res, false, nil + } + + if _, err := git(ctx, dir, "add", "-A"); err != nil { + return nil, false, fmt.Errorf("git add: %w", err) + } + body := "Added by the Dcntrlized license bot to the files this pull request\n" + + "introduced without a license header. Only the files this pull request adds\n" + + "are touched, and the header text comes from .license-header.tpl.\n\n" + + "This commit is separate from the contributor's work, so it can be reverted\n" + + "or dropped freely.\n\nFiles:\n" + for _, f := range res.Changed { + body += "- " + f + "\n" + } + if _, err := git(ctx, dir, "-c", "user.name="+opts.BotName, "-c", "user.email="+opts.BotEmail, + "commit", "--quiet", "-m", commitSubject, "-m", body); err != nil { + return nil, false, fmt.Errorf("git commit: %w", err) + } + newSHA, err := git(ctx, dir, "rev-parse", "HEAD") + if err != nil { + return nil, false, fmt.Errorf("rev-parse: %w", err) + } + res.NewSHA = strings.TrimSpace(newSHA) + + // Fast-forward push only: no --force, no --force-with-lease. If anyone + // pushed in the meantime the push is rejected and the caller starts over + // from the new tip rather than rewriting their history. + out, err := git(ctx, dir, "push", "--quiet", opts.RemoteURL, "HEAD:refs/heads/"+opts.Ref) + if err != nil { + msg := strings.TrimSpace(out) + if isPushRefused(msg) { + res.Blocked = true + res.BlockedReason = Redact(msg) + return res, false, nil + } + if isRemoteMoved(msg) { + res.NewSHA = "" + return res, true, nil + } + return res, false, fmt.Errorf("git push %s: %w: %s", Redact(opts.RemoteURL), err, Redact(msg)) + } + res.Pushed = true + return res, false, nil +} + +func commitExists(ctx context.Context, dir, sha string) bool { + _, err := git(ctx, dir, "cat-file", "-e", sha+"^{commit}") + return err == nil +} + +func git(ctx context.Context, dir string, args ...string) (string, error) { + return runEnv(ctx, dir, []string{"GIT_TERMINAL_PROMPT=0"}, "git", args...) +} + +func run(ctx context.Context, dir, bin string, args ...string) (string, error) { + return runEnv(ctx, dir, nil, bin, args...) +} + +// runEnv runs a command and, on failure, returns an error carrying the +// redacted combined output so diagnostics never leak the bot token. +func runEnv(ctx context.Context, dir string, extraEnv []string, bin string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), extraEnv...) + var buf strings.Builder + cmd.Stdout = &buf + cmd.Stderr = &buf + err := cmd.Run() + out := buf.String() + if ctx.Err() != nil { + return out, ctx.Err() + } + if err != nil { + if msg := strings.TrimSpace(Redact(out)); msg != "" { + return out, fmt.Errorf("%w: %s", err, msg) + } + } + return out, err +} + +var ( + // A refusal the bot cannot fix by retrying: permissions, disabled + // maintainer edits, protected branch rules. + rePushDenied = regexp.MustCompile(`(?i)(403|forbidden|denied|not allowed|not permitted|permission|protected)`) + // The remote advanced under us: a plain non-fast-forward rejection. + reRemoteMoved = regexp.MustCompile(`(?i)(non-fast-forward|fetch first|stale info|\[remote rejected\]|behind)`) +) + +func isPushRefused(msg string) bool { return rePushDenied.MatchString(msg) } +func isRemoteMoved(msg string) bool { return reRemoteMoved.MatchString(msg) } + +// ChangedFiles extracts paths from `git status --porcelain` output. +func ChangedFiles(porcelain string) []string { + var out []string + for _, line := range strings.Split(porcelain, "\n") { + if len(line) < 4 { + continue + } + p := line[3:] + if strings.HasPrefix(p, "\"") { + if uq, err := strconv.Unquote(p); err == nil { + p = uq + } + } + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// ReadIgnorePatterns returns the non-empty, non-comment lines of a +// .licenseignore file. A missing file yields no patterns. +func ReadIgnorePatterns(path string) []string { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + return ParseIgnore(string(b)) +} + +// ParseIgnore splits .licenseignore content into addlicense -ignore patterns. +// addlicense's patterns are doublestar globs, not prefixes, so lines are used +// verbatim. +func ParseIgnore(body string) []string { + var out []string + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + return out +} + +// Redact removes credentials embedded in a URL or message so logs and PR +// comments never leak the bot token. +func Redact(s string) string { + // scheme://user:pass@host -> scheme://host + re := regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]*@`) + return re.ReplaceAllString(s, "$1") +} diff --git a/license-bot/internal/licenser/licenser_test.go b/license-bot/internal/licenser/licenser_test.go new file mode 100644 index 0000000..d8512b4 --- /dev/null +++ b/license-bot/internal/licenser/licenser_test.go @@ -0,0 +1,460 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package licenser + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestParseIgnore(t *testing.T) { + got := ParseIgnore("# comment\n\n**/*.md\n vendor/** \n*_test.go\n") + want := []string{"**/*.md", "vendor/**", "*_test.go"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } +} + +func TestChangedFiles(t *testing.T) { + porcelain := " M internal/a.go\n?? new file.go\nA added.go\n M \"docs/with space.md\"\n" + got := ChangedFiles(porcelain) + want := []string{"internal/a.go", "new file.go", "added.go", "docs/with space.md"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } +} + +func TestRedact(t *testing.T) { + in := "fatal: unable to access 'http://oauth2:supersecret@192.168.122.183:3000/o/r.git/'" + got := Redact(in) + if strings.Contains(got, "supersecret") { + t.Fatalf("token leaked: %s", got) + } + if !strings.Contains(got, "http://192.168.122.183:3000/o/r.git") { + t.Fatalf("host lost: %s", got) + } +} + +// TestEnsureHeadersEndToEnd exercises the real flow — clone, addlicense, commit +// a separate commit, fast-forward push — against a local bare repository +// standing in for the fork or base repo. +func TestEnsureHeadersEndToEnd(t *testing.T) { + bin := os.Getenv("ADDLICENSE_BIN") + if bin == "" { + bin = "addlicense" + } + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("addlicense binary not available: %v", err) + } + + root := t.TempDir() + work := filepath.Join(root, "work") + bare := filepath.Join(root, "remote.git") + + mustGit(t, root, "init", "-q", "--initial-branch=main", work) + mustGit(t, work, "config", "user.name", "Tester") + mustGit(t, work, "config", "user.email", "tester@example.local") + writeFile(t, filepath.Join(work, ".license-header.tpl"), + "Copyright (C) {{.Year}} {{.Holder}}\n\nGNU Affero General Public License v3.\n") + writeFile(t, filepath.Join(work, ".licenseignore"), "# skip docs\n**/*.md\n") + writeFile(t, filepath.Join(work, "main.go"), "package main\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "initial") + mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) + mustGit(t, work, "remote", "add", "origin", bare) + mustGit(t, work, "push", "-q", "-u", "origin", "main") + + mustGit(t, work, "checkout", "-qb", "feature") + writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") + writeFile(t, filepath.Join(work, "README.md"), "documentation without a header\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "add an unlicensed file") + sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) + mustGit(t, work, "push", "-q", "-u", "origin", "feature") + + res, err := EnsureHeaders(context.Background(), Options{ + RemoteURL: bare, + Ref: "feature", + SHA: sha, + Files: []string{"new.go", "README.md"}, + AddlicenseBin: bin, + Holder: "Dcntrlized", + BotName: "Dcntrlized Auto CopyLeft Bot", + BotEmail: "copyleft+bot@dcntrlized.org", + WorkRoot: root, + }) + if err != nil { + t.Fatalf("EnsureHeaders: %v", err) + } + if !res.Pushed { + t.Fatalf("expected the amend to be pushed, got %+v", res) + } + if len(res.Changed) != 1 || res.Changed[0] != "new.go" { + t.Fatalf("changed = %v, want [new.go]", res.Changed) + } + if res.NewSHA == "" || res.NewSHA == sha { + t.Fatalf("expected a new commit, got %q (old %q)", res.NewSHA, sha) + } + + verify := filepath.Join(root, "verify") + mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) + body := readFile(t, filepath.Join(verify, "new.go")) + if !strings.Contains(body, "GNU Affero General Public License") { + t.Fatalf("header not added:\n%s", body) + } + if strings.Contains(readFile(t, filepath.Join(verify, "README.md")), "Affero") { + t.Fatal("README.md must stay untouched (ignored)") + } + // The contributor's commit must be preserved byte-for-byte: same message, + // same author, same sha. The bot only adds a commit on top of it. + if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%H")); got != sha { + t.Fatalf("contributor commit sha changed: %s, want %s", got, sha) + } + if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%s")); got != "add an unlicensed file" { + t.Fatalf("contributor commit message changed: %q", got) + } + if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%an <%ae>")); got != "Tester " { + t.Fatalf("contributor commit author changed: %q", got) + } + if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--pretty=%cn <%ce>")); got != "Dcntrlized Auto CopyLeft Bot " { + t.Fatalf("committer should be the bot: %q", got) + } + if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--pretty=%s")); got != "chore(license): add AGPL-3.0 headers" { + t.Fatalf("bot commit subject = %q", got) + } + if body := mustGit(t, verify, "log", "-1", "--pretty=%b"); !strings.Contains(body, "reverted") { + t.Fatalf("bot commit body should say the commit is revertable: %q", body) + } + // initial + contributor + bot, and the bot's commit sits directly on top. + if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "3" { + t.Fatalf("expected initial + contributor + bot commit, got %s", got) + } + if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD^")); got != sha { + t.Fatalf("bot commit parent = %s, want %s", got, sha) + } +} + +// TestEnsureHeadersRemoteMoved pins the self-healing race behavior: when the +// branch advances after the bot read it, the fast-forward push is rejected, the +// bot drops its local work, re-reads the branch, re-runs the check on top of the +// new tip and pushes again. Nothing is overwritten and nothing is lost. +func TestEnsureHeadersRemoteMoved(t *testing.T) { + bin := os.Getenv("ADDLICENSE_BIN") + if bin == "" { + bin = "addlicense" + } + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("addlicense binary not available: %v", err) + } + + root := t.TempDir() + work := filepath.Join(root, "work") + bare := filepath.Join(root, "remote.git") + mustGit(t, root, "init", "-q", "--initial-branch=main", work) + mustGit(t, work, "config", "user.name", "Tester") + mustGit(t, work, "config", "user.email", "tester@example.local") + writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") + writeFile(t, filepath.Join(work, "main.go"), "package main\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "initial") + mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) + mustGit(t, work, "remote", "add", "origin", bare) + mustGit(t, work, "push", "-q", "-u", "origin", "main") + + mustGit(t, work, "checkout", "-qb", "feature") + writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "add an unlicensed file") + staleSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) + mustGit(t, work, "push", "-q", "-u", "origin", "feature") + + // Someone else pushes to the same branch while the bot is working. + writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n\nfunc Later() {}\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "work pushed concurrently") + movedSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) + mustGit(t, work, "push", "-q", "origin", "feature") + + res, err := EnsureHeaders(context.Background(), Options{ + RemoteURL: bare, Ref: "feature", SHA: staleSHA, Files: []string{"new.go"}, + AddlicenseBin: bin, Holder: "Dcntrlized", + BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, + }) + if err != nil { + t.Fatalf("the bot must self-heal a moved branch, got %v (res %+v)", err, res) + } + if !res.Pushed || res.NewSHA == "" { + t.Fatalf("expected the retried commit to be pushed, got %+v", res) + } + if len(res.Changed) != 1 || res.Changed[0] != "new.go" { + t.Fatalf("changed = %v, want [new.go]", res.Changed) + } + + verify := filepath.Join(root, "verify") + mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) + if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != res.NewSHA { + t.Fatalf("remote head = %s, want the bot commit %s", got, res.NewSHA) + } + // The bot's commit sits on top of the CONCURRENT commit, not the stale one. + if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD^")); got != movedSHA { + t.Fatalf("bot commit parent = %s, want the concurrent commit %s", got, movedSHA) + } + // Nothing may be lost: the concurrent change and both original commits stay. + if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "4" { + t.Fatalf("expected initial + contributor + concurrent + bot, got %s commits", got) + } + if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD~2")); got != staleSHA { + t.Fatalf("the contributor commit changed: %s, want %s", got, staleSHA) + } + body := readFile(t, filepath.Join(verify, "new.go")) + if !strings.Contains(body, "Copyright (C)") { + t.Fatalf("header not applied after the retry:\n%s", body) + } + if !strings.Contains(body, "func Later()") { + t.Fatalf("the concurrent change was lost:\n%s", body) + } +} + +// TestEnsureHeadersRemoteMovedAlreadyFixed covers the retry that finds nothing +// left to do because the concurrent push added the header itself: no commit is +// published and the branch is left exactly as the other writer left it. +func TestEnsureHeadersRemoteMovedAlreadyFixed(t *testing.T) { + bin := os.Getenv("ADDLICENSE_BIN") + if bin == "" { + bin = "addlicense" + } + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("addlicense binary not available: %v", err) + } + + root := t.TempDir() + work := filepath.Join(root, "work") + bare := filepath.Join(root, "remote.git") + mustGit(t, root, "init", "-q", "--initial-branch=main", work) + mustGit(t, work, "config", "user.name", "Tester") + mustGit(t, work, "config", "user.email", "tester@example.local") + writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") + writeFile(t, filepath.Join(work, "main.go"), "package main\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "initial") + mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) + mustGit(t, work, "remote", "add", "origin", bare) + mustGit(t, work, "push", "-q", "-u", "origin", "main") + + mustGit(t, work, "checkout", "-qb", "feature") + writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "add an unlicensed file") + staleSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) + mustGit(t, work, "push", "-q", "-u", "origin", "feature") + + // The concurrent writer licenses the file itself. + writeFile(t, filepath.Join(work, "new.go"), "// Copyright (C) 2026 Dcntrlized\n\npackage main\n\nfunc New() {}\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "license the file by hand") + movedSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) + mustGit(t, work, "push", "-q", "origin", "feature") + + res, err := EnsureHeaders(context.Background(), Options{ + RemoteURL: bare, Ref: "feature", SHA: staleSHA, Files: []string{"new.go"}, + AddlicenseBin: bin, Holder: "Dcntrlized", + BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, + }) + if err != nil { + t.Fatalf("EnsureHeaders: %v", err) + } + if res.Pushed || res.NewSHA != "" { + t.Fatalf("nothing to publish, got %+v", res) + } + if len(res.Changed) != 0 { + t.Fatalf("changed = %v, want none", res.Changed) + } + verify := filepath.Join(root, "verify") + mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) + if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != movedSHA { + t.Fatalf("remote head = %s, want %s (untouched)", got, movedSHA) + } + if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "3" { + t.Fatalf("no commit may be added, got %s", got) + } +} + +// TestEnsureHeadersGivesUpWhenBranchKeepsMoving bounds the retry loop: if every +// push is rejected as a non-fast-forward, the attempt budget is exhausted, the +// error is ErrRemoteMoved and nothing is published. +func TestEnsureHeadersGivesUpWhenBranchKeepsMoving(t *testing.T) { + bin := os.Getenv("ADDLICENSE_BIN") + if bin == "" { + bin = "addlicense" + } + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("addlicense binary not available: %v", err) + } + + root := t.TempDir() + work := filepath.Join(root, "work") + bare := filepath.Join(root, "remote.git") + mustGit(t, root, "init", "-q", "--initial-branch=main", work) + mustGit(t, work, "config", "user.name", "Tester") + mustGit(t, work, "config", "user.email", "tester@example.local") + writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") + writeFile(t, filepath.Join(work, "main.go"), "package main\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "initial") + mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) + mustGit(t, work, "remote", "add", "origin", bare) + mustGit(t, work, "push", "-q", "-u", "origin", "main") + mustGit(t, work, "checkout", "-qb", "feature") + writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "add an unlicensed file") + sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) + mustGit(t, work, "push", "-q", "-u", "origin", "feature") + + hook := filepath.Join(bare, "hooks", "pre-receive") + writeFile(t, hook, "#!/bin/sh\necho '! [rejected] feature -> feature (non-fast-forward)' >&2\nexit 1\n") + if err := os.Chmod(hook, 0o755); err != nil { + t.Fatal(err) + } + + res, err := EnsureHeaders(context.Background(), Options{ + RemoteURL: bare, Ref: "feature", SHA: sha, Files: []string{"new.go"}, + AddlicenseBin: bin, Holder: "Dcntrlized", + BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, + }) + if !errors.Is(err, ErrRemoteMoved) { + t.Fatalf("err = %v, want ErrRemoteMoved (res %+v)", err, res) + } + if res == nil || res.Pushed { + t.Fatalf("nothing may be published when the branch keeps moving: %+v", res) + } + if res.NewSHA != "" { + t.Fatalf("no remote commit may be reported, got %q", res.NewSHA) + } + verify := filepath.Join(root, "verify") + mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) + if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != sha { + t.Fatalf("remote head = %s, want %s", got, sha) + } +} + +// TestEnsureHeadersPushRefused covers the allow_maintainer_edit=false case: the +// server refuses the force-push, and the bot must report Blocked instead of an +// error so it can tell the contributor to add the header by hand. +func TestEnsureHeadersPushRefused(t *testing.T) { + bin := os.Getenv("ADDLICENSE_BIN") + if bin == "" { + bin = "addlicense" + } + if _, err := exec.LookPath(bin); err != nil { + t.Skipf("addlicense binary not available: %v", err) + } + + root := t.TempDir() + work := filepath.Join(root, "work") + bare := filepath.Join(root, "remote.git") + mustGit(t, root, "init", "-q", "--initial-branch=main", work) + mustGit(t, work, "config", "user.name", "Tester") + mustGit(t, work, "config", "user.email", "tester@example.local") + writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") + writeFile(t, filepath.Join(work, "main.go"), "package main\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "initial") + mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) + mustGit(t, work, "remote", "add", "origin", bare) + mustGit(t, work, "push", "-q", "-u", "origin", "main") + mustGit(t, work, "checkout", "-qb", "feature") + writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") + mustGit(t, work, "add", "-A") + mustGit(t, work, "commit", "-qm", "add an unlicensed file") + sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) + mustGit(t, work, "push", "-q", "-u", "origin", "feature") + + hook := filepath.Join(bare, "hooks", "pre-receive") + writeFile(t, hook, "#!/bin/sh\necho 'Forbidden: maintainer edits are disabled for this pull request' >&2\nexit 1\n") + if err := os.Chmod(hook, 0o755); err != nil { + t.Fatal(err) + } + + res, err := EnsureHeaders(context.Background(), Options{ + RemoteURL: bare, Ref: "feature", SHA: sha, Files: []string{"new.go"}, + AddlicenseBin: bin, Holder: "Dcntrlized", + BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, + }) + if err != nil { + t.Fatalf("a refused push must not be a hard error: %v", err) + } + if !res.Blocked || res.Pushed { + t.Fatalf("expected Blocked, got %+v", res) + } + if res.NewSHA == "" || res.NewSHA == sha { + t.Fatalf("the amended commit should still be reported: %+v", res) + } + if !strings.Contains(res.BlockedReason, "Forbidden") { + t.Fatalf("reason = %q", res.BlockedReason) + } + // The refusal must leave the remote branch untouched. + verify := filepath.Join(root, "verify") + mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) + if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != sha { + t.Fatalf("remote head = %s, want %s", got, sha) + } +} + +func mustGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/license-bot/main.go b/license-bot/main.go new file mode 100644 index 0000000..169b74f --- /dev/null +++ b/license-bot/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + + "git.dcntrlized.org/Dcntrlized/license-bot/internal/bot" + "git.dcntrlized.org/Dcntrlized/license-bot/internal/config" +) + +// version is overridden at build time with -ldflags "-X main.version=...". +var version = "0.0.1" + +func main() { + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel()})) + + cfg, err := config.FromEnv() + if err != nil { + log.Error("invalid configuration", "err", err) + os.Exit(2) + } + log.Info("starting license-bot", + "version", version, + "repos", strings.Join(cfg.Repos, ","), + "listen", cfg.Listen, + "dry_run", cfg.DryRun, + ) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := bot.New(cfg, log).Run(ctx); err != nil { + log.Error("license-bot stopped", "err", err) + os.Exit(1) + } +} + +func logLevel() slog.Level { + switch strings.ToLower(os.Getenv("LOG_LEVEL")) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +}