feat(core): add roadmapsync package for milestone work item creation

- Add develop_match.go/handlers for matching Plane tasks to local develop areas
- Add identity.go for external ID mapping and lookup
- Add milestone_parser.go for parsing agent-roadmap milestone files
- Include unit tests for all new modules
- Archive completed subtask documents for 03+02_develop_match
This commit is contained in:
toki 2026-06-13 22:09:45 +09:00
parent 6ab8dd8f63
commit dde07f9dea
12 changed files with 1542 additions and 86 deletions

View file

@ -0,0 +1,134 @@
<!-- task=m-milestone-work-item-creation-sync/03+02_develop_match plan=0 tag=API -->
# 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-<milestone-slug>`이면 런타임 완료 이벤트 메타데이터를 보고한다.
## 계획 대비 변경 사항
- 계획대로 `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/<phase>/milestones/<file>.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`를 작성한다.

View file

@ -0,0 +1,188 @@
<!-- task=m-milestone-work-item-creation-sync/03+02_develop_match plan=1 tag=REVIEW_API -->
# 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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,235 @@
<!-- task=m-milestone-work-item-creation-sync/03+02_develop_match plan=1 tag=REVIEW_API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,86 +0,0 @@
<!-- task=m-milestone-work-item-creation-sync/03+02_develop_match plan=0 tag=API -->
# 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-<milestone-slug>`이면 런타임 완료 이벤트 메타데이터를 보고한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 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)
```
---

View file

@ -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/<phase>/milestones/<file>.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
}

View file

@ -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)
}
}
}

View file

@ -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"`
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}
}