nomadcode/services/core/internal/gitoevents/events.go
toki 9258f9de54 feat: gito http webhook consumer readiness milestone completion
- Update milestone and SDD documents
- Add webhook HTTP receiver with idempotency checks
- Add config for webhook endpoints
- Implement gito events processing
- Add HTTP handlers and router updates
- Archive completed task files
2026-06-19 14:06:24 +09:00

205 lines
6.6 KiB
Go

// Package gitoevents consumes the Gito-provided proto-socket branch event
// contract (gito.forgejo-branch-events.v1) inside NomadCode core.
//
// 책임 경계: NomadCode core는 `branch.updated`를 wakeup signal로만 소비한다.
// `changed_files`는 roadmap sync 관련성 판단을 위한 힌트이며, target branch
// revision의 실제 검증/스캔은 후속 bridge(revision-scan)의 책임이다.
package gitoevents
import (
"fmt"
"strings"
"github.com/nomadcode/nomadcode-core/internal/protosocket"
)
const (
// EventChannel is the proto-socket channel used for Gito branch events.
EventChannel = "event"
// SubscribeAction is the action used to request a subscription.
SubscribeAction = "event.subscribe"
// BranchUpdatedAction is the broadcast event action emitted by Gito.
BranchUpdatedAction = "branch.updated"
// DefaultBranch is used when no branch is configured.
DefaultBranch = "develop"
)
// SubscribeRequest is the payload sent to Gito to subscribe to branch events.
type SubscribeRequest struct {
Events []string `json:"events"`
RepoID string `json:"repo_id"`
Branch string `json:"branch"`
}
// ChangedFile is a single file changed by a push, used only as a wakeup hint.
type ChangedFile struct {
Path string `json:"path"`
ChangeType string `json:"change_type"`
}
// BranchUpdatedEvent is the decoded `branch.updated` broadcast payload.
type BranchUpdatedEvent struct {
ID string `json:"id"`
Type string `json:"type"`
Provider string `json:"provider"`
DeliveryID string `json:"delivery_id"`
RepoID string `json:"repo_id"`
Branch string `json:"branch"`
Before string `json:"before"`
After string `json:"after"`
ChangedFiles []ChangedFile `json:"changed_files"`
ObservedAt string `json:"observed_at"`
CreatedAt string `json:"created_at"`
}
// BuildSubscribeEnvelope builds the `event.subscribe` request envelope for the
// given repo and branch. An empty branch falls back to DefaultBranch.
func BuildSubscribeEnvelope(id, repoID, branch string) protosocket.Envelope {
if branch == "" {
branch = DefaultBranch
}
return protosocket.Envelope{
ProtocolVersion: protosocket.ProtocolVersion,
ID: id,
Type: "request",
Channel: EventChannel,
Action: SubscribeAction,
Payload: map[string]any{
"events": []any{BranchUpdatedAction},
"repo_id": repoID,
"branch": branch,
},
}
}
// DecodeBranchUpdatedPayload decodes a `branch.updated` base payload into a
// BranchUpdatedEvent. It validates required fields (repo_id, branch) and the
// type field, and decodes optional fields without requiring them. This function
// has no envelope knowledge so it can be shared between proto-socket envelope
// and raw HTTP JSON body consumers.
//
// Constraints:
//
// - type must be "branch.updated" if present
// - repo_id and branch are required
// - changed_files must be a list of objects if present
func DecodeBranchUpdatedPayload(payload map[string]any) (BranchUpdatedEvent, error) {
if payload == nil {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: nil payload")
}
ev := BranchUpdatedEvent{}
ev.ID = stringField(payload, "id")
ev.Type = stringField(payload, "type")
ev.Provider = stringField(payload, "provider")
ev.DeliveryID = stringField(payload, "delivery_id")
ev.RepoID = stringField(payload, "repo_id")
ev.Branch = stringField(payload, "branch")
ev.Before = stringField(payload, "before")
ev.After = stringField(payload, "after")
ev.ObservedAt = stringField(payload, "observed_at")
ev.CreatedAt = stringField(payload, "created_at")
// Validate required fields
if ev.RepoID == "" {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing required field repo_id")
}
if ev.Branch == "" {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing required field branch")
}
// Validate type field if present
if ev.Type != "" && ev.Type != BranchUpdatedAction {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload type %q, want %q", ev.Type, BranchUpdatedAction)
}
// Decode changed_files (optional but must be valid if present)
files, err := decodeChangedFiles(payload["changed_files"])
if err != nil {
return BranchUpdatedEvent{}, err
}
ev.ChangedFiles = files
return ev, nil
}
// DecodeBranchUpdatedEnvelope validates the envelope channel/action and decodes
// its payload into a BranchUpdatedEvent. It rejects wrong channel/action and
// malformed payloads with a descriptive error instead of returning a partial
// event.
func DecodeBranchUpdatedEnvelope(env protosocket.Envelope) (BranchUpdatedEvent, error) {
if env.Channel != EventChannel {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: unexpected channel %q, want %q", env.Channel, EventChannel)
}
if env.Action != BranchUpdatedAction {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: unexpected action %q, want %q", env.Action, BranchUpdatedAction)
}
if env.Payload == nil {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: missing payload")
}
return DecodeBranchUpdatedPayload(env.Payload)
}
// IsTarget reports whether the event matches the watched repo and branch.
func (e BranchUpdatedEvent) IsTarget(repoID, branch string) bool {
if branch == "" {
branch = DefaultBranch
}
return e.RepoID == repoID && e.Branch == branch
}
// MilestoneChangedFiles returns the changed files that look like roadmap
// Milestone documents (`agent-roadmap/phase/<phase>/milestones/<slug>.md`).
// This is a wakeup-relevance hint only; revision verification is downstream.
func (e BranchUpdatedEvent) MilestoneChangedFiles() []ChangedFile {
var out []ChangedFile
for _, f := range e.ChangedFiles {
if isMilestonePath(f.Path) {
out = append(out, f)
}
}
return out
}
func isMilestonePath(path string) bool {
p := strings.TrimPrefix(path, "./")
if !strings.HasPrefix(p, "agent-roadmap/phase/") {
return false
}
if !strings.HasSuffix(p, ".md") {
return false
}
return strings.Contains(p, "/milestones/")
}
func decodeChangedFiles(raw any) ([]ChangedFile, error) {
if raw == nil {
return nil, nil
}
list, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("gitoevents: changed_files is not a list")
}
out := make([]ChangedFile, 0, len(list))
for i, item := range list {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("gitoevents: changed_files[%d] is not an object", i)
}
out = append(out, ChangedFile{
Path: stringField(m, "path"),
ChangeType: stringField(m, "change_type"),
})
}
return out, nil
}
func stringField(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}