init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user