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