11
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user