Add yaju-is-always-watching-you: Forgejo goroku-check webhook bot
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>
This commit is contained in:
awayatan 2026-07-26 22:47:12 +09:00
commit f47fef177b
18 changed files with 1103 additions and 0 deletions

17
.forgejo/workflows/ci.yml Normal file
View file

@ -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 ./...

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/config.yaml
/yaju-keisatsu
*.test
/dist/

13
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

@ -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.

63
README.md Normal file
View file

@ -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://<yaju-keisatsuを立てたホスト>/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歓迎。

View file

@ -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)
}
}

46
config.example.yaml Normal file
View file

@ -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時間タタカエマスカ"

17
go.mod Normal file
View file

@ -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
)

38
go.sum Normal file
View file

@ -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=

168
internal/bot/bot.go Normal file
View file

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

157
internal/bot/bot_test.go Normal file
View file

@ -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)
}
}

65
internal/config/config.go Normal file
View file

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

View file

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

View file

@ -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)
}
}

View file

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

View file

@ -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"`
}

106
internal/webhook/handler.go Normal file
View file

@ -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 ""
}

View file

@ -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")
}
}