// 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)
}