This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
package webhook
import (
"regexp"
"strconv"
)
var (
closesRe = regexp.MustCompile(`(?i)\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)`)
branchRe = regexp.MustCompile(`(?:^|[-/])(\d+)(?:[-/]|$)`)
nonceRe = regexp.MustCompile(`(?i)<!--\s*spx:nonce=([0-9a-f-]+)\s*-->`)
reviewRe = regexp.MustCompile(`(?i)<!--\s*spx:review=1\s*-->`)
)
// ResolveIssueNumber resolves the issue number in priority order:
// 1. PR body keyword (Closes/Fixes/Resolves #N)
// 2. Branch name containing a number
// 3. Legacy path number
func ResolveIssueNumber(prBody, branch string, legacyNumber int) (int, bool) {
if m := closesRe.FindStringSubmatch(prBody); m != nil {
n, _ := strconv.Atoi(m[1])
return n, true
}
if branch != "" {
if m := branchRe.FindStringSubmatch(branch); m != nil {
n, _ := strconv.Atoi(m[1])
return n, true
}
}
if legacyNumber > 0 {
return legacyNumber, true
}
return 0, false
}
// ShouldAutoReview returns the effective auto-review flag.
// The per-issue stateJSON "autoReview" bool takes precedence over globalAutoReview.
func ShouldAutoReview(stateJSON map[string]any, globalAutoReview bool) bool {
if v, ok := stateJSON["autoReview"].(bool); ok {
return v
}
return globalAutoReview
}
// ExtractNonce extracts the nonce value from a <!-- spx:nonce=... --> marker.
func ExtractNonce(issueBody string) (string, bool) {
m := nonceRe.FindStringSubmatch(issueBody)
if m == nil || m[1] == "" {
return "", false
}
return m[1], true
}
// IsReviewMarker reports whether commentBody contains <!-- spx:review=1 -->.
func IsReviewMarker(commentBody string) bool {
return reviewRe.MatchString(commentBody)
}
+145
View File
@@ -0,0 +1,145 @@
package webhook_test
import (
"testing"
"superwork-tui/internal/webhook"
)
func TestResolveIssueNumber_FromBody(t *testing.T) {
cases := []struct {
body string
want int
}{
{"Closes #5", 5},
{"closes #10", 10},
{"Fixes #3", 3},
{"fixes #99", 99},
{"Resolves #42", 42},
{"resolves #1", 1},
{"close #7", 7},
{"fix #8", 8},
{"resolve #9", 9},
{"Some text\nCloses #22\nmore", 22},
}
for _, c := range cases {
n, ok := webhook.ResolveIssueNumber(c.body, "", 0)
if !ok {
t.Errorf("body=%q: want ok=true", c.body)
}
if n != c.want {
t.Errorf("body=%q: want %d, got %d", c.body, c.want, n)
}
}
}
func TestResolveIssueNumber_FromBranch(t *testing.T) {
// branch like "issue-5-something"
n, ok := webhook.ResolveIssueNumber("", "issue-5-my-feat", 0)
if !ok {
t.Errorf("want ok=true for branch with issue number")
}
if n != 5 {
t.Errorf("want 5, got %d", n)
}
}
func TestResolveIssueNumber_LegacyPath(t *testing.T) {
n, ok := webhook.ResolveIssueNumber("", "", 42)
if !ok {
t.Errorf("want ok=true for legacy number")
}
if n != 42 {
t.Errorf("want 42, got %d", n)
}
}
func TestResolveIssueNumber_BodyTakesPrecedence(t *testing.T) {
// body "Closes #5" should win over legacy=42
n, ok := webhook.ResolveIssueNumber("Closes #5", "issue-42", 99)
if !ok {
t.Errorf("want ok=true")
}
if n != 5 {
t.Errorf("body should win; want 5, got %d", n)
}
}
func TestResolveIssueNumber_NoneMatch(t *testing.T) {
_, ok := webhook.ResolveIssueNumber("no closes here", "feat-branch", 0)
if ok {
t.Error("want ok=false when nothing matches")
}
}
func TestShouldAutoReview_StateOverride(t *testing.T) {
// state has autoReview=false, global=true → false
state := map[string]any{"autoReview": false}
if webhook.ShouldAutoReview(state, true) {
t.Error("state false should override global true")
}
// state has autoReview=true, global=false → true
state2 := map[string]any{"autoReview": true}
if !webhook.ShouldAutoReview(state2, false) {
t.Error("state true should override global false")
}
}
func TestShouldAutoReview_GlobalFallback(t *testing.T) {
// no autoReview in state → use global
if webhook.ShouldAutoReview(map[string]any{}, true) != true {
t.Error("should fallback to global true")
}
if webhook.ShouldAutoReview(map[string]any{}, false) != false {
t.Error("should fallback to global false")
}
// non-bool autoReview in state → use global
if webhook.ShouldAutoReview(map[string]any{"autoReview": "yes"}, true) != true {
t.Error("non-bool should fallback to global")
}
}
func TestExtractNonce(t *testing.T) {
cases := []struct {
body string
want string
ok bool
}{
{"<!-- spx:nonce=abc123 -->", "abc123", true},
{"<!-- spx:nonce=550e8400-e29b-41d4-a716-446655440000 -->", "550e8400-e29b-41d4-a716-446655440000", true},
{"text <!-- spx:nonce=deadbeef --> more", "deadbeef", true},
{"no nonce here", "", false},
{"<!-- spx:nonce= -->", "", false}, // empty value → false
}
for _, c := range cases {
got, ok := webhook.ExtractNonce(c.body)
if ok != c.ok {
t.Errorf("body=%q: ok want %v got %v", c.body, c.ok, ok)
}
if got != c.want {
t.Errorf("body=%q: want %q got %q", c.body, c.want, got)
}
}
}
func TestIsReviewMarker(t *testing.T) {
cases := []struct {
body string
want bool
}{
{"<!-- spx:review=1 -->", true},
{"<!--spx:review=1-->", true},
{"<!-- SPX:REVIEW=1 -->", true},
{"some text\n<!-- spx:review=1 -->\nmore", true},
{"no marker here", false},
{"<!-- spx:review=0 -->", false},
{"<!-- spx:nonce=abc -->", false},
}
for _, c := range cases {
got := webhook.IsReviewMarker(c.body)
if got != c.want {
t.Errorf("body=%q: want %v got %v", c.body, c.want, got)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package webhook_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"testing"
"time"
"superwork-tui/internal/webhook"
)
// freePort returns a random available TCP port on localhost.
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
port := l.Addr().(*net.TCPAddr).Port
l.Close()
return port
}
func TestServerLifecycle_EventArrivesViaChannel(t *testing.T) {
ch := make(chan webhook.Event, 4)
srv := webhook.NewServer(func(ev webhook.Event) {
ch <- ev
})
port := freePort(t)
addr := fmt.Sprintf(":%d", port)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
errCh := make(chan error, 1)
go func() {
errCh <- srv.Start(ctx, addr)
}()
// Wait until the server accepts connections.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 50*time.Millisecond)
if err == nil {
conn.Close()
break
}
time.Sleep(20 * time.Millisecond)
}
// POST a push event.
payload := map[string]any{
"ref": "refs/heads/main",
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("http://localhost%s/webhook", addr), bytes.NewReader(body))
if err != nil {
t.Fatalf("build request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gitea-Event", "push")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST /webhook: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("want 200, got %d", resp.StatusCode)
}
// Expect a PushEvent on the channel.
select {
case ev := <-ch:
pushEv, ok := ev.(webhook.PushEvent)
if !ok {
t.Fatalf("want PushEvent, got %T", ev)
}
if pushEv.Branch != "main" {
t.Errorf("want Branch=main, got %q", pushEv.Branch)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for PushEvent")
}
// Cancel context → server shuts down; Start should return.
cancel()
select {
case <-errCh:
// OK — server stopped
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for server shutdown")
}
}
func TestServerLifecycle_IssueCommentEvent(t *testing.T) {
ch := make(chan webhook.Event, 4)
srv := webhook.NewServer(func(ev webhook.Event) {
ch <- ev
})
port := freePort(t)
addr := fmt.Sprintf(":%d", port)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go srv.Start(ctx, addr) //nolint:errcheck
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 50*time.Millisecond)
if err == nil {
conn.Close()
break
}
time.Sleep(20 * time.Millisecond)
}
payload := map[string]any{
"action": "created",
"issue": map[string]any{
"number": float64(42),
},
"comment": map[string]any{
"body": "<!-- spx:review=1 -->\nLooks good",
"html_url": "https://example.com/comment/1",
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("http://localhost%s/webhook", addr), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gitea-Event", "issue_comment")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST /webhook: %v", err)
}
resp.Body.Close()
select {
case ev := <-ch:
ce, ok := ev.(webhook.IssueCommentEvent)
if !ok {
t.Fatalf("want IssueCommentEvent, got %T", ev)
}
if ce.IssueNumber != 42 {
t.Errorf("want IssueNumber=42, got %d", ce.IssueNumber)
}
if !webhook.IsReviewMarker(ce.CommentBody) {
t.Errorf("expected comment to contain review marker")
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for IssueCommentEvent")
}
}
+237
View File
@@ -0,0 +1,237 @@
package webhook
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
)
// Event is the discriminated union type for webhook events.
type Event interface{ webhookEvent() }
type PrEvent struct {
IssueNumber int // 0 on canonical /webhook route; set on /webhook/:N
Action string
PR string
Branch string
HTMLUrl string
Title string
Body string
Raw any
}
type IssueEvent struct {
Action string
IssueNumber int
Title string
Body string
HTMLUrl string
Raw any
}
type IssueCommentEvent struct {
Action string
IssueNumber int
PRNumber int // 0 when comment is not on a PR
CommentBody string
CommentHTMLUrl string
Raw any
}
type PushEvent struct {
Branch string
Raw any
}
func (PrEvent) webhookEvent() {}
func (IssueEvent) webhookEvent() {}
func (IssueCommentEvent) webhookEvent() {}
func (PushEvent) webhookEvent() {}
// Server parses Gitea webhooks and calls onEvent for each accepted payload.
type Server struct {
onEvent func(Event)
mux *http.ServeMux
}
var legacyRouteRe = regexp.MustCompile(`^/webhook/(\d+)$`)
func NewServer(onEvent func(Event)) *Server {
s := &Server{onEvent: onEvent}
mux := http.NewServeMux()
mux.HandleFunc("/", s.handle)
s.mux = mux
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// Start begins listening on addr and stops when ctx is cancelled.
func (s *Server) Start(ctx context.Context, addr string) error {
srv := &http.Server{Addr: addr, Handler: s}
go func() {
<-ctx.Done()
srv.Shutdown(context.Background()) //nolint
}()
return srv.ListenAndServe()
}
func jsonResp(w http.ResponseWriter, code int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(body) //nolint
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResp(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "method_not_allowed"})
return
}
issueNumber := 0
path := r.URL.Path
if m := legacyRouteRe.FindStringSubmatch(path); m != nil {
n, _ := strconv.Atoi(m[1])
issueNumber = n
} else if path != "/webhook" {
jsonResp(w, http.StatusNotFound, map[string]any{"ok": false, "error": "not_found"})
return
}
raw, err := io.ReadAll(r.Body)
if err != nil {
jsonResp(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "read_error"})
return
}
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
jsonResp(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "invalid_json"})
return
}
eventHeader := r.Header.Get("X-Gitea-Event")
if ev := parseEvent(eventHeader, issueNumber, payload); ev != nil {
s.onEvent(ev)
} else {
slog.Info("webhook: event not matched or missing required fields", "header", eventHeader)
}
jsonResp(w, http.StatusOK, map[string]any{"ok": true})
}
func parseEvent(eventHeader string, issueNumber int, raw any) Event {
obj, ok := raw.(map[string]any)
if !ok {
return nil
}
switch eventHeader {
case "push":
ref, _ := obj["ref"].(string)
branch := strings.TrimPrefix(ref, "refs/heads/")
if branch == "" || branch == ref {
return nil
}
return PushEvent{Branch: branch, Raw: raw}
case "issue_comment":
issue, _ := obj["issue"].(map[string]any)
comment, _ := obj["comment"].(map[string]any)
if issue == nil || comment == nil {
return nil
}
num := toInt(issue["number"])
if num == 0 {
return nil
}
prNumber := 0
if pr, exists := issue["pull_request"]; exists && pr != nil {
if _, isMap := pr.(map[string]any); isMap {
prNumber = num
}
}
action, _ := obj["action"].(string)
commentBody, _ := comment["body"].(string)
commentHTMLUrl, _ := comment["html_url"].(string)
return IssueCommentEvent{
Action: action,
IssueNumber: num,
PRNumber: prNumber,
CommentBody: commentBody,
CommentHTMLUrl: commentHTMLUrl,
Raw: raw,
}
case "issues":
issue, _ := obj["issue"].(map[string]any)
if issue == nil {
return nil
}
num := toInt(issue["number"])
if num == 0 {
return nil
}
action, _ := obj["action"].(string)
htmlUrl, _ := issue["html_url"].(string)
title, _ := issue["title"].(string)
body, _ := issue["body"].(string)
return IssueEvent{
Action: action,
IssueNumber: num,
Title: title,
Body: body,
HTMLUrl: htmlUrl,
Raw: raw,
}
default: // "pull_request" or unknown → attempt PR parse
pr, _ := obj["pull_request"].(map[string]any)
if pr == nil {
return nil
}
num := toInt(pr["number"])
if num == 0 {
return nil
}
htmlUrl, _ := pr["html_url"].(string)
head, _ := pr["head"].(map[string]any)
var branch string
if head != nil {
branch, _ = head["ref"].(string)
}
if branch == "" || htmlUrl == "" {
return nil
}
action, _ := obj["action"].(string)
title, _ := pr["title"].(string)
body, _ := pr["body"].(string)
return PrEvent{
IssueNumber: issueNumber,
Action: action,
PR: strconv.Itoa(num),
Branch: branch,
HTMLUrl: htmlUrl,
Title: title,
Body: body,
Raw: raw,
}
}
}
func toInt(v any) int {
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
}
return 0
}
+214
View File
@@ -0,0 +1,214 @@
package webhook_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"superwork-tui/internal/webhook"
)
func post(t *testing.T, srv *webhook.Server, path, eventHeader, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("X-Gitea-Event", eventHeader)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
return w
}
func TestServer_PREvent(t *testing.T) {
var got webhook.Event
srv := webhook.NewServer(func(e webhook.Event) { got = e })
payload := `{"action":"opened","pull_request":{"number":7,"html_url":"http://x/pr/7","head":{"ref":"feat/my-branch"},"title":"My PR","body":"Closes #3"}}`
w := post(t, srv, "/webhook", "pull_request", payload)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
pr, ok := got.(webhook.PrEvent)
if !ok {
t.Fatalf("want PrEvent, got %T", got)
}
if pr.Action != "opened" {
t.Errorf("action: want opened, got %q", pr.Action)
}
if pr.PR != "7" {
t.Errorf("pr: want 7, got %q", pr.PR)
}
if pr.Branch != "feat/my-branch" {
t.Errorf("branch: %q", pr.Branch)
}
if pr.HTMLUrl != "http://x/pr/7" {
t.Errorf("htmlUrl: %q", pr.HTMLUrl)
}
if pr.Title != "My PR" {
t.Errorf("title: %q", pr.Title)
}
if pr.Body != "Closes #3" {
t.Errorf("body: %q", pr.Body)
}
if pr.IssueNumber != 0 {
t.Errorf("issueNumber should be 0 on canonical route, got %d", pr.IssueNumber)
}
}
func TestServer_PREventLegacyRoute(t *testing.T) {
var got webhook.Event
srv := webhook.NewServer(func(e webhook.Event) { got = e })
payload := `{"action":"opened","pull_request":{"number":7,"html_url":"http://x/pr/7","head":{"ref":"feat/x"},"title":"T","body":""}}`
w := post(t, srv, "/webhook/42", "pull_request", payload)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
pr := got.(webhook.PrEvent)
if pr.IssueNumber != 42 {
t.Errorf("issueNumber: want 42, got %d", pr.IssueNumber)
}
}
func TestServer_IssueEvent(t *testing.T) {
var got webhook.Event
srv := webhook.NewServer(func(e webhook.Event) { got = e })
payload := `{"action":"opened","issue":{"number":5,"html_url":"http://x/issues/5","title":"Issue title","body":"some body"}}`
w := post(t, srv, "/webhook", "issues", payload)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
ev, ok := got.(webhook.IssueEvent)
if !ok {
t.Fatalf("want IssueEvent, got %T", got)
}
if ev.Action != "opened" {
t.Errorf("action: %q", ev.Action)
}
if ev.IssueNumber != 5 {
t.Errorf("issueNumber: %d", ev.IssueNumber)
}
if ev.Title != "Issue title" {
t.Errorf("title: %q", ev.Title)
}
if ev.Body != "some body" {
t.Errorf("body: %q", ev.Body)
}
if ev.HTMLUrl != "http://x/issues/5" {
t.Errorf("htmlUrl: %q", ev.HTMLUrl)
}
}
func TestServer_IssueCommentEvent_OnPR(t *testing.T) {
var got webhook.Event
srv := webhook.NewServer(func(e webhook.Event) { got = e })
// issue.pull_request non-null → prNumber = issue.number
payload := `{"action":"created","issue":{"number":9,"pull_request":{"merged_at":null}},"comment":{"body":"<!-- spx:review=1 -->","html_url":"http://x/c/1"}}`
w := post(t, srv, "/webhook", "issue_comment", payload)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
ev, ok := got.(webhook.IssueCommentEvent)
if !ok {
t.Fatalf("want IssueCommentEvent, got %T", got)
}
if ev.IssueNumber != 9 {
t.Errorf("issueNumber: %d", ev.IssueNumber)
}
if ev.PRNumber != 9 {
t.Errorf("prNumber: want 9 (PR comment), got %d", ev.PRNumber)
}
if ev.CommentBody != "<!-- spx:review=1 -->" {
t.Errorf("commentBody: %q", ev.CommentBody)
}
}
func TestServer_IssueCommentEvent_NotOnPR(t *testing.T) {
var got webhook.Event
srv := webhook.NewServer(func(e webhook.Event) { got = e })
payload := `{"action":"created","issue":{"number":9},"comment":{"body":"hello","html_url":"http://x/c/2"}}`
w := post(t, srv, "/webhook", "issue_comment", payload)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
ev := got.(webhook.IssueCommentEvent)
if ev.PRNumber != 0 {
t.Errorf("prNumber: want 0 (not a PR), got %d", ev.PRNumber)
}
}
func TestServer_PushEvent(t *testing.T) {
var got webhook.Event
srv := webhook.NewServer(func(e webhook.Event) { got = e })
payload := `{"ref":"refs/heads/main"}`
w := post(t, srv, "/webhook", "push", payload)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
ev, ok := got.(webhook.PushEvent)
if !ok {
t.Fatalf("want PushEvent, got %T", got)
}
if ev.Branch != "main" {
t.Errorf("branch: want main, got %q", ev.Branch)
}
}
func TestServer_MalformedJSON_Returns400(t *testing.T) {
called := false
srv := webhook.NewServer(func(e webhook.Event) { called = true })
w := post(t, srv, "/webhook", "pull_request", "{bad json")
if w.Code != 400 {
t.Fatalf("want 400, got %d", w.Code)
}
if called {
t.Error("OnEvent should not be called for malformed JSON")
}
}
func TestServer_WrongMethod_Returns405(t *testing.T) {
srv := webhook.NewServer(func(e webhook.Event) {})
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != 405 {
t.Fatalf("want 405, got %d", w.Code)
}
}
func TestServer_UnknownPath_Returns404(t *testing.T) {
srv := webhook.NewServer(func(e webhook.Event) {})
req := httptest.NewRequest(http.MethodPost, "/unknown", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != 404 {
t.Fatalf("want 404, got %d", w.Code)
}
}
func TestServer_NonMatchingEventHeader_Returns200_NoEvent(t *testing.T) {
called := false
srv := webhook.NewServer(func(e webhook.Event) { called = true })
// push event but missing ref → null parse → no event emitted, still 200
w := post(t, srv, "/webhook", "push", `{"action":"opened"}`)
if w.Code != 200 {
t.Fatalf("want 200, got %d", w.Code)
}
if called {
t.Error("OnEvent should not be called when event doesn't parse")
}
}
func TestServer_ResponseBodyIsJSON(t *testing.T) {
srv := webhook.NewServer(func(e webhook.Event) {})
payload := `{"action":"opened","pull_request":{"number":1,"html_url":"http://x","head":{"ref":"x"},"title":"","body":""}}`
w := post(t, srv, "/webhook", "pull_request", payload)
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response not JSON: %v", err)
}
if resp["ok"] != true {
t.Errorf("want ok:true, got %v", resp["ok"])
}
}