Files
infra/license-bot/internal/licenser/licenser.go
T
2026-10-20 02:43:27 +01:00

356 lines
12 KiB
Go

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