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:
parent
6ab8dd8f63
commit
dde07f9dea
12 changed files with 1542 additions and 86 deletions
|
|
@ -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`를 작성한다.
|
||||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
154
services/core/internal/roadmapsync/develop_match.go
Normal file
154
services/core/internal/roadmapsync/develop_match.go
Normal 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
|
||||
}
|
||||
163
services/core/internal/roadmapsync/develop_match_test.go
Normal file
163
services/core/internal/roadmapsync/develop_match_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
161
services/core/internal/roadmapsync/identity.go
Normal file
161
services/core/internal/roadmapsync/identity.go
Normal 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"`
|
||||
}
|
||||
102
services/core/internal/roadmapsync/identity_test.go
Normal file
102
services/core/internal/roadmapsync/identity_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
210
services/core/internal/roadmapsync/milestone_parser.go
Normal file
210
services/core/internal/roadmapsync/milestone_parser.go
Normal 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
|
||||
}
|
||||
149
services/core/internal/roadmapsync/milestone_parser_test.go
Normal file
149
services/core/internal/roadmapsync/milestone_parser_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue