init
This commit is contained in:
@@ -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. |
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
license-bot
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
|
# `<project>_<key>`. 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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
/bin/
|
||||||
|
/license-bot
|
||||||
|
*.log
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
@@ -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/**
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# 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/[email protected]
|
||||||
|
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"]
|
||||||
@@ -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` / `[email protected]` | 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(<ip>)'` 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.
|
||||||
@@ -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/<head_sha>` 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/<head_sha>` 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 <gateuser>`), `{"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/<sha>` 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-<PR>`, 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.
|
||||||
Executable
+251
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# 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 `<project>_<key>`, 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:-<default five 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}}' <gitea-container>" >&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 " '<project>_<key>', 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 <<NEXT
|
||||||
|
|
||||||
|
Deployed. Remaining instance-side steps:
|
||||||
|
|
||||||
|
0. PREREQUISITE: let Gitea call this service. With its default
|
||||||
|
security.ALLOWED_HOST_LIST, Gitea refuses webhook deliveries to an internal
|
||||||
|
Docker alias -- the delivery is recorded as failed with
|
||||||
|
webhook can only call allowed HTTP servers ... deny '$CONTAINER_NAME(<ip>)'
|
||||||
|
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/<id>" \
|
||||||
|
-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/<repo>/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/<repo>/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
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# 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 `<project>_<key>`: 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}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.dcntrlized.org/Dcntrlized/license-bot
|
||||||
|
|
||||||
|
go 1.24
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// 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 = "<!-- dcntrlized-copyleft-bot:problem -->"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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\n<sub>server said: " + reason + "</sub>"
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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: "[email protected]",
|
||||||
|
Holder: "Dcntrlized",
|
||||||
|
CommentMarker: "<!-- marker -->",
|
||||||
|
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<!-- marker --> <!-- dcntrlized-copyleft-bot:problem -->"}
|
||||||
|
]`)
|
||||||
|
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, "<!-- marker -->") {
|
||||||
|
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<!-- marker --> <!-- dcntrlized-copyleft-bot:problem -->"},
|
||||||
|
{"id":42,"body":"the bot added the headers\n\n<!-- marker -->"},
|
||||||
|
{"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// 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", "[email protected]"),
|
||||||
|
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", "<!-- dcntrlized-copyleft-bot -->"),
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:[email protected]: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", "[email protected]")
|
||||||
|
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: "[email protected]",
|
||||||
|
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 <[email protected]>" {
|
||||||
|
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 <[email protected]>" {
|
||||||
|
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", "[email protected]")
|
||||||
|
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: "[email protected]", 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", "[email protected]")
|
||||||
|
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: "[email protected]", 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", "[email protected]")
|
||||||
|
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: "[email protected]", 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", "[email protected]")
|
||||||
|
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: "[email protected]", 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user