diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_0.log new file mode 100644 index 0000000..a5bc5cb --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_0.log @@ -0,0 +1,134 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> Fill implementation-owned sections, run verification, keep active files in place, and report ready for review. Do not prompt the user directly during implementation. + +## 개요 + +date=2026-06-13 +task=m-milestone-work-item-creation-sync/03+02_develop_match, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `cycle-develop-sync`: sync layer가 `branches/develop` 또는 remote `develop`에서 pushed Milestone을 감지하고 원래 Plane 티켓 identity와 매칭한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Roadmap Sync Identity Model | [x] | +| [API-2] Milestone Identity Parser | [x] | +| [API-3] Develop Projection Candidate Gate | [x] | + +## 구현 체크리스트 + +- [x] `develop` branch workspace 또는 remote revision scan 결과를 표현하는 `roadmapsync` domain model을 추가한다. +- [x] Milestone 파일에서 provider, tenant, project, work item id, milestone path/id를 추출하는 parser를 추가한다. +- [x] pushed/scanned Milestone이 원래 Plane work item identity와 일치할 때만 projection-ready result를 반환한다. +- [x] slot local dirty path나 push 실패 result는 projection-ready가 아니도록 테스트한다. +- [x] `cd services/core && go test ./...`와 `git diff --check`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] active 파일을 `.log`로 아카이브하고 PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다. +- [ ] PASS이고 task group이 `m-`이면 런타임 완료 이벤트 메타데이터를 보고한다. + +## 계획 대비 변경 사항 + +- 계획대로 `services/core/internal/roadmapsync` 패키지에 6개 파일(identity, milestone_parser, develop_match 및 각 test)을 추가했고 기존 파일 수정 없이 신규 패키지로 한정했다. +- 계획의 typed shape `Identity`, `Revision`, `ScanResult`, `ProjectionCandidate`를 모두 contract note 5.2 필드명에 맞춰 추가했다. 계획에 없던 `ErrNoProviderIdentity` sentinel을 parser에 추가했는데, "block 없음"과 "block은 있으나 필수 필드 누락"을 같은 not-ready 신호로 호출자에 전달하면서 generic `ErrInvalidIdentity`와 구분하기 위함이다. +- `MatchDevelopMilestone`은 계획대로 develop revision/branch, changed files, expected identity를 입력받아 `ProjectionCandidate{Ready, Reason}`을 반환한다. 계획 문구의 "slot local path만 들어온 경우 false"는 slot scan이 develop pushed revision을 갖지 못한다는 점을 이용해 `revision==""`를 `reasonNoRevision` not-ready로 처리하는 방식으로 구현했다. + +## 주요 설계 결정 + +- **Identity 최소 match key**: 정규화는 `provider + work_item_id + roadmap_milestone_path`를 필수로 강제한다. milestone 마일스톤은 path를 외부(파일 경로)에서 주입하므로 parser가 block 내부에 path를 중복 기재하도록 요구하지 않는다. authoring_run_id는 milestone 문서 epic [provider-identity] 검증 문구대로 필수 매칭 키에서 제외했다. +- **Parser 엄격성**: ad hoc string scan을 `scanIdentityFields`/`parseFieldLine` 내부로 제한하고 외부 API는 typed `Identity`만 반환한다. heading(`## Provider identity`) block과 단일 `- Provider identity:` bullet block 두 형식을 지원하되, 다음 heading을 만나면 block을 닫아 `완료 리뷰` 같은 후속 섹션의 provider 라인을 흡수하지 않는다. 필수 필드가 빠진 block은 조용히 성공하지 않고 `ErrNoProviderIdentity`로 거부한다. +- **Develop gate**: `develop` 외 branch, pushed revision 없음(slot local/push 실패), milestone path 불일치, milestone 형식이 아닌 변경 파일을 모두 별도 stable reason 문자열로 분리해 sync layer가 원인별로 분기할 수 있게 했다. milestone 경로 판정은 `agent-roadmap/phase//milestones/.md` 5-segment 형태를 정확히 요구한다. +- **Revision 기본값**: Milestone sync source-of-truth branch는 contract note대로 `develop`을 기본값으로 둔다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- slot local file이나 push 실패만으로 projection-ready가 되지 않는지 확인한다. +- Milestone parser가 provider identity 누락을 조용히 성공 처리하지 않는지 확인한다. +- sync identity field가 contract note와 충돌하지 않는지 확인한다. + +## 검증 결과 + +### API-1/API-2/API-3 중간 검증 +```bash +$ cd services/core && go test ./internal/roadmapsync +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s +$ gofmt -l ./internal/roadmapsync/ +(no output) +$ go vet ./internal/roadmapsync +(no output) +``` + +### 최종 검증 +```bash +$ cd services/core && go test ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached) +? github.com/nomadcode/nomadcode-core/cmd/server [no test files] +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached) +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/authoring (cached) +ok github.com/nomadcode/nomadcode-core/internal/config (cached) +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +ok github.com/nomadcode/nomadcode-core/internal/http (cached) +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification (cached) +ok github.com/nomadcode/nomadcode-core/internal/projectsync (cached) +ok github.com/nomadcode/nomadcode-core/internal/protosocket (cached) +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync (cached) +ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached) +ok github.com/nomadcode/nomadcode-core/internal/storage (cached) +ok github.com/nomadcode/nomadcode-core/internal/workflow (cached) +ok github.com/nomadcode/nomadcode-core/internal/workitem (cached) +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached) + +$ git add -N services/core/internal/roadmapsync/ && git diff --check +(no output, exit 0) +``` + +--- + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Pass + - code quality: Pass + - plan deviation: Fail + - verification trust: Pass +- 발견된 문제: + - Required: `MatchDevelopMilestone`가 changed path와 expected identity 유효성만으로 `Ready=true`를 반환합니다. 현재 `ScanResult`에는 pushed Milestone 문서에서 파싱한 identity가 없고, `MatchDevelopMilestone`도 `ParseMilestoneIdentity` 결과나 `Identity.Matches`를 사용하지 않습니다. 따라서 `agent-roadmap/.../milestones/foo.md`가 변경되기만 하면 그 파일 안의 provider/work item id가 누락되었거나 다른 Plane 티켓이어도 projection-ready가 됩니다. `services/core/internal/roadmapsync/develop_match.go:38`에서 scanned Milestone identity(또는 milestone contents/parser result)를 입력받아 expected identity와 비교하고, missing/mismatched provider identity는 not-ready reason으로 반환하세요. 회귀 테스트는 "changed milestone path matches but parsed provider identity is missing/mismatched -> not ready"를 추가하세요. + - Required: bullet형 `Provider identity` block parser가 block의 indent/end boundary를 지키지 않습니다. `scanIdentityFields`는 각 줄을 `strings.TrimSpace`한 뒤 `- Provider identity:`를 만나면 `inBlock=true`로 두고, 다음 heading 전까지 모든 `key: value` bullet을 field로 흡수합니다. 이 때문에 nested bullet이 아닌 뒤쪽 sibling bullet의 `provider`나 `work item id`도 identity로 오인할 수 있습니다. `services/core/internal/roadmapsync/milestone_parser.go:95`에서 raw indentation을 보존해 parent bullet보다 깊은 child bullet만 허용하고, blank/outdent/non-child 또는 다음 heading에서 bullet block을 닫도록 고치세요. 회귀 테스트는 `- Provider identity:` 아래에 nested 필드가 없고 뒤쪽 top-level bullet에 `work item id`가 있는 경우 `ErrNoProviderIdentity`가 나는지 확인해야 합니다. +- 다음 단계: FAIL follow-up으로 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_1.log new file mode 100644 index 0000000..d03091e --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_1.log @@ -0,0 +1,188 @@ + + +# Code Review Reference - REVIEW_API + +> **[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`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-13 +task=m-milestone-work-item-creation-sync/03+02_develop_match, plan=1, tag=REVIEW_API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `cycle-develop-sync`: sync layer가 `branches/develop` 또는 remote `develop`에서 pushed Milestone을 감지하고 원래 Plane 티켓 identity와 매칭한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/03+02_develop_match/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-milestone-work-item-creation-sync`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] Develop Gate Identity Match | [x] | +| [REVIEW_API-2] Provider Identity Bullet Boundary | [x] | + +## 구현 체크리스트 + +- [x] `MatchDevelopMilestone`가 changed milestone path뿐 아니라 scanned Milestone의 parsed provider identity를 expected identity와 비교하도록 입력 모델과 gate를 보완한다. +- [x] bullet형 `Provider identity` parser가 nested child bullet boundary만 field로 인정하도록 indentation/end-of-block 처리를 고친다. +- [x] missing/mismatched scanned identity와 bullet outdent parser 회귀 테스트를 추가하고 `cd services/core && go test -count=1 ./internal/roadmapsync`, `cd services/core && go test ./...`, `git diff --check`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.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/m-milestone-work-item-creation-sync/03+02_develop_match/`를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/03+02_develop_match/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-milestone-work-item-creation-sync`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-milestone-work-item-creation-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] 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로 이동한다. + +## 계획 대비 변경 사항 + +- 계획대로 `roadmapsync` 패키지와 테스트만 수정했고 새 외부 API/migration은 추가하지 않았다. 수정 파일은 계획 표의 5개 파일과 동일하다(identity.go, develop_match.go, develop_match_test.go, milestone_parser.go, milestone_parser_test.go). +- REVIEW_API-1에서 계획이 제시한 `ScannedMilestone`/`ScanResult.ScannedMilestones` shape를 그대로 도입했다. gate는 not-ready 원인을 `reasonNoScannedIdentity`(path는 맞지만 parsed identity 없음/normalize 실패)와 `reasonIdentityMismatch`(provider/work item id 불일치)로 분리했다. 기존 `ChangedFiles` path guard는 유지하고 그 뒤에 scanned identity match를 추가했다. +- 검증 명령은 계획의 고정 계약(`go test -count=1 ./internal/roadmapsync`, `go test ./...`, `git diff --check`)을 그대로 실행했다. 대체 없음. + +## 주요 설계 결정 + +- **Parser block kind 분리(REVIEW_API-2)**: heading block과 bullet block을 `blockKind`로 구분했다. bullet block(`- Provider identity:`)은 parent bullet의 leading indent를 기록하고, 그보다 **깊게** 들여쓴 child bullet만 field로 인정한다. 빈 줄, outdent, 같은/얕은 indent의 sibling bullet, key:value 형식이 아닌 줄, 다음 heading 중 하나라도 만나면 bullet block을 닫아 뒤쪽의 무관한 `provider:`/`work item id:` bullet을 흡수하지 않는다. heading block(`## Provider identity`)은 기존대로 다음 heading까지 field를 허용한다. indent는 tab을 1열로 보는 상대 깊이만 사용한다(정확한 컬럼 모델 불필요). +- **Scanned identity match(REVIEW_API-1)**: `scannedIdentityForPath`는 expected path와 일치하는 scanned Milestone을 찾고 그 identity를 `Normalize()`한다. normalize 실패(필수 필드 빈 값 포함)는 "missing"으로 처리해 조용한 통과를 막는다. ready 최종 판정은 `expected.Matches(scanned)`까지 통과해야 하므로, path만 맞고 identity가 없거나 다른 work item이면 절대 ready가 되지 않는다. +- **Reason 분리 유지**: 모든 not-ready 경로는 stable reason 문자열로 분리되어 sync layer가 원인별로 분기할 수 있다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `MatchDevelopMilestone`가 changed path만으로 ready를 반환하지 않는지 확인한다. +- scanned Milestone identity missing/mismatch가 not-ready reason으로 분리되는지 확인한다. +- bullet형 `Provider identity` parser가 nested child bullet만 field로 읽는지 확인한다. +- 추가된 회귀 테스트가 실패 사례를 실제로 검증하는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### REVIEW_API-1/REVIEW_API-2 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/roadmapsync +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.002s + +$ gofmt -l ./internal/roadmapsync/ +(no output) +$ go vet ./internal/roadmapsync +(no output) +``` + +### 최종 검증 +```bash +$ cd services/core && go test ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached) +? github.com/nomadcode/nomadcode-core/cmd/server [no test files] +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached) +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached) +? github.com/nomadcode/nomadcode-core/internal/agent [no test files] +ok github.com/nomadcode/nomadcode-core/internal/authoring (cached) +ok github.com/nomadcode/nomadcode-core/internal/config (cached) +? github.com/nomadcode/nomadcode-core/internal/db [no test files] +ok github.com/nomadcode/nomadcode-core/internal/http (cached) +? github.com/nomadcode/nomadcode-core/internal/model [no test files] +ok github.com/nomadcode/nomadcode-core/internal/notification (cached) +ok github.com/nomadcode/nomadcode-core/internal/projectsync (cached) +ok github.com/nomadcode/nomadcode-core/internal/protosocket (cached) +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s +ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached) +ok github.com/nomadcode/nomadcode-core/internal/storage (cached) +ok github.com/nomadcode/nomadcode-core/internal/workflow (cached) +ok github.com/nomadcode/nomadcode-core/internal/workitem (cached) +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached) + +$ git add -N services/core/internal/roadmapsync/ && git diff --check +(no output, exit 0) +``` + +--- + +> **[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. + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. | +| Roadmap Targets | Fixed at stub creation from plan | Code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only. | +| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only; final checkbox is mandatory. | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user-owned input is required. | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus. | +| 검증 결과 | Implementing agent | Fill actual command output. | +| 코드리뷰 결과 | Review agent | Append only during review. | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 `complete.log`를 작성하고 active task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/complete.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/complete.log new file mode 100644 index 0000000..caf9967 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/complete.log @@ -0,0 +1,46 @@ +# Complete - m-milestone-work-item-creation-sync/03+02_develop_match + +## 완료 일시 + +2026-06-13 + +## 요약 + +Develop Milestone match follow-up loop 2에서 pushed Milestone의 parsed provider identity와 expected Plane work item identity 비교, bullet형 Provider identity parser boundary 회귀를 보완하고 최종 PASS로 종결했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | path-only develop gate와 bullet parser boundary 문제로 follow-up 작성 | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | scanned identity match와 parser boundary 회귀 테스트 보완 확인 | + +## 구현/정리 내용 + +- `ScanResult`에 `ScannedMilestone`을 추가하고 `MatchDevelopMilestone`이 changed path뿐 아니라 parsed identity를 expected identity와 비교하도록 보완했다. +- missing scanned identity와 provider/work item mismatch를 not-ready reason으로 분리했다. +- bullet형 `Provider identity` parser가 parent보다 깊은 child field만 읽고 blank/outdent/sibling에서 block을 닫도록 고쳤다. +- missing/mismatched scanned identity와 bullet boundary 회귀 테스트를 추가했다. + +## 최종 검증 + +- `cd services/core && go test -count=1 ./internal/roadmapsync` - PASS; `ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s` +- `cd services/core && go test ./...` - PASS; all core packages passed, unchanged packages may be cached. +- `git diff --check` - PASS; whitespace 오류 없음. +- `cd services/core && gofmt -l ./internal/roadmapsync` - PASS; no output. +- `cd services/core && go vet ./internal/roadmapsync` - PASS; no output. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Completed task ids: + - `cycle-develop-sync`: PASS; evidence=`agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/plan_cloud_G07_1.log`, `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/roadmapsync`, `cd services/core && go test ./...`, `git diff --check` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-milestone-work-item-creation-sync/03+02_develop_match/PLAN-cloud-G07.md b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-milestone-work-item-creation-sync/03+02_develop_match/PLAN-cloud-G07.md rename to agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/plan_cloud_G07_1.log new file mode 100644 index 0000000..99ab844 --- /dev/null +++ b/agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/plan_cloud_G07_1.log @@ -0,0 +1,235 @@ + + +# Plan - REVIEW_API Develop Match Follow-up + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 검증 명령을 실행하고 실제 출력과 구현 메모를 남긴 뒤 active 파일을 유지한 채 리뷰 준비를 보고한다. 사용자 전용 결정, 사용자 소유 외부 환경 prerequisite, 또는 범위 충돌은 review stub의 `사용자 리뷰 요청`에 정확한 근거와 함께 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. `USER_REVIEW.md`, 로그 아카이브, `complete.log` 작성은 code-review 전용이다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +1차 구현은 `roadmapsync` 패키지와 테스트를 추가했지만, develop projection gate가 실제 pushed Milestone 문서의 provider identity를 expected Plane work item identity와 비교하지 않는다. 또한 bullet형 `Provider identity` parser가 indentation boundary를 보지 않아 unrelated sibling bullet을 identity field로 흡수할 수 있다. 이 follow-up은 두 Required만 닫는다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단은 active review stub의 `사용자 리뷰 요청`에 기록한다. code-review가 요청 타당성을 검증하며, 구현 에이전트는 사용자에게 직접 질문하지 않는다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- Task ids: + - `cycle-develop-sync`: sync layer가 `branches/develop` 또는 remote `develop`에서 pushed Milestone을 감지하고 원래 Plane 티켓 identity와 매칭한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-roadmap/current.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-ops/rules/project/domain/contracts/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/core-smoke.md` +- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` +- `packages/contracts/notes/flutter-core-api-candidates.md` +- `services/core/internal/workitem/provider.go` +- `services/core/internal/roadmapsync/identity.go` +- `services/core/internal/roadmapsync/milestone_parser.go` +- `services/core/internal/roadmapsync/develop_match.go` +- `services/core/internal/roadmapsync/identity_test.go` +- `services/core/internal/roadmapsync/milestone_parser_test.go` +- `services/core/internal/roadmapsync/develop_match_test.go` +- `agent-task/m-milestone-work-item-creation-sync/03+02_develop_match/plan_cloud_G07_0.log` +- `agent-task/m-milestone-work-item-creation-sync/03+02_develop_match/code_review_cloud_G07_0.log` +- `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/complete.log` + +### 테스트 환경 규칙 + +- test_env: `local`. +- `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다. +- core 코드 변경 필수 검증: `cd services/core && go test ./...`. +- 변경 패키지는 cache 신뢰를 피하기 위해 `cd services/core && go test -count=1 ./internal/roadmapsync`를 중간 검증으로 사용한다. +- whitespace 검증: `git diff --check`. + +### 테스트 커버리지 공백 + +- `MatchDevelopMilestone`는 changed milestone path가 맞더라도 parsed provider identity가 missing/mismatched이면 not-ready가 되어야 한다. 현재 테스트 없음. +- bullet형 `Provider identity`는 nested child bullet만 field로 읽어야 한다. 현재 sibling/outdent bullet 오인 회귀 테스트 없음. + +### 심볼 참조 + +- renamed/removed symbols: none. +- `rg -n "roadmapsync|MatchDevelopMilestone|ParseMilestoneIdentity|ProjectionCandidate|ScanResult" services/core` 결과, 신규 패키지 외 call site는 없다. + +### 분할 판단 + +- 기존 split task `m-milestone-work-item-creation-sync/03+02_develop_match`의 review follow-up이다. +- predecessor index `02`는 `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/02+01_iop_authoring_bridge/complete.log`로 충족되었다. +- 새 sibling을 만들지 않고 같은 subtask loop에서 Required 2건만 보완한다. Plane Todo projection(04), idempotency/retry(05)는 그대로 제외한다. + +### 범위 결정 근거 + +- Plane comment/body/status projection, identity map persistence, retry/idempotency DB 작업은 이 follow-up 범위가 아니다. +- 새 외부 API나 migration을 만들지 않는다. +- `roadmapsync` 패키지와 그 테스트만 수정한다. + +### 빌드 등급 + +- build: `cloud-G07`. provider identity, roadmap parser, develop gate의 cross-domain correctness를 다시 맞춰야 하는 후속 수정이다. + +## 구현 체크리스트 + +- [ ] `MatchDevelopMilestone`가 changed milestone path뿐 아니라 scanned Milestone의 parsed provider identity를 expected identity와 비교하도록 입력 모델과 gate를 보완한다. +- [ ] bullet형 `Provider identity` parser가 nested child bullet boundary만 field로 인정하도록 indentation/end-of-block 처리를 고친다. +- [ ] missing/mismatched scanned identity와 bullet outdent parser 회귀 테스트를 추가하고 `cd services/core && go test -count=1 ./internal/roadmapsync`, `cd services/core && go test ./...`, `git diff --check`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +1. Parser boundary를 먼저 고쳐 parser 결과 신뢰성을 확보한다. +2. Develop gate가 scanned identity를 입력받아 expected identity와 비교하게 한다. +3. 회귀 테스트와 core 검증을 실행한다. + +### [REVIEW_API-1] Develop Gate Identity Match + +#### 문제 + +`services/core/internal/roadmapsync/develop_match.go:38`의 `MatchDevelopMilestone`는 expected identity normalize, develop branch/revision, changed path만 확인한다. + +```go +// services/core/internal/roadmapsync/develop_match.go:38 +func MatchDevelopMilestone(in MatchInput) ProjectionCandidate { + revision := in.Scan.Revision.Normalize() + + expected, err := in.Expected.Normalize() + if err != nil { + return notReady(in.Expected, revision, reasonInvalidIdentity) + } + + if !revision.OnDevelop() { + return notReady(expected, revision, reasonNotOnDevelop) + } + if revision.Revision == "" { + return notReady(expected, revision, reasonNoRevision) + } + if !changedFilesContainMilestone(in.Scan.ChangedFiles, expected.RoadmapMilestonePath) { + return notReady(expected, revision, reasonNoMilestoneFile) + } +``` + +이 구조에서는 변경된 Milestone 파일에 provider identity가 없거나 다른 Plane work item id가 있어도 path만 맞으면 ready가 된다. + +#### 해결 방법 + +`ScanResult`에 changed Milestone path와 parsed `Identity`를 함께 담는 typed shape를 추가한다. 예: + +```go +type ScannedMilestone struct { + Path string `json:"path"` + Identity Identity `json:"identity"` +} + +type ScanResult struct { + Revision Revision `json:"revision"` + ChangedFiles []string `json:"changed_files,omitempty"` + ScannedMilestones []ScannedMilestone `json:"scanned_milestones,omitempty"` +} +``` + +`MatchDevelopMilestone`는 expected path와 같은 scanned milestone을 찾고, 그 `Identity.Normalize()`와 `expected.Matches(scanned)`를 통과할 때만 ready를 반환한다. path는 맞지만 scanned identity가 없거나 normalize되지 않으면 not-ready reason을 추가한다. provider/work item id mismatch도 not-ready reason으로 분리한다. 기존 `ChangedFiles` path guard는 유지하되, ready 조건은 scanned identity match까지 포함한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/roadmapsync/identity.go` +- [ ] `services/core/internal/roadmapsync/develop_match.go` +- [ ] `services/core/internal/roadmapsync/develop_match_test.go` + +#### 테스트 작성 + +- `TestMatchDevelopMilestoneReady`는 scanned milestone identity까지 포함해 ready를 검증한다. +- changed path는 expected와 같지만 scanned identity가 비어 있으면 not-ready. +- changed path는 expected와 같지만 scanned work item id/provider가 다르면 not-ready. +- 기존 slot local/no revision, feature branch, non-milestone path 테스트는 유지한다. + +#### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/roadmapsync +``` + +### [REVIEW_API-2] Provider Identity Bullet Boundary + +#### 문제 + +`services/core/internal/roadmapsync/milestone_parser.go:95`의 parser는 raw line을 바로 trim한 뒤 block 상태를 관리한다. + +```go +// services/core/internal/roadmapsync/milestone_parser.go:95 +for scanner.Scan() { + raw := scanner.Text() + line := strings.TrimSpace(raw) + if line == "" { + continue + } +``` + +`services/core/internal/roadmapsync/milestone_parser.go:122`에서 `- Provider identity:`를 만나면 `inBlock=true`가 되고, 이후 next heading 전까지 모든 `key: value` bullet을 field로 흡수한다. + +```go +// services/core/internal/roadmapsync/milestone_parser.go:122 +if !inBlock && strings.EqualFold(key, identityHeading) { + inBlock = true + found = true + continue +} +``` + +따라서 nested child bullet이 아닌 뒤쪽 sibling bullet의 `provider` 또는 `work item id`도 provider identity field로 오인될 수 있다. + +#### 해결 방법 + +heading block과 bullet block을 구분한다. bullet block에서는 `raw`의 leading space/tab indent를 보존하고, `- Provider identity:` parent indent보다 깊게 들여쓴 child bullet만 field로 읽는다. 빈 줄, outdent, 같은 indent의 sibling bullet, non-child line, 또는 다음 heading을 만나면 bullet block을 닫는다. heading block은 기존처럼 다음 heading까지 허용하되, 다른 heading에서 닫는다. + +#### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/roadmapsync/milestone_parser.go` +- [ ] `services/core/internal/roadmapsync/milestone_parser_test.go` + +#### 테스트 작성 + +- `- Provider identity:` 아래 nested provider/work item id는 계속 성공한다. +- `- Provider identity:` 뒤에 nested field가 없고, 뒤쪽 top-level sibling bullet에 `provider`/`work item id`가 있어도 `ErrNoProviderIdentity`를 반환한다. +- heading block의 `## 완료 리뷰` 이후 provider line을 무시하는 기존 테스트는 유지한다. + +#### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/roadmapsync +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/core/internal/roadmapsync/identity.go` | REVIEW_API-1 | +| `services/core/internal/roadmapsync/develop_match.go` | REVIEW_API-1 | +| `services/core/internal/roadmapsync/develop_match_test.go` | REVIEW_API-1 | +| `services/core/internal/roadmapsync/milestone_parser.go` | REVIEW_API-2 | +| `services/core/internal/roadmapsync/milestone_parser_test.go` | REVIEW_API-2 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./internal/roadmapsync +cd services/core && go test ./... +git diff --check +``` + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-milestone-work-item-creation-sync/03+02_develop_match/CODE_REVIEW-cloud-G07.md b/agent-task/m-milestone-work-item-creation-sync/03+02_develop_match/CODE_REVIEW-cloud-G07.md deleted file mode 100644 index 022de15..0000000 --- a/agent-task/m-milestone-work-item-creation-sync/03+02_develop_match/CODE_REVIEW-cloud-G07.md +++ /dev/null @@ -1,86 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> Fill implementation-owned sections, run verification, keep active files in place, and report ready for review. Do not prompt the user directly during implementation. - -## 개요 - -date=2026-06-13 -task=m-milestone-work-item-creation-sync/03+02_develop_match, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md` -- Task ids: - - `cycle-develop-sync`: sync layer가 `branches/develop` 또는 remote `develop`에서 pushed Milestone을 감지하고 원래 Plane 티켓 identity와 매칭한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다. - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Roadmap Sync Identity Model | [ ] | -| [API-2] Milestone Identity Parser | [ ] | -| [API-3] Develop Projection Candidate Gate | [ ] | - -## 구현 체크리스트 - -- [ ] `develop` branch workspace 또는 remote revision scan 결과를 표현하는 `roadmapsync` domain model을 추가한다. -- [ ] Milestone 파일에서 provider, tenant, project, work item id, milestone path/id를 추출하는 parser를 추가한다. -- [ ] pushed/scanned Milestone이 원래 Plane work item identity와 일치할 때만 projection-ready result를 반환한다. -- [ ] slot local dirty path나 push 실패 result는 projection-ready가 아니도록 테스트한다. -- [ ] `cd services/core && go test ./...`와 `git diff --check`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] active 파일을 `.log`로 아카이브하고 PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다. -- [ ] PASS이고 task group이 `m-`이면 런타임 완료 이벤트 메타데이터를 보고한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- slot local file이나 push 실패만으로 projection-ready가 되지 않는지 확인한다. -- Milestone parser가 provider identity 누락을 조용히 성공 처리하지 않는지 확인한다. -- sync identity field가 contract note와 충돌하지 않는지 확인한다. - -## 검증 결과 - -### API-1 중간 검증 -```bash -$ cd services/core && go test ./internal/roadmapsync -(output) -``` - -### 최종 검증 -```bash -$ cd services/core && go test ./... -$ git diff --check -(output) -``` - ---- diff --git a/services/core/internal/roadmapsync/develop_match.go b/services/core/internal/roadmapsync/develop_match.go new file mode 100644 index 0000000..caaaa7a --- /dev/null +++ b/services/core/internal/roadmapsync/develop_match.go @@ -0,0 +1,154 @@ +package roadmapsync + +import ( + "path" + "strings" +) + +// MatchInput is the input to the develop projection gate: the scan result a +// sync layer observed on branches/develop or remote develop, plus the expected +// work item identity carried from the originating Plane ticket. The expected +// identity supplies the Milestone path and provider/work item id the pushed +// change must match. +type MatchInput struct { + Scan ScanResult + Expected Identity +} + +// Reasons explaining a not-ready projection decision. These are stable strings +// so a sync layer can branch on the cause without re-deriving it. +const ( + reasonReady = "pushed develop milestone matched expected identity" + reasonInvalidIdentity = "expected identity is invalid or incomplete" + reasonNotOnDevelop = "scan revision is not on the develop branch" + reasonNoRevision = "scan has no pushed develop revision" + reasonNoMilestoneFile = "no changed agent-roadmap milestone file matched the expected milestone path" + reasonNoScannedIdentity = "no scanned milestone provider identity for the expected milestone path" + reasonIdentityMismatch = "scanned milestone identity does not match the expected work item identity" +) + +// MatchDevelopMilestone decides whether a pushed/scanned develop change is +// projection-ready for the expected Plane work item. It is ready only when: +// - the expected identity normalizes (provider + work item id + milestone path), +// - the scan revision is anchored on develop (not a slot-local or feature branch), +// - the scan carries an actual pushed revision, +// - one of the changed files is the expected Milestone file under +// agent-roadmap/phase/*/milestones/*.md, and +// - the scanned Milestone at that path carries a provider identity that +// matches the expected work item identity. +// +// A slot-local path or push-failure scan supplies no develop revision and so is +// never ready. A path-only match whose Milestone has no provider identity, or a +// different work item id/provider, is not ready. The reason is always +// populated. +func MatchDevelopMilestone(in MatchInput) ProjectionCandidate { + revision := in.Scan.Revision.Normalize() + + expected, err := in.Expected.Normalize() + if err != nil { + return notReady(in.Expected, revision, reasonInvalidIdentity) + } + + if !revision.OnDevelop() { + return notReady(expected, revision, reasonNotOnDevelop) + } + if revision.Revision == "" { + return notReady(expected, revision, reasonNoRevision) + } + if !changedFilesContainMilestone(in.Scan.ChangedFiles, expected.RoadmapMilestonePath) { + return notReady(expected, revision, reasonNoMilestoneFile) + } + + scanned, ok := scannedIdentityForPath(in.Scan.ScannedMilestones, expected.RoadmapMilestonePath) + if !ok { + // The path changed but no parsed provider identity backs it, so the + // pushed Milestone cannot be tied to the expected work item. + return notReady(expected, revision, reasonNoScannedIdentity) + } + if !expected.Matches(scanned) { + return notReady(expected, revision, reasonIdentityMismatch) + } + + return ProjectionCandidate{ + Identity: expected, + Revision: revision, + Ready: true, + Reason: reasonReady, + } +} + +// scannedIdentityForPath returns the normalized parsed identity of the scanned +// Milestone at the expected path. It reports ok=false when no scanned Milestone +// matches the path or its identity does not normalize, so a present-but-empty +// identity is treated as missing rather than silently matched. +func scannedIdentityForPath(scanned []ScannedMilestone, expectedPath string) (Identity, bool) { + want := normalizeRepoPath(expectedPath) + if want == "" { + return Identity{}, false + } + for _, sm := range scanned { + if normalizeRepoPath(sm.Path) != want { + continue + } + identity, err := sm.Identity.Normalize() + if err != nil { + return Identity{}, false + } + return identity, true + } + return Identity{}, false +} + +func notReady(identity Identity, revision Revision, reason string) ProjectionCandidate { + return ProjectionCandidate{ + Identity: identity, + Revision: revision, + Ready: false, + Reason: reason, + } +} + +// changedFilesContainMilestone reports whether any changed file is the expected +// Milestone path and sits under the agent-roadmap milestones layout. The +// expected path must itself be a milestone-shaped path; matching is exact on +// the cleaned repository-relative path so an unrelated edit elsewhere never +// counts. +func changedFilesContainMilestone(changedFiles []string, expectedPath string) bool { + want := normalizeRepoPath(expectedPath) + if want == "" || !isMilestonePath(want) { + return false + } + for _, f := range changedFiles { + if normalizeRepoPath(f) == want { + return true + } + } + return false +} + +// isMilestonePath reports whether a cleaned repo-relative path matches +// agent-roadmap/phase//milestones/.md. +func isMilestonePath(p string) bool { + if !strings.HasSuffix(p, ".md") { + return false + } + parts := strings.Split(p, "/") + if len(parts) != 5 { + return false + } + return parts[0] == "agent-roadmap" && + parts[1] == "phase" && + parts[3] == "milestones" +} + +func normalizeRepoPath(p string) string { + trimmed := strings.TrimSpace(p) + if trimmed == "" { + return "" + } + cleaned := path.Clean(strings.TrimPrefix(trimmed, "./")) + if cleaned == "." || strings.HasPrefix(cleaned, "..") { + return "" + } + return cleaned +} diff --git a/services/core/internal/roadmapsync/develop_match_test.go b/services/core/internal/roadmapsync/develop_match_test.go new file mode 100644 index 0000000..7eab684 --- /dev/null +++ b/services/core/internal/roadmapsync/develop_match_test.go @@ -0,0 +1,163 @@ +package roadmapsync + +import "testing" + +func readyExpected() Identity { + return Identity{ + RoadmapMilestonePath: "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md", + Provider: "plane", + Tenant: "general", + Project: "NOMAD", + WorkItemID: "NOMAD-13", + } +} + +func scannedReady() ScannedMilestone { + return ScannedMilestone{ + Path: "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md", + Identity: readyExpected(), + } +} + +func TestMatchDevelopMilestoneReady(t *testing.T) { + in := MatchInput{ + Scan: ScanResult{ + Revision: Revision{Branch: "develop", Revision: "abc123"}, + ChangedFiles: []string{ + "services/core/main.go", + "./agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md", + }, + ScannedMilestones: []ScannedMilestone{scannedReady()}, + }, + Expected: readyExpected(), + } + got := MatchDevelopMilestone(in) + if !got.Ready { + t.Fatalf("expected ready, got reason %q", got.Reason) + } + if got.Reason != reasonReady { + t.Errorf("reason = %q", got.Reason) + } + if got.Revision.Branch != "develop" { + t.Errorf("revision branch = %q", got.Revision.Branch) + } +} + +func TestMatchDevelopMilestoneNotReady(t *testing.T) { + milestoneFile := "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md" + cases := map[string]struct { + in MatchInput + wantReason string + }{ + "slot local path / no develop revision": { + in: MatchInput{ + // A slot-local scan carries no develop revision. + Scan: ScanResult{Revision: Revision{Branch: "develop"}, ChangedFiles: []string{milestoneFile}, ScannedMilestones: []ScannedMilestone{scannedReady()}}, + Expected: readyExpected(), + }, + wantReason: reasonNoRevision, + }, + "feature branch": { + in: MatchInput{ + Scan: ScanResult{Revision: Revision{Branch: "feature/x", Revision: "abc"}, ChangedFiles: []string{milestoneFile}, ScannedMilestones: []ScannedMilestone{scannedReady()}}, + Expected: readyExpected(), + }, + wantReason: reasonNotOnDevelop, + }, + "path matches but no scanned identity": { + in: MatchInput{ + // Path changed on develop, but no parsed provider identity backs it. + Scan: ScanResult{Revision: Revision{Branch: "develop", Revision: "abc"}, ChangedFiles: []string{milestoneFile}}, + Expected: readyExpected(), + }, + wantReason: reasonNoScannedIdentity, + }, + "path matches but scanned identity has empty fields": { + in: MatchInput{ + Scan: ScanResult{ + Revision: Revision{Branch: "develop", Revision: "abc"}, + ChangedFiles: []string{milestoneFile}, + ScannedMilestones: []ScannedMilestone{{Path: milestoneFile, Identity: Identity{RoadmapMilestonePath: milestoneFile}}}, + }, + Expected: readyExpected(), + }, + wantReason: reasonNoScannedIdentity, + }, + "path matches but scanned work item id differs": { + in: MatchInput{ + Scan: ScanResult{ + Revision: Revision{Branch: "develop", Revision: "abc"}, + ChangedFiles: []string{milestoneFile}, + ScannedMilestones: []ScannedMilestone{{ + Path: milestoneFile, + Identity: Identity{RoadmapMilestonePath: milestoneFile, Provider: "plane", WorkItemID: "NOMAD-99"}, + }}, + }, + Expected: readyExpected(), + }, + wantReason: reasonIdentityMismatch, + }, + "path matches but scanned provider differs": { + in: MatchInput{ + Scan: ScanResult{ + Revision: Revision{Branch: "develop", Revision: "abc"}, + ChangedFiles: []string{milestoneFile}, + ScannedMilestones: []ScannedMilestone{{ + Path: milestoneFile, + Identity: Identity{RoadmapMilestonePath: milestoneFile, Provider: "jira", WorkItemID: "NOMAD-13"}, + }}, + }, + Expected: readyExpected(), + }, + wantReason: reasonIdentityMismatch, + }, + "identity mismatch path": { + in: MatchInput{ + Scan: ScanResult{Revision: Revision{Branch: "develop", Revision: "abc"}, ChangedFiles: []string{"agent-roadmap/phase/other/milestones/other.md"}}, + Expected: readyExpected(), + }, + wantReason: reasonNoMilestoneFile, + }, + "non-milestone changed file": { + in: MatchInput{ + Scan: ScanResult{Revision: Revision{Branch: "develop", Revision: "abc"}, ChangedFiles: []string{"agent-roadmap/phase/p/notes.md"}}, + Expected: readyExpected(), + }, + wantReason: reasonNoMilestoneFile, + }, + "invalid expected identity": { + in: MatchInput{ + Scan: ScanResult{Revision: Revision{Branch: "develop", Revision: "abc"}, ChangedFiles: []string{milestoneFile}}, + Expected: Identity{Provider: "plane"}, + }, + wantReason: reasonInvalidIdentity, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := MatchDevelopMilestone(tc.in) + if got.Ready { + t.Fatalf("expected not ready") + } + if got.Reason != tc.wantReason { + t.Errorf("reason = %q, want %q", got.Reason, tc.wantReason) + } + }) + } +} + +func TestIsMilestonePath(t *testing.T) { + if !isMilestonePath("agent-roadmap/phase/p/milestones/m.md") { + t.Errorf("expected milestone path to match") + } + for _, p := range []string{ + "agent-roadmap/phase/p/milestones/m.txt", + "agent-roadmap/phase/p/notes/m.md", + "agent-roadmap/milestones/m.md", + "other/phase/p/milestones/m.md", + } { + if isMilestonePath(p) { + t.Errorf("path %q should not match", p) + } + } +} diff --git a/services/core/internal/roadmapsync/identity.go b/services/core/internal/roadmapsync/identity.go new file mode 100644 index 0000000..693c57b --- /dev/null +++ b/services/core/internal/roadmapsync/identity.go @@ -0,0 +1,161 @@ +// Package roadmapsync owns the provider-neutral domain for matching pushed +// agent-roadmap Milestone changes back to the originating provider work item. +// The source of truth is the agent-roadmap on the develop branch (or remote +// develop), never a slot-local edit or an unpushed change. +package roadmapsync + +import ( + "errors" + "strings" + + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +var ErrInvalidIdentity = errors.New("invalid roadmap sync identity") + +// Shape distinguishes a Milestone-level identity from a Task-level identity, as +// described in the contract sync identity note. The first slice only requires +// the Milestone shape for develop matching; Task is defined for forward +// compatibility with child work item sync. +type Shape string + +const ( + ShapeMilestone Shape = "milestone" + ShapeTask Shape = "task" +) + +func (s Shape) Valid() bool { + switch s { + case ShapeMilestone, ShapeTask: + return true + default: + return false + } +} + +// Identity is the provider-neutral shape used to find and compare the same +// Milestone item regardless of which side changed. Field set follows the +// contract sync identity candidate (packages/contracts note 5.2). The minimal +// required match key is provider + work item id plus the Milestone path; the +// authoring run id is intentionally not a required match key in this slice. +type Identity struct { + Shape Shape `json:"shape"` + RoadmapMilestonePath string `json:"roadmap_milestone_path"` + RoadmapItemID string `json:"roadmap_item_id,omitempty"` + Provider workitem.ProviderID `json:"provider"` + Tenant string `json:"tenant,omitempty"` + Project string `json:"project,omitempty"` + WorkItemID string `json:"work_item_id"` + ParentWorkItemID string `json:"parent_work_item_id,omitempty"` +} + +// Normalize trims fields and enforces the minimal required match key: a valid +// shape, a milestone path, a provider, and a work item id. Task shapes +// additionally require a parent work item id so a Task is never mistaken for a +// top-level Milestone. Missing required fields are a hard error rather than a +// silently accepted partial identity. +func (i Identity) Normalize() (Identity, error) { + shape := Shape(strings.TrimSpace(string(i.Shape))) + if shape == "" { + shape = ShapeMilestone + } + if !shape.Valid() { + return Identity{}, ErrInvalidIdentity + } + + normalized := Identity{ + Shape: shape, + RoadmapMilestonePath: strings.TrimSpace(i.RoadmapMilestonePath), + RoadmapItemID: strings.TrimSpace(i.RoadmapItemID), + Provider: workitem.ProviderID(strings.TrimSpace(string(i.Provider))), + Tenant: strings.TrimSpace(i.Tenant), + Project: strings.TrimSpace(i.Project), + WorkItemID: strings.TrimSpace(i.WorkItemID), + ParentWorkItemID: strings.TrimSpace(i.ParentWorkItemID), + } + + if normalized.RoadmapMilestonePath == "" || + normalized.Provider == "" || + normalized.WorkItemID == "" { + return Identity{}, ErrInvalidIdentity + } + if normalized.Shape == ShapeTask && normalized.ParentWorkItemID == "" { + return Identity{}, ErrInvalidIdentity + } + return normalized, nil +} + +// Matches reports whether two identities refer to the same Milestone item. The +// comparison is on the normalized provider + work item id + milestone path, so +// callers must normalize first; an un-normalizable identity never matches. +func (i Identity) Matches(other Identity) bool { + left, err := i.Normalize() + if err != nil { + return false + } + right, err := other.Normalize() + if err != nil { + return false + } + return left.Provider == right.Provider && + left.WorkItemID == right.WorkItemID && + left.RoadmapMilestonePath == right.RoadmapMilestonePath +} + +// Revision captures the branch and revision context that a scan observed, so a +// projection decision can record what develop state it was based on. Milestone +// sync defaults Branch to develop per the contract note. +type Revision struct { + Branch string `json:"roadmap_branch"` + Revision string `json:"roadmap_revision,omitempty"` +} + +// DefaultBranch is the source-of-truth branch for Milestone sync. +const DefaultBranch = "develop" + +// Normalize defaults an empty branch to develop and trims the revision. +func (r Revision) Normalize() Revision { + branch := strings.TrimSpace(r.Branch) + if branch == "" { + branch = DefaultBranch + } + return Revision{ + Branch: branch, + Revision: strings.TrimSpace(r.Revision), + } +} + +// OnDevelop reports whether the revision is anchored on the develop branch. +func (r Revision) OnDevelop() bool { + return r.Normalize().Branch == DefaultBranch +} + +// ScannedMilestone pairs a changed Milestone file path with the provider +// identity parsed out of that file. The develop gate matches the parsed +// identity against the expected work item identity, so a path change alone is +// never enough to project. +type ScannedMilestone struct { + Path string `json:"path"` + Identity Identity `json:"identity"` +} + +// ScanResult is the input describing what a develop scan observed: the revision +// it looked at, the set of roadmap-relevant files that changed, and the +// Milestone files whose provider identity was parsed. ChangedFiles are +// repository-relative paths. +type ScanResult struct { + Revision Revision `json:"revision"` + ChangedFiles []string `json:"changed_files,omitempty"` + ScannedMilestones []ScannedMilestone `json:"scanned_milestones,omitempty"` +} + +// ProjectionCandidate is the typed result of matching a scan against an +// expected work item identity. Ready is only true when a pushed develop +// Milestone change matched the identity; Reason always explains the decision so +// a not-ready result is never silent. +type ProjectionCandidate struct { + Identity Identity `json:"identity"` + Revision Revision `json:"revision"` + Ready bool `json:"ready"` + Reason string `json:"reason"` +} diff --git a/services/core/internal/roadmapsync/identity_test.go b/services/core/internal/roadmapsync/identity_test.go new file mode 100644 index 0000000..514ec6a --- /dev/null +++ b/services/core/internal/roadmapsync/identity_test.go @@ -0,0 +1,102 @@ +package roadmapsync + +import ( + "errors" + "testing" +) + +func TestIdentityNormalizeValid(t *testing.T) { + in := Identity{ + RoadmapMilestonePath: " agent-roadmap/phase/p/milestones/m.md ", + Provider: " plane ", + Tenant: " general ", + Project: " NOMAD ", + WorkItemID: " NOMAD-13 ", + } + got, err := in.Normalize() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Shape != ShapeMilestone { + t.Errorf("shape = %q, want default milestone", got.Shape) + } + if got.RoadmapMilestonePath != "agent-roadmap/phase/p/milestones/m.md" { + t.Errorf("path = %q", got.RoadmapMilestonePath) + } + if got.Provider != "plane" || got.WorkItemID != "NOMAD-13" { + t.Errorf("provider/work item not trimmed: %+v", got) + } +} + +func TestIdentityNormalizeRejectsMissingFields(t *testing.T) { + cases := map[string]Identity{ + "missing provider": { + RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", + WorkItemID: "NOMAD-13", + }, + "missing work item id": { + RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", + Provider: "plane", + }, + "missing milestone path": { + Provider: "plane", + WorkItemID: "NOMAD-13", + }, + "task without parent": { + Shape: ShapeTask, + RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", + Provider: "plane", + WorkItemID: "NOMAD-13", + }, + "unknown shape": { + Shape: Shape("epic"), + RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", + Provider: "plane", + WorkItemID: "NOMAD-13", + }, + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + if _, err := in.Normalize(); !errors.Is(err, ErrInvalidIdentity) { + t.Fatalf("err = %v, want ErrInvalidIdentity", err) + } + }) + } +} + +func TestIdentityMatches(t *testing.T) { + base := Identity{ + RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", + Provider: "plane", + WorkItemID: "NOMAD-13", + } + same := base + same.Project = "NOMAD" // non-key field differs, still a match + if !base.Matches(same) { + t.Errorf("expected match ignoring non-key fields") + } + + differentWorkItem := base + differentWorkItem.WorkItemID = "NOMAD-99" + if base.Matches(differentWorkItem) { + t.Errorf("different work item id must not match") + } + + invalid := Identity{Provider: "plane"} + if base.Matches(invalid) { + t.Errorf("invalid identity must not match") + } +} + +func TestRevisionNormalizeDefaultsDevelop(t *testing.T) { + got := Revision{}.Normalize() + if got.Branch != DefaultBranch { + t.Errorf("branch = %q, want develop", got.Branch) + } + if !(Revision{Branch: "develop", Revision: "abc"}).OnDevelop() { + t.Errorf("expected OnDevelop true") + } + if (Revision{Branch: "feature/x"}).OnDevelop() { + t.Errorf("feature branch must not be OnDevelop") + } +} diff --git a/services/core/internal/roadmapsync/milestone_parser.go b/services/core/internal/roadmapsync/milestone_parser.go new file mode 100644 index 0000000..5277c29 --- /dev/null +++ b/services/core/internal/roadmapsync/milestone_parser.go @@ -0,0 +1,210 @@ +package roadmapsync + +import ( + "bufio" + "errors" + "strings" + + "github.com/nomadcode/nomadcode-core/internal/workitem" +) + +// ErrNoProviderIdentity is returned when a Milestone document carries no +// provider identity block. A Milestone without provider + work item id cannot +// be matched to a Plane ticket, so this is an explicit miss rather than a +// silently empty success. +var ErrNoProviderIdentity = errors.New("milestone has no provider identity") + +// identityFieldKeys maps the bullet/field labels accepted inside a provider +// identity block to the Identity field they populate. Keys are matched +// case-insensitively after trimming, with both spaced and snake_case spellings +// accepted so the authoring agent's Markdown does not have to match one exact +// form. +var identityFieldKeys = map[string]string{ + "provider": "provider", + "tenant": "tenant", + "project": "project", + "work item id": "work_item_id", + "work_item_id": "work_item_id", + "parent work item id": "parent_work_item_id", + "parent_work_item_id": "parent_work_item_id", + "roadmap item id": "roadmap_item_id", + "roadmap_item_id": "roadmap_item_id", + "milestone id": "roadmap_item_id", +} + +// identityHeading is the section/bullet label that opens a provider identity +// block. Matched case-insensitively. +const identityHeading = "provider identity" + +// ParseMilestoneIdentity reads a strict provider identity block out of a +// Milestone Markdown document and returns a normalized Identity. milestonePath +// is the repository-relative path of the Milestone file; it becomes the +// identity's RoadmapMilestonePath since the path is not duplicated inside the +// block. ad hoc string scanning is confined to this function; the external +// result is a typed Identity only. +// +// The block is recognized either as a heading ("## Provider identity") followed +// by `- field: value` bullets, or as a single "Provider identity" bullet whose +// nested bullets carry the fields. Parsing stops at the next heading or the end +// of the indented bullet group. +func ParseMilestoneIdentity(milestonePath, markdown string) (Identity, error) { + path := strings.TrimSpace(milestonePath) + if path == "" { + return Identity{}, ErrInvalidIdentity + } + + fields, found := scanIdentityFields(markdown) + if !found || len(fields) == 0 { + return Identity{}, ErrNoProviderIdentity + } + + identity := Identity{ + Shape: ShapeMilestone, + RoadmapMilestonePath: path, + RoadmapItemID: fields["roadmap_item_id"], + Provider: workitem.ProviderID(fields["provider"]), + Tenant: fields["tenant"], + Project: fields["project"], + WorkItemID: fields["work_item_id"], + ParentWorkItemID: fields["parent_work_item_id"], + } + if identity.ParentWorkItemID != "" { + identity.Shape = ShapeTask + } + + normalized, err := identity.Normalize() + if err != nil { + // A present-but-incomplete identity block is a provider identity miss, + // not a generic invalid-identity, so callers can distinguish "no block" + // from "block missing required fields" via the same not-ready signal. + return Identity{}, ErrNoProviderIdentity + } + return normalized, nil +} + +// blockKind distinguishes how the open provider identity block was introduced, +// because the two forms have different boundaries. +type blockKind int + +const ( + blockNone blockKind = iota + blockHeading // "## Provider identity" heading; fields run until the next heading. + blockBullet // "- Provider identity:" bullet; fields are only deeper-indented child bullets. +) + +// scanIdentityFields walks the document looking for the provider identity block +// and collects its field/value pairs keyed by the normalized Identity field +// name. The second return reports whether the block heading/bullet was found at +// all, so a present-but-empty block is distinguishable from an absent one. +// +// Heading blocks ("## Provider identity") accept field lines until the next +// heading. Bullet blocks ("- Provider identity:") only accept child bullets +// indented deeper than the parent bullet; a blank line, an outdent, a same- or +// shallower-indent sibling, or a heading closes the bullet block so a later +// unrelated `provider:`/`work item id:` bullet is never absorbed. +func scanIdentityFields(markdown string) (map[string]string, bool) { + fields := map[string]string{} + scanner := bufio.NewScanner(strings.NewReader(markdown)) + + kind := blockNone + found := false + parentIndent := 0 + for scanner.Scan() { + raw := scanner.Text() + line := strings.TrimSpace(raw) + + if line == "" { + // A blank line ends a bullet block; heading blocks tolerate blanks. + if kind == blockBullet { + kind = blockNone + } + continue + } + + if isMarkdownHeading(line) { + if headingMatchesIdentity(line) { + kind = blockHeading + found = true + continue + } + // Any other heading closes an open block. + if kind != blockNone { + kind = blockNone + } + continue + } + + indent := leadingIndent(raw) + key, value, ok := parseFieldLine(line) + if !ok { + // A non field-shaped line breaks an open bullet block. + if kind == blockBullet { + kind = blockNone + } + continue + } + + // A standalone "Provider identity" bullet opens a bullet block; only its + // deeper-indented child bullets count as fields. + if kind == blockNone && strings.EqualFold(key, identityHeading) { + kind = blockBullet + found = true + parentIndent = indent + continue + } + + switch kind { + case blockBullet: + if indent <= parentIndent { + // Sibling or outdent: the bullet block is over. The current line + // is not a child field; do not absorb it. + kind = blockNone + continue + } + case blockHeading: + // Heading blocks accept any field line until the next heading. + default: + continue + } + + if field, known := identityFieldKeys[strings.ToLower(key)]; known && value != "" { + fields[field] = value + } + } + return fields, found +} + +// leadingIndent counts the leading whitespace columns of a raw line, treating a +// tab as one column. Only relative depth between the parent bullet and its +// children matters, so an exact column model is unnecessary. +func leadingIndent(raw string) int { + return len(raw) - len(strings.TrimLeft(raw, " \t")) +} + +func isMarkdownHeading(line string) bool { + return strings.HasPrefix(line, "#") +} + +func headingMatchesIdentity(line string) bool { + text := strings.TrimSpace(strings.TrimLeft(line, "#")) + return strings.EqualFold(text, identityHeading) +} + +// parseFieldLine extracts a `key: value` pair from a bullet or plain field +// line, stripping a leading list marker. It reports ok=false for lines that are +// not key/value shaped. +func parseFieldLine(line string) (key, value string, ok bool) { + trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*")) + idx := strings.Index(trimmed, ":") + if idx < 0 { + return "", "", false + } + key = strings.TrimSpace(trimmed[:idx]) + value = strings.TrimSpace(trimmed[idx+1:]) + value = strings.Trim(value, "`") + value = strings.TrimSpace(value) + if key == "" { + return "", "", false + } + return key, value, true +} diff --git a/services/core/internal/roadmapsync/milestone_parser_test.go b/services/core/internal/roadmapsync/milestone_parser_test.go new file mode 100644 index 0000000..d75a2d7 --- /dev/null +++ b/services/core/internal/roadmapsync/milestone_parser_test.go @@ -0,0 +1,149 @@ +package roadmapsync + +import ( + "errors" + "testing" +) + +const milestonePath = "agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md" + +func TestParseMilestoneIdentityHeadingBlock(t *testing.T) { + md := `# Milestone + +본문 텍스트. + +## Provider identity + +- provider: plane +- tenant: general +- project: NOMAD +- work item id: ` + "`NOMAD-13`" + ` + +## 완료 리뷰 +- provider: ignored-after-block +` + got, err := ParseMilestoneIdentity(milestonePath, md) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Provider != "plane" || got.WorkItemID != "NOMAD-13" { + t.Errorf("identity = %+v", got) + } + if got.Tenant != "general" || got.Project != "NOMAD" { + t.Errorf("tenant/project not parsed: %+v", got) + } + if got.RoadmapMilestonePath != milestonePath { + t.Errorf("path = %q", got.RoadmapMilestonePath) + } + if got.Shape != ShapeMilestone { + t.Errorf("shape = %q", got.Shape) + } +} + +func TestParseMilestoneIdentityBulletBlock(t *testing.T) { + md := `# Milestone + +- Provider identity: + - provider: plane + - work_item_id: NOMAD-42 +` + got, err := ParseMilestoneIdentity(milestonePath, md) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Provider != "plane" || got.WorkItemID != "NOMAD-42" { + t.Errorf("identity = %+v", got) + } +} + +func TestParseMilestoneIdentityTaskShape(t *testing.T) { + md := `## Provider identity +- provider: plane +- work item id: NOMAD-50 +- parent work item id: NOMAD-13 +` + got, err := ParseMilestoneIdentity(milestonePath, md) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Shape != ShapeTask || got.ParentWorkItemID != "NOMAD-13" { + t.Errorf("expected task shape with parent: %+v", got) + } +} + +func TestParseMilestoneIdentityBulletBoundaryRejectsSiblings(t *testing.T) { + // "- Provider identity:" has no nested child fields; the later top-level + // sibling bullets must not be absorbed as identity fields. + md := `# Milestone + +- Provider identity: +- provider: plane +- work item id: NOMAD-13 +` + _, err := ParseMilestoneIdentity(milestonePath, md) + if !errors.Is(err, ErrNoProviderIdentity) { + t.Fatalf("err = %v, want ErrNoProviderIdentity", err) + } +} + +func TestParseMilestoneIdentityBulletBoundaryClosesOnOutdent(t *testing.T) { + // Nested children supply a partial identity, then an outdent sibling tries + // to add work item id. The outdent closes the block, so the identity stays + // incomplete and is rejected. + md := `# Milestone + +- Provider identity: + - provider: plane +- work item id: NOMAD-13 +` + _, err := ParseMilestoneIdentity(milestonePath, md) + if !errors.Is(err, ErrNoProviderIdentity) { + t.Fatalf("err = %v, want ErrNoProviderIdentity", err) + } +} + +func TestParseMilestoneIdentityBulletBoundaryClosesOnBlankLine(t *testing.T) { + md := `# Milestone + +- Provider identity: + - provider: plane + + - work item id: NOMAD-13 +` + _, err := ParseMilestoneIdentity(milestonePath, md) + if !errors.Is(err, ErrNoProviderIdentity) { + t.Fatalf("err = %v, want ErrNoProviderIdentity (blank line closes block)", err) + } +} + +func TestParseMilestoneIdentityNoBlock(t *testing.T) { + md := `# Milestone + +provider 정보가 본문에 없다. + +## 완료 리뷰 +- 상태: 없음 +` + _, err := ParseMilestoneIdentity(milestonePath, md) + if !errors.Is(err, ErrNoProviderIdentity) { + t.Fatalf("err = %v, want ErrNoProviderIdentity", err) + } +} + +func TestParseMilestoneIdentityIncompleteBlock(t *testing.T) { + // Block present but missing work item id: must not silently succeed. + md := `## Provider identity +- provider: plane +- project: NOMAD +` + _, err := ParseMilestoneIdentity(milestonePath, md) + if !errors.Is(err, ErrNoProviderIdentity) { + t.Fatalf("err = %v, want ErrNoProviderIdentity", err) + } +} + +func TestParseMilestoneIdentityEmptyPath(t *testing.T) { + if _, err := ParseMilestoneIdentity(" ", "## Provider identity\n- provider: plane\n- work item id: X\n"); !errors.Is(err, ErrInvalidIdentity) { + t.Fatalf("err = %v, want ErrInvalidIdentity", err) + } +}