- 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
205 lines
6.6 KiB
Go
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 ""
|
|
}
|