From f47fef177b7ee6d71c14b4bfaa63566f20e71129 Mon Sep 17 00:00:00 2001 From: awayatan Date: Sun, 26 Jul 2026 22:47:12 +0900 Subject: [PATCH] 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 --- .forgejo/workflows/ci.yml | 17 +++ .gitignore | 4 + Dockerfile | 13 ++ LICENSE | 21 +++ README.md | 63 +++++++++ cmd/yaju-is-always-watching-you/main.go | 45 +++++++ config.example.yaml | 46 +++++++ go.mod | 17 +++ go.sum | 38 ++++++ internal/bot/bot.go | 168 ++++++++++++++++++++++++ internal/bot/bot_test.go | 157 ++++++++++++++++++++++ internal/config/config.go | 65 +++++++++ internal/detector/detector.go | 51 +++++++ internal/detector/detector_test.go | 39 ++++++ internal/forgejo/client.go | 87 ++++++++++++ internal/forgejo/payload.go | 52 ++++++++ internal/webhook/handler.go | 106 +++++++++++++++ internal/webhook/handler_test.go | 114 ++++++++++++++++ 18 files changed, 1103 insertions(+) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/yaju-is-always-watching-you/main.go create mode 100644 config.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/bot/bot.go create mode 100644 internal/bot/bot_test.go create mode 100644 internal/config/config.go create mode 100644 internal/detector/detector.go create mode 100644 internal/detector/detector_test.go create mode 100644 internal/forgejo/client.go create mode 100644 internal/forgejo/payload.go create mode 100644 internal/webhook/handler.go create mode 100644 internal/webhook/handler_test.go diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..7a85344 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + runs-on: docker + container: + image: golang:1.26-alpine + steps: + - uses: actions/checkout@v4 + - run: go build ./... + - run: go vet ./... + - run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd009d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/config.yaml +/yaju-keisatsu +*.test +/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d45e2ae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/yaju-keisatsu ./cmd/yaju-is-always-watching-you + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=build /out/yaju-keisatsu /usr/local/bin/yaju-keisatsu +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/yaju-keisatsu"] +CMD ["-config", "/etc/yaju-keisatsu/config.yaml"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad91585 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 awayatan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..624f5e0 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# yaju-is-always-watching-you (語録警察) + +Forgejo用の言葉遣い是正bot。淫夢語録その他のネタ言葉遣いをissue/PRコメントやpushされたファイルから検知し、 + +- コメント/PRへの投稿 → 投稿者に `@mention` して警告リプライ +- push → 該当箇所を伏字にした修正ブランチを自動で切り、是正PRをオープン(原文の`git push`やコミット履歴は一切書き換えない) + +を行う、クソ真面目にクソどうでもいい機能を実装したbotです。野獣はいつもお前を見ている。 + +## 仕組み + +Forgejoのwebhook(`issue_comment` / `push`)を受け取るだけの単純なHTTPサーバーです。リポジトリごとに何かを仕込む必要はなく、Organization/インスタンス全体のwebhookとして1箇所に登録するだけで全リポジトリに効きます。 + +``` +Forgejo --webhook--> yaju-keisatsu --Forgejo API--> コメント投稿 / 是正PR作成 +``` + +## セットアップ + +### 1. bot用アカウントとトークンを作る + +Forgejo上にbot専用アカウント(例: `yaju-keisatsu-bot`)を作り、そのアカウントで Settings > Applications からAPIトークンを発行する。対象リポジトリへの読み書き・issue・pull request権限があれば十分。 + +### 2. 設定ファイルを用意 + +```bash +cp config.example.yaml config.yaml +``` + +`config.yaml` を編集し、`forgejo.base_url` / `forgejo.token` / `webhook_secret` / `bot.username` を埋める。`phrases` は同梱の一部だけなので、必要に応じて自分の `config.yaml` に自由に追加してよい(このリストは本体の意図的にライトな内容にとどめてあります)。 + +`config.yaml` は秘密情報を含むため絶対にコミットしないこと(`.gitignore` 済み)。 + +### 3. 起動 + +```bash +go run ./cmd/yaju-is-always-watching-you -config config.yaml +``` + +または Docker で: + +```bash +docker build -t yaju-keisatsu . +docker run -p 8080:8080 -v $(pwd)/config.yaml:/etc/yaju-keisatsu/config.yaml yaju-keisatsu +``` + +### 4. Forgejoにwebhookを登録 + +インスタンス管理画面(サイト全体)、または Organization / リポジトリ単位の Settings > Webhooks から新規Webhookを追加: + +- Target URL: `https:///webhook` +- HTTP Method: `POST` +- Trigger On: `Issue Comment`, `Push` +- Secret: `config.yaml` の `webhook_secret` と同じ値 + +## 動作の安全設計 + +- **削除や強制pushは一切行わない。** コメントは削除せず警告リプライのみ、pushされたコミット履歴も書き換えず、常に新規ブランチ+PRという提案どまりの形にしている。 +- **自己ループ防止。** `bot.username` に設定したアカウント自身のコメント/pushには反応しない。これがないとbotの警告リプライ自体に語録が含まれて無限に自己反応してしまう。 + +## ライセンス + +MIT License。[LICENSE](./LICENSE) 参照。Issue・PR歓迎。 diff --git a/cmd/yaju-is-always-watching-you/main.go b/cmd/yaju-is-always-watching-you/main.go new file mode 100644 index 0000000..6edabdf --- /dev/null +++ b/cmd/yaju-is-always-watching-you/main.go @@ -0,0 +1,45 @@ +// Command yaju-keisatsu runs a Forgejo webhook bot that flags 淫夢語録 +// (and other configured phrases) in issue/PR comments and pushed files. +package main + +import ( + "flag" + "log" + "net/http" + + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/bot" + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/config" + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/detector" + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/forgejo" + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/webhook" +) + +func main() { + configPath := flag.String("config", "config.yaml", "path to config.yaml") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + + fgClient, err := forgejo.New(cfg.Forgejo.BaseURL, cfg.Forgejo.Token) + if err != nil { + log.Fatalf("create forgejo client: %v", err) + } + + det := detector.New(cfg.Phrases) + b := bot.New(fgClient, det, cfg.Bot.Username, cfg.Bot.BranchPrefix, cfg.Bot.TargetExtensions) + h := webhook.NewHandler(cfg.WebhookSecret, b) + + mux := http.NewServeMux() + mux.Handle("/webhook", h) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + log.Printf("yaju-keisatsu listening on %s", cfg.ListenAddr) + if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { + log.Fatalf("serve: %v", err) + } +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..38a7a61 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,46 @@ +# goroku-keisatsu の設定ファイル例。 +# コピーして config.yaml として使う。秘密情報が入るので config.yaml は絶対にコミットしないこと。 + +listen_addr: ":8080" + +# 是正PRやコメント投稿時にリクエストの署名検証に使う共有シークレット。 +# Forgejo側のWebhook設定(Settings > Webhooks)で入力する値と同じにする。 +webhook_secret: "change-me" + +forgejo: + # 自ホストのForgejoのベースURL + base_url: "https://git.example.dev" + # bot専用アカウントで発行したAPIトークン(Settings > Applications)。 + # 権限は対象リポジトリへの read/write + issue + pull-request で十分。 + token: "change-me" + +bot: + # 上のtokenを発行したbotアカウントのユーザー名。 + # このアカウント自身の発言/pushには反応しない(無限ループ防止)。 + username: "goroku-keisatsu-bot" + # 是正PR用に作成するブランチの接頭辞。 + branch_prefix: "bot/goroku-fix/" + # push時にスキャンする対象拡張子。空にすると全ファイルを対象にする。 + target_extensions: + - ".md" + - ".txt" + - ".go" + - ".py" + - ".js" + - ".ts" + +# 検知する語録リスト。有名どころの一部だけを同梱しているので、 +# 必要に応じて自分のconfig.yamlで自由に追加・削除すること。 +phrases: + - "しょうもな" + - "たまげたなあ" + - "変態糞土方" + - "絶対に許さない" + - "24時間戦えますか" + - "本当に感謝しております" + - "巨大感謝" + - "覚悟はいいか?俺はできてる" + - "もうだめだ" + - "ンアッー" + - "ファッ!?" + - "24時間タタカエマスカ" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d589fc5 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.folja.dev/awayatan/yaju-is-always-watching-you + +go 1.26 + +require ( + code.gitea.io/sdk/gitea v0.25.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/42wim/httpsig v1.2.4 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..573e791 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE= +code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg= +github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= +github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..520196d --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,168 @@ +// 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 +} diff --git a/internal/bot/bot_test.go b/internal/bot/bot_test.go new file mode 100644 index 0000000..c40be07 --- /dev/null +++ b/internal/bot/bot_test.go @@ -0,0 +1,157 @@ +package bot + +import ( + "testing" + + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/detector" + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/forgejo" +) + +type fakeClient struct { + comments []string + corrections []string + prs int + fileContent map[string]string +} + +func (f *fakeClient) ReplyToIssue(owner, repo string, index int64, body string) error { + f.comments = append(f.comments, body) + return nil +} + +func (f *fakeClient) FileContent(owner, repo, ref, path string) (string, string, error) { + return f.fileContent[path], "sha-" + path, nil +} + +func (f *fakeClient) CommitCorrection(owner, repo, baseBranch, newBranch, path, sha, newContent, message string) error { + f.corrections = append(f.corrections, path) + return nil +} + +func (f *fakeClient) OpenCorrectionPR(owner, repo, head, base, title, body string) error { + f.prs++ + return nil +} + +func newTestBot(fc *fakeClient) *Bot { + det := detector.New([]string{"しょうもな"}) + return New(fc, det, "yaju-keisatsu-bot", "bot/goroku-fix/", nil) +} + +func TestHandleIssueComment_Flagged(t *testing.T) { + fc := &fakeClient{} + b := newTestBot(fc) + + err := b.HandleIssueComment(forgejo.IssueCommentPayload{ + Action: "created", + Issue: forgejo.WebhookIssue{Number: 1}, + Comment: forgejo.WebhookComment{Body: "それはしょうもな", User: forgejo.WebhookUser{Login: "alice"}}, + Repository: forgejo.WebhookRepository{Name: "repo", Owner: forgejo.WebhookUser{Login: "owner"}}, + Sender: forgejo.WebhookUser{Login: "alice"}, + }) + if err != nil { + t.Fatalf("HandleIssueComment() error = %v", err) + } + if len(fc.comments) != 1 { + t.Fatalf("expected 1 warning comment, got %d", len(fc.comments)) + } +} + +func TestHandleIssueComment_IgnoresSelf(t *testing.T) { + fc := &fakeClient{} + b := newTestBot(fc) + + err := b.HandleIssueComment(forgejo.IssueCommentPayload{ + Action: "created", + Comment: forgejo.WebhookComment{Body: "それはしょうもな", User: forgejo.WebhookUser{Login: "yaju-keisatsu-bot"}}, + Repository: forgejo.WebhookRepository{Name: "repo", Owner: forgejo.WebhookUser{Login: "owner"}}, + }) + if err != nil { + t.Fatalf("HandleIssueComment() error = %v", err) + } + if len(fc.comments) != 0 { + t.Fatalf("bot must not reply to its own comments, got %d replies", len(fc.comments)) + } +} + +func TestHandleIssueComment_Clean(t *testing.T) { + fc := &fakeClient{} + b := newTestBot(fc) + + err := b.HandleIssueComment(forgejo.IssueCommentPayload{ + Action: "created", + Comment: forgejo.WebhookComment{Body: "普通のコメントです", User: forgejo.WebhookUser{Login: "alice"}}, + Repository: forgejo.WebhookRepository{Name: "repo", Owner: forgejo.WebhookUser{Login: "owner"}}, + Sender: forgejo.WebhookUser{Login: "alice"}, + }) + if err != nil { + t.Fatalf("HandleIssueComment() error = %v", err) + } + if len(fc.comments) != 0 { + t.Fatalf("expected no reply for clean comment, got %d", len(fc.comments)) + } +} + +func TestHandlePush_OpensCorrectionPR(t *testing.T) { + fc := &fakeClient{fileContent: map[string]string{ + "README.md": "これはしょうもな話だ", + }} + b := newTestBot(fc) + + err := b.HandlePush(forgejo.PushPayload{ + Ref: "refs/heads/main", + After: "abcdef1234567890", + Commits: []forgejo.WebhookCommit{ + {ID: "abcdef1234567890", Modified: []string{"README.md"}}, + }, + Repository: forgejo.WebhookRepository{Name: "repo", Owner: forgejo.WebhookUser{Login: "owner"}}, + Pusher: forgejo.WebhookUser{Login: "alice"}, + }) + if err != nil { + t.Fatalf("HandlePush() error = %v", err) + } + if len(fc.corrections) != 1 || fc.corrections[0] != "README.md" { + t.Fatalf("expected a correction commit for README.md, got %v", fc.corrections) + } + if fc.prs != 1 { + t.Fatalf("expected 1 correction PR, got %d", fc.prs) + } +} + +func TestHandlePush_IgnoresSelf(t *testing.T) { + fc := &fakeClient{fileContent: map[string]string{"README.md": "しょうもな"}} + b := newTestBot(fc) + + err := b.HandlePush(forgejo.PushPayload{ + Ref: "refs/heads/main", + After: "abc", + Commits: []forgejo.WebhookCommit{{ID: "abc", Modified: []string{"README.md"}}}, + Repository: forgejo.WebhookRepository{Name: "repo", Owner: forgejo.WebhookUser{Login: "owner"}}, + Pusher: forgejo.WebhookUser{Login: "yaju-keisatsu-bot"}, + }) + if err != nil { + t.Fatalf("HandlePush() error = %v", err) + } + if fc.prs != 0 { + t.Fatalf("bot must not react to its own pushes, got %d PRs", fc.prs) + } +} + +func TestHandlePush_CleanFileNoOp(t *testing.T) { + fc := &fakeClient{fileContent: map[string]string{"README.md": "普通のREADMEです"}} + b := newTestBot(fc) + + err := b.HandlePush(forgejo.PushPayload{ + Ref: "refs/heads/main", + After: "abc", + Commits: []forgejo.WebhookCommit{{ID: "abc", Modified: []string{"README.md"}}}, + Repository: forgejo.WebhookRepository{Name: "repo", Owner: forgejo.WebhookUser{Login: "owner"}}, + Pusher: forgejo.WebhookUser{Login: "alice"}, + }) + if err != nil { + t.Fatalf("HandlePush() error = %v", err) + } + if fc.prs != 0 || len(fc.corrections) != 0 { + t.Fatalf("expected no correction for clean file, got prs=%d corrections=%v", fc.prs, fc.corrections) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..569d1e1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,65 @@ +// Package config loads yaju-keisatsu's YAML configuration file. +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ListenAddr string `yaml:"listen_addr"` + WebhookSecret string `yaml:"webhook_secret"` + Forgejo Forgejo `yaml:"forgejo"` + Bot Bot `yaml:"bot"` + Phrases []string `yaml:"phrases"` +} + +type Forgejo struct { + BaseURL string `yaml:"base_url"` + Token string `yaml:"token"` +} + +type Bot struct { + // Username is the Forgejo account the API token belongs to. Comments + // authored by this account are ignored so the bot never replies to itself. + Username string `yaml:"username"` + // BranchPrefix is prepended to the auto-generated correction branch name. + BranchPrefix string `yaml:"branch_prefix"` + // TargetExtensions limits which pushed files are scanned for corrections. + // An empty list scans every file. + TargetExtensions []string `yaml:"target_extensions"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + cfg := &Config{ + ListenAddr: ":8080", + Bot: Bot{ + BranchPrefix: "bot/goroku-fix/", + }, + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + if cfg.Forgejo.BaseURL == "" { + return nil, fmt.Errorf("forgejo.base_url is required") + } + if cfg.Forgejo.Token == "" { + return nil, fmt.Errorf("forgejo.token is required") + } + if cfg.WebhookSecret == "" { + return nil, fmt.Errorf("webhook_secret is required") + } + if len(cfg.Phrases) == 0 { + return nil, fmt.Errorf("phrases list is empty") + } + + return cfg, nil +} diff --git a/internal/detector/detector.go b/internal/detector/detector.go new file mode 100644 index 0000000..93e5261 --- /dev/null +++ b/internal/detector/detector.go @@ -0,0 +1,51 @@ +// Package detector implements plain substring matching against a configured +// phrase list (the "goroku" list). Entries are literal fixed phrases rather +// than regexes because the source material is a closed set of copypasta +// lines, not a pattern language. +package detector + +import "strings" + +type Detector struct { + phrases []string +} + +func New(phrases []string) *Detector { + uniq := make([]string, 0, len(phrases)) + seen := make(map[string]struct{}, len(phrases)) + for _, p := range phrases { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + uniq = append(uniq, p) + } + return &Detector{phrases: uniq} +} + +// Detect returns every registered phrase found in text, in registration order. +func (d *Detector) Detect(text string) []string { + var found []string + for _, p := range d.phrases { + if strings.Contains(text, p) { + found = append(found, p) + } + } + return found +} + +// Redact returns text with every matched phrase replaced by a same-width +// mask, along with the list of phrases that were matched. +func (d *Detector) Redact(text string) (string, []string) { + matched := d.Detect(text) + redacted := text + for _, p := range matched { + mask := strings.Repeat("○", len([]rune(p))) + redacted = strings.ReplaceAll(redacted, p, mask) + } + return redacted, matched +} diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go new file mode 100644 index 0000000..31026d4 --- /dev/null +++ b/internal/detector/detector_test.go @@ -0,0 +1,39 @@ +package detector + +import ( + "reflect" + "testing" +) + +func TestDetect(t *testing.T) { + d := New([]string{"しょうもな", "たまげたなあ"}) + + got := d.Detect("いやしょうもな、たまげたなあ") + want := []string{"しょうもな", "たまげたなあ"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Detect() = %v, want %v", got, want) + } + + if got := d.Detect("普通のコメントです"); got != nil { + t.Fatalf("Detect() = %v, want nil", got) + } +} + +func TestRedact(t *testing.T) { + d := New([]string{"しょうもな"}) + + redacted, matched := d.Redact("それはしょうもな案件だ") + if want := "それは○○○○○案件だ"; redacted != want { + t.Fatalf("Redact() text = %q, want %q", redacted, want) + } + if want := []string{"しょうもな"}; !reflect.DeepEqual(matched, want) { + t.Fatalf("Redact() matched = %v, want %v", matched, want) + } +} + +func TestNewDeduplicates(t *testing.T) { + d := New([]string{"a", "a", " b ", "", "b"}) + if got := d.phrases; !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("phrases = %v, want [a b]", got) + } +} diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go new file mode 100644 index 0000000..be84451 --- /dev/null +++ b/internal/forgejo/client.go @@ -0,0 +1,87 @@ +// Package forgejo wraps the parts of the Gitea/Forgejo SDK that +// yaju-keisatsu needs: replying to comments and opening a correction PR. +package forgejo + +import ( + "encoding/base64" + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" +) + +type Client struct { + sdk *gitea.Client +} + +func New(baseURL, token string) (*Client, error) { + c, err := gitea.NewClient(baseURL, gitea.SetToken(token)) + if err != nil { + return nil, fmt.Errorf("create gitea client: %w", err) + } + return &Client{sdk: c}, nil +} + +// ReplyToIssue posts a comment on an issue or pull request. +func (c *Client) ReplyToIssue(owner, repo string, index int64, body string) error { + _, _, err := c.sdk.CreateIssueComment(owner, repo, index, gitea.CreateIssueCommentOption{Body: body}) + if err != nil { + return fmt.Errorf("create issue comment: %w", err) + } + return nil +} + +// FileContent fetches a file's current text content and blob SHA at ref. +func (c *Client) FileContent(owner, repo, ref, path string) (content, sha string, err error) { + meta, _, err := c.sdk.GetContents(owner, repo, ref, path) + if err != nil { + return "", "", fmt.Errorf("get file %s: %w", path, err) + } + if meta.Content == nil { + return "", "", fmt.Errorf("get file %s: not a regular file", path) + } + // The API may line-wrap the base64 payload; strip whitespace before decoding. + clean := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' { + return -1 + } + return r + }, *meta.Content) + decoded, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + return "", "", fmt.Errorf("decode file %s: %w", path, err) + } + return string(decoded), meta.SHA, nil +} + +// CommitCorrection commits newContent for path on a new branch (branched off +// baseBranch) without touching baseBranch itself. +func (c *Client) CommitCorrection(owner, repo, baseBranch, newBranch, path, sha, newContent, message string) error { + _, _, err := c.sdk.UpdateFile(owner, repo, path, gitea.UpdateFileOptions{ + SHA: sha, + Content: base64.StdEncoding.EncodeToString([]byte(newContent)), + FileOptions: gitea.FileOptions{ + Message: message, + BranchName: baseBranch, + NewBranchName: newBranch, + }, + }) + if err != nil { + return fmt.Errorf("update file %s: %w", path, err) + } + return nil +} + +// OpenCorrectionPR opens a pull request from head into base. +func (c *Client) OpenCorrectionPR(owner, repo, head, base, title, body string) error { + _, _, err := c.sdk.CreatePullRequest(owner, repo, gitea.CreatePullRequestOption{ + Head: head, + Base: base, + Title: title, + Body: body, + }) + if err != nil { + return fmt.Errorf("create pull request: %w", err) + } + return nil +} diff --git a/internal/forgejo/payload.go b/internal/forgejo/payload.go new file mode 100644 index 0000000..2d154b7 --- /dev/null +++ b/internal/forgejo/payload.go @@ -0,0 +1,52 @@ +package forgejo + +// The structs below are minimal subsets of the Forgejo/Gitea webhook JSON +// payloads (https://forgejo.org/docs/latest/user/webhooks/) — only the +// fields yaju-keisatsu actually reads. + +type WebhookUser struct { + Login string `json:"login"` +} + +type WebhookRepository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Owner WebhookUser `json:"owner"` + DefaultBranch string `json:"default_branch"` +} + +type WebhookIssue struct { + Number int64 `json:"number"` + Body string `json:"body"` +} + +type WebhookComment struct { + Body string `json:"body"` + User WebhookUser `json:"user"` +} + +// IssueCommentPayload is the body of a Forgejo "issue_comment" webhook +// event. It fires for comments on both issues and pull requests. +type IssueCommentPayload struct { + Action string `json:"action"` + Issue WebhookIssue `json:"issue"` + Comment WebhookComment `json:"comment"` + Repository WebhookRepository `json:"repository"` + Sender WebhookUser `json:"sender"` +} + +type WebhookCommit struct { + ID string `json:"id"` + Message string `json:"message"` + Added []string `json:"added"` + Modified []string `json:"modified"` +} + +// PushPayload is the body of a Forgejo "push" webhook event. +type PushPayload struct { + Ref string `json:"ref"` + After string `json:"after"` + Commits []WebhookCommit `json:"commits"` + Repository WebhookRepository `json:"repository"` + Pusher WebhookUser `json:"pusher"` +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go new file mode 100644 index 0000000..19d2666 --- /dev/null +++ b/internal/webhook/handler.go @@ -0,0 +1,106 @@ +// Package webhook is the HTTP transport layer: it verifies Forgejo's +// webhook signature, decodes the event payload, and dispatches to bot.Bot. +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log" + "net/http" + + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/forgejo" +) + +// Dispatcher is satisfied by *bot.Bot. +type Dispatcher interface { + HandleIssueComment(forgejo.IssueCommentPayload) error + HandlePush(forgejo.PushPayload) error +} + +type Handler struct { + secret []byte + dispatcher Dispatcher +} + +func NewHandler(secret string, dispatcher Dispatcher) *Handler { + return &Handler{secret: []byte(secret), dispatcher: dispatcher} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10MiB cap + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + + if !h.validSignature(r, body) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + event := firstHeader(r, "X-Forgejo-Event", "X-Gitea-Event") + var dispatchErr error + switch event { + case "issue_comment": + var p forgejo.IssueCommentPayload + if err := json.Unmarshal(body, &p); err != nil { + http.Error(w, "decode payload", http.StatusBadRequest) + return + } + dispatchErr = h.dispatcher.HandleIssueComment(p) + case "push": + var p forgejo.PushPayload + if err := json.Unmarshal(body, &p); err != nil { + http.Error(w, "decode payload", http.StatusBadRequest) + return + } + dispatchErr = h.dispatcher.HandlePush(p) + default: + // Unhandled event types are not an error — the webhook may be + // subscribed to more events than this bot reacts to. + } + + if dispatchErr != nil { + log.Printf("yaju-keisatsu: handling %s event: %v", event, dispatchErr) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// validSignature checks X-Forgejo-Signature (falling back to +// X-Gitea-Signature for webhooks configured in Gitea-compat mode): the hex +// HMAC-SHA256 of the raw body under the shared webhook secret. +func (h *Handler) validSignature(r *http.Request, body []byte) bool { + sig := firstHeader(r, "X-Forgejo-Signature", "X-Gitea-Signature") + if sig == "" { + return false + } + got, err := hex.DecodeString(sig) + if err != nil { + return false + } + + mac := hmac.New(sha256.New, h.secret) + mac.Write(body) + want := mac.Sum(nil) + + return hmac.Equal(got, want) +} + +func firstHeader(r *http.Request, names ...string) string { + for _, n := range names { + if v := r.Header.Get(n); v != "" { + return v + } + } + return "" +} diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go new file mode 100644 index 0000000..7aef20b --- /dev/null +++ b/internal/webhook/handler_test.go @@ -0,0 +1,114 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.folja.dev/awayatan/yaju-is-always-watching-you/internal/forgejo" +) + +type fakeDispatcher struct { + comments int + pushes int +} + +func (f *fakeDispatcher) HandleIssueComment(forgejo.IssueCommentPayload) error { + f.comments++ + return nil +} + +func (f *fakeDispatcher) HandlePush(forgejo.PushPayload) error { + f.pushes++ + return nil +} + +func sign(secret, body string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(body)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestServeHTTP_RejectsBadSignature(t *testing.T) { + fd := &fakeDispatcher{} + h := NewHandler("secret", fd) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("X-Forgejo-Event", "push") + req.Header.Set("X-Forgejo-Signature", "deadbeef") + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if fd.pushes != 0 { + t.Fatalf("dispatcher should not run on bad signature, pushes = %d", fd.pushes) + } +} + +func TestServeHTTP_DispatchesPush(t *testing.T) { + fd := &fakeDispatcher{} + h := NewHandler("secret", fd) + + body := `{"ref":"refs/heads/main"}` + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("X-Forgejo-Event", "push") + req.Header.Set("X-Forgejo-Signature", sign("secret", body)) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + if fd.pushes != 1 { + t.Fatalf("pushes = %d, want 1", fd.pushes) + } +} + +func TestServeHTTP_DispatchesIssueComment(t *testing.T) { + fd := &fakeDispatcher{} + h := NewHandler("secret", fd) + + body := `{"action":"created"}` + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("X-Forgejo-Event", "issue_comment") + req.Header.Set("X-Forgejo-Signature", sign("secret", body)) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + if fd.comments != 1 { + t.Fatalf("comments = %d, want 1", fd.comments) + } +} + +func TestServeHTTP_IgnoresUnhandledEvent(t *testing.T) { + fd := &fakeDispatcher{} + h := NewHandler("secret", fd) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("X-Forgejo-Event", "fork") + req.Header.Set("X-Forgejo-Signature", sign("secret", body)) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if fd.comments != 0 || fd.pushes != 0 { + t.Fatalf("dispatcher should not be called for unhandled events") + } +}