Some checks failed
CI / test (push) Failing after 4s
Watches issue/PR comments and pushes for flagged phrases; warns commenters via reply and opens a redacted correction PR for flagged file content, never rewriting history or deleting content directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
168 lines
4.7 KiB
Go
168 lines
4.7 KiB
Go
// Package bot implements yaju-keisatsu's reaction to Forgejo events:
|
|
// warning replies on flagged comments, and auto-opened correction PRs for
|
|
// flagged file content in pushes.
|
|
package bot
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
|
|
"git.folja.dev/awayatan/yaju-is-always-watching-you/internal/detector"
|
|
"git.folja.dev/awayatan/yaju-is-always-watching-you/internal/forgejo"
|
|
)
|
|
|
|
type Client interface {
|
|
ReplyToIssue(owner, repo string, index int64, body string) error
|
|
FileContent(owner, repo, ref, path string) (content, sha string, err error)
|
|
CommitCorrection(owner, repo, baseBranch, newBranch, path, sha, newContent, message string) error
|
|
OpenCorrectionPR(owner, repo, head, base, title, body string) error
|
|
}
|
|
|
|
type Bot struct {
|
|
client Client
|
|
detector *detector.Detector
|
|
botUsername string
|
|
branchPrefix string
|
|
targetExt map[string]struct{} // empty means "scan every file"
|
|
}
|
|
|
|
func New(client Client, det *detector.Detector, botUsername, branchPrefix string, targetExtensions []string) *Bot {
|
|
ext := make(map[string]struct{}, len(targetExtensions))
|
|
for _, e := range targetExtensions {
|
|
ext[e] = struct{}{}
|
|
}
|
|
return &Bot{
|
|
client: client,
|
|
detector: det,
|
|
botUsername: botUsername,
|
|
branchPrefix: branchPrefix,
|
|
targetExt: ext,
|
|
}
|
|
}
|
|
|
|
// HandleIssueComment reacts to a comment on an issue or pull request.
|
|
func (b *Bot) HandleIssueComment(p forgejo.IssueCommentPayload) error {
|
|
if p.Action != "created" {
|
|
return nil
|
|
}
|
|
// Never react to our own replies, or we'd warn ourselves forever.
|
|
if p.Comment.User.Login == b.botUsername {
|
|
return nil
|
|
}
|
|
|
|
matched := b.detector.Detect(p.Comment.Body)
|
|
if len(matched) == 0 {
|
|
return nil
|
|
}
|
|
|
|
body := fmt.Sprintf(
|
|
"@%s 淫夢語録を検知しました: %s\n\n言葉は選びましょう ( ᐛ )",
|
|
p.Sender.Login, strings.Join(matched, "、"),
|
|
)
|
|
return b.client.ReplyToIssue(p.Repository.Owner.Login, p.Repository.Name, p.Issue.Number, body)
|
|
}
|
|
|
|
// HandlePush reacts to a push by scanning added/modified files and, if any
|
|
// contain flagged phrases, opening a correction pull request against a new
|
|
// bot-owned branch. The pushed branch itself is never touched.
|
|
func (b *Bot) HandlePush(p forgejo.PushPayload) error {
|
|
if p.Pusher.Login == b.botUsername {
|
|
return nil
|
|
}
|
|
|
|
owner := p.Repository.Owner.Login
|
|
repo := p.Repository.Name
|
|
baseBranch := strings.TrimPrefix(p.Ref, "refs/heads/")
|
|
if baseBranch == p.Ref {
|
|
// Not a branch push (e.g. a tag) — nothing to open a PR against.
|
|
return nil
|
|
}
|
|
|
|
type correction struct {
|
|
path, sha, content string
|
|
}
|
|
var corrections []correction
|
|
var allMatches []string
|
|
seen := make(map[string]bool)
|
|
|
|
for _, commit := range p.Commits {
|
|
paths := append(append([]string{}, commit.Added...), commit.Modified...)
|
|
for _, path := range paths {
|
|
if seen[path] || !b.isTargetFile(path) {
|
|
continue
|
|
}
|
|
seen[path] = true
|
|
|
|
content, sha, err := b.client.FileContent(owner, repo, p.After, path)
|
|
if err != nil {
|
|
log.Printf("yaju-keisatsu: skip %s/%s %s: %v", owner, repo, path, err)
|
|
continue
|
|
}
|
|
|
|
redacted, matched := b.detector.Redact(content)
|
|
if len(matched) == 0 {
|
|
continue
|
|
}
|
|
allMatches = append(allMatches, matched...)
|
|
corrections = append(corrections, correction{path: path, sha: sha, content: redacted})
|
|
}
|
|
}
|
|
|
|
if len(corrections) == 0 {
|
|
return nil
|
|
}
|
|
|
|
branch := b.branchPrefix + shortSHA(p.After)
|
|
for i, c := range corrections {
|
|
msg := fmt.Sprintf("yaju-keisatsu: %s を是正", c.path)
|
|
from := baseBranch
|
|
if i > 0 {
|
|
// The branch already exists after the first commit; keep
|
|
// committing onto it instead of branching again.
|
|
from = branch
|
|
}
|
|
if err := b.client.CommitCorrection(owner, repo, from, branch, c.path, c.sha, c.content, msg); err != nil {
|
|
return fmt.Errorf("commit correction for %s: %w", c.path, err)
|
|
}
|
|
}
|
|
|
|
title := "是正PR: 淫夢語録を検知しました"
|
|
body := fmt.Sprintf(
|
|
"@%s\n\n以下の語録を検知したので、該当箇所を伏字にしたブランチを用意しました。内容を確認のうえマージするか判断してください。\n\n- %s",
|
|
p.Pusher.Login, strings.Join(dedupe(allMatches), "\n- "),
|
|
)
|
|
return b.client.OpenCorrectionPR(owner, repo, branch, baseBranch, title, body)
|
|
}
|
|
|
|
func (b *Bot) isTargetFile(path string) bool {
|
|
if len(b.targetExt) == 0 {
|
|
return true
|
|
}
|
|
for ext := range b.targetExt {
|
|
if strings.HasSuffix(path, ext) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func shortSHA(sha string) string {
|
|
if len(sha) > 8 {
|
|
return sha[:8]
|
|
}
|
|
return sha
|
|
}
|
|
|
|
func dedupe(items []string) []string {
|
|
seen := make(map[string]struct{}, len(items))
|
|
out := make([]string, 0, len(items))
|
|
for _, s := range items {
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|