This commit is contained in:
2026-10-20 02:43:27 +01:00
commit 545a9b6b88
21 changed files with 3654 additions and 0 deletions
+642
View File
@@ -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)
}
+558
View File
@@ -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)
}
}
+133
View File
@@ -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")
}
}
+241
View File
@@ -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)
}
+147
View File
@@ -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)
}
}
+355
View File
@@ -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)
}