Add yaju-is-always-watching-you: Forgejo goroku-check webhook bot
Some checks failed
CI / test (push) Failing after 4s
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:
commit
f47fef177b
18 changed files with 1103 additions and 0 deletions
17
.forgejo/workflows/ci.yml
Normal file
17
.forgejo/workflows/ci.yml
Normal 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
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/config.yaml
|
||||
/yaju-keisatsu
|
||||
*.test
|
||||
/dist/
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
21
LICENSE
Normal 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
63
README.md
Normal 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歓迎。
|
||||
45
cmd/yaju-is-always-watching-you/main.go
Normal file
45
cmd/yaju-is-always-watching-you/main.go
Normal 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
46
config.example.yaml
Normal 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
17
go.mod
Normal 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
38
go.sum
Normal 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
168
internal/bot/bot.go
Normal 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
157
internal/bot/bot_test.go
Normal 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
65
internal/config/config.go
Normal 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
|
||||
}
|
||||
51
internal/detector/detector.go
Normal file
51
internal/detector/detector.go
Normal 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
|
||||
}
|
||||
39
internal/detector/detector_test.go
Normal file
39
internal/detector/detector_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
87
internal/forgejo/client.go
Normal file
87
internal/forgejo/client.go
Normal 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
|
||||
}
|
||||
52
internal/forgejo/payload.go
Normal file
52
internal/forgejo/payload.go
Normal 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
106
internal/webhook/handler.go
Normal 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 ""
|
||||
}
|
||||
114
internal/webhook/handler_test.go
Normal file
114
internal/webhook/handler_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue