yaju-is-always-watching-you/internal/bot/bot.go
awayatan f47fef177b
Some checks failed
CI / test (push) Failing after 4s
Add yaju-is-always-watching-you: Forgejo goroku-check webhook bot
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>
2026-07-26 22:47:12 +09:00

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
}