// 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//milestones/.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 "" }