// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Package licenser applies AGPL headers to a pull request's added files and // pushes them back as its own commit on top of the head — to the base // repository for same-repo PRs and to the contributor's fork for cross-repo PRs // (Gitea's allow_maintainer_edit, on by default). The contributor's commit is // never rewritten, and a branch that advances while the bot works is re-read and // re-processed instead of being abandoned. package licenser import ( "context" "errors" "fmt" "log/slog" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" ) // Options describes one pull-request head to fix. type Options struct { // RemoteURL is the credential-bearing clone/push URL of the HEAD repository // (the fork, or the base repository for same-repo PRs). RemoteURL string // Ref is the head branch name; SHA is the head commit the PR was read at. // A later attempt works from the branch's current tip instead. Ref string SHA string // Files are repository-relative paths of changed files to check. Files []string // AddlicenseBin is the addlicense executable. AddlicenseBin string // Holder is the copyright holder passed to addlicense -c. Holder string // BotName/BotEmail are the committer identity of the amended commit. BotName string BotEmail string // WorkRoot is the parent directory for the temporary worktree. WorkRoot string // DryRun stops before committing/pushing. DryRun bool // Logger receives progress; may be nil. Logger *slog.Logger } // Result reports what happened. type Result struct { // Changed lists files that received a header. Changed []string // NewSHA is the bot's commit on top of the pull request head, empty when // nothing changed. NewSHA string // Pushed reports whether that commit reached the remote. Pushed bool // Blocked reports a push refused by the server (typically // allow_maintainer_edit disabled on the PR, or a protected branch). Blocked bool // BlockedReason carries the redacted server message for Blocked. BlockedReason string } // ErrRemoteMoved means the remote head kept advancing for every attempt: each // rejection makes the bot drop its local work, re-read the branch, re-apply the // headers and push again, and this error is only returned once that loop is // exhausted. Nothing is ever force-pushed or rewritten, so the caller can simply // try again later. var ErrRemoteMoved = errors.New("remote branch kept moving; will retry later") // maxPushAttempts bounds the re-read/re-apply/push loop. A normal race is // resolved on the second attempt; the bound only stops a pathological // push storm from spinning forever. const maxPushAttempts = 5 const cloneDepth = 50 // commitSubject/commitBody describe the bot's own commit. It is deliberately a // new commit on top of the pull request head — never an amend — so the // contributor's work and message stay byte-for-byte intact and the bot's change // can be reverted or dropped with a single `git revert` / `git reset`. const commitSubject = "chore(license): add AGPL-3.0 headers" // EnsureHeaders runs addlicense over opts.Files in a fresh worktree of the PR // head, records the result in a separate commit and pushes it on top of the // head branch (fast-forward, no force). func EnsureHeaders(ctx context.Context, opts Options) (*Result, error) { log := opts.Logger if log == nil { log = slog.Default() } if opts.RemoteURL == "" { return nil, errors.New("RemoteURL is required") } if opts.Ref == "" || opts.SHA == "" { return nil, errors.New("Ref and SHA are required") } var last *Result for attempt := 1; attempt <= maxPushAttempts; attempt++ { res, moved, err := ensureHeadersOnce(ctx, opts, log, attempt == 1) if err != nil { return res, err } if !moved { return res, nil } // The branch advanced under us: drop the local work (the temp clone is // discarded), re-read the branch, re-run the check and try again. last = res log.Warn("branch advanced while working; re-reading it and re-applying the headers", "ref", opts.Ref, "attempt", attempt, "of", maxPushAttempts) } if last != nil { last.NewSHA = "" // nothing reached the remote } return last, fmt.Errorf("%w: %s moved on every one of %d attempts", ErrRemoteMoved, opts.Ref, maxPushAttempts) } // ensureHeadersOnce performs one clone/apply/commit/push round. first selects // the starting point: the head commit the pull request was read at on the first // attempt, the branch's current tip afterwards. moved reports a rejected // non-fast-forward push, which the caller answers by starting over. func ensureHeadersOnce(ctx context.Context, opts Options, log *slog.Logger, first bool) (res *Result, moved bool, err error) { dir, err := os.MkdirTemp(opts.WorkRoot, "license-bot-*") if err != nil { return nil, false, fmt.Errorf("create worktree: %w", err) } defer os.RemoveAll(dir) if _, err := git(ctx, dir, "clone", "--quiet", "--depth", strconv.Itoa(cloneDepth), "--single-branch", "--branch", opts.Ref, opts.RemoteURL, dir); err != nil { return nil, false, fmt.Errorf("clone %s: %w", Redact(opts.RemoteURL), err) } target := opts.SHA if !first { // Re-read the branch: the previous attempt lost a race, so the work must // be redone on top of whatever is there now. if out, err := git(ctx, dir, "rev-parse", "origin/"+opts.Ref); err == nil { if tip := strings.TrimSpace(out); tip != "" { target = tip } } } if !commitExists(ctx, dir, target) { if _, err := git(ctx, dir, "fetch", "--quiet", "--unshallow", "origin"); err != nil { log.Warn("unshallow failed, using fetched history", "err", err) } } if !commitExists(ctx, dir, target) { return nil, false, fmt.Errorf("head commit %s not present in %s", target, Redact(opts.RemoteURL)) } if _, err := git(ctx, dir, "checkout", "--quiet", target); err != nil { return nil, false, fmt.Errorf("checkout %s: %w", target, err) } tpl := filepath.Join(dir, ".license-header.tpl") if _, err := os.Stat(tpl); err != nil { return nil, false, fmt.Errorf(".license-header.tpl missing at %s: %w", target, err) } patterns := ReadIgnorePatterns(filepath.Join(dir, ".licenseignore")) res = &Result{} year := strconv.Itoa(time.Now().Year()) for _, f := range opts.Files { if f == "" { continue } abs := filepath.Join(dir, filepath.FromSlash(f)) if st, err := os.Stat(abs); err != nil || st.IsDir() { continue // removed again or not a regular file } args := []string{"-v", "-f", ".license-header.tpl", "-c", opts.Holder, "-y", year} for _, p := range patterns { args = append(args, "-ignore", p) } args = append(args, f) out, err := run(ctx, dir, opts.AddlicenseBin, args...) if err != nil { return nil, false, fmt.Errorf("addlicense %s: %w: %s", f, err, strings.TrimSpace(out)) } } porcelain, err := git(ctx, dir, "status", "--porcelain") if err != nil { return nil, false, fmt.Errorf("git status: %w", err) } res.Changed = ChangedFiles(porcelain) if len(res.Changed) == 0 { // Everything this pull request added is already licensed, so there is // nothing to publish — including on a retry after a concurrent push // fixed the files itself. return res, false, nil } log.Info("adding license headers", "files", len(res.Changed), "dry_run", opts.DryRun) if opts.DryRun { return res, false, nil } if _, err := git(ctx, dir, "add", "-A"); err != nil { return nil, false, fmt.Errorf("git add: %w", err) } body := "Added by the Dcntrlized license bot to the files this pull request\n" + "introduced without a license header. Only the files this pull request adds\n" + "are touched, and the header text comes from .license-header.tpl.\n\n" + "This commit is separate from the contributor's work, so it can be reverted\n" + "or dropped freely.\n\nFiles:\n" for _, f := range res.Changed { body += "- " + f + "\n" } if _, err := git(ctx, dir, "-c", "user.name="+opts.BotName, "-c", "user.email="+opts.BotEmail, "commit", "--quiet", "-m", commitSubject, "-m", body); err != nil { return nil, false, fmt.Errorf("git commit: %w", err) } newSHA, err := git(ctx, dir, "rev-parse", "HEAD") if err != nil { return nil, false, fmt.Errorf("rev-parse: %w", err) } res.NewSHA = strings.TrimSpace(newSHA) // Fast-forward push only: no --force, no --force-with-lease. If anyone // pushed in the meantime the push is rejected and the caller starts over // from the new tip rather than rewriting their history. out, err := git(ctx, dir, "push", "--quiet", opts.RemoteURL, "HEAD:refs/heads/"+opts.Ref) if err != nil { msg := strings.TrimSpace(out) if isPushRefused(msg) { res.Blocked = true res.BlockedReason = Redact(msg) return res, false, nil } if isRemoteMoved(msg) { res.NewSHA = "" return res, true, nil } return res, false, fmt.Errorf("git push %s: %w: %s", Redact(opts.RemoteURL), err, Redact(msg)) } res.Pushed = true return res, false, nil } func commitExists(ctx context.Context, dir, sha string) bool { _, err := git(ctx, dir, "cat-file", "-e", sha+"^{commit}") return err == nil } func git(ctx context.Context, dir string, args ...string) (string, error) { return runEnv(ctx, dir, []string{"GIT_TERMINAL_PROMPT=0"}, "git", args...) } func run(ctx context.Context, dir, bin string, args ...string) (string, error) { return runEnv(ctx, dir, nil, bin, args...) } // runEnv runs a command and, on failure, returns an error carrying the // redacted combined output so diagnostics never leak the bot token. func runEnv(ctx context.Context, dir string, extraEnv []string, bin string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, bin, args...) cmd.Dir = dir cmd.Env = append(os.Environ(), extraEnv...) var buf strings.Builder cmd.Stdout = &buf cmd.Stderr = &buf err := cmd.Run() out := buf.String() if ctx.Err() != nil { return out, ctx.Err() } if err != nil { if msg := strings.TrimSpace(Redact(out)); msg != "" { return out, fmt.Errorf("%w: %s", err, msg) } } return out, err } var ( // A refusal the bot cannot fix by retrying: permissions, disabled // maintainer edits, protected branch rules. rePushDenied = regexp.MustCompile(`(?i)(403|forbidden|denied|not allowed|not permitted|permission|protected)`) // The remote advanced under us: a plain non-fast-forward rejection. reRemoteMoved = regexp.MustCompile(`(?i)(non-fast-forward|fetch first|stale info|\[remote rejected\]|behind)`) ) func isPushRefused(msg string) bool { return rePushDenied.MatchString(msg) } func isRemoteMoved(msg string) bool { return reRemoteMoved.MatchString(msg) } // ChangedFiles extracts paths from `git status --porcelain` output. func ChangedFiles(porcelain string) []string { var out []string for _, line := range strings.Split(porcelain, "\n") { if len(line) < 4 { continue } p := line[3:] if strings.HasPrefix(p, "\"") { if uq, err := strconv.Unquote(p); err == nil { p = uq } } if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } // ReadIgnorePatterns returns the non-empty, non-comment lines of a // .licenseignore file. A missing file yields no patterns. func ReadIgnorePatterns(path string) []string { b, err := os.ReadFile(path) if err != nil { return nil } return ParseIgnore(string(b)) } // ParseIgnore splits .licenseignore content into addlicense -ignore patterns. // addlicense's patterns are doublestar globs, not prefixes, so lines are used // verbatim. func ParseIgnore(body string) []string { var out []string for _, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } out = append(out, line) } return out } // Redact removes credentials embedded in a URL or message so logs and PR // comments never leak the bot token. func Redact(s string) string { // scheme://user:pass@host -> scheme://host re := regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]*@`) return re.ReplaceAllString(s, "$1") }