diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md index 881151a..b71ca51 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md @@ -6,7 +6,7 @@ ## 목표 -로드맵 기반 agent-ops 운영 루프, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent 제어 표면을 Core action과 MCP tool 계층으로 분리한다. 현재는 Milestone Work Item Creation Sync를 진행중 마일스톤으로 두고, 실행 lifecycle은 계획 후보로 둔다. +로드맵 기반 agent-ops 운영 루프, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent 제어 표면을 Core action과 MCP tool 계층으로 분리한다. 현재는 Milestone Work Item Creation Sync를 검토중 완료 후보로 두고, Gito branch event bridge와 실행 lifecycle은 계획 후보로 둔다. ## Milestone 흐름 @@ -18,9 +18,9 @@ - 경로: `agent-roadmap/archive/phase/agent-ops-mcp-control-plane/milestones/workspace-port-env-standardization.md` - 요약: NomadCode client/core/compose/test runner 포트와 IOP Edge 연동 endpoint를 workspace 공통 대역과 remote runner 문서 기준으로 정리하고, remote code-server preview 정상 노출 확인 후 archive했다. -- [진행중] Milestone Work Item Creation Sync +- [검토중] Milestone Work Item Creation Sync - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` - - 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 마일스톤이다. 코드레벨 검토에서 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime sync orchestrator 누락이 확인되어 `creation-sync-orchestrator` 보완 계획을 진행한다. + - 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 마일스톤이다. `creation-sync-orchestrator` 완료 로그까지 반영되어 모든 기능 Task가 완료 후보가 되었고, 사용자 최종 확인과 archive 승인 대기 상태다. - [계획] Gito Branch Event Creation Sync Bridge - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md index a9e0d32..09f3a92 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md @@ -11,7 +11,7 @@ Plane-origin 경로로 Milestone을 생성하고, `develop` branch의 agent-road ## 상태 -[진행중] +[검토중] ## 구현 잠금 @@ -102,21 +102,21 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 - [x] [plane-todo] sync layer가 `develop` push 완료 또는 roadmap scan으로 감지한 Milestone을 기준으로 Plane 제목을 `[milestone-id] 제목` 형식으로 바꾸고 티켓을 `Todo`로 이동한다. 검증: milestone id는 `develop`의 Milestone 파일 slug를 기본값으로 쓰고, 실제 identity는 제목이 아니라 identity map의 Milestone path와 provider work item id로 추적된다. - [x] [creation-idmap] Milestone path, Milestone id, provider, work item id, provider revision, roadmap revision을 identity map으로 연결한다. 검증: 같은 Plane 티켓 또는 같은 Milestone path가 재처리되어도 같은 Milestone/work item pair를 찾을 수 있다. - [x] [creation-retry] 생성 동기화의 idempotency와 부분 실패 재시도 정책을 정한다. 검증: develop 반영, 댓글 보존, 본문 치환, 제목 변경, Todo 이동 중 일부만 성공해도 재시도 시 중복 생성 없이 남은 단계만 적용된다. -- [ ] [creation-sync-orchestrator] develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동을 하나의 runtime sync orchestrator로 연결한다. 검증: provider fake와 store fake 기반 테스트에서 not-ready는 provider 호출 없음, ready는 ledger 단계별 재시도와 Plane projection 순서를 보장한다. 계획: `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/` +- [x] [creation-sync-orchestrator] develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동을 하나의 runtime sync orchestrator로 연결한다. 검증: provider fake와 store fake 기반 테스트에서 not-ready는 provider 호출 없음, ready는 ledger 단계별 재시도와 Plane projection 순서를 보장한다. 계획: `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/` ## 완료 리뷰 -- 상태: 보완 필요 +- 상태: 요청됨 - 요청일: 2026-06-14 - 완료 근거: - - 코드레벨 검토 결과, 현재 Core에는 `roadmapsync` helper와 storage wrapper가 있으나 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime orchestrator/caller가 없다. - - `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check`는 PASS였지만 helper 미연결을 검출하지 못한다. - - 보완 계획은 `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`에 작성했다. + - `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/complete.log`의 `Roadmap Completion` 근거로 `[creation-sync-orchestrator]`를 완료 처리했다. + - 검증 근거는 `roadmapsyncpipeline.Service`의 develop match -> identity/step ledger -> Plane projection 연결, optional `RoadmapCreationSyncWorker`, `EnqueueRoadmapCreationSync` seam, `cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다. + - Gito proto-socket `branch.updated` backend bridge는 후속 `Gito Branch Event Creation Sync Bridge` Milestone으로 분리했고, 이 마일스톤의 기능 Task는 모두 완료 후보 상태다. - 리뷰 필요: - - [ ] `creation-sync-orchestrator` 계획이 PASS되고 runtime caller가 검증되었다 + - [x] `creation-sync-orchestrator` 계획이 PASS되고 runtime caller가 검증되었다 - [ ] 이후 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 코드레벨 검토 결과 runtime sync orchestrator 누락이 확인되어 완료/아카이브를 보류한다. +- 리뷰 코멘트: 모든 기능 Task가 evidence와 함께 완료 후보가 되었으므로 사용자 최종 확인과 archive 승인 대기 상태로 전환한다. ## 범위 제외 @@ -161,8 +161,9 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 - Creation sync 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `workitem.BuildCreateTaskInput`, `authoring.BuildAuthoringGenerateInput`, scheduler authoring route, `roadmapsync.MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, roadmap sync identity/step store, `ReconcileCreationCycle` 근거로 `[plane-agent-authoring]`, `[develop-truth]`, `[pushed-milestone-match]`, `[plane-preserve]`, `[plane-todo]`, `[creation-idmap]`, `[creation-retry]`를 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/authoring ./internal/roadmapsync ./internal/adapters/plane ./internal/storage ./internal/http ./internal/scheduler` PASS, `git diff --check` PASS. 당시 남은 `[plane-trigger]`, `[plane-draft]`는 후속 완료 로그 기준으로 아래 현황 동기화에서 닫았다. - Creation trigger/draft 현황 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/09_creation_trigger_gate/complete.log`의 `Roadmap Completion` 근거로 `[plane-trigger]`를 완료 처리했다. 검증 근거는 ignored trigger를 workspace provision/slot reservation 전에 판정하는 gate, legacy Plane route trigger 전달 테스트, `cd services/core && go test -count=1 ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `git diff --check` PASS다. `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/complete.log`의 `Roadmap Completion` 근거로 `[plane-draft]`를 완료 처리했다. 검증 근거는 full provider identity context 보존, idempotent Milestone draft instructions, scheduler authoring route regression, `cd services/core && go test -count=1 ./internal/authoring ./internal/roadmapsync ./internal/adapters/openai ./internal/scheduler`, `cd services/core && go test -count=1 ./...`, `git diff --check` PASS다. - 완료 보완 리뷰(2026-06-14): 현재 Core 코드에는 `roadmapsync` helper와 저장소는 있으나 이를 호출해 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime sync orchestrator가 없다. 보완 계획: `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`. -- 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, authoring run 성공/실패 판정, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry는 helper와 단위 경계 완료 근거가 있다. runtime orchestrator 연결은 `creation-sync-orchestrator` 완료 전까지 미완료다. -- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위의 완료 판정은 `creation-sync-orchestrator` 완료 후 재검토하며, `In Progress` 이후 실행은 후속 잠금 범위다. +- Creation sync orchestrator 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/complete.log`의 `Roadmap Completion` 근거로 `[creation-sync-orchestrator]`를 완료 처리했다. 검증 근거는 `roadmapsyncpipeline.Service`, optional `RoadmapCreationSyncWorker`, `EnqueueRoadmapCreationSync` seam, `cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다. +- 검토 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, authoring run 성공/실패 판정, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry, runtime orchestrator 연결까지 완료 후보 근거가 있다. 사용자 최종 확인과 archive 승인 전까지 `[검토중]`으로 유지한다. +- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위는 완료 후보이며, `In Progress` 이후 실행은 후속 잠금 범위다. - 후속 잠금: `Todo -> In Progress` 이후 실행 lifecycle은 사용자가 명시적으로 잠금을 해제할 때까지 진행하지 않는다. - Plane work item 경계: 이 마일스톤에서 말하는 Plane Milestone은 Plane native milestone object가 아니라 1상위 티켓 = 1 agent-roadmap Milestone으로 보는 work item mapping이다. - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/code_review_cloud_G07_0.log new file mode 100644 index 0000000..9568bc9 --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/code_review_cloud_G07_0.log @@ -0,0 +1,66 @@ + + +# Code Review: Gito Branch Event Client + +## 리뷰 범위 +- Roadmap milestone: `gito-branch-event-creation-sync-bridge` +- Task IDs: + - `[contract-consume]` + - `[core-client]` + - `[branch-filter]` +- Plan: `PLAN-cloud-G07.md` + +## 구현 항목별 완료 여부 +- [x] Gito branch event 계약 소비 메모 보강 — `packages/contracts/notes/flutter-core-api-candidates.md` 1.8을 Flutter client wakeup(1.8.1)과 Core backend bridge(1.8.2)로 분리. +- [x] Core `gitoevents` DTO/파서 추가 — `services/core/internal/gitoevents/events.go` (`BranchUpdatedEvent`, `BuildSubscribeEnvelope`, `DecodeBranchUpdatedEnvelope`, `IsTarget`, `MilestoneChangedFiles`). +- [x] Core proto-socket event client seam 추가 — `services/core/internal/gitoevents/client.go` (`Transport` interface, `Client.Run`). +- [x] Gito endpoint/repo/branch 설정 추가 — `services/core/internal/config/config.go` (`GitoProtoSocketURL`, `GitoRepoID`, `GitoBranch`, `GitoBranchEventsEnabled()`). +- [x] branch/repo/path 필터 테스트 추가 — `events_test.go` (`IsTarget`, `MilestoneChangedFiles`, wrong action/channel/malformed reject). +- [x] client subscribe/dispatch 테스트 추가 — `client_test.go` (fake transport subscribe envelope 검증, on-target handler forward, off-target/malformed drop). +- [x] 전체 검증 통과 — 아래 검증 명령 4개 모두 통과. + +## 리뷰 체크리스트 +- [x] `branch.updated`가 wakeup-only semantics로만 사용되는가 — client는 decode된 event를 handler로 전달만 하고, Plane/roadmap mutation을 수행하지 않음. revision 검증은 후속 bridge로 분리(events.go 패키지 doc, 1.8.2 메모). +- [x] `changed_files`를 최종 truth로 신뢰하지 않는가 — `MilestoneChangedFiles`는 wakeup 관련성 힌트로만 노출되며, 동기화 결정 근거로 쓰지 않음. 계약 메모 1.8.2에 명시. +- [x] repo/branch/channel/action mismatch가 enqueue path로 흐르지 않는가 — `handleEnvelope`에서 wrong channel/action, off-target repo/branch, malformed payload는 log path로 처리하고 handler에 넘기지 않음. `TestClientDropsOffTargetAndMalformedEvents`로 고정. +- [x] endpoint 미설정 시 기존 Core 동작이 바뀌지 않는가 — config 필드 추가만 했고 server/scheduler wiring은 이 G07 범위 밖. `GitoBranchEventsEnabled()`는 endpoint+repo 모두 있어야 true. `TestConfigGitoBranchDefaultsAndDisabled`로 고정. +- [x] 계약 문서에 Flutter client 소비와 Core backend bridge 책임이 분리되어 있는가 — 1.8.1 / 1.8.2로 분리. + +## 검증 명령 +- [x] `cd services/core && go test -count=1 ./internal/gitoevents ./internal/config ./internal/protosocket` — ok (3 packages) +- [x] `cd services/core && go test -count=1 ./...` — ok (전체 통과) +- [x] `cd services/core && go vet ./...` — clean +- [x] `git diff --check` — clean + +## 리뷰 결과 +- Status: PASS +- Findings: + - 차단 결함 없음. 3개 Task(`[contract-consume]`, `[core-client]`, `[branch-filter]`) 구현과 검증이 plan 완료 조건을 충족함. + - 범위 메모: production `Transport`(실제 websocket 연결)과 server/scheduler lifecycle wiring, revision-scan/creation-enqueue는 후속 Task(`revision-scan`, `creation-enqueue`, `runtime-wiring`) 책임이며 본 G07 범위 밖. + - 사용자 확인 대기: Gito 설정명(`GITO_PROTO_SOCKET_URL` / `GITO_REPO_ID` / `GITO_BRANCH`)이 운영 환경에 맞는지, wakeup-only + 후속 revision 검증 분리가 의도와 맞는지. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 `complete.log` 작성 후 task directory를 archive로 이동한다. `m-gito-branch-event-creation-sync-bridge` 완료 이벤트 메타데이터를 보고하고, roadmap 수정은 런타임 책임으로 남긴다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_0.log`로 아카이브한다. +- [x] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_0.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이므로 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이므로 active task directory `agent-task/m-gito-branch-event-creation-sync-bridge/01_event_client/`를 `agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/`로 이동한다. +- [x] PASS이고 task group이 `m-gito-branch-event-creation-sync-bridge`이므로 런타임이 읽을 완료 이벤트 메타데이터를 보고 대상으로 정리하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이며 active parent `agent-task/m-gito-branch-event-creation-sync-bridge/`는 sibling `02+01_creation_enqueue_bridge`가 남아 있어 유지한다. diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/complete.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/complete.log new file mode 100644 index 0000000..83ad5b4 --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/complete.log @@ -0,0 +1,46 @@ +# Complete - m-gito-branch-event-creation-sync-bridge/01_event_client + +## 완료 일시 + +2026-06-14 + +## 요약 + +Gito branch event client 계약 메모, Core DTO/parser/client seam, 설정, 테스트를 1회 리뷰 루프로 검토했고 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | 계획된 계약 소비 메모, Core `gitoevents` DTO/parser/client seam, config, 테스트가 통과했고 Required/Suggested 이슈 없음 | + +## 구현/정리 내용 + +- `packages/contracts/notes/flutter-core-api-candidates.md`의 Gito branch event consumer 메모를 Flutter wakeup과 Core backend bridge 책임으로 분리했다. +- `services/core/internal/gitoevents`에 `branch.updated` subscribe envelope, typed decode, repo/branch/path filter, transport-injected client seam을 추가했다. +- `services/core/internal/config`에 Gito proto-socket endpoint/repo/branch 설정과 enable 판정을 추가했다. +- 계약 fixture, wrong channel/action/malformed/off-target drop, config default/override를 테스트로 고정했다. + +## 최종 검증 + +- `go test -count=1 ./internal/gitoevents ./internal/config ./internal/protosocket` - PASS; `internal/gitoevents`, `internal/config`, `internal/protosocket` 모두 ok. +- `go test -count=1 ./...` - PASS; `services/core` 전체 패키지 테스트 통과. +- `go vet ./...` - PASS; 출력 없음. +- `git diff --check` - PASS; 출력 없음. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` +- Completed task ids: + - `[contract-consume]`: PASS; evidence=`agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/code_review_cloud_G07_0.log`; verification=`git diff --check` + - `[core-client]`: PASS; evidence=`agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/code_review_cloud_G07_0.log`; verification=`go test -count=1 ./internal/gitoevents ./internal/config ./internal/protosocket`, `go test -count=1 ./...`, `go vet ./...` + - `[branch-filter]`: PASS; evidence=`agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/code_review_cloud_G07_0.log`; verification=`go test -count=1 ./internal/gitoevents ./internal/config ./internal/protosocket`, `go test -count=1 ./...` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/plan_cloud_G07_0.log new file mode 100644 index 0000000..06e99b8 --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/01_event_client/plan_cloud_G07_0.log @@ -0,0 +1,112 @@ + + +# Plan: Gito Branch Event Client + +## 메타 +- Milestone: `gito-branch-event-creation-sync-bridge` +- Build grade: cloud-G07 +- Mode: implementation +- Scope: Core가 Gito proto-socket `branch.updated` 이벤트를 소비할 수 있는 계약/클라이언트/필터 계층을 추가한다. + +## Roadmap Targets +- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` +- Task IDs: + - `[contract-consume]` + - `[core-client]` + - `[branch-filter]` +- Completion mode: check-on-pass + +## 문제 요약 +현재 `Milestone Work Item Creation Sync` 구현은 `EnqueueRoadmapCreationSync` 이후의 worker/orchestrator 경로는 존재하지만, Gito `branch.updated` 이벤트를 Core가 받아 enqueue 후보로 넘기는 백엔드 소비자가 없다. + +확인된 코드 레벨 근거: +- [services/core/internal/config/config.go](/config/workspace/nomadcode/services/core/internal/config/config.go:8): Core 설정에 Gito proto-socket endpoint/repo/branch consumer 설정이 없다. +- [services/core/internal/scheduler/roadmap_sync_jobs.go](/config/workspace/nomadcode/services/core/internal/scheduler/roadmap_sync_jobs.go:18): creation sync job은 내부 develop-scan handoff가 enqueue한다고 가정하지만, 해당 handoff의 Gito 이벤트 입력이 없다. +- [packages/contracts/notes/flutter-core-api-candidates.md](/config/workspace/nomadcode/packages/contracts/notes/flutter-core-api-candidates.md:164): 현재 문서는 Flutter client wakeup 소비를 설명하며, Core backend bridge와 Plane/roadmap mutation 책임을 분리해 적어야 한다. + +## 관련 계약 +- Provider: `/config/workspace/gito/agent-contract/provided/gito-forgejo-branch-events-v1.md` +- Consumed protocol: + - `event.subscribe` + - `branch.updated` + - filter: `repo_id`, `branch` + - `changed_files`는 wakeup 힌트이며, 실제 target branch revision 검증은 후속 브리지에서 수행한다. + +## 구현 계획 + +### 1. 계약 소비 메모 보강 +- [packages/contracts/notes/flutter-core-api-candidates.md](/config/workspace/nomadcode/packages/contracts/notes/flutter-core-api-candidates.md:164)에 Core backend consumer 메모를 추가한다. +- 기존 Flutter client wakeup 설명은 유지한다. +- Core backend bridge는 다음을 명시한다. + - Gito 제공 계약 `gito.forgejo-branch-events.v1` 소비. + - `branch.updated`는 enqueue wakeup으로만 사용. + - `changed_files` 자체를 신뢰해 동기화하지 않고 target branch revision 확인은 backend bridge 책임. + +### 2. Core 이벤트 DTO/파서 추가 +- 새 패키지 후보: `services/core/internal/gitoevents` +- `events.go` + - `SubscribeRequest` + - `ChangedFile` + - `BranchUpdatedEvent` + - `BuildSubscribeEnvelope(repoID, branch string) protosocket.Envelope` + - `DecodeBranchUpdatedEnvelope(env protosocket.Envelope) (BranchUpdatedEvent, error)` + - `IsTarget(repoID, branch string) bool` + - `MilestoneChangedFiles() []ChangedFile` +- 기존 [services/core/internal/protosocket/envelope.go](/config/workspace/nomadcode/services/core/internal/protosocket/envelope.go:8)의 `Envelope`와 `ProtocolVersion`을 사용한다. + +### 3. Core proto-socket event client seam 추가 +- `client.go` + - `Transport` interface: connect/read/write/close를 테스트 가능하게 분리. + - `Runner` 또는 `Client`: 시작 시 `event.subscribe` 전송, 이후 `branch.updated` envelope를 handler로 전달. + - wrong channel/action, wrong repo/branch, malformed payload는 handler로 넘기지 않고 관찰 가능한 error/log path로 처리. +- production transport는 이미 의존 중인 `nhooyr.io/websocket`과 `git.toki-labs.com/toki/proto-socket/go` 계열을 우선 검토한다. 새 의존성은 추가하지 않는다. + +### 4. 설정 추가 +- [services/core/internal/config/config.go](/config/workspace/nomadcode/services/core/internal/config/config.go:8) + - `GitoProtoSocketURL` + - `GitoRepoID` + - `GitoBranch` + - `GitoBranchEventsEnabled` 또는 endpoint 기반 enable 판정 +- 기본값: + - endpoint empty이면 disabled. + - branch는 비어 있으면 `develop`. + - repo id는 명시 설정 없이는 subscribe하지 않는다. + +### 5. 테스트 +- `services/core/internal/gitoevents/events_test.go` + - Gito 계약 fixture로 `event.subscribe` payload 검증. + - `branch.updated` fixture parse 검증. + - wrong action/channel reject. + - wrong repo/branch reject. + - milestone markdown path filter 검증. +- `services/core/internal/gitoevents/client_test.go` + - fake transport가 subscribe envelope를 받는지 검증. + - fake event stream이 handler로 `BranchUpdatedEvent`를 전달하는지 검증. +- `services/core/internal/config/config_test.go` + - env load/default/disabled 동작 검증. + +## 완료 조건 +- Core 내부에 Gito branch event client와 parser가 있고, 후속 enqueue bridge가 typed event를 받을 수 있다. +- Core 설정으로 endpoint/repo/branch를 제어할 수 있다. +- Flutter client wakeup 문서와 Core backend bridge 책임이 계약 메모에서 구분된다. +- 아래 검증이 통과한다. + - `cd services/core && go test -count=1 ./internal/gitoevents ./internal/config ./internal/protosocket` + - `cd services/core && go test -count=1 ./...` + - `cd services/core && go vet ./...` + - `git diff --check` + +## 구현 후 사용자 리뷰 요청 + +아래 내용을 포함해 사용자 리뷰를 요청한다. + +```markdown +## 사용자 리뷰 요청 + +- 구현 요약: + - Core Gito branch event client/DTO/filter 계층 추가 + - Gito 제공 계약 소비 메모 보강 + - 설정 및 테스트 추가 +- 확인 요청: + - Gito endpoint/repo_id/branch 설정명이 운영 환경에 맞는지 + - `branch.updated`를 wakeup-only로 소비하고 revision 검증을 후속 bridge에서 수행하는 분리가 맞는지 +``` diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/code_review_cloud_G07_0.log new file mode 100644 index 0000000..5433ace --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/code_review_cloud_G07_0.log @@ -0,0 +1,72 @@ + + +# Code Review: Gito Creation Enqueue Bridge + +## 리뷰 범위 +- Roadmap milestone: `gito-branch-event-creation-sync-bridge` +- Task IDs: + - `[revision-scan]` + - `[creation-enqueue]` + - `[runtime-wiring]` +- Plan: `PLAN-cloud-G07.md` + +## 선행 의존성 +- [x] `internal/gitoevents` (01_event_client) 구현됨: client.go/events.go + 테스트 통과 + +## 구현 항목별 완료 여부 +- [x] target branch revision scanner 추가 (`internal/gitosync/scanner.go`) +- [x] milestone markdown scan result 변환 추가 (scanner + `ParseMilestoneIdentity`) +- [x] Plane work item 조회 및 job args 구성 추가 (`internal/gitosync/bridge.go`) +- [x] `EnqueueRoadmapCreationSync` bridge 추가 (`Enqueuer` seam) +- [x] duplicate/same revision guard 추가 (`InMemoryRevisionStore`) +- [x] server runtime wiring 추가 (`cmd/server/main.go` + `gitosync/runner.go`) +- [x] scanner/bridge/config 테스트 추가 +- [x] 전체 검증 통과 + +## 리뷰 체크리스트 +- [x] Gito `changed_files`를 wakeup hint로만 사용하고 target revision에서 다시 검증하는가 (scanner가 fetch/rev-parse 후 `git diff`/`git show`로 재검증) +- [x] event `after`와 remote target revision mismatch 시 enqueue하지 않는가 (`ErrNotReady`) +- [x] provider identity가 없는 milestone을 잘못 enqueue하지 않는가 (scanner가 Docs에서 제외) +- [x] endpoint/todo-state-id 미설정 시 기존 server 동작이 유지되는가 (`GitoBranchEventsEnabled` 게이트) +- [x] 중복 이벤트가 같은 revision job을 반복 enqueue하지 않는가 (`InMemoryRevisionStore`) + +## 검증 명령 +- [x] `cd services/core && go test -count=1 ./internal/gitoevents ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler ./internal/config` +- [x] `cd services/core && go test -count=1 ./...` +- [x] `cd services/core && go vet ./...` +- [x] `git diff --check` + +## 리뷰 결과 +- Status: implemented; pending user review +- Findings: 없음 (전체 검증 통과) + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Pass + - code quality: Pass + - plan deviation: Fail + - verification trust: Pass +- 발견된 문제: + - Required: `services/core/internal/gitosync/bridge.go:156`에서 Plane work item의 현재 `State`를 `ProviderRevision`으로 저장하고 있습니다. `roadmapsync.ReconcileCreationCycle`는 기존 identity가 있으면 저장된 `ProviderRevision`과 새 trigger의 `ProviderRevision` mismatch를 conflict로 처리한 뒤 completed ledger 확인에 도달합니다(`services/core/internal/roadmapsync/retry.go:132`). 따라서 첫 sync가 Plane state를 Todo로 옮긴 뒤 프로세스 재시작/메모리 dedup 유실 상태에서 같은 `(repo, branch, after)` 이벤트를 다시 받으면, bridge가 새로 fetch한 `item.State`는 Todo이고 기존 row의 `provider_revision`은 이전 state라서 이미 완료된 ledger라도 `ActionComplete`가 아니라 conflict가 됩니다. 실제 provider revision token이 없는 현재 bridge에서는 `ProviderRevision`을 비워 두거나 안정적인 provider revision 필드를 도입하고, 같은 roadmap revision 재처리가 completed ledger로 `complete`가 되는 테스트를 추가해야 합니다. +- 다음 단계: + - FAIL 후속: `ProviderRevision`에 mutable Plane state를 넣지 않도록 bridge/job args를 수정하고 재처리 idempotency 테스트를 추가한다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_0.log`로 아카이브한다. +- [x] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_0.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/code_review_cloud_G07_1.log new file mode 100644 index 0000000..e332315 --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/code_review_cloud_G07_1.log @@ -0,0 +1,162 @@ + + +# Code Review Reference - REVIEW_GITO_CREATION_ENQUEUE + +> **[IMPLEMENTING AGENT - READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only. + +## 개요 + +date=2026-06-14 +task=m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge, plan=1, tag=REVIEW_GITO_CREATION_ENQUEUE + +## Roadmap Targets +- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` +- Task IDs: + - `[revision-scan]` + - `[creation-enqueue]` + - `[runtime-wiring]` +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_GITO_CREATION_ENQUEUE-1] ProviderRevision 안정화 | [x] | + +## 구현 체크리스트 + +- [x] `services/core/internal/gitosync/bridge.go`에서 `ProviderRevision`에 Plane `State`를 전달하지 않는다. +- [x] `services/core/internal/gitosync/bridge_test.go`에 Plane state가 `ProviderRevision`으로 매핑되지 않는 회귀 테스트를 추가하거나 기존 exact args 테스트에 명시 assertion을 더한다. +- [x] 같은 roadmap revision 재처리는 mutable provider state가 달라도 revision conflict를 만들지 않고, 다른 roadmap revision은 기존 `RoadmapRevision` guard로 보호된다는 의도를 코드/테스트에서 확인한다. +- [x] `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [x] `cd services/core && go test -count=1 ./...`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [x] `cd services/core && go vet ./...`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [x] `git diff --check`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [x] `CODE_REVIEW-cloud-G07.md`의 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 모두 채운다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_1.log`로 아카이브한다. +- [x] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_1.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- 없음. 계획(`REVIEW_GITO_CREATION_ENQUEUE-1`) 범위 그대로, `bridge.go`의 job args 구성과 `bridge_test.go`만 수정했다. scanner git flow, server wiring, Gito event DTO, public HTTP API는 건드리지 않았다. + +## 주요 설계 결정 + +- `Bridge.enqueueDoc`에서 `ProviderRevision`을 비워 두고, mutable Plane state를 revision으로 쓰지 않는 이유를 코드 주석으로 남겼다. 근거: `roadmapsync.mismatch`는 빈 값(unknown)을 conflict로 보지 않으므로(`retry.go`), `ProviderRevision`이 비면 같은 develop revision 재처리가 provider-revision conflict를 만들지 않는다. 반면 `item.State`를 넣으면 sync가 state를 Todo로 옮긴 뒤 재처리 시 backlog→todo로 값이 달라져 completed ledger 확인 전에 `reasonProviderRevMismatch` conflict가 발생한다. +- `RoadmapRevision`은 develop revision 그대로 유지해, 다른 develop revision 재처리에 대한 기존 conflict guard(`reasonRoadmapRevMismatch`)를 약화시키지 않았다. +- 테스트는 (1) 기존 exact-args 테스트에 `ProviderRevision == ""` assertion 추가, (2) reconcile 계약을 직접 고정하는 회귀 테스트 추가 — 같은 develop revision 재처리는 비-conflict, 다른 develop revision은 `RoadmapRevision` guard로 conflict. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `ProviderRevision`이 빈 값이면 `roadmapsync.mismatch`가 conflict를 만들지 않는 기존 계약과 맞는가. +- `RoadmapRevision`은 그대로 유지되어 다른 develop revision 재처리 conflict guard가 약해지지 않는가. +- 테스트가 mutable Plane state를 revision으로 쓰지 않는 계약을 직접 검증하는가. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### REVIEW_GITO_CREATION_ENQUEUE-1 중간 검증 +``` +$ cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler +ok github.com/nomadcode/nomadcode-core/internal/gitosync 0.004s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.014s +``` + +### 최종 검증 +``` +$ cd services/core && go test -count=1 ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.003s +? github.com/nomadcode/nomadcode-core/cmd/server [no test files] +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.007s +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.007s +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.006s +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.008s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.010s +ok github.com/nomadcode/nomadcode-core/internal/authoring 0.005s +ok github.com/nomadcode/nomadcode-core/internal/config 0.002s +ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.006s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 0.005s +ok github.com/nomadcode/nomadcode-core/internal/http 0.005s +ok github.com/nomadcode/nomadcode-core/internal/notification 0.002s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.008s +ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.013s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.016s +ok github.com/nomadcode/nomadcode-core/internal/storage 0.007s +ok github.com/nomadcode/nomadcode-core/internal/workflow 0.006s +ok github.com/nomadcode/nomadcode-core/internal/workitem 0.004s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s + +$ cd services/core && go vet ./... +(no output; vet clean) + +$ git diff --check +(no output; diff --check clean) +``` + +--- + +> **[IMPLEMENTING AGENT - BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: + - PASS: `complete.log` 작성 후 task 디렉터리를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/complete.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/complete.log new file mode 100644 index 0000000..89c4ebc --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/complete.log @@ -0,0 +1,46 @@ +# Complete - m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge + +## 완료 일시 + +2026-06-14 + +## 요약 + +Gito branch event creation enqueue bridge는 2회 리뷰 루프를 거쳐 PASS로 완료됐다. 1차 리뷰의 `ProviderRevision` idempotency 문제를 후속 루프에서 수정하고 검증했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | Plane의 mutable `State`를 `ProviderRevision`으로 저장해 같은 develop revision 재처리가 completed ledger 확인 전에 conflict가 날 수 있는 Required 이슈 발견 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | `ProviderRevision`을 비워 안정화하고, `RoadmapRevision` guard와 회귀 테스트로 재처리 idempotency를 고정 | + +## 구현/정리 내용 + +- Gito `branch.updated` 이벤트를 target branch revision scan, milestone markdown parse, Plane work item 조회, `EnqueueRoadmapCreationSync` job 구성으로 연결했다. +- `ProviderRevision`에 mutable Plane state를 넣지 않도록 정리해 같은 develop revision 재처리에서 provider revision conflict가 발생하지 않게 했다. +- scanner/bridge/config/reconcile 관련 테스트와 전체 core 검증을 통과했다. + +## 최종 검증 + +- `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler` - PASS; `internal/gitosync`, `internal/roadmapsync`, `internal/roadmapsyncpipeline`, `internal/scheduler` 모두 ok. +- `cd services/core && go test -count=1 ./...` - PASS; `services/core` 전체 패키지 테스트 통과. +- `cd services/core && go vet ./...` - PASS; 출력 없음. +- `git diff --check` - PASS; 출력 없음. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` +- Completed task ids: + - `[revision-scan]`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler`, `cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check` + - `[creation-enqueue]`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler`, `cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check` + - `[runtime-wiring]`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler`, `cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/plan_cloud_G07_0.log new file mode 100644 index 0000000..3bb1396 --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/plan_cloud_G07_0.log @@ -0,0 +1,109 @@ + + +# Plan: Gito Creation Enqueue Bridge + +## 메타 +- Milestone: `gito-branch-event-creation-sync-bridge` +- Build grade: cloud-G07 +- Mode: implementation +- Scope: Gito `branch.updated` 이벤트를 target branch revision 검증, milestone markdown scan, Plane work item 조회, `EnqueueRoadmapCreationSync` 호출로 연결한다. + +## 분할 판단 +- 선행 작업: `01_event_client` +- 선행 완료 조건: `agent-task/m-gito-branch-event-creation-sync-bridge/01_event_client/complete.log` +- 현재 상태: 선행 계획은 존재하지만 아직 구현 완료 전이다. 이 계획은 후속 구현 계획으로 남기며, 실제 구현은 선행 이벤트 클라이언트가 완료된 뒤 진행한다. + +## Roadmap Targets +- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` +- Task IDs: + - `[revision-scan]` + - `[creation-enqueue]` + - `[runtime-wiring]` +- Completion mode: check-on-pass + +## 문제 요약 +Core에는 creation sync worker와 enqueue 함수가 있지만, Gito 이벤트에서 자동으로 job args를 구성해 enqueue하는 bridge가 없다. + +확인된 코드 레벨 근거: +- [services/core/internal/scheduler/river.go](/config/workspace/nomadcode/services/core/internal/scheduler/river.go:99): `EnqueueRoadmapCreationSync`는 존재하지만 외부 이벤트 wakeup producer가 없다. +- [services/core/internal/roadmapsyncpipeline/service.go](/config/workspace/nomadcode/services/core/internal/roadmapsyncpipeline/service.go:113): `SyncCreation`은 scan result와 expected Plane todo args를 요구하므로, 이벤트에서 이 입력을 구성하는 계층이 필요하다. +- [services/core/cmd/server/main.go](/config/workspace/nomadcode/services/core/cmd/server/main.go:103): scheduler와 creation sync service는 wiring되어 있지만 Gito event runner는 없다. + +## 구현 계획 + +### 1. Revision scan seam 추가 +- 패키지 후보: `services/core/internal/gitosync` +- `BranchRevisionScanner` + - 입력: `gitoevents.BranchUpdatedEvent` + - 설정: develop branch checkout path, remote name, target branch + - 출력: `roadmapsync.ScanResult` 후보 목록 +- `CommandRunner` seam을 둬 git command를 테스트 가능하게 만든다. +- 권장 git 흐름: + - `git -C fetch --prune origin develop` + - `git -C rev-parse refs/remotes/origin/develop` + - remote revision이 event `after`와 다르면 not-ready로 skip + - `git -C diff --name-only -- agent-roadmap/phase/*/milestones/*.md` + - `git -C show :`로 milestone markdown을 읽고 [services/core/internal/roadmapsync/milestone_parser.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/milestone_parser.go:1)의 parser를 사용한다. +- Gito `changed_files`는 scan 시작 여부를 줄이는 hint로만 사용하고 최종 변경 파일은 target revision에서 다시 검증한다. + +### 2. Creation enqueue bridge 추가 +- `Bridge` 또는 `Handler` + - Gito event target repo/branch/path 검증. + - scanner로 milestone candidates 구성. + - milestone identity에서 provider key/ref를 읽는다. + - [services/core/internal/workitem/provider.go](/config/workspace/nomadcode/services/core/internal/workitem/provider.go:1)의 reader/Plane client 계층으로 기존 Plane work item을 조회한다. + - original body는 HTML description 우선, 없으면 text description fallback을 사용한다. + - Todo state id는 설정에서 주입한다. 예: `PLANE_TODO_STATE_ID` 또는 `ROADMAP_CREATION_TODO_STATE_ID`. + - `scheduler.RoadmapCreationSyncJobArgs`를 구성해 [services/core/internal/scheduler/river.go](/config/workspace/nomadcode/services/core/internal/scheduler/river.go:99)의 enqueue 함수를 호출한다. +- duplicate/same revision 처리: + - MVP에서는 bridge 내부 `ProcessedRevisionStore` seam을 둔다. + - `(repo_id, branch, after)`가 이미 처리된 경우 enqueue하지 않는다. + - 실제 sync step은 기존 identity/ledger idempotency를 함께 사용한다. + +### 3. Runtime wiring +- [services/core/cmd/server/main.go](/config/workspace/nomadcode/services/core/cmd/server/main.go:103) + - config에 Gito endpoint/repo/branch/todo-state-id가 모두 있을 때만 event runner를 시작한다. + - `context.Context` 종료와 함께 runner를 stop한다. + - endpoint 미설정이면 기존 Core server 동작은 동일해야 한다. +- public HTTP API는 추가하지 않는다. + +### 4. 테스트 +- `services/core/internal/gitosync/scanner_test.go` + - fake command runner로 fetch/rev-parse/diff/show 순서 검증. + - remote revision이 event `after`와 다르면 enqueue 후보 없음. + - milestone markdown parser 결과가 scan result로 변환되는지 검증. +- `services/core/internal/gitosync/bridge_test.go` + - wrong repo/branch/path는 enqueue하지 않음. + - provider identity 없는 milestone은 skip 또는 명시 error 처리. + - Plane work item 조회 결과로 exact `RoadmapCreationSyncJobArgs` 생성. + - same `(repo_id, branch, after)` 이벤트는 중복 enqueue하지 않음. +- `services/core/internal/config/config_test.go` + - Todo state id env/default/disabled 동작 검증. + +## 완료 조건 +- Gito `branch.updated`가 Core creation sync enqueue로 연결된다. +- target branch revision 확인 후 milestone markdown을 스캔한다. +- Plane original body/state 기반으로 creation sync job args를 구성한다. +- endpoint 또는 todo-state-id 미설정 시 기존 runtime은 disabled 상태로 유지된다. +- 아래 검증이 통과한다. + - `cd services/core && go test -count=1 ./internal/gitoevents ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler ./internal/config` + - `cd services/core && go test -count=1 ./...` + - `cd services/core && go vet ./...` + - `git diff --check` + +## 구현 후 사용자 리뷰 요청 + +아래 내용을 포함해 사용자 리뷰를 요청한다. + +```markdown +## 사용자 리뷰 요청 + +- 구현 요약: + - Gito branch event에서 target branch revision scan 수행 + - milestone markdown을 `roadmapsync.ScanResult`로 변환 + - Plane work item 조회 후 `EnqueueRoadmapCreationSync` 연결 + - runtime 설정 기반 runner wiring +- 확인 요청: + - Todo state id 설정명이 운영 배포 방식에 맞는지 + - 중복 이벤트 처리를 MVP in-memory revision guard로 시작해도 되는지 +``` diff --git a/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/plan_cloud_G07_1.log new file mode 100644 index 0000000..0deab8e --- /dev/null +++ b/agent-task/archive/2026/06/m-gito-branch-event-creation-sync-bridge/02+01_creation_enqueue_bridge/plan_cloud_G07_1.log @@ -0,0 +1,58 @@ + + +# Plan: Review Gito Creation Enqueue Bridge + +## 메타 +- Milestone: `gito-branch-event-creation-sync-bridge` +- Build grade: cloud-G07 +- Mode: review-follow-up +- Scope: `ProviderRevision`에 mutable Plane state를 넣어 durable idempotency가 conflict로 깨지는 문제를 수정한다. + +## Roadmap Targets +- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md` +- Task IDs: + - `[revision-scan]` + - `[creation-enqueue]` + - `[runtime-wiring]` +- Completion mode: check-on-pass + +## 범위 결정 근거 +- 이전 리뷰 로그: `code_review_cloud_G07_0.log` +- Required 이슈는 `services/core/internal/gitosync/bridge.go`의 job args 구성과 이를 고정하는 테스트에 한정된다. +- public HTTP API, Gito event DTO, scanner git command flow, server wiring은 이번 후속 범위에서 바꾸지 않는다. + +## 구현 지시 +- 구현 중 사용자에게 직접 질문하거나 선택지를 제시하지 않는다. +- 사용자만 결정할 수 있는 blocker가 실제로 생기면 `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션을 채우고 active 파일을 둔 채 중단한다. +- 검증 증거 부족은 사용자 리뷰 요청이 아니라 후속 에이전트가 재실행할 수 있는 일반 구현/검증 작업으로 처리한다. + +## 구현 계획 + +### REVIEW_GITO_CREATION_ENQUEUE-1. ProviderRevision 안정화 +- 문제: `Bridge.enqueueDoc`가 `workitem.WorkItem.State`를 `scheduler.RoadmapCreationSyncJobArgs.ProviderRevision`에 넣는다. Sync가 Plane state를 Todo로 이동한 뒤 같은 roadmap revision이 재처리되면 기존 identity의 provider revision과 새 trigger provider revision이 달라져 completed ledger 확인 전에 conflict가 날 수 있다. +- 해결: + - Gito develop push bridge에는 안정적인 provider revision token이 없으므로 `ProviderRevision`을 비워 둔다. + - `RoadmapRevision`은 develop revision으로 유지해 다른 develop revision 재처리는 기존 conflict guard가 계속 잡게 한다. + - `bridge_test.go`에서 Plane `State`가 있어도 enqueued job의 `ProviderRevision`이 비어 있음을 검증한다. + - 필요한 경우 `Bridge`/job args 주석을 조정해 mutable state를 revision으로 쓰지 않는 이유를 짧게 남긴다. + +## 구현 체크리스트 +- [ ] `services/core/internal/gitosync/bridge.go`에서 `ProviderRevision`에 Plane `State`를 전달하지 않는다. +- [ ] `services/core/internal/gitosync/bridge_test.go`에 Plane state가 `ProviderRevision`으로 매핑되지 않는 회귀 테스트를 추가하거나 기존 exact args 테스트에 명시 assertion을 더한다. +- [ ] 같은 roadmap revision 재처리는 mutable provider state가 달라도 revision conflict를 만들지 않고, 다른 roadmap revision은 기존 `RoadmapRevision` guard로 보호된다는 의도를 코드/테스트에서 확인한다. +- [ ] `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] `cd services/core && go test -count=1 ./...`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] `cd services/core && go vet ./...`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] `git diff --check`를 실행하고 결과를 `CODE_REVIEW-cloud-G07.md`에 기록한다. +- [ ] `CODE_REVIEW-cloud-G07.md`의 구현 항목별 완료 여부, 구현 체크리스트, 계획 대비 변경 사항, 주요 설계 결정, 검증 결과를 모두 채운다. + +## 리뷰어 체크포인트 +- `ProviderRevision`이 빈 값이면 `roadmapsync.mismatch`가 conflict를 만들지 않는 기존 계약과 맞는가. +- `RoadmapRevision`은 그대로 유지되어 다른 develop revision 재처리 conflict guard가 약해지지 않는가. +- 테스트가 mutable Plane state를 revision으로 쓰지 않는 계약을 직접 검증하는가. + +## 최종 검증 +- `cd services/core && go test -count=1 ./internal/gitosync ./internal/roadmapsync ./internal/roadmapsyncpipeline ./internal/scheduler` +- `cd services/core && go test -count=1 ./...` +- `cd services/core && go vet ./...` +- `git diff --check` diff --git a/packages/contracts/notes/flutter-core-api-candidates.md b/packages/contracts/notes/flutter-core-api-candidates.md index db2e1a2..99c5447 100644 --- a/packages/contracts/notes/flutter-core-api-candidates.md +++ b/packages/contracts/notes/flutter-core-api-candidates.md @@ -162,10 +162,20 @@ - 검증은 최소한 channel/action dispatch, auth boundary, error envelope, event broadcast 또는 reconnect 영향 중 해당되는 항목을 테스트로 고정한다. ### 1.8 외부 Gito Branch Event Consumer -- **설명**: NomadCode 클라이언트는 Gito의 branch event를 proto-socket을 통해 수신하는 소비자입니다. +- **설명**: NomadCode는 Gito의 branch event를 proto-socket을 통해 수신하는 소비자입니다. 소비 표면은 Flutter client wakeup과 Core backend bridge 두 책임으로 나뉩니다. - **원문 계약**: Gito `agent-contract/provided/gito-forgejo-branch-events-v1.md` (계약 ID: `gito.forgejo-branch-events.v1`) - **소비 액션**: `event.subscribe` (구독 요청) 및 `branch.updated` (브로드캐스트 이벤트) -- **책임 경계**: NomadCode는 `branch.updated` 이벤트를 단순 wakeup signal로만 해석하고 자체적인 Plane/roadmap mutation은 실행하지 않으며, 이벤트 수신 후 target branch revision에 대한 재검증 및 후속 동기화 여부를 직접 확인해야 합니다. 계약 본문 전체는 Gito 원문 계약을 따릅니다. +- **공통 책임 경계**: NomadCode는 `branch.updated` 이벤트를 단순 wakeup signal로만 해석하고 자체적인 Plane/roadmap mutation은 실행하지 않으며, 이벤트 수신 후 target branch revision에 대한 재검증 및 후속 동기화 여부를 직접 확인해야 합니다. 계약 본문 전체는 Gito 원문 계약을 따릅니다. + +#### 1.8.1 Flutter client wakeup +- **소비자**: `apps/client` Flutter 앱. +- **용도**: branch event를 UI wakeup 힌트로만 사용합니다. 화면 갱신/알림 트리거에 쓰며, 자체적으로 source-of-truth 상태를 변경하지 않습니다. + +#### 1.8.2 Core backend bridge +- **소비자**: `services/core` 내부 Gito branch event client (`services/core/internal/gitoevents`). +- **용도**: Core는 Gito 제공 계약 `gito.forgejo-branch-events.v1`을 소비해 `branch.updated`를 backend sync enqueue wakeup으로만 사용합니다. +- **`changed_files` 취급**: `changed_files`는 roadmap Milestone 관련성 판단을 위한 wakeup 힌트이며, 그 자체를 신뢰해 동기화하지 않습니다. 실제 target branch revision 확인/스캔은 backend bridge(후속 revision-scan 단계)의 책임입니다. +- **구독 필터**: `repo_id`, `branch`로 구독하며, 다른 repo/branch event는 enqueue 후보로 넘기지 않습니다. --- diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index b9fa754..5069e2a 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -18,6 +18,8 @@ import ( "github.com/nomadcode/nomadcode-core/internal/agent" "github.com/nomadcode/nomadcode-core/internal/config" "github.com/nomadcode/nomadcode-core/internal/db" + "github.com/nomadcode/nomadcode-core/internal/gitoevents" + "github.com/nomadcode/nomadcode-core/internal/gitosync" apphttp "github.com/nomadcode/nomadcode-core/internal/http" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/protosocket" @@ -113,6 +115,24 @@ func run(logger *slog.Logger) error { return err } + // Gito branch event consumer: drives Plane-origin Milestone creation sync + // from a develop push. Started only when the endpoint, repo, local develop + // checkout, and Todo state id are all configured; otherwise the Core server + // behaves exactly as before. Cancelled with the root context on shutdown. + if cfg.GitoBranchEventsEnabled() { + gitoRunner, err := newGitoRunner(cfg, planeClient, taskScheduler, logger) + if err != nil { + return err + } + go func() { + if err := gitoRunner.Run(ctx); err != nil { + logger.Error("gito branch event runner stopped", "error", err) + } + }() + logger.Info("gito branch event consumer enabled", + "repo_id", cfg.GitoRepoID, "branch", cfg.GitoBranch) + } + workflowService := workflow.NewService(store, taskScheduler, logger) projectBinder := apphttp.NewProjectBinder(store) handler := apphttp.NewHandler( @@ -165,3 +185,26 @@ func run(logger *slog.Logger) error { return nil } + +// newGitoRunner assembles the Gito branch event consumer: an exec-backed develop +// scanner, the Plane work item reader, the scheduler enqueuer, and an in-memory +// duplicate-revision guard, wired into a proto-socket runner. +func newGitoRunner(cfg config.Config, reader *plane.Client, enqueuer *scheduler.Client, logger *slog.Logger) (*gitoevents.Client, error) { + scanner, err := gitosync.NewBranchRevisionScanner(gitosync.ExecCommandRunner{}, gitosync.ScannerConfig{ + DevelopRepoPath: cfg.GitoDevelopRepoPath, + RemoteName: cfg.GitoRemoteName, + Branch: cfg.GitoBranch, + }) + if err != nil { + return nil, err + } + bridge, err := gitosync.NewBridge(scanner, reader, enqueuer, nil, gitosync.BridgeConfig{ + RepoID: cfg.GitoRepoID, + Branch: cfg.GitoBranch, + TodoStateID: cfg.RoadmapCreationTodoStateID, + }, logger) + if err != nil { + return nil, err + } + return gitosync.NewRunner(cfg.GitoProtoSocketURL, cfg.GitoRepoID, cfg.GitoBranch, bridge, logger) +} diff --git a/services/core/internal/config/config.go b/services/core/internal/config/config.go index 03c091c..bb40697 100644 --- a/services/core/internal/config/config.go +++ b/services/core/internal/config/config.go @@ -34,6 +34,23 @@ type Config struct { ProtoSocketPath string ProtoSocketHeartbeatIntervalSec int ProtoSocketHeartbeatWaitSec int + GitoProtoSocketURL string + GitoRepoID string + GitoBranch string + GitoDevelopRepoPath string + GitoRemoteName string + RoadmapCreationTodoStateID string +} + +// GitoBranchEventsEnabled reports whether the Gito branch event consumer should +// run. It requires an endpoint, a repo id, a local develop checkout path to +// scan against, and a Todo state id to project into. Any missing piece keeps the +// consumer disabled so the rest of the Core server behaves exactly as before. +func (c Config) GitoBranchEventsEnabled() bool { + return c.GitoProtoSocketURL != "" && + c.GitoRepoID != "" && + c.GitoDevelopRepoPath != "" && + c.RoadmapCreationTodoStateID != "" } func Load() Config { @@ -66,6 +83,12 @@ func Load() Config { ProtoSocketPath: getEnv("PROTO_SOCKET_PATH", "/proto-socket"), ProtoSocketHeartbeatIntervalSec: getEnvInt("PROTO_SOCKET_HEARTBEAT_INTERVAL_SEC", 30), ProtoSocketHeartbeatWaitSec: getEnvInt("PROTO_SOCKET_HEARTBEAT_WAIT_SEC", 10), + GitoProtoSocketURL: os.Getenv("GITO_PROTO_SOCKET_URL"), + GitoRepoID: os.Getenv("GITO_REPO_ID"), + GitoBranch: getEnv("GITO_BRANCH", "develop"), + GitoDevelopRepoPath: os.Getenv("GITO_DEVELOP_REPO_PATH"), + GitoRemoteName: getEnv("GITO_REMOTE_NAME", "origin"), + RoadmapCreationTodoStateID: firstEnv("ROADMAP_CREATION_TODO_STATE_ID", "PLANE_TODO_STATE_ID"), } } diff --git a/services/core/internal/config/config_test.go b/services/core/internal/config/config_test.go index eb0f301..449cf0a 100644 --- a/services/core/internal/config/config_test.go +++ b/services/core/internal/config/config_test.go @@ -143,6 +143,85 @@ func TestConfigMattermostFieldsAreEmptyByDefault(t *testing.T) { } } +func TestConfigGitoBranchDefaultsAndDisabled(t *testing.T) { + cfg := Load() + + if cfg.GitoProtoSocketURL != "" { + t.Fatalf("GitoProtoSocketURL default: got %q, want empty", cfg.GitoProtoSocketURL) + } + if cfg.GitoRepoID != "" { + t.Fatalf("GitoRepoID default: got %q, want empty", cfg.GitoRepoID) + } + if cfg.GitoBranch != "develop" { + t.Fatalf("GitoBranch default: got %q, want develop", cfg.GitoBranch) + } + if cfg.GitoDevelopRepoPath != "" { + t.Fatalf("GitoDevelopRepoPath default: got %q, want empty", cfg.GitoDevelopRepoPath) + } + if cfg.GitoRemoteName != "origin" { + t.Fatalf("GitoRemoteName default: got %q, want origin", cfg.GitoRemoteName) + } + if cfg.RoadmapCreationTodoStateID != "" { + t.Fatalf("RoadmapCreationTodoStateID default: got %q, want empty", cfg.RoadmapCreationTodoStateID) + } + if cfg.GitoBranchEventsEnabled() { + t.Fatal("GitoBranchEventsEnabled: want false when endpoint/repo unset") + } +} + +func TestConfigGitoBranchEventsEnabledRequiresAllInputs(t *testing.T) { + t.Setenv("GITO_PROTO_SOCKET_URL", "ws://gito.example/proto-socket") + if Load().GitoBranchEventsEnabled() { + t.Fatal("GitoBranchEventsEnabled: want false when repo id is unset") + } + + t.Setenv("GITO_REPO_ID", "nomadcode") + if Load().GitoBranchEventsEnabled() { + t.Fatal("GitoBranchEventsEnabled: want false when develop repo path is unset") + } + + t.Setenv("GITO_DEVELOP_REPO_PATH", "/srv/develop") + if Load().GitoBranchEventsEnabled() { + t.Fatal("GitoBranchEventsEnabled: want false when todo state id is unset") + } + + t.Setenv("ROADMAP_CREATION_TODO_STATE_ID", "state-todo") + t.Setenv("GITO_BRANCH", "release") + cfg := Load() + + if cfg.GitoProtoSocketURL != "ws://gito.example/proto-socket" { + t.Fatalf("GitoProtoSocketURL: got %q", cfg.GitoProtoSocketURL) + } + if cfg.GitoRepoID != "nomadcode" { + t.Fatalf("GitoRepoID: got %q", cfg.GitoRepoID) + } + if cfg.GitoBranch != "release" { + t.Fatalf("GitoBranch override: got %q", cfg.GitoBranch) + } + if cfg.GitoDevelopRepoPath != "/srv/develop" { + t.Fatalf("GitoDevelopRepoPath: got %q", cfg.GitoDevelopRepoPath) + } + if cfg.RoadmapCreationTodoStateID != "state-todo" { + t.Fatalf("RoadmapCreationTodoStateID: got %q", cfg.RoadmapCreationTodoStateID) + } + if !cfg.GitoBranchEventsEnabled() { + t.Fatal("GitoBranchEventsEnabled: want true when all inputs set") + } +} + +func TestConfigRoadmapCreationTodoStateIDFallsBackToPlaneEnv(t *testing.T) { + t.Setenv("PLANE_TODO_STATE_ID", "plane-state") + cfg := Load() + if cfg.RoadmapCreationTodoStateID != "plane-state" { + t.Fatalf("RoadmapCreationTodoStateID fallback: got %q, want plane-state", cfg.RoadmapCreationTodoStateID) + } + + t.Setenv("ROADMAP_CREATION_TODO_STATE_ID", "roadmap-state") + if Load().RoadmapCreationTodoStateID != "roadmap-state" { + t.Fatalf("RoadmapCreationTodoStateID: ROADMAP_CREATION_TODO_STATE_ID should win") + } +} + func TestConfigJiraFieldsAreEmptyByDefault(t *testing.T) { cfg := Load() diff --git a/services/core/internal/gitoevents/client.go b/services/core/internal/gitoevents/client.go new file mode 100644 index 0000000..7c53888 --- /dev/null +++ b/services/core/internal/gitoevents/client.go @@ -0,0 +1,150 @@ +package gitoevents + +import ( + "context" + "errors" + "log/slog" + + "github.com/nomadcode/nomadcode-core/internal/protosocket" +) + +// Transport abstracts the proto-socket connection to Gito so the client can be +// driven by a fake transport in tests. Production transports wrap the existing +// websocket / proto-socket dependencies; no new dependency is introduced here. +type Transport interface { + // Connect establishes the underlying connection. + Connect(ctx context.Context) error + // Write sends an envelope to Gito (e.g. the subscribe request). + Write(ctx context.Context, env protosocket.Envelope) error + // Read blocks for the next inbound envelope. It returns an error wrapping + // io.EOF / context cancellation when the stream ends. + Read(ctx context.Context) (protosocket.Envelope, error) + // Close releases the connection. + Close() error +} + +// Handler receives decoded, on-target branch events. +type Handler func(ctx context.Context, event BranchUpdatedEvent) + +// Client subscribes to Gito branch events over a Transport and forwards +// matching `branch.updated` events to a Handler. Wrong channel/action, wrong +// repo/branch, and malformed payloads are dropped through an observable log +// path instead of reaching the handler. +type Client struct { + transport Transport + repoID string + branch string + handler Handler + logger *slog.Logger + idFunc func() string +} + +// Options configures a Client. +type Options struct { + RepoID string + Branch string + Handler Handler + Logger *slog.Logger + // IDFunc generates envelope ids for outbound requests. Defaults to a + // fixed seed if nil; production callers should provide a unique generator. + IDFunc func() string +} + +// NewClient builds a Client. RepoID must be non-empty; an empty branch falls +// back to DefaultBranch. +func NewClient(transport Transport, opts Options) (*Client, error) { + if transport == nil { + return nil, errors.New("gitoevents: transport is nil") + } + if opts.RepoID == "" { + return nil, errors.New("gitoevents: repo_id is required") + } + branch := opts.Branch + if branch == "" { + branch = DefaultBranch + } + idFunc := opts.IDFunc + if idFunc == nil { + idFunc = func() string { return "gito-subscribe" } + } + return &Client{ + transport: transport, + repoID: opts.RepoID, + branch: branch, + handler: opts.Handler, + logger: opts.Logger, + idFunc: idFunc, + }, nil +} + +// Run connects, sends the subscribe request, then reads events until the +// context is cancelled or the stream ends. It returns the terminating error, +// which is nil on context cancellation. +func (c *Client) Run(ctx context.Context) error { + if err := c.transport.Connect(ctx); err != nil { + return err + } + defer func() { _ = c.transport.Close() }() + + subscribe := BuildSubscribeEnvelope(c.idFunc(), c.repoID, c.branch) + if err := c.transport.Write(ctx, subscribe); err != nil { + return err + } + c.logInfo("gito branch event subscription sent", "repo_id", c.repoID, "branch", c.branch) + + for { + if ctx.Err() != nil { + return nil + } + env, err := c.transport.Read(ctx) + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + c.handleEnvelope(ctx, env) + } +} + +func (c *Client) handleEnvelope(ctx context.Context, env protosocket.Envelope) { + // Ignore non-event traffic (e.g. the subscribe response) silently. + if env.Channel != EventChannel || env.Action != BranchUpdatedAction { + c.logInfo("gito envelope ignored", "channel", env.Channel, "action", env.Action) + return + } + + event, err := DecodeBranchUpdatedEnvelope(env) + if err != nil { + c.logError("gito branch event decode failed", "error", err, "channel", env.Channel, "action", env.Action) + return + } + + if !event.IsTarget(c.repoID, c.branch) { + c.logInfo("gito branch event off-target", + "event_repo_id", event.RepoID, + "event_branch", event.Branch, + "want_repo_id", c.repoID, + "want_branch", c.branch, + ) + return + } + + if c.handler != nil { + c.handler(ctx, event) + } +} + +func (c *Client) logInfo(msg string, args ...any) { + if c.logger == nil { + return + } + c.logger.Info(msg, args...) +} + +func (c *Client) logError(msg string, args ...any) { + if c.logger == nil { + return + } + c.logger.Error(msg, args...) +} diff --git a/services/core/internal/gitoevents/client_test.go b/services/core/internal/gitoevents/client_test.go new file mode 100644 index 0000000..146efce --- /dev/null +++ b/services/core/internal/gitoevents/client_test.go @@ -0,0 +1,181 @@ +package gitoevents + +import ( + "context" + "errors" + "io" + "sync" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/protosocket" +) + +// fakeTransport is a scripted Transport for driving Client in tests. +type fakeTransport struct { + mu sync.Mutex + connected bool + closed bool + written []protosocket.Envelope + inbound []protosocket.Envelope + readIdx int +} + +func (f *fakeTransport) Connect(context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + f.connected = true + return nil +} + +func (f *fakeTransport) Write(_ context.Context, env protosocket.Envelope) error { + f.mu.Lock() + defer f.mu.Unlock() + f.written = append(f.written, env) + return nil +} + +func (f *fakeTransport) Read(context.Context) (protosocket.Envelope, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.readIdx >= len(f.inbound) { + return protosocket.Envelope{}, io.EOF + } + env := f.inbound[f.readIdx] + f.readIdx++ + return env, nil +} + +func (f *fakeTransport) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + f.closed = true + return nil +} + +func TestClientSendsSubscribeEnvelope(t *testing.T) { + ft := &fakeTransport{} + client, err := NewClient(ft, Options{RepoID: "nomadcode", Branch: "develop"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + if err := client.Run(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("Run: got %v, want io.EOF", err) + } + + if !ft.connected { + t.Fatal("expected transport to be connected") + } + if !ft.closed { + t.Fatal("expected transport to be closed") + } + if len(ft.written) != 1 { + t.Fatalf("written envelopes: got %d, want 1", len(ft.written)) + } + sub := ft.written[0] + if sub.Channel != EventChannel || sub.Action != SubscribeAction { + t.Fatalf("subscribe envelope: got %q/%q", sub.Channel, sub.Action) + } + if sub.Payload["repo_id"] != "nomadcode" || sub.Payload["branch"] != "develop" { + t.Fatalf("subscribe payload: got %v", sub.Payload) + } +} + +func TestClientForwardsTargetEventToHandler(t *testing.T) { + ft := &fakeTransport{ + inbound: []protosocket.Envelope{branchUpdatedEnvelope()}, + } + + var got []BranchUpdatedEvent + client, err := NewClient(ft, Options{ + RepoID: "nomadcode", + Branch: "develop", + Handler: func(_ context.Context, ev BranchUpdatedEvent) { + got = append(got, ev) + }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + if err := client.Run(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("Run: got %v, want io.EOF", err) + } + + if len(got) != 1 { + t.Fatalf("handler events: got %d, want 1", len(got)) + } + if got[0].RepoID != "nomadcode" || got[0].After != "new-sha" { + t.Fatalf("forwarded event: got %+v", got[0]) + } +} + +func TestClientDropsOffTargetAndMalformedEvents(t *testing.T) { + offRepo := branchUpdatedEnvelope() + offRepo.Payload["repo_id"] = "other-repo" + + offBranch := branchUpdatedEnvelope() + offBranch.Payload["branch"] = "main" + + wrongAction := branchUpdatedEnvelope() + wrongAction.Action = "task.status.changed" + + malformed := branchUpdatedEnvelope() + malformed.Payload["changed_files"] = "not-a-list" + + onTarget := branchUpdatedEnvelope() + + ft := &fakeTransport{ + inbound: []protosocket.Envelope{offRepo, offBranch, wrongAction, malformed, onTarget}, + } + + var got []BranchUpdatedEvent + client, err := NewClient(ft, Options{ + RepoID: "nomadcode", + Branch: "develop", + Handler: func(_ context.Context, ev BranchUpdatedEvent) { + got = append(got, ev) + }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + if err := client.Run(context.Background()); !errors.Is(err, io.EOF) { + t.Fatalf("Run: got %v, want io.EOF", err) + } + + if len(got) != 1 { + t.Fatalf("handler events: got %d, want only the on-target event", len(got)) + } +} + +func TestClientStopsOnContextCancel(t *testing.T) { + ft := &fakeTransport{ + inbound: []protosocket.Envelope{branchUpdatedEnvelope()}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + client, err := NewClient(ft, Options{RepoID: "nomadcode", Branch: "develop"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + if err := client.Run(ctx); err != nil { + t.Fatalf("Run with cancelled context: got %v, want nil", err) + } +} + +func TestNewClientRequiresRepoID(t *testing.T) { + if _, err := NewClient(&fakeTransport{}, Options{}); err == nil { + t.Fatal("expected error when repo_id is empty") + } +} + +func TestNewClientRequiresTransport(t *testing.T) { + if _, err := NewClient(nil, Options{RepoID: "nomadcode"}); err == nil { + t.Fatal("expected error when transport is nil") + } +} diff --git a/services/core/internal/gitoevents/events.go b/services/core/internal/gitoevents/events.go new file mode 100644 index 0000000..064bf37 --- /dev/null +++ b/services/core/internal/gitoevents/events.go @@ -0,0 +1,179 @@ +// 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 "" +} diff --git a/services/core/internal/gitoevents/events_test.go b/services/core/internal/gitoevents/events_test.go new file mode 100644 index 0000000..610a735 --- /dev/null +++ b/services/core/internal/gitoevents/events_test.go @@ -0,0 +1,171 @@ +package gitoevents + +import ( + "testing" + + "github.com/nomadcode/nomadcode-core/internal/protosocket" +) + +// branchUpdatedEnvelope builds a contract-shaped broadcast envelope fixture. +func branchUpdatedEnvelope() protosocket.Envelope { + return protosocket.Envelope{ + ProtocolVersion: protosocket.ProtocolVersion, + ID: "event-id", + Type: "event", + Channel: EventChannel, + Action: BranchUpdatedAction, + Payload: map[string]any{ + "id": "event-id", + "type": "branch.updated", + "provider": "forgejo", + "delivery_id": "delivery-id", + "repo_id": "nomadcode", + "branch": "develop", + "before": "old-sha", + "after": "new-sha", + "changed_files": []any{ + map[string]any{ + "path": "agent-roadmap/phase/example/milestones/example.md", + "change_type": "modified", + }, + map[string]any{ + "path": "README.md", + "change_type": "modified", + }, + }, + "observed_at": "2026-06-13T00:00:00Z", + "created_at": "2026-06-13T00:00:00Z", + }, + } +} + +func TestBuildSubscribeEnvelopeMatchesContract(t *testing.T) { + env := BuildSubscribeEnvelope("msg-1", "nomadcode", "develop") + + if env.ProtocolVersion != protosocket.ProtocolVersion { + t.Fatalf("protocol_version: got %q", env.ProtocolVersion) + } + if env.ID != "msg-1" { + t.Fatalf("id: got %q", env.ID) + } + if env.Type != "request" { + t.Fatalf("type: got %q, want request", env.Type) + } + if env.Channel != EventChannel { + t.Fatalf("channel: got %q, want %q", env.Channel, EventChannel) + } + if env.Action != SubscribeAction { + t.Fatalf("action: got %q, want %q", env.Action, SubscribeAction) + } + if got := env.Payload["repo_id"]; got != "nomadcode" { + t.Fatalf("payload.repo_id: got %v", got) + } + if got := env.Payload["branch"]; got != "develop" { + t.Fatalf("payload.branch: got %v", got) + } + events, ok := env.Payload["events"].([]any) + if !ok || len(events) != 1 || events[0] != BranchUpdatedAction { + t.Fatalf("payload.events: got %v", env.Payload["events"]) + } +} + +func TestBuildSubscribeEnvelopeDefaultsBranch(t *testing.T) { + env := BuildSubscribeEnvelope("msg-1", "nomadcode", "") + if got := env.Payload["branch"]; got != DefaultBranch { + t.Fatalf("payload.branch default: got %v, want %q", got, DefaultBranch) + } +} + +func TestDecodeBranchUpdatedEnvelope(t *testing.T) { + ev, err := DecodeBranchUpdatedEnvelope(branchUpdatedEnvelope()) + if err != nil { + t.Fatalf("decode: unexpected error: %v", err) + } + if ev.RepoID != "nomadcode" || ev.Branch != "develop" { + t.Fatalf("repo/branch: got %q/%q", ev.RepoID, ev.Branch) + } + if ev.Before != "old-sha" || ev.After != "new-sha" { + t.Fatalf("before/after: got %q/%q", ev.Before, ev.After) + } + if ev.DeliveryID != "delivery-id" || ev.Provider != "forgejo" { + t.Fatalf("delivery/provider: got %q/%q", ev.DeliveryID, ev.Provider) + } + if len(ev.ChangedFiles) != 2 { + t.Fatalf("changed_files: got %d, want 2", len(ev.ChangedFiles)) + } + if ev.ChangedFiles[0].Path != "agent-roadmap/phase/example/milestones/example.md" { + t.Fatalf("changed_files[0].path: got %q", ev.ChangedFiles[0].Path) + } +} + +func TestDecodeBranchUpdatedEnvelopeRejectsWrongAction(t *testing.T) { + env := branchUpdatedEnvelope() + env.Action = "task.status.changed" + if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { + t.Fatal("expected error for wrong action") + } +} + +func TestDecodeBranchUpdatedEnvelopeRejectsWrongChannel(t *testing.T) { + env := branchUpdatedEnvelope() + env.Channel = "task" + if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { + t.Fatal("expected error for wrong channel") + } +} + +func TestDecodeBranchUpdatedEnvelopeRejectsMalformedChangedFiles(t *testing.T) { + env := branchUpdatedEnvelope() + env.Payload["changed_files"] = "not-a-list" + if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { + t.Fatal("expected error for malformed changed_files") + } +} + +func TestDecodeBranchUpdatedEnvelopeRejectsMissingRepoID(t *testing.T) { + env := branchUpdatedEnvelope() + delete(env.Payload, "repo_id") + if _, err := DecodeBranchUpdatedEnvelope(env); err == nil { + t.Fatal("expected error for missing repo_id") + } +} + +func TestIsTarget(t *testing.T) { + ev := BranchUpdatedEvent{RepoID: "nomadcode", Branch: "develop"} + + if !ev.IsTarget("nomadcode", "develop") { + t.Fatal("expected match for same repo/branch") + } + if ev.IsTarget("other-repo", "develop") { + t.Fatal("expected no match for different repo") + } + if ev.IsTarget("nomadcode", "main") { + t.Fatal("expected no match for different branch") + } + if !ev.IsTarget("nomadcode", "") { + t.Fatal("expected empty branch to default to develop and match") + } +} + +func TestMilestoneChangedFiles(t *testing.T) { + ev := BranchUpdatedEvent{ + ChangedFiles: []ChangedFile{ + {Path: "agent-roadmap/phase/example/milestones/example.md", ChangeType: "modified"}, + {Path: "./agent-roadmap/phase/p/milestones/m.md", ChangeType: "added"}, + {Path: "README.md", ChangeType: "modified"}, + {Path: "agent-roadmap/phase/example/PHASE.md", ChangeType: "modified"}, + {Path: "agent-roadmap/phase/example/milestones/notes.txt", ChangeType: "modified"}, + }, + } + + got := ev.MilestoneChangedFiles() + if len(got) != 2 { + t.Fatalf("milestone changed files: got %d, want 2 (%+v)", len(got), got) + } + if got[0].Path != "agent-roadmap/phase/example/milestones/example.md" { + t.Fatalf("milestone[0]: got %q", got[0].Path) + } + if got[1].Path != "./agent-roadmap/phase/p/milestones/m.md" { + t.Fatalf("milestone[1]: got %q", got[1].Path) + } +} diff --git a/services/core/internal/gitosync/bridge.go b/services/core/internal/gitosync/bridge.go new file mode 100644 index 0000000..706be12 --- /dev/null +++ b/services/core/internal/gitosync/bridge.go @@ -0,0 +1,224 @@ +package gitosync + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync" + + "github.com/nomadcode/nomadcode-core/internal/gitoevents" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" + "github.com/nomadcode/nomadcode-core/internal/scheduler" + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +// Scanner is the develop-scan seam the bridge drives. It is satisfied by +// *BranchRevisionScanner and by test fakes. +type Scanner interface { + Scan(ctx context.Context, ev gitoevents.BranchUpdatedEvent) (ScanOutput, error) +} + +// Enqueuer is the scheduler seam the bridge hands a built job to. It is +// satisfied by *scheduler.Client and by test fakes, so the bridge stays +// testable without River or a database. +type Enqueuer interface { + EnqueueRoadmapCreationSync(ctx context.Context, args scheduler.RoadmapCreationSyncJobArgs) error +} + +// ProcessedRevisionStore is the duplicate guard seam: it reports whether a +// (repo, branch, after) revision has already been processed and records it. +// MarkProcessed must only be called after the revision's enqueue work +// succeeded, so a failed run is retried rather than silently swallowed. +type ProcessedRevisionStore interface { + Seen(repoID, branch, after string) bool + MarkProcessed(repoID, branch, after string) +} + +// BridgeConfig configures a Bridge. +type BridgeConfig struct { + RepoID string + Branch string + // TodoStateID is the Plane state id the creation sync moves the work item + // to. Required; an empty value disables the bridge at construction. + TodoStateID string + // ExternalSource/ExternalID tag the preservation comment so a re-run is + // recognized as nomadcode-authored. ExternalSource defaults to "nomadcode". + ExternalSource string +} + +// Bridge turns a verified develop scan into one EnqueueRoadmapCreationSync call +// per matched Milestone. It validates the event's repo/branch, dedups by +// (repo, branch, after) revision, fetches the originating Plane work item to +// carry the original body and ref into the job, and skips Milestones whose +// scanned identity does not match the fetched work item's provider. +type Bridge struct { + scanner Scanner + reader workitem.Reader + enqueuer Enqueuer + processed ProcessedRevisionStore + cfg BridgeConfig + logger *slog.Logger +} + +// NewBridge builds a Bridge. scanner, reader, enqueuer, and a non-empty +// TodoStateID are required. +func NewBridge(scanner Scanner, reader workitem.Reader, enqueuer Enqueuer, processed ProcessedRevisionStore, cfg BridgeConfig, logger *slog.Logger) (*Bridge, error) { + if scanner == nil { + return nil, errors.New("gitosync: scanner is nil") + } + if reader == nil { + return nil, errors.New("gitosync: work item reader is nil") + } + if enqueuer == nil { + return nil, errors.New("gitosync: enqueuer is nil") + } + if strings.TrimSpace(cfg.TodoStateID) == "" { + return nil, errors.New("gitosync: todo state id is required") + } + if processed == nil { + processed = NewInMemoryRevisionStore() + } + if strings.TrimSpace(cfg.ExternalSource) == "" { + cfg.ExternalSource = "nomadcode" + } + return &Bridge{ + scanner: scanner, + reader: reader, + enqueuer: enqueuer, + processed: processed, + cfg: cfg, + logger: logger, + }, nil +} + +// Handle processes one branch event: it gates on the target repo/branch and the +// duplicate-revision guard, scans develop, and enqueues a creation sync job for +// each matched Milestone. ErrNotReady from the scanner is a normal not-yet +// outcome (the revision is not recorded as processed) and is returned to the +// caller to log. A wrong repo/branch is a silent no-op. +func (b *Bridge) Handle(ctx context.Context, ev gitoevents.BranchUpdatedEvent) error { + if !ev.IsTarget(b.cfg.RepoID, b.cfg.Branch) { + b.logInfo("gito event off-target, ignored", + "event_repo_id", ev.RepoID, "event_branch", ev.Branch) + return nil + } + if b.processed.Seen(ev.RepoID, ev.Branch, ev.After) { + b.logInfo("gito revision already processed, skipped", + "repo_id", ev.RepoID, "branch", ev.Branch, "after", ev.After) + return nil + } + + out, err := b.scanner.Scan(ctx, ev) + if err != nil { + // Not-ready is a normal not-yet case; leave the revision unprocessed so a + // later event for the same after can retry once develop catches up. + return err + } + + for _, doc := range out.Docs { + if err := b.enqueueDoc(ctx, out.Result, doc); err != nil { + return err + } + } + + // Every matched Milestone was enqueued (or there were none); record the + // revision so a duplicate event does not re-enqueue the same jobs. + b.processed.MarkProcessed(ev.RepoID, ev.Branch, ev.After) + return nil +} + +// enqueueDoc fetches the originating Plane work item, builds the job args, and +// enqueues one creation sync job for a single scanned Milestone. A scanned +// identity whose provider/work item id does not resolve to a fetchable work +// item is a hard error so a misconfigured identity is surfaced, not swallowed. +func (b *Bridge) enqueueDoc(ctx context.Context, scan roadmapsync.ScanResult, doc ScannedMilestoneDoc) error { + identity := doc.Milestone.Identity + ref := workitem.Ref{ + Provider: identity.Provider, + Tenant: identity.Tenant, + Project: identity.Project, + ID: identity.WorkItemID, + } + + item, err := b.reader.FetchWorkItem(ctx, ref) + if err != nil { + return err + } + + args := scheduler.RoadmapCreationSyncJobArgs{ + Scan: scan, + Expected: identity, + Ref: ref, + OriginalBody: originalBody(item), + TodoStateID: b.cfg.TodoStateID, + MilestoneMarkdown: doc.Markdown, + // ProviderRevision is intentionally left empty. A Gito develop push has no + // stable provider revision token; the Plane work item State is mutable and + // the sync itself moves it to the Todo state, so feeding State here would + // make a re-process of the same develop revision observe a different + // provider revision and trip the reconcile's provider-revision conflict + // before the completed-step ledger is even consulted. The develop revision + // in RoadmapRevision is the stable guard for a different-revision reprocess. + RoadmapRevision: scan.Revision.Revision, + ExternalSource: b.cfg.ExternalSource, + ExternalID: identity.WorkItemID, + } + + if err := b.enqueuer.EnqueueRoadmapCreationSync(ctx, args); err != nil { + return err + } + b.logInfo("gito creation sync enqueued", + "work_item_id", identity.WorkItemID, + "milestone_path", identity.RoadmapMilestonePath, + "revision", scan.Revision.Revision, + ) + return nil +} + +// originalBody prefers the HTML description and falls back to the text +// description so the preservation comment keeps the original Plane body. +func originalBody(item workitem.WorkItem) string { + if strings.TrimSpace(item.DescriptionHTML) != "" { + return item.DescriptionHTML + } + return item.DescriptionText +} + +func (b *Bridge) logInfo(msg string, args ...any) { + if b.logger == nil { + return + } + b.logger.Info(msg, args...) +} + +// InMemoryRevisionStore is the MVP duplicate guard: a process-lifetime set of +// processed (repo, branch, after) revisions. It is intentionally not persisted; +// the durable idempotency lives in the identity/ledger the sync step uses, and +// this only suppresses repeated enqueues within one process run. +type InMemoryRevisionStore struct { + mu sync.Mutex + seen map[string]struct{} +} + +// NewInMemoryRevisionStore builds an empty in-memory revision store. +func NewInMemoryRevisionStore() *InMemoryRevisionStore { + return &InMemoryRevisionStore{seen: make(map[string]struct{})} +} + +func (s *InMemoryRevisionStore) Seen(repoID, branch, after string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.seen[revisionKey(repoID, branch, after)] + return ok +} + +func (s *InMemoryRevisionStore) MarkProcessed(repoID, branch, after string) { + s.mu.Lock() + defer s.mu.Unlock() + s.seen[revisionKey(repoID, branch, after)] = struct{}{} +} + +func revisionKey(repoID, branch, after string) string { + return repoID + "\x00" + branch + "\x00" + after +} diff --git a/services/core/internal/gitosync/bridge_test.go b/services/core/internal/gitosync/bridge_test.go new file mode 100644 index 0000000..0ca7a4e --- /dev/null +++ b/services/core/internal/gitosync/bridge_test.go @@ -0,0 +1,267 @@ +package gitosync + +import ( + "context" + "errors" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/gitoevents" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" + "github.com/nomadcode/nomadcode-core/internal/scheduler" + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +type fakeScanner struct { + out ScanOutput + err error +} + +func (f fakeScanner) Scan(context.Context, gitoevents.BranchUpdatedEvent) (ScanOutput, error) { + return f.out, f.err +} + +type fakeReader struct { + item workitem.WorkItem + err error + refs []workitem.Ref +} + +func (f *fakeReader) FetchWorkItem(_ context.Context, ref workitem.Ref) (workitem.WorkItem, error) { + f.refs = append(f.refs, ref) + if f.err != nil { + return workitem.WorkItem{}, f.err + } + return f.item, nil +} + +type fakeEnqueuer struct { + jobs []scheduler.RoadmapCreationSyncJobArgs + err error +} + +func (f *fakeEnqueuer) EnqueueRoadmapCreationSync(_ context.Context, args scheduler.RoadmapCreationSyncJobArgs) error { + if f.err != nil { + return f.err + } + f.jobs = append(f.jobs, args) + return nil +} + +func matchedIdentity() roadmapsync.Identity { + id, err := roadmapsync.Identity{ + Shape: roadmapsync.ShapeMilestone, + RoadmapMilestonePath: milestonePath, + Provider: "plane", + Tenant: "acme", + Project: "proj-1", + WorkItemID: "wi-123", + }.Normalize() + if err != nil { + panic(err) + } + return id +} + +func matchedScanOutput() ScanOutput { + identity := matchedIdentity() + scanned := roadmapsync.ScannedMilestone{Path: milestonePath, Identity: identity} + return ScanOutput{ + Result: roadmapsync.ScanResult{ + Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"}, + ChangedFiles: []string{milestonePath}, + ScannedMilestones: []roadmapsync.ScannedMilestone{scanned}, + }, + Docs: []ScannedMilestoneDoc{{Milestone: scanned, Markdown: milestoneMarkdownWithIdentity}}, + } +} + +func newTestBridge(t *testing.T, scanner Scanner, reader workitem.Reader, enq Enqueuer) *Bridge { + t.Helper() + b, err := NewBridge(scanner, reader, enq, NewInMemoryRevisionStore(), BridgeConfig{ + RepoID: "nomadcode", + Branch: "develop", + TodoStateID: "state-todo", + }, nil) + if err != nil { + t.Fatalf("NewBridge: %v", err) + } + return b +} + +func TestBridgeEnqueuesExactJobArgs(t *testing.T) { + reader := &fakeReader{item: workitem.WorkItem{ + Title: "WI", + DescriptionHTML: "

original html

", + DescriptionText: "original text", + State: "state-backlog", + }} + enq := &fakeEnqueuer{} + bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) + + if err := bridge.Handle(context.Background(), newScanEvent()); err != nil { + t.Fatalf("Handle: %v", err) + } + + if len(enq.jobs) != 1 { + t.Fatalf("jobs: got %d, want 1", len(enq.jobs)) + } + job := enq.jobs[0] + if job.Expected.WorkItemID != "wi-123" { + t.Fatalf("Expected.WorkItemID: got %q", job.Expected.WorkItemID) + } + if job.Ref.Provider != "plane" || job.Ref.ID != "wi-123" || job.Ref.Project != "proj-1" { + t.Fatalf("Ref: got %+v", job.Ref) + } + if job.OriginalBody != "

original html

" { + t.Fatalf("OriginalBody: got %q, want HTML preferred", job.OriginalBody) + } + if job.TodoStateID != "state-todo" { + t.Fatalf("TodoStateID: got %q", job.TodoStateID) + } + if job.MilestoneMarkdown != milestoneMarkdownWithIdentity { + t.Fatalf("MilestoneMarkdown mismatch") + } + if job.RoadmapRevision != "bbbb" { + t.Fatalf("RoadmapRevision: got %q", job.RoadmapRevision) + } + // Plane State is mutable and the sync moves it to Todo; it must never become + // the ProviderRevision, or a same-develop-revision reprocess would conflict + // before the step ledger is consulted. + if job.ProviderRevision != "" { + t.Fatalf("ProviderRevision: got %q, want empty (mutable Plane state must not be a revision)", job.ProviderRevision) + } + if job.ExternalSource != "nomadcode" || job.ExternalID != "wi-123" { + t.Fatalf("External tags: got source=%q id=%q", job.ExternalSource, job.ExternalID) + } +} + +// TestBridgeSameRoadmapRevisionReprocessDoesNotConflictOnProviderState pins the +// reconcile contract this review targets: with ProviderRevision left empty, a +// re-process of the SAME develop revision after the Plane state has moved (e.g. +// backlog -> todo) reconciles cleanly (complete/resume), while a DIFFERENT +// roadmap revision is still guarded by RoadmapRevision. +func TestBridgeSameRoadmapRevisionReprocessDoesNotConflictOnProviderState(t *testing.T) { + identity := matchedIdentity() + // Persisted identity from a first cycle: same develop revision, no provider + // revision recorded, and the Plane state already moved to todo since. + existing := roadmapsync.ExistingIdentity{ + Found: true, + Identity: identity, + ProviderRevision: "", + RoadmapRevision: "bbbb", + } + + // Trigger args as the bridge now builds them: empty ProviderRevision, develop + // revision in RoadmapRevision. + sameRevision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{ + Identity: identity, + ProviderRevision: "", + RoadmapRevision: "bbbb", + Existing: existing, + CompletedSteps: map[roadmapsync.Step]bool{}, + }) + if sameRevision.Action == roadmapsync.ActionConflict { + t.Fatalf("same develop revision reprocess must not conflict, got %q (%s)", + sameRevision.Action, sameRevision.Reason) + } + + diffRevision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{ + Identity: identity, + ProviderRevision: "", + RoadmapRevision: "cccc", + Existing: existing, + CompletedSteps: map[roadmapsync.Step]bool{}, + }) + if diffRevision.Action != roadmapsync.ActionConflict { + t.Fatalf("different develop revision reprocess must conflict via RoadmapRevision guard, got %q", + diffRevision.Action) + } +} + +func TestBridgeOriginalBodyFallsBackToText(t *testing.T) { + reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "only text"}} + enq := &fakeEnqueuer{} + bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) + + if err := bridge.Handle(context.Background(), newScanEvent()); err != nil { + t.Fatalf("Handle: %v", err) + } + if enq.jobs[0].OriginalBody != "only text" { + t.Fatalf("OriginalBody fallback: got %q", enq.jobs[0].OriginalBody) + } +} + +func TestBridgeIgnoresWrongRepoBranch(t *testing.T) { + enq := &fakeEnqueuer{} + bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, &fakeReader{}, enq) + + ev := newScanEvent() + ev.RepoID = "other-repo" + + if err := bridge.Handle(context.Background(), ev); err != nil { + t.Fatalf("Handle: %v", err) + } + if len(enq.jobs) != 0 { + t.Fatalf("wrong repo: want no enqueue, got %d", len(enq.jobs)) + } +} + +func TestBridgeDeduplicatesSameRevision(t *testing.T) { + enq := &fakeEnqueuer{} + reader := &fakeReader{item: workitem.WorkItem{DescriptionText: "body"}} + bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) + + ev := newScanEvent() + if err := bridge.Handle(context.Background(), ev); err != nil { + t.Fatalf("Handle first: %v", err) + } + if err := bridge.Handle(context.Background(), ev); err != nil { + t.Fatalf("Handle second: %v", err) + } + if len(enq.jobs) != 1 { + t.Fatalf("dedup: want 1 enqueue across two identical events, got %d", len(enq.jobs)) + } +} + +func TestBridgeNotReadyDoesNotMarkProcessed(t *testing.T) { + enq := &fakeEnqueuer{} + store := NewInMemoryRevisionStore() + b, err := NewBridge(fakeScanner{err: ErrNotReady}, &fakeReader{}, enq, store, BridgeConfig{ + RepoID: "nomadcode", + Branch: "develop", + TodoStateID: "state-todo", + }, nil) + if err != nil { + t.Fatalf("NewBridge: %v", err) + } + + ev := newScanEvent() + if err := b.Handle(context.Background(), ev); !errors.Is(err, ErrNotReady) { + t.Fatalf("Handle: got %v, want ErrNotReady", err) + } + if store.Seen(ev.RepoID, ev.Branch, ev.After) { + t.Fatal("not-ready revision must not be marked processed") + } +} + +func TestBridgeFetchErrorIsSurfaced(t *testing.T) { + enq := &fakeEnqueuer{} + reader := &fakeReader{err: errors.New("fetch boom")} + bridge := newTestBridge(t, fakeScanner{out: matchedScanOutput()}, reader, enq) + + if err := bridge.Handle(context.Background(), newScanEvent()); err == nil { + t.Fatal("Handle: want fetch error surfaced, got nil") + } + if len(enq.jobs) != 0 { + t.Fatalf("want no enqueue on fetch error, got %d", len(enq.jobs)) + } +} + +func TestNewBridgeRequiresTodoStateID(t *testing.T) { + _, err := NewBridge(fakeScanner{}, &fakeReader{}, &fakeEnqueuer{}, nil, BridgeConfig{ + RepoID: "nomadcode", + }, nil) + if err == nil { + t.Fatal("NewBridge: want error when todo state id is empty") + } +} diff --git a/services/core/internal/gitosync/runner.go b/services/core/internal/gitosync/runner.go new file mode 100644 index 0000000..195e770 --- /dev/null +++ b/services/core/internal/gitosync/runner.go @@ -0,0 +1,110 @@ +package gitosync + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "os/exec" + "strings" + + "nhooyr.io/websocket" + + "github.com/nomadcode/nomadcode-core/internal/gitoevents" + "github.com/nomadcode/nomadcode-core/internal/protosocket" +) + +// ExecCommandRunner is the production CommandRunner backed by os/exec. It runs +// git in the given directory and returns stdout with the trailing newline left +// intact for the caller to trim. +type ExecCommandRunner struct{} + +func (ExecCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +// websocketTransport is the production gitoevents.Transport that dials the Gito +// proto-socket endpoint over a websocket and exchanges JSON envelopes. It is +// kept unexported; callers wire it through NewRunner. +type websocketTransport struct { + url string + conn *websocket.Conn +} + +func (t *websocketTransport) Connect(ctx context.Context) error { + conn, _, err := websocket.Dial(ctx, t.url, nil) + if err != nil { + return err + } + t.conn = conn + return nil +} + +func (t *websocketTransport) Write(ctx context.Context, env protosocket.Envelope) error { + if t.conn == nil { + return errors.New("gitosync: transport not connected") + } + data, err := json.Marshal(env) + if err != nil { + return err + } + return t.conn.Write(ctx, websocket.MessageText, data) +} + +func (t *websocketTransport) Read(ctx context.Context) (protosocket.Envelope, error) { + if t.conn == nil { + return protosocket.Envelope{}, errors.New("gitosync: transport not connected") + } + _, data, err := t.conn.Read(ctx) + if err != nil { + return protosocket.Envelope{}, err + } + var env protosocket.Envelope + if err := json.Unmarshal(data, &env); err != nil { + return protosocket.Envelope{}, err + } + return env, nil +} + +func (t *websocketTransport) Close() error { + if t.conn == nil { + return nil + } + return t.conn.Close(websocket.StatusNormalClosure, "") +} + +// NewRunner builds the gitoevents client wired to forward on-target branch +// events into the bridge. It returns a *gitoevents.Client whose Run blocks +// until the context is cancelled or the stream ends; the caller owns starting +// it in a goroutine and cancelling the context on shutdown. The bridge's Handle +// error is logged (not propagated) so a single failed event does not tear down +// the long-lived subscription. +func NewRunner(url, repoID, branch string, bridge *Bridge, logger *slog.Logger) (*gitoevents.Client, error) { + if strings.TrimSpace(url) == "" { + return nil, errors.New("gitosync: proto-socket url is required") + } + if bridge == nil { + return nil, errors.New("gitosync: bridge is nil") + } + transport := &websocketTransport{url: url} + handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) { + if err := bridge.Handle(ctx, ev); err != nil { + if logger != nil { + logger.Error("gito creation sync handle failed", + "error", err, "repo_id", ev.RepoID, "after", ev.After) + } + } + } + return gitoevents.NewClient(transport, gitoevents.Options{ + RepoID: repoID, + Branch: branch, + Handler: handler, + Logger: logger, + }) +} diff --git a/services/core/internal/gitosync/scanner.go b/services/core/internal/gitosync/scanner.go new file mode 100644 index 0000000..d0921eb --- /dev/null +++ b/services/core/internal/gitosync/scanner.go @@ -0,0 +1,209 @@ +// Package gitosync bridges Gito `branch.updated` wakeup events to the Core +// Plane-origin Milestone creation sync. It is the layer that turns a wakeup +// signal into a verified develop scan and an EnqueueRoadmapCreationSync call: +// the scanner re-verifies the target branch revision from a local develop +// checkout (never trusting the event's changed_files alone), and the bridge +// matches each scanned Milestone to its Plane work item and enqueues a job. +package gitosync + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/nomadcode/nomadcode-core/internal/gitoevents" + "github.com/nomadcode/nomadcode-core/internal/roadmapsync" +) + +// ErrNotReady is returned by the scanner when the local develop checkout's +// remote revision does not yet match the event's `after`, so the pushed state +// the event announced is not observable locally and nothing should be enqueued. +var ErrNotReady = errors.New("gitosync: develop revision not ready") + +// CommandRunner runs a git command and returns its stdout. It is the test seam +// that keeps the scanner unit-testable without a real git checkout; production +// wraps os/exec. trimming of the trailing newline is the caller's job. +type CommandRunner interface { + Run(ctx context.Context, dir string, name string, args ...string) (string, error) +} + +// ScannerConfig configures a BranchRevisionScanner. DevelopRepoPath is the +// local develop checkout the scan runs against; RemoteName is the git remote to +// fetch and resolve (default origin); Branch is the develop branch name. +type ScannerConfig struct { + DevelopRepoPath string + RemoteName string + Branch string +} + +func (c ScannerConfig) remote() string { + if strings.TrimSpace(c.RemoteName) == "" { + return "origin" + } + return c.RemoteName +} + +func (c ScannerConfig) branch() string { + if strings.TrimSpace(c.Branch) == "" { + return gitoevents.DefaultBranch + } + return c.Branch +} + +// ScannedMilestoneDoc pairs a scanned Milestone with the Markdown the scanner +// read at the target revision, so the bridge can both match the provider +// identity and project the develop content without re-reading git. +type ScannedMilestoneDoc struct { + Milestone roadmapsync.ScannedMilestone + Markdown string +} + +// ScanOutput is the verified result of a develop scan: the provider-neutral +// ScanResult the develop match gate consumes, plus the per-path Milestone +// Markdown the bridge projects. +type ScanOutput struct { + Result roadmapsync.ScanResult + Docs []ScannedMilestoneDoc +} + +// BranchRevisionScanner re-verifies a Gito branch event against the local +// develop checkout and produces a verified ScanResult. It never trusts the +// event's changed_files as the final set; those are only a wakeup hint. +type BranchRevisionScanner struct { + runner CommandRunner + cfg ScannerConfig +} + +// NewBranchRevisionScanner builds a scanner. The runner and a non-empty develop +// repo path are required. +func NewBranchRevisionScanner(runner CommandRunner, cfg ScannerConfig) (*BranchRevisionScanner, error) { + if runner == nil { + return nil, errors.New("gitosync: command runner is nil") + } + if strings.TrimSpace(cfg.DevelopRepoPath) == "" { + return nil, errors.New("gitosync: develop repo path is required") + } + return &BranchRevisionScanner{runner: runner, cfg: cfg}, nil +} + +// Scan fetches the remote develop branch, verifies its revision matches the +// event's `after`, diffs the Milestone files changed between `before` and +// `after`, and reads/parses each at `after`. It returns ErrNotReady when the +// local remote revision does not yet match the event so a caller skips the +// enqueue instead of acting on stale state. Milestone files without a provider +// identity are dropped from Docs but kept in the ScanResult ChangedFiles so the +// match gate's reason stays accurate. +func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUpdatedEvent) (ScanOutput, error) { + dir := s.cfg.DevelopRepoPath + remote := s.cfg.remote() + branch := s.cfg.branch() + + if _, err := s.runner.Run(ctx, dir, "git", "fetch", "--prune", remote, branch); err != nil { + return ScanOutput{}, fmt.Errorf("gitosync: fetch failed: %w", err) + } + + remoteRev, err := s.runner.Run(ctx, dir, "git", "rev-parse", remoteRef(remote, branch)) + if err != nil { + return ScanOutput{}, fmt.Errorf("gitosync: rev-parse failed: %w", err) + } + remoteRev = strings.TrimSpace(remoteRev) + if remoteRev == "" || remoteRev != strings.TrimSpace(ev.After) { + // The local remote-tracking ref has not caught up to the revision the + // event announced; acting now would scan stale content. + return ScanOutput{}, ErrNotReady + } + + diffRange := diffRange(ev.Before, ev.After) + out, err := s.runner.Run(ctx, dir, "git", "diff", "--name-only", diffRange, "--", "agent-roadmap/phase") + if err != nil { + return ScanOutput{}, fmt.Errorf("gitosync: diff failed: %w", err) + } + + changed := milestonePaths(out) + + result := roadmapsync.ScanResult{ + Revision: roadmapsync.Revision{ + Branch: branch, + Revision: remoteRev, + }, + ChangedFiles: changed, + } + + var docs []ScannedMilestoneDoc + for _, path := range changed { + markdown, err := s.runner.Run(ctx, dir, "git", "show", ev.After+":"+path) + if err != nil { + return ScanOutput{}, fmt.Errorf("gitosync: show %q failed: %w", path, err) + } + identity, err := roadmapsync.ParseMilestoneIdentity(path, markdown) + if err != nil { + // No provider identity block: keep the changed path in the result so + // the match gate's reason stays accurate, but do not project it. + continue + } + scanned := roadmapsync.ScannedMilestone{Path: path, Identity: identity} + result.ScannedMilestones = append(result.ScannedMilestones, scanned) + docs = append(docs, ScannedMilestoneDoc{Milestone: scanned, Markdown: markdown}) + } + + return ScanOutput{Result: result, Docs: docs}, nil +} + +func remoteRef(remote, branch string) string { + return fmt.Sprintf("refs/remotes/%s/%s", remote, branch) +} + +// diffRange builds the `git diff` range. A missing/zero `before` (a new branch +// push) falls back to a single-revision diff so the first push still scans its +// Milestone files. +func diffRange(before, after string) string { + b := strings.TrimSpace(before) + if b == "" || isZeroSHA(b) { + return strings.TrimSpace(after) + } + return b + ".." + strings.TrimSpace(after) +} + +func isZeroSHA(s string) bool { + if s == "" { + return true + } + for _, r := range s { + if r != '0' { + return false + } + } + return true +} + +// milestonePaths keeps only the lines that look like roadmap Milestone +// documents, normalizing each to a cleaned repo-relative path. +func milestonePaths(diffOutput string) []string { + var out []string + for _, line := range strings.Split(diffOutput, "\n") { + path := strings.TrimSpace(line) + if path == "" { + continue + } + if isMilestonePath(path) { + out = append(out, path) + } + } + return out +} + +// isMilestonePath reports whether a path matches +// agent-roadmap/phase//milestones/.md. +func isMilestonePath(p string) bool { + if !strings.HasSuffix(p, ".md") { + return false + } + parts := strings.Split(strings.TrimPrefix(p, "./"), "/") + if len(parts) != 5 { + return false + } + return parts[0] == "agent-roadmap" && + parts[1] == "phase" && + parts[3] == "milestones" +} diff --git a/services/core/internal/gitosync/scanner_test.go b/services/core/internal/gitosync/scanner_test.go new file mode 100644 index 0000000..687e9ae --- /dev/null +++ b/services/core/internal/gitosync/scanner_test.go @@ -0,0 +1,183 @@ +package gitosync + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/gitoevents" +) + +const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md" + +const milestoneMarkdownWithIdentity = `# Milestone m1 + +## Provider identity +- provider: plane +- tenant: acme +- project: proj-1 +- work item id: wi-123 +` + +const milestoneMarkdownNoIdentity = `# Milestone m2 + +Just a body, no provider identity block. +` + +// fakeRunner returns scripted stdout per (args joined) command and records the +// ordered sequence of commands it ran. +type fakeRunner struct { + responses map[string]string + errs map[string]error + calls []string +} + +func newFakeRunner() *fakeRunner { + return &fakeRunner{responses: map[string]string{}, errs: map[string]error{}} +} + +func (f *fakeRunner) Run(_ context.Context, _ string, name string, args ...string) (string, error) { + key := name + " " + strings.Join(args, " ") + f.calls = append(f.calls, key) + if err, ok := f.errs[key]; ok { + return "", err + } + return f.responses[key], nil +} + +func newScanEvent() gitoevents.BranchUpdatedEvent { + return gitoevents.BranchUpdatedEvent{ + RepoID: "nomadcode", + Branch: "develop", + Before: "aaaa", + After: "bbbb", + } +} + +func newTestScanner(t *testing.T, runner CommandRunner) *BranchRevisionScanner { + t.Helper() + s, err := NewBranchRevisionScanner(runner, ScannerConfig{ + DevelopRepoPath: "/repo", + RemoteName: "origin", + Branch: "develop", + }) + if err != nil { + t.Fatalf("NewBranchRevisionScanner: %v", err) + } + return s +} + +func TestScanRunsFetchRevParseDiffShowInOrder(t *testing.T) { + runner := newFakeRunner() + runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n" + runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestonePath + "\n" + runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity + + out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) + if err != nil { + t.Fatalf("Scan: %v", err) + } + + wantOrder := []string{ + "git fetch --prune origin develop", + "git rev-parse refs/remotes/origin/develop", + "git diff --name-only aaaa..bbbb -- agent-roadmap/phase", + "git show bbbb:" + milestonePath, + } + if len(runner.calls) != len(wantOrder) { + t.Fatalf("call count: got %d (%v), want %d", len(runner.calls), runner.calls, len(wantOrder)) + } + for i, want := range wantOrder { + if runner.calls[i] != want { + t.Fatalf("call[%d]: got %q, want %q", i, runner.calls[i], want) + } + } + + if out.Result.Revision.Revision != "bbbb" { + t.Fatalf("revision: got %q, want bbbb", out.Result.Revision.Revision) + } + if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath { + t.Fatalf("scanned milestones: got %+v", out.Result.ScannedMilestones) + } + if got := out.Result.ScannedMilestones[0].Identity.WorkItemID; got != "wi-123" { + t.Fatalf("identity work item id: got %q, want wi-123", got) + } + if len(out.Docs) != 1 || out.Docs[0].Markdown != milestoneMarkdownWithIdentity { + t.Fatalf("docs: got %+v", out.Docs) + } +} + +func TestScanReturnsNotReadyOnRevisionMismatch(t *testing.T) { + runner := newFakeRunner() + // Remote develop has not caught up to the event's `after`. + runner.responses["git rev-parse refs/remotes/origin/develop"] = "cccc\n" + + _, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) + if !errors.Is(err, ErrNotReady) { + t.Fatalf("Scan: got %v, want ErrNotReady", err) + } + // It must not diff/show once it knows the revision is stale. + for _, c := range runner.calls { + if strings.HasPrefix(c, "git diff") || strings.HasPrefix(c, "git show") { + t.Fatalf("unexpected command after mismatch: %q", c) + } + } +} + +func TestScanDropsMilestoneWithoutProviderIdentity(t *testing.T) { + runner := newFakeRunner() + runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" + runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = milestonePath + runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownNoIdentity + + out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) + if err != nil { + t.Fatalf("Scan: %v", err) + } + // Kept in ChangedFiles (so the match gate's reason stays accurate) but not + // projectable. + if len(out.Result.ChangedFiles) != 1 { + t.Fatalf("changed files: got %v", out.Result.ChangedFiles) + } + if len(out.Result.ScannedMilestones) != 0 { + t.Fatalf("scanned milestones: want none, got %+v", out.Result.ScannedMilestones) + } + if len(out.Docs) != 0 { + t.Fatalf("docs: want none, got %+v", out.Docs) + } +} + +func TestScanIgnoresNonMilestoneChangedFiles(t *testing.T) { + runner := newFakeRunner() + runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" + runner.responses["git diff --name-only aaaa..bbbb -- agent-roadmap/phase"] = + "agent-roadmap/phase/p1/PHASE.md\n" + milestonePath + runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity + + out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != milestonePath { + t.Fatalf("changed files: got %v, want only the milestone path", out.Result.ChangedFiles) + } +} + +func TestScanUsesSingleRevisionRangeForNewBranchPush(t *testing.T) { + runner := newFakeRunner() + runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" + runner.responses["git diff --name-only bbbb -- agent-roadmap/phase"] = "" + + ev := newScanEvent() + ev.Before = "0000000000000000000000000000000000000000" + + if _, err := newTestScanner(t, runner).Scan(context.Background(), ev); err != nil { + t.Fatalf("Scan: %v", err) + } + for _, c := range runner.calls { + if strings.HasPrefix(c, "git diff") && !strings.Contains(c, "bbbb -- ") { + t.Fatalf("expected single-revision diff range, got %q", c) + } + } +} diff --git a/services/core/internal/roadmapsync/plane_projection.go b/services/core/internal/roadmapsync/plane_projection.go index 6b90a17..6c27fe8 100644 --- a/services/core/internal/roadmapsync/plane_projection.go +++ b/services/core/internal/roadmapsync/plane_projection.go @@ -14,7 +14,7 @@ import ( var ErrNotReady = errors.New("develop projection candidate is not ready") // ErrInvalidProjectionInput is returned when a required projection input -// (provider ref, original body, todo state, or milestone content) is missing. +// (provider ref, todo state, or milestone content) is missing. var ErrInvalidProjectionInput = errors.New("invalid plane todo projection input") // preserveCommentPrefix opens the comment that preserves the original Plane @@ -34,8 +34,9 @@ type PlaneTodoProvider interface { // ProjectPlaneTodoInput carries everything ProjectPlaneTodo needs without // reaching into provider DTOs: the develop match decision, the original Plane -// body to preserve, the Todo state id to move to, and the develop Milestone -// markdown to project. Ref is the provider work item the projection targets. +// body to preserve when present, the Todo state id to move to, and the develop +// Milestone markdown to project. Ref is the provider work item the projection +// targets. type ProjectPlaneTodoInput struct { Candidate ProjectionCandidate Ref workitem.Ref @@ -85,9 +86,13 @@ func ProjectPlaneTodo(ctx context.Context, p PlaneTodoProvider, in ProjectPlaneT // succeeds and skip the step on a resume. Inputs are not re-validated here; // ProjectPlaneTodo and the orchestrator validate before calling. func PreserveOriginalComment(ctx context.Context, p workitem.Commenter, in ProjectPlaneTodoInput) error { + body := preserveCommentPrefix + if strings.TrimSpace(in.OriginalBody) != "" { + body += " " + in.OriginalBody + } return p.AppendComment(ctx, workitem.CommentInput{ Ref: in.Ref, - Body: preserveCommentPrefix + " " + in.OriginalBody, + Body: body, Format: workitem.CommentFormatHTML, ExternalSource: in.ExternalSource, ExternalID: in.ExternalID, @@ -126,7 +131,6 @@ func ValidateProjectionInput(p PlaneTodoProvider, in ProjectPlaneTodoInput) erro if p == nil || strings.TrimSpace(string(in.Ref.Provider)) == "" || strings.TrimSpace(in.Ref.ID) == "" || - strings.TrimSpace(in.OriginalBody) == "" || strings.TrimSpace(in.TodoStateID) == "" || strings.TrimSpace(in.MilestoneMarkdown) == "" { return ErrInvalidProjectionInput diff --git a/services/core/internal/roadmapsync/plane_projection_test.go b/services/core/internal/roadmapsync/plane_projection_test.go index df08c31..e17c80c 100644 --- a/services/core/internal/roadmapsync/plane_projection_test.go +++ b/services/core/internal/roadmapsync/plane_projection_test.go @@ -78,6 +78,22 @@ func TestProjectPlaneTodoSuccessOrderAndPreservation(t *testing.T) { } } +func TestProjectPlaneTodoAllowsEmptyOriginalBody(t *testing.T) { + f := &fakeProvider{} + in := readyInput() + in.OriginalBody = " " + + if err := ProjectPlaneTodo(context.Background(), f, in); err != nil { + t.Fatalf("ProjectPlaneTodo returned error: %v", err) + } + if got := strings.Join(f.calls, ","); got != "comment,body,status" { + t.Fatalf("call order: got %q", got) + } + if f.commentBody != preserveCommentPrefix { + t.Fatalf("comment body = %q, want %q", f.commentBody, preserveCommentPrefix) + } +} + func TestProjectPlaneTodoStopsWhenCommentFails(t *testing.T) { f := &fakeProvider{commentErr: errors.New("comment boom")} err := ProjectPlaneTodo(context.Background(), f, readyInput()) @@ -116,7 +132,7 @@ func TestProjectPlaneTodoNotReadyDoesNotCallProvider(t *testing.T) { func TestProjectPlaneTodoRejectsMissingInput(t *testing.T) { f := &fakeProvider{} in := readyInput() - in.OriginalBody = " " + in.TodoStateID = " " err := ProjectPlaneTodo(context.Background(), f, in) if !errors.Is(err, ErrInvalidProjectionInput) { t.Fatalf("expected ErrInvalidProjectionInput, got %v", err)