// 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, }, } } // 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) { var ev BranchUpdatedEvent if env.Channel != EventChannel { return ev, fmt.Errorf("gitoevents: unexpected channel %q, want %q", env.Channel, EventChannel) } if env.Action != BranchUpdatedAction { return ev, fmt.Errorf("gitoevents: unexpected action %q, want %q", env.Action, BranchUpdatedAction) } if env.Payload == nil { return ev, fmt.Errorf("gitoevents: missing payload") } ev.ID = stringField(env.Payload, "id") ev.Type = stringField(env.Payload, "type") ev.Provider = stringField(env.Payload, "provider") ev.DeliveryID = stringField(env.Payload, "delivery_id") ev.RepoID = stringField(env.Payload, "repo_id") ev.Branch = stringField(env.Payload, "branch") ev.Before = stringField(env.Payload, "before") ev.After = stringField(env.Payload, "after") ev.ObservedAt = stringField(env.Payload, "observed_at") ev.CreatedAt = stringField(env.Payload, "created_at") if ev.RepoID == "" { return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing repo_id") } if ev.Branch == "" { return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing branch") } files, err := decodeChangedFiles(env.Payload["changed_files"]) if err != nil { return BranchUpdatedEvent{}, err } ev.ChangedFiles = files return ev, nil } // 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 "" }