// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Package bot wires Gitea pull-request webhooks to the license fixer. // // The service is webhook-only: every run is caused by an HMAC-verified // delivery, so a run that does not happen can only mean the service itself is // down. On startup it reconciles every open pull request of the allowlisted // repositories once, so a delivery lost while the service was down -- or a // check left pending by a crash -- is replaired without a polling loop. package bot import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strings" "sync" "time" "git.dcntrlized.org/Dcntrlized/license-bot/internal/config" "git.dcntrlized.org/Dcntrlized/license-bot/internal/gitea" "git.dcntrlized.org/Dcntrlized/license-bot/internal/licenser" ) const ( // deliveryTTL bounds the replay cache for webhook delivery ids. deliveryTTL = 5 * time.Minute // maxDeliveries caps the replay cache; expired entries are pruned first. maxDeliveries = 4096 // reconcileTimeout bounds the startup scan. reconcileTimeout = 15 * time.Minute // handleTimeout bounds one pull-request processing run. handleTimeout = 5 * time.Minute // problemMarker tags a comment that reports a problem the contributor has // to act on. A pass that finds nothing left to fix deletes such a comment, // so a resolved failure never sits next to a green status. The // informational "the bot added the headers" comment carries no marker and // is left in place. problemMarker = "" ) // Bot reacts to pull requests by adding missing AGPL headers in a separate, // revertable commit on the pull request head. type Bot struct { cfg config.Config api *gitea.Client log *slog.Logger mu sync.Mutex handled map[string]bool // repo#pr@sha already processed (or in progress) seen map[string]time.Time // webhook delivery ids already accepted } // New builds a bot from configuration. func New(cfg config.Config, log *slog.Logger) *Bot { return &Bot{ cfg: cfg, api: gitea.New(cfg.GiteaURL, cfg.Token), log: log, handled: make(map[string]bool), seen: make(map[string]time.Time), } } type webhookPayload struct { Action string `json:"action"` Number int `json:"number"` Repository struct { FullName string `json:"full_name"` } `json:"repository"` PullRequest *struct { Number int `json:"number"` } `json:"pull_request"` } // allowedActions are the pull_request payload actions that start a check. // Gitea 1.27.2 spells a head-branch push "synchronized" (measured: its // X-Gitea-Event is pull_request with action synchronized), while the API/docs // usually say "synchronize" -- accept both spellings. var allowedActions = map[string]bool{ "opened": true, "synchronize": true, "synchronized": true, "reopened": true, "ready_for_review": true, } // SignatureValid verifies Gitea's X-Gitea-Signature (hex HMAC-SHA256 of the raw body). func (b *Bot) SignatureValid(sig string, body []byte) bool { sig = strings.TrimPrefix(strings.TrimSpace(sig), "sha256=") if sig == "" || b.cfg.WebhookSecret == "" { return false } mac := hmac.New(sha256.New, []byte(b.cfg.WebhookSecret)) mac.Write(body) want := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(strings.ToLower(sig)), []byte(want)) } // ServeHTTP routes the webhook endpoint and the health route. func (b *Bot) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/hook", "/webhook": b.handleHook(w, r) case "/healthz": b.handleHealth(w, r) default: http.NotFound(w, r) } } // handleHealth is the container health route: it answers as soon as the process // is accepting connections. func (b *Bot) handleHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", http.MethodGet) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") io.WriteString(w, "ok\n") } func (b *Bot) handleHook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20)) if err != nil { http.Error(w, "read body", http.StatusBadRequest) return } if !b.SignatureValid(r.Header.Get("X-Gitea-Signature"), body) { b.log.Warn("rejected webhook with bad signature", "remote", r.RemoteAddr) http.Error(w, "invalid signature", http.StatusForbidden) return } if id := r.Header.Get("X-Gitea-Delivery"); b.duplicateDelivery(id) { b.log.Info("ignoring duplicate webhook delivery", "delivery", id) w.WriteHeader(http.StatusAccepted) return } // Gitea's X-Gitea-Event carries the concrete sub-event, not the family: // a push to a pull request head arrives as `pull_request_sync`, a review // comment as `pull_request_comment`. Match the whole family and let the // payload's `action` decide -- that is the field Gitea actually varies // (opened/synchronize/reopened/ready_for_review), and every other // sub-event (comment, review, label, milestone, assign) carries an action // outside the allowlist. Measured on 1.27.2: a synchronize delivery has // X-Gitea-Event: pull_request_sync with action: synchronize. event := r.Header.Get("X-Gitea-Event") if event != "" && !strings.HasPrefix(event, "pull_request") { w.WriteHeader(http.StatusAccepted) return } var p webhookPayload if err := json.Unmarshal(body, &p); err != nil { http.Error(w, "bad payload", http.StatusBadRequest) return } if !allowedActions[p.Action] { b.log.Debug("ignoring webhook", "event", event, "action", p.Action, "delivery", r.Header.Get("X-Gitea-Delivery")) w.WriteHeader(http.StatusAccepted) return } b.log.Debug("webhook accepted", "event", event, "action", p.Action, "delivery", r.Header.Get("X-Gitea-Delivery")) number := p.Number if p.PullRequest != nil && p.PullRequest.Number != 0 { number = p.PullRequest.Number } repo := p.Repository.FullName if number == 0 || !b.cfg.Allows(repo) { w.WriteHeader(http.StatusAccepted) return } // Acknowledge immediately: Gitea should not wait for git or API round-trips. go func() { ctx, cancel := context.WithTimeout(context.Background(), handleTimeout) defer cancel() if err := b.HandlePull(ctx, repo, number); err != nil { b.log.Error("handle pull failed", "repo", repo, "pr", number, "err", err) } }() w.WriteHeader(http.StatusAccepted) } // duplicateDelivery reports whether a delivery id was already accepted inside // the replay window, recording it otherwise. An empty id is never a duplicate. func (b *Bot) duplicateDelivery(id string) bool { if id == "" { return false } now := time.Now() b.mu.Lock() defer b.mu.Unlock() if t, ok := b.seen[id]; ok && now.Sub(t) < deliveryTTL { return true } if len(b.seen) >= maxDeliveries { for k, t := range b.seen { if now.Sub(t) >= deliveryTTL { delete(b.seen, k) } } } b.seen[id] = now return false } // Run serves the webhook endpoint and reconciles every allowlisted repository // once at startup, until ctx is done. func (b *Bot) Run(ctx context.Context) error { srv := &http.Server{Addr: b.cfg.Listen, Handler: b, ReadHeaderTimeout: 10 * time.Second} runCtx, cancel := context.WithCancel(ctx) defer cancel() var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() recCtx, recCancel := context.WithTimeout(runCtx, reconcileTimeout) defer recCancel() b.log.Info("startup reconciliation: scanning allowlisted repositories", "repos", len(b.cfg.Repos)) if err := b.Reconcile(recCtx); err != nil { b.log.Error("startup reconciliation finished with errors", "err", err) return } b.log.Info("startup reconciliation complete") }() go func() { <-runCtx.Done() shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutCancel() _ = srv.Shutdown(shutCtx) }() b.log.Info("webhook listening", "addr", b.cfg.Listen) serveErr := srv.ListenAndServe() if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { cancel() } wg.Wait() if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { return serveErr } return nil } // Reconcile scans every allowlisted repository for open pull requests that do // not yet carry a successful status from this bot and processes them. The // commit status is the durable record of what the bot is already aware of, so // the scan is self-healing without a separate cursor: a PR left pending by a // crash, or whose check failed while the service was down, is picked up again. func (b *Bot) Reconcile(ctx context.Context) error { var firstErr error for _, repo := range b.cfg.Repos { if err := ctx.Err(); err != nil { return err } pulls, err := b.api.ListOpenPulls(ctx, repo) if err != nil { b.log.Error("list pulls failed", "repo", repo, "err", err) if firstErr == nil { firstErr = err } continue } for _, pr := range pulls { green, err := b.hasSuccess(ctx, repo, pr.Head.SHA) if err != nil { // Reading statuses failed; processing is idempotent, so it is // safer to check the pull request than to skip it. b.log.Warn("read statuses failed; checking anyway", "repo", repo, "pr", pr.Number, "err", err) } else if green { b.log.Debug("already green; skipping", "repo", repo, "pr", pr.Number, "sha", short(pr.Head.SHA)) continue } if err := b.HandlePull(ctx, repo, pr.Number); err != nil { b.log.Error("handle pull failed", "repo", repo, "pr", pr.Number, "err", err) if firstErr == nil { firstErr = err } } } } return firstErr } // hasSuccess reports whether sha already carries a successful status with the // bot's context. func (b *Bot) hasSuccess(ctx context.Context, repo, sha string) (bool, error) { if sha == "" { return false, nil } statuses, err := b.api.ListStatuses(ctx, repo, sha) if err != nil { return false, err } for _, s := range statuses { if s.Context == b.cfg.StatusContext && s.Status == "success" { return true, nil } } return false, nil } // HandlePull re-reads the pull request and processes it. func (b *Bot) HandlePull(ctx context.Context, repo string, number int) error { if !b.cfg.Allows(repo) { return nil } pr, err := b.api.GetPull(ctx, repo, number) if err != nil { return fmt.Errorf("get pull %s#%d: %w", repo, number, err) } return b.ProcessPull(ctx, repo, pr) } // fixableFiles returns the paths the bot may touch: every file a pull request // changed that still exists at the head, so an existing unlicensed file gets the // header the first time a pull request touches it. "removed" has no content at // the head to license (addlicense would fail on the path) and "unchanged" is not // part of the delta. Unknown statuses are kept on purpose: Gitea only reports a // path here if it changed, and if it turns out not to exist the bot fails // visibly (error status + comment) instead of silently skipping the file. func fixableFiles(files []gitea.File) []string { out := make([]string, 0, len(files)) for _, f := range files { switch f.Status { case "removed", "unchanged": continue } out = append(out, f.Filename) } return out } // ProcessPull is the whole per-PR flow: detect changed files, fix headers, add a // separate revertable commit, comment and report a commit status. func (b *Bot) ProcessPull(ctx context.Context, repo string, pr *gitea.Pull) error { if pr == nil || pr.Number == 0 { return errors.New("invalid pull request") } if pr.State != "open" { return nil } key := fmt.Sprintf("%s#%d@%s", repo, pr.Number, pr.Head.SHA) if !b.claim(key) { return nil } files, err := b.api.ListPullFiles(ctx, repo, pr.Number) if err != nil { b.release(key) return fmt.Errorf("list files %s#%d: %w", repo, pr.Number, err) } targets := fixableFiles(files) if len(targets) == 0 { // Only deletions (or no files at all), so there is nothing to license. // Log it: with no comment, no pending status and no error, this path is // otherwise invisible. b.log.Info("no changed files to check (removals only)", "repo", repo, "pr", pr.Number, "sha", pr.Head.SHA, "files", len(files)) b.status(ctx, repo, pr.Head.SHA, "success", "no changed files need a license header") b.clearProblemComment(ctx, repo, pr) return nil } b.status(ctx, repo, pr.Head.SHA, "pending", "checking changed files for AGPL headers") b.log.Info("processing pull request", "repo", repo, "pr", pr.Number, "sha", pr.Head.SHA, "files", len(files), "targets", len(targets)) res, err := licenser.EnsureHeaders(ctx, licenser.Options{ RemoteURL: b.headRemoteURL(pr), Ref: pr.Head.Ref, SHA: pr.Head.SHA, Files: targets, AddlicenseBin: b.cfg.AddlicenseBin, Holder: b.cfg.Holder, BotName: b.cfg.BotName, BotEmail: b.cfg.BotEmail, WorkRoot: b.cfg.WorkRoot, DryRun: b.cfg.DryRun, Logger: b.log, }) if err != nil { b.release(key) if errors.Is(err, licenser.ErrRemoteMoved) { b.log.Warn("branch kept moving after the retry budget; will retry on the next event", "repo", repo, "pr", pr.Number) return err } b.status(ctx, repo, pr.Head.SHA, "error", "license bot failed, see the pull request comment") b.comment(ctx, repo, pr, failureComment(err), true) return err } switch { case res.Blocked: b.status(ctx, repo, pr.Head.SHA, "failure", "could not add the header: maintainer edits are disabled") b.comment(ctx, repo, pr, b.blockedComment(pr, targets, res.BlockedReason), true) case len(res.Changed) == 0: b.status(ctx, repo, pr.Head.SHA, "success", "all changed files carry a license header") b.clearProblemComment(ctx, repo, pr) case b.cfg.DryRun: b.log.Info("dry run: would have committed", "repo", repo, "pr", pr.Number, "files", res.Changed) default: if res.Pushed { b.comment(ctx, repo, pr, b.caughtComment(pr, res), false) b.status(ctx, repo, res.NewSHA, "success", "license headers added by the bot") b.claim(fmt.Sprintf("%s#%d@%s", repo, pr.Number, res.NewSHA)) b.log.Info("committed and pushed", "repo", repo, "pr", pr.Number, "head", short(pr.Head.SHA), "commit", short(res.NewSHA)) } else { b.status(ctx, repo, pr.Head.SHA, "failure", "license headers are missing") b.comment(ctx, repo, pr, b.missingComment(pr, targets), true) } } return nil } func (b *Bot) headRemoteURL(pr *gitea.Pull) string { full := pr.Head.Repo.FullName if full == "" { full = pr.Base.Repo.FullName } host := strings.TrimPrefix(strings.TrimPrefix(b.cfg.GiteaURL, "https://"), "http://") scheme := "http" if strings.HasPrefix(b.cfg.GiteaURL, "https://") { scheme = "https" } return fmt.Sprintf("%s://oauth2:%s@%s/%s.git", scheme, b.cfg.Token, host, full) } func (b *Bot) status(ctx context.Context, repo, sha, state, description string) { if sha == "" { return } if err := b.api.CreateStatus(ctx, repo, sha, state, b.cfg.StatusContext, description, ""); err != nil { b.log.Warn("post status failed", "repo", repo, "sha", short(sha), "state", state, "err", err) } } // comment replaces the bot's comment on the pull request. The previous comment // is deleted and a new one is posted rather than edited in place: Gitea keeps a // comment's position, so an edited message stayed buried near the top of the // timeline while the pull request moved on (a resolved failure kept sitting // next to a green status). Posting fresh puts the message where it belongs, at // the bottom, next to the latest status. func (b *Bot) comment(ctx context.Context, repo string, pr *gitea.Pull, body string, problem bool) { body += "\n\n" + b.cfg.CommentMarker if problem { body += " " + problemMarker } b.deleteComments(ctx, repo, pr, func(gitea.Comment) bool { return true }) if _, err := b.api.CreateIssueComment(ctx, repo, pr.Number, body); err != nil { b.log.Error("post comment failed", "repo", repo, "pr", pr.Number, "err", err) } } // clearProblemComment deletes the bot's own problem comments without posting // anything. Called when a pass finds nothing left to fix: the failure comment // is stale, and the commit status already tells the real story. func (b *Bot) clearProblemComment(ctx context.Context, repo string, pr *gitea.Pull) { b.deleteComments(ctx, repo, pr, func(c gitea.Comment) bool { return strings.Contains(c.Body, problemMarker) }) } // deleteComments removes the bot's own comments for which del returns true. // Failures are logged and never fatal: the check result does not depend on // comment bookkeeping. func (b *Bot) deleteComments(ctx context.Context, repo string, pr *gitea.Pull, del func(gitea.Comment) bool) { comments, err := b.api.ListIssueComments(ctx, repo, pr.Number) if err != nil { b.log.Warn("list comments failed", "repo", repo, "pr", pr.Number, "err", err) return } for _, c := range comments { if !strings.Contains(c.Body, b.cfg.CommentMarker) || !del(c) { continue } if err := b.api.DeleteIssueComment(ctx, repo, c.ID); err != nil { b.log.Warn("delete comment failed", "repo", repo, "pr", pr.Number, "comment", c.ID, "err", err) } } } // caughtComment is the message the contributor sees when the bot fixed things. // Body text is the wording this project already used in the Gitea workflow, // kept verbatim except for the clause that used to say the bot amended the // commit — the bot now adds a separate, revertable commit and never rewrites // the contributor's own. func (b *Bot) caughtComment(pr *gitea.Pull, res *licenser.Result) string { return fmt.Sprintf(`## Uh oh! Unlicensed code detected! You just tried to push **unlicensed code**! This repository is covered by the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code requires a license header. Thankfully, our helpful bot caught it and added the missing licensing in a separate commit ([`+"`%s`"+`](%s)) — your own commit was left untouched, so you can revert or drop the bot's commit if you would rather add the headers yourself. If this looks wrong, tag **@Admin** so we can take a look!`, short(res.NewSHA), b.commitURL(pr, res.NewSHA)) } // blockedComment is used when the server refuses the bot's push (maintainer // edits disabled for the pull request, protected branch, missing permission): // the bot must not bypass that, so it asks the contributor to add the header. func (b *Bot) blockedComment(pr *gitea.Pull, targets []string, reason string) string { msg := fmt.Sprintf(`## Uh oh! Unlicensed code detected! You just tried to push **unlicensed code**! This repository is covered by the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code requires a license header. The bot could not add the header for you: maintainer edits are disabled for this pull request (or the fork does not allow them). These files still need one: %s Copy the header from [`+"`.license-header.tpl`"+`](%s) — or enable "allow edits by maintainers" on this pull request — and push again. If this looks wrong, tag **@Admin** so we can take a look!`, fileList(targets), b.treeURL(pr, ".license-header.tpl")) if reason != "" { msg += "\n\nserver said: " + reason + "" } return msg } // missingComment is the fallback when the bot produced a commit it did not // publish (dry run, or a push that neither succeeded nor reported a refusal). func (b *Bot) missingComment(pr *gitea.Pull, targets []string) string { return fmt.Sprintf(`## Uh oh! Unlicensed code detected! You just tried to push **unlicensed code**! This repository is covered by the [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code requires a license header. These files still need one: %s Copy the header from [`+"`.license-header.tpl`"+`](%s) and push again. If this looks wrong, tag **@Admin** so we can take a look!`, fileList(targets), b.treeURL(pr, ".license-header.tpl")) } func fileList(files []string) string { var sb strings.Builder for _, f := range files { sb.WriteString(fmt.Sprintf("- `%s`\n", f)) } return sb.String() } func failureComment(err error) string { return "The license bot could not finish checking this pull request: `" + licenser.Redact(err.Error()) + "`.\n\n" + "A maintainer should look at the bot logs." } // commitURL links the bot's commit on the repository that actually holds it // (the fork, for cross-repository pull requests). func (b *Bot) commitURL(pr *gitea.Pull, sha string) string { return fmt.Sprintf("%s/%s/commit/%s", b.instanceURL(), b.headRepo(pr), sha) } // treeURL links a file on the head repository. func (b *Bot) treeURL(pr *gitea.Pull, path string) string { return fmt.Sprintf("%s/%s/src/commit/%s/%s", b.instanceURL(), b.headRepo(pr), pr.Head.SHA, path) } // instanceURL is the URL used for links a human clicks, so it may differ from // the URL the bot calls (the internal Docker alias is not resolvable from a // browser). func (b *Bot) instanceURL() string { if b.cfg.PublicURL != "" { return strings.TrimSuffix(b.cfg.PublicURL, "/") } return strings.TrimSuffix(b.cfg.GiteaURL, "/") } func (b *Bot) headRepo(pr *gitea.Pull) string { if pr.Head.Repo.FullName != "" { return pr.Head.Repo.FullName } return pr.Base.Repo.FullName } func short(sha string) string { if len(sha) > 12 { return sha[:12] } return sha } // claim marks key as handled, returning false when it already was. It // serialises concurrent work on one head sha and stops the bot from re-running // the check for a head it has already resolved. func (b *Bot) claim(key string) bool { b.mu.Lock() defer b.mu.Unlock() if b.handled[key] { return false } b.handled[key] = true return true } // release forgets key so a failed run can be retried on the next event. func (b *Bot) release(key string) { b.mu.Lock() defer b.mu.Unlock() delete(b.handled, key) }