feat: milestone work item creation/sync 구현 및 roadmap sync 기능 추가

- Plane Todo projection 및 idempotency/retry 메커니즘 구현
- Roadmap sync identity 및 step 모델/DB 마이그레이션/쿼리 추가
- OpenAI 및 Plane adapter 개선
- roadmaps sync package: plane_format, plane_projection, retry 모듈 추가
- Storage layer: roadmap_sync_identities, roadmap_sync_steps 추가
- agent-task 아카이브 정리
This commit is contained in:
toki 2026-06-14 04:08:15 +09:00
parent dde07f9dea
commit fad5627ae6
36 changed files with 2620 additions and 213 deletions

View file

@ -50,11 +50,11 @@ Plane-origin 경로로 Milestone을 생성하고, `develop` branch의 agent-road
Plane `Backlog + AGENT assignee` 상위 티켓 하나가 NomadCode project sync와 IOP Edge HTTP workspace agent 실행을 거쳐 `develop` source-of-truth에 Milestone 산출물을 만들고, 다시 Plane `Todo` 검토 상태로 투영되는 한 사이클을 최상위 실행 흐름으로 둔다. Plane `Backlog + AGENT assignee` 상위 티켓 하나가 NomadCode project sync와 IOP Edge HTTP workspace agent 실행을 거쳐 `develop` source-of-truth에 Milestone 산출물을 만들고, 다시 Plane `Todo` 검토 상태로 투영되는 한 사이클을 최상위 실행 흐름으로 둔다.
- [x] [cycle-project-config] Plane project target, git remote, source-of-truth branch, workspace base/repo 설정을 project sync 기준으로 해석할 수 있다. 검증: `[project-config]` Epic의 완료 근거와 `workitempipeline` project binding/slot metadata 연결로 확인했다. - [x] [cycle-project-config] Plane project target, git remote, source-of-truth branch, workspace base/repo 설정을 project sync 기준으로 해석할 수 있다. 검증: `[project-config]` Epic의 완료 근거와 `workitempipeline` project binding/slot metadata 연결로 확인했다.
- [ ] [cycle-workspace-topology] project workspace root 아래 `branches/main`, `branches/develop`, `slots/000+` 구조를 준비하고 branch workspace와 agent slot 상태를 분리한다. 검증: `branches/develop`은 source-of-truth scan 기준, `branches/main`은 release/promote 기준, `slots/<index>`는 authoring 쓰기 작업 기준으로만 사용된다. - [x] [cycle-workspace-topology] project workspace root 아래 `branches/main`, `branches/develop`, `slots/000+` 구조를 준비하고 branch workspace와 agent slot 상태를 분리한다. 검증: `branches/develop`은 source-of-truth scan 기준, `branches/main`은 release/promote 기준, `slots/<index>`는 authoring 쓰기 작업 기준으로만 사용된다.
- [ ] [cycle-iop-http-ready] IOP Edge OpenAI-compatible HTTP(`/v1/responses`) workspace agent 실행 통로를 선행 조건으로 확인한다. 검증: NomadCode가 IOP CLI를 직접 실행하지 않고, `model`, `input`, `metadata.workspace` 요청만으로 workspace-bound agent target을 실행할 수 있다. - [x] [cycle-iop-http-ready] IOP Edge OpenAI-compatible HTTP(`/v1/responses`) workspace agent 실행 통로를 선행 조건으로 확인한다. 검증: NomadCode가 IOP CLI를 직접 실행하지 않고, `model`, `input`, `metadata.workspace` 요청만으로 workspace-bound agent target을 실행할 수 있다.
- [ ] [cycle-authoring-run] Plane 티켓 제목/본문과 provider work item identity를 입력으로 `slots/<index>` workspace에서 1회 authoring run을 실행한다. 검증: agent가 slot 안에서 Milestone 파일을 작성/갱신하고 `develop`에 commit/push한다. - [x] [cycle-authoring-run] Plane 티켓 제목/본문과 provider work item identity를 입력으로 `slots/<index>` workspace에서 1회 authoring run을 실행한다. 검증: agent가 slot 안에서 Milestone 파일을 작성/갱신하고 `develop`에 commit/push한다.
- [ ] [cycle-develop-sync] sync layer가 `branches/develop` 또는 remote `develop`에서 pushed Milestone을 감지하고 원래 Plane 티켓 identity와 매칭한다. 검증: slot local 변경이나 push 실패 상태만으로 Plane `Todo` projection을 수행하지 않는다. - [x] [cycle-develop-sync] sync layer가 `branches/develop` 또는 remote `develop`에서 pushed Milestone을 감지하고 원래 Plane 티켓 identity와 매칭한다. 검증: slot local 변경이나 push 실패 상태만으로 Plane `Todo` projection을 수행하지 않는다.
- [ ] [cycle-plane-todo] 원본 Plane 본문을 `사용자 요청:` 댓글로 보존하고, `develop`의 Milestone 내용으로 Plane 본문/제목을 갱신한 뒤 `Todo`로 이동한다. 검증: 사용자에게 Plane Todo에서 `develop` 반영 결과를 검토할 수 있는 상태가 된다. - [x] [cycle-plane-todo] 원본 Plane 본문을 `사용자 요청:` 댓글로 보존하고, `develop`의 Milestone 내용으로 Plane 본문/제목을 갱신한 뒤 `Todo`로 이동한다. 검증: 사용자에게 Plane Todo에서 `develop` 반영 결과를 검토할 수 있는 상태가 된다.
- [ ] [cycle-idempotency] 같은 Plane 티켓 또는 같은 Milestone path 재처리 시 중복 생성 없이 남은 단계만 재시도한다. 검증: identity map, actor guard, provider revision, roadmap revision 기준으로 부분 실패를 복구한다. - [ ] [cycle-idempotency] 같은 Plane 티켓 또는 같은 Milestone path 재처리 시 중복 생성 없이 남은 단계만 재시도한다. 검증: identity map, actor guard, provider revision, roadmap revision 기준으로 부분 실패를 복구한다.
### Epic: [project-config] Project sync configuration ### Epic: [project-config] Project sync configuration
@ -146,9 +146,9 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토
- Checkout 기준: 안정성을 우선해 branch workspace와 각 slot은 같은 git remote를 기준으로 독립 checkout을 갖는 작업공간으로 둔다. 초기 workspace 생성 때 clone 비용이 들지만 이후 branch sync와 agent 작업은 각 checkout 안에서 분리되므로 운영 리스크를 낮춘다. - Checkout 기준: 안정성을 우선해 branch workspace와 각 slot은 같은 git remote를 기준으로 독립 checkout을 갖는 작업공간으로 둔다. 초기 workspace 생성 때 clone 비용이 들지만 이후 branch sync와 agent 작업은 각 checkout 안에서 분리되므로 운영 리스크를 낮춘다.
- 후속 UI/UX: project settings 화면에서 Plane project target, git remote, workspace 설정을 확인하고 수정하는 UX가 필요하다. - 후속 UI/UX: project settings 화면에서 Plane project target, git remote, workspace 설정을 확인하고 수정하는 UX가 필요하다.
- 완료 근거(2026-06-07): `[project-config]``services/core/internal/projectsync/`, `services/core/migrations/00004_create_project_sync_settings.sql`, `services/core/migrations/00005_create_workspace_slots.sql`, `services/core/queries/project_sync_settings.sql`, `services/core/queries/workspace_slots.sql`, `packages/contracts/notes/flutter-core-api-candidates.md`의 모델/저장/slot/UI 후보 경계와 테스트로 확인했다. 검증: `cd services/core && go test ./...` PASS. - 완료 근거(2026-06-07): `[project-config]``services/core/internal/projectsync/`, `services/core/migrations/00004_create_project_sync_settings.sql`, `services/core/migrations/00005_create_workspace_slots.sql`, `services/core/queries/project_sync_settings.sql`, `services/core/queries/workspace_slots.sql`, `packages/contracts/notes/flutter-core-api-candidates.md`의 모델/저장/slot/UI 후보 경계와 테스트로 확인했다. 검증: `cd services/core && go test ./...` PASS.
- 현황 동기화(2026-06-10): 활성 `agent-task` 산출물은 없고, git worktree는 clean이다. 코드 기준 완료 근거는 `[project-config]``workitempipeline`의 project binding/slot metadata 연결까지 확인했으며, branch/slot workspace topology 갱신, IOP Edge HTTP authoring run, pushed Milestone identity map, Plane `Todo` projection은 아직 완료 evidence가 없어 미완료로 유지한다. - 현황 동기화(2026-06-13): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/*/complete.log``Roadmap Completion` 근거로 `cycle-workspace-topology`, `cycle-iop-http-ready`, `cycle-authoring-run`, `cycle-develop-sync`, `cycle-plane-todo`를 완료 처리했다. 현재 active 작업은 `agent-task/m-milestone-work-item-creation-sync/05+03,04_idempotency_retry`(`cycle-idempotency`)이며, 이 Task는 완료 evidence가 없어 미완료로 유지한다.
- 중지 지점: project sync 설정과 기존 workspace slot/checkout metadata는 준비되었고, 다음 실행 단위는 `workspace-topology`의 branch/slot path 갱신과 `agent-bridge``iop-http-channel` 정의/구현이다. Plane-origin 생성이 Plane `Todo` 상위 티켓과 `develop` agent-roadmap Milestone identity로 연결된 상태까지는 아직 도달 근거가 없다. - 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection은 완료 근거가 있다. 다음 실행 단위는 identity map 기반 idempotency/retry다.
- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/<index>` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 현재`[project-config]` 완료 이후 `workspace-topology``iop-http-channel`부터 미완료다. `In Progress` 이후 실행은 후속 잠금 범위다. - 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/<index>` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 현재 최상위 사이클에서 남은 실행 단위는 `cycle-idempotency`이며, `In Progress` 이후 실행은 후속 잠금 범위다.
- 후속 잠금: `Todo -> In Progress` 이후 실행 lifecycle은 사용자가 명시적으로 잠금을 해제할 때까지 진행하지 않는다. - 후속 잠금: `Todo -> In Progress` 이후 실행 lifecycle은 사용자가 명시적으로 잠금을 해제할 때까지 진행하지 않는다.
- Plane work item 경계: 이 마일스톤에서 말하는 Plane Milestone은 Plane native milestone object가 아니라 1상위 티켓 = 1 agent-roadmap Milestone으로 보는 work item mapping이다. - Plane work item 경계: 이 마일스톤에서 말하는 Plane Milestone은 Plane native milestone object가 아니라 1상위 티켓 = 1 agent-roadmap Milestone으로 보는 work item mapping이다.
- 확인 필요: 없음 - 확인 필요: 없음

View file

@ -0,0 +1,141 @@
<!-- task=m-milestone-work-item-creation-sync/04+03_plane_todo_projection 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/04+03_plane_todo_projection, 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-plane-todo`: 원본 Plane 본문을 `사용자 요청:` 댓글로 보존하고, `develop`의 Milestone 내용으로 Plane 본문/제목을 갱신한 뒤 `Todo`로 이동한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Plane Body And Title Patch | [x] |
| [API-2] Projection Service Ordering | [x] |
| [API-3] Plane Body Formatter | [x] |
## 구현 체크리스트
- [x] Plane adapter에 work item body/title update capability를 additive로 추가한다.
- [x] Projection service가 댓글 보존 -> body/title patch -> Todo status 이동 순서로 실행하고 중간 실패 시 다음 단계를 건너뛰게 한다.
- [x] Milestone 내용을 Plane body로 변환하는 작은 formatter를 추가하고 제목은 `[milestone-id] 제목`으로 만든다.
- [x] develop projection candidate가 없으면 no-op 또는 explicit not-ready error를 반환한다.
- [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로 이동한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임 완료 이벤트 메타데이터를 보고한다.
## 계획 대비 변경 사항
- 의존성 상태 차이: 계획 본문은 predecessor `03_develop_match`의 `complete.log`가 missing이라 명시하지만, 해당 산출물은 이후 `roadmapsync` 패키지로 커밋(`dde07f9`)되어 코드 의존성(`ProjectionCandidate`, `Identity`, `Revision`, `MatchDevelopMilestone`)이 이미 존재한다. 따라서 코드 차원 선행 의존성은 충족된 상태로 보고 구현을 진행했다. `03` task 디렉터리는 active tree에 없고 archive 접근은 하지 않았다.
- API-1 provider.go 반영 방식: adapter에 `UpdateWorkItem`만 추가하는 대신, provider-neutral 경계 유지를 위해 `workitem`에 `CapabilityBodyProjection`, `BodyProjector` 인터페이스, `BodyProjection` 타입을 추가하고 Plane `Client.ProjectBody`로 구현했다. 이는 status projection과 body projection을 독립 단계로 유지하라는 도메인 규칙(상태/본문 분리)에 맞춘 선택이다.
- 기존 `UpdateIssueStatus`는 제거 없이 `UpdateWorkItem` wrapper로 재구성해 호출부 호환을 유지했다(계획의 "wrapper로 유지" 허용 범위).
## 주요 설계 결정
- PATCH는 non-empty field만 담는다(`UpdateWorkItem`이 `name`/`description_html`/`state`를 개별 판단). blank title/body가 기존 값을 지우지 않으며, 모든 field가 비면 `ErrInvalidInput`으로 no-op patch를 막는다.
- `ProjectPlaneTodo`는 provider DTO를 직접 받지 않고 `PlaneTodoProvider`(Commenter+BodyProjector+StatusProjector) facet과 neutral 입력만 받는다. 순서는 comment(`사용자 요청:` 보존) → body/title → Todo status이며, 각 단계 실패 시 다음 단계를 호출하지 않는다.
- not-ready 후보는 provider를 한 번도 호출하지 않고 `ErrNotReady`를 반환한다. 필수 입력(ref/original body/todo state/milestone markdown) 누락은 `ErrInvalidProjectionInput`으로 조기 차단한다.
- formatter는 Milestone 제목 + `목표`/`범위` 섹션만 HTML-escape하여 본문에 싣는다. Provider identity 블록(provider, work item id 등 secret-like metadata)은 본문 섹션이 아니므로 투영에서 제외된다. 제목은 `[<milestone-slug>] 제목` 형식이며 slug는 Milestone 파일명에서 도출한다.
- 실제 Plane dev server Todo 이동 smoke는 plan 범위 밖이므로 adapter httptest와 projection fake로 보존 순서/patch body/status를 검증했다. raw token은 코드/테스트/문서에 기록하지 않았다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- comment 보존 실패가 body/status mutation을 막는지 확인한다.
- Todo 이동이 develop projection candidate 없이 실행되지 않는지 확인한다.
- Plane custom field 전제를 넣지 않았는지 확인한다.
## 검증 결과
### API-1 중간 검증
```bash
$ cd services/core && go test ./internal/adapters/plane ./internal/workitem
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
```
### API-2 / API-3 중간 검증
```bash
$ cd services/core && go test ./internal/roadmapsync ./internal/adapters/plane
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached)
```
### 최종 검증
```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 0.008s
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)
ok github.com/nomadcode/nomadcode-core/internal/authoring (cached)
ok github.com/nomadcode/nomadcode-core/internal/config (cached)
ok github.com/nomadcode/nomadcode-core/internal/http 0.004s
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 0.010s
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 0.003s
$ git diff --check
OK: no whitespace errors
```
---
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 리뷰 중 조치: `services/core/internal/roadmapsync/plane_format_test.go`의 formatting-only drift를 `gofmt`로 정리했다. 동작/API 변경은 없다.
- 검증:
- `cd services/core && go test ./internal/adapters/plane ./internal/workitem` - PASS
- `cd services/core && go test ./internal/roadmapsync ./internal/adapters/plane` - PASS
- `cd services/core && go test ./...` - PASS
- `cd services/core && gofmt -l ./internal/roadmapsync ./internal/adapters/plane ./internal/workitem` - PASS; output 없음
- `git diff --check` - PASS; output 없음
- 다음 단계: PASS 절차로 active plan/review를 `.log`로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,45 @@
# Complete - m-milestone-work-item-creation-sync/04+03_plane_todo_projection
## 완료 일시
2026-06-13
## 요약
Plane Todo projection slice를 1회 루프로 검토 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | Plane 원문 댓글 보존, develop Milestone 본문/제목 projection, Todo 상태 이동 순서와 실패 중단 조건을 확인했다. |
## 구현/정리 내용
- Plane adapter에 body/title projection capability를 additive로 추가하고 기존 status update wrapper 호환을 유지했다.
- `roadmapsync.ProjectPlaneTodo`가 comment preserve -> body/title patch -> Todo status 순서로 실행되며 중간 실패 시 후속 mutation을 멈추도록 구현했다.
- develop Milestone Markdown을 Plane title/body로 변환하는 formatter와 관련 단위 테스트를 추가했다.
- 리뷰 중 `services/core/internal/roadmapsync/plane_format_test.go`의 formatting-only drift를 `gofmt`로 정리했다.
## 최종 검증
- `cd services/core && go test ./internal/adapters/plane ./internal/workitem` - PASS; Plane adapter/workitem capability tests passed.
- `cd services/core && go test ./internal/roadmapsync ./internal/adapters/plane` - PASS; projection ordering/formatter and Plane adapter tests passed.
- `cd services/core && go test ./...` - PASS; all core packages passed.
- `cd services/core && gofmt -l ./internal/roadmapsync ./internal/adapters/plane ./internal/workitem` - PASS; output 없음.
- `git diff --check` - PASS; output 없음.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Completed task ids:
- `cycle-plane-todo`: PASS; evidence=`agent-task/archive/2026/06/m-milestone-work-item-creation-sync/04+03_plane_todo_projection/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/04+03_plane_todo_projection/code_review_cloud_G07_0.log`; verification=`cd services/core && go test ./...`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,152 @@
<!-- task=m-milestone-work-item-creation-sync/05+03,04_idempotency_retry 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/05+03,04_idempotency_retry, 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-idempotency`: 같은 Plane 티켓 또는 같은 Milestone path 재처리 시 중복 생성 없이 남은 단계만 재시도한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Identity Map Persistence | [x] |
| [API-2] Step Ledger | [x] |
| [API-3] Retry Decision Service | [x] |
## 구현 체크리스트
- [x] Milestone path, provider, tenant, project, work item id, provider revision, roadmap revision을 저장하는 identity map migration/query/store를 추가한다.
- [x] provider work item 또는 Milestone path 재처리 시 existing identity를 찾고 중복 Milestone 생성/authoring을 막는다.
- [x] develop match, comment preserve, body/title update, Todo status move의 step ledger를 저장하고 성공한 step은 재시도하지 않는다.
- [x] actor guard와 revision mismatch가 silent overwrite 대신 conflict/retryable result를 반환하게 한다.
- [x] `cd services/core && go test ./...`와 `git diff --check`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] active 파일을 `.log`로 아카이브하고 FAIL follow-up `PLAN-cloud-G07.md` / `CODE_REVIEW-cloud-G07.md`를 작성한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임 완료 이벤트 메타데이터를 보고한다.
## 계획 대비 변경 사항
- 계획의 dependency 메모는 `03_develop_match`/`04_plane_todo_projection` complete.log가 missing이라고 적혀 있으나, 현재 두 predecessor 모두 `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/03+02_develop_match/complete.log`와 `04+03_plane_todo_projection/complete.log`로 아카이브되어 있어 선행 의존성이 충족된 상태로 진행했다. (CLAUDE.md의 split subtask 선행 의존성 확인 예외에 따라 동일 task group의 후보 complete.log 존재만 확인)
- 계획은 identity 충돌(`conflict sentinel`)을 query 단계에서 처리하는 것처럼 표현했으나, `(provider, tenant, project, work_item_id)`와 `(roadmap_milestone_path)` 두 unique index는 단일 `ON CONFLICT` target으로 묶을 수 없다. 따라서 store 계층 `UpsertRoadmapSyncIdentity`에서 두 lookup을 선행 비교해 mismatched pair를 `ErrRoadmapSyncIdentityConflict`로 반환하도록 했다. raw unique-index 위반 대신 typed conflict로 막는다.
- API-3의 retry decision service는 계획대로 `internal/roadmapsync`에 두되, 기존 패키지가 `workitem`만 import하는 경계를 유지하기 위해 `db`/`storage`를 import하지 않는다. 호출자가 persisted identity(`ExistingIdentity`)와 completed step set(`map[Step]bool`)을 주입하는 provider-neutral 시그니처(`ReconcileCreationCycle`)로 설계했다.
- 함수명은 계획의 `ReconcileCreationCycle` 그대로 사용했다.
## 주요 설계 결정
- **이중 unique index로 양방향 중복 차단**: 같은 Plane 티켓(`provider+tenant+project+work_item_id`)과 같은 Milestone path(`roadmap_milestone_path`) 양쪽에 unique index를 두어, 어느 쪽으로 재처리해도 동일 identity row를 재사용한다. upsert는 work item key 기준 `ON CONFLICT ... DO UPDATE`이며, path가 다른 ticket에 이미 묶여 있거나 ticket이 다른 path에 묶여 있으면 conflict로 막는다.
- **step ledger의 idempotent upsert**: `(roadmap_sync_identity_id, step)` unique index + `ON CONFLICT DO UPDATE`(no-op self-assign)로 이미 완료한 step을 재마킹해도 원본 `completed_at`을 유지하는 no-op이 된다. step은 DB CHECK과 store `Valid()` 양쪽에서 closed set(`develop_matched`, `original_comment_preserved`, `plane_body_updated`, `plane_todo_moved`)으로 제한하고, unknown step은 DB를 건드리기 전에 `ErrUnknownRoadmapSyncStep`으로 거부한다.
- **resume 결정은 ordered step의 first-incomplete**: `ReconcileCreationCycle`은 completed set에 없는 첫 ordered step부터 재개(`ActionResume`)하고, 전부 완료면 `ActionComplete`를 반환한다. 성공한 step은 다시 호출되지 않는다.
- **actor guard**: trigger actor가 `SelfActor`(NomadCode self mutation)와 같으면 `ActionSkipSelfMutation`을 반환해 NomadCode가 직접 만든 body/title 변경이 새 authoring trigger로 오인되지 않게 한다.
- **revision mismatch는 silent overwrite 대신 conflict**: 이미 identity가 존재할 때 trigger의 provider/roadmap revision이 persisted 값과 다르면 `ActionConflict`를 반환한다. 단, 둘 중 하나가 빈 값(미기록)이면 첫 cycle을 막지 않도록 conflict로 보지 않는다.
- **generated db code 수동 작성**: 로컬에 sqlc 바이너리가 없어(`bin/migrate-up`은 goose만 `go run`으로 사용) 기존 `workspace_slots.sql.go` 등과 동일한 sqlc v1.31.1 스타일로 `*.sql.go`와 `models.go` 구조체를 손으로 작성했다. nullable `provider_revision`/`roadmap_revision`은 `sqlc.yaml`의 `emit_pointers_for_null_types`에 맞춰 `*string`으로 두었다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- provider work item id와 Milestone path 양쪽 unique path가 중복 생성을 막는지 확인한다.
- completed step이 재시도에서 다시 호출되지 않는지 확인한다.
- actor guard가 NomadCode self mutation을 새 user trigger로 처리하지 않는지 확인한다.
## 검증 결과
### API-1 중간 검증
```bash
$ cd services/core && go test ./internal/storage ./internal/db
ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
```
### API-2 중간 검증
```bash
$ cd services/core && go test ./internal/storage
ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s
```
### API-3 중간 검증
```bash
$ cd services/core && go test ./internal/roadmapsync ./internal/storage
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s
ok github.com/nomadcode/nomadcode-core/internal/storage (cached)
```
### 최종 검증
```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 0.009s
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 0.004s
? 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 0.012s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync (cached)
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.013s
ok github.com/nomadcode/nomadcode-core/internal/storage (cached)
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitem (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached)
$ git diff --check
(출력 없음, exit 0)
$ cd services/core && go vet ./internal/roadmapsync ./internal/storage ./internal/db && gofmt -l internal/roadmapsync internal/storage internal/db
(출력 없음, vet exit 0 / gofmt 차이 없음)
```
---
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Fail
- verification trust: Warn
- 발견된 문제:
- Required: `services/core/queries/roadmap_sync_identities.sql:16`의 `ON CONFLICT (provider, tenant, project, work_item_id) DO UPDATE`가 기존 row의 `roadmap_milestone_path` 일치 조건 없이 path를 덮어쓴다. `services/core/internal/storage/roadmap_sync_identities.go:55`의 선조회는 순차 실행에서는 충돌을 잡지만 원자적이지 않다. 두 재처리가 같은 provider work item에 서로 다른 Milestone path로 동시에 들어오면 둘 다 선조회에서 no rows를 본 뒤, 나중 upsert가 기존 identity를 새 path로 조용히 재바인딩할 수 있다. 수정: upsert SQL에 `WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path` 같은 조건부 update를 추가하고, 조건 불일치로 `RETURNING` row가 없거나 path unique 위반이 발생하면 `ErrRoadmapSyncIdentityConflict`로 변환한다. 이 경로를 단위 테스트로 고정한다.
- Required: `services/core/internal/roadmapsync/identity.go:100`의 `Identity.Matches`가 `tenant`와 `project`를 비교하지 않아, `services/core/internal/roadmapsync/retry.go:117`의 retry decision이 같은 provider/work item id/path라도 다른 Plane workspace/project identity를 같은 identity로 재사용할 수 있다. 이번 plan과 DB unique key는 provider, tenant, project, work item id를 identity key로 저장하므로 retry decision도 동일한 키를 비교해야 한다. 수정: retry/develop matching에서 쓰는 identity match가 tenant/project까지 비교하도록 보완하거나, retry 전용 strict identity match를 도입하고 tenant/project mismatch 테스트를 추가한다.
- Suggested: `./bin/sqlc` 재생성을 시도했지만 `queries/workspace_slots.sql:19:11: column reference "project_sync_setting_id" is ambiguous`로 실패해 신규 generated DB 파일이 실제 sqlc 산출물과 일치하는지 도구로 재검증하지 못했다. follow-up에서는 위 Required 수정 뒤 `go test`와 함께 이 생성 경로의 현재 차단 사유를 명시하거나, 가능한 경우 sqlc 재생성 증거를 남긴다.
- 다음 단계: FAIL follow-up plan/review를 생성해 Required 이슈를 수정하고 검증한다.

View file

@ -0,0 +1,194 @@
<!-- task=m-milestone-work-item-creation-sync/05+03,04_idempotency_retry 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`.
> 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.
## 개요
date=2026-06-13
task=m-milestone-work-item-creation-sync/05+03,04_idempotency_retry, 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-idempotency`: 같은 Plane 티켓 또는 같은 Milestone path 재처리 시 중복 생성 없이 남은 단계만 재시도한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-{review_lane}-GNN.md` -> `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` -> `plan_{build_lane}_GNN_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task directory를 archive로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Atomic Identity Conflict Handling | [x] |
| [REVIEW_API-2] Strict Retry Identity Key | [x] |
## 구현 체크리스트
- [x] `UpsertRoadmapSyncIdentityByWorkItem`가 기존 provider work item row의 Milestone path가 다르면 update하지 않고 typed conflict로 반환하게 SQL/query/store/tests를 보완한다.
- [x] path unique race/raw unique violation을 `ErrRoadmapSyncIdentityConflict`로 변환하고 테스트를 추가한다.
- [x] retry identity comparison이 provider, tenant, project, work item id, Milestone path를 같은 identity key로 비교하도록 보완하고 mismatch 테스트를 추가한다.
- [x] `./bin/sqlc` 재생성 또는 차단 증거, `go test -count=1 ./internal/storage ./internal/roadmapsync ./internal/adapters/plane`, `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_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- **REVIEW_API-1**: 계획은 `DO UPDATE`에 path 일치 조건을 두고 no-row를 conflict로 변환하라고 했고 그대로 구현했다. 추가로, conflict 보호를 단일 statement 내에서 원자적으로 만들기 위해 `DO UPDATE`의 SET 목록에서 `roadmap_milestone_path = EXCLUDED.roadmap_milestone_path`를 제거했다(기존 path를 유지). 그리고 첫 리뷰에서 비원자적이라 지적된 store 계층의 선조회(by-path/by-work-item lookup) 2건을 upsert 전에서 제거했다 — 이제 보호는 전적으로 SQL(conditional `DO UPDATE` + path unique index)이 담당하므로 read-then-write race가 사라진다. `GetRoadmapSyncIdentityByWorkItem`/`ByMilestonePath` lookup 함수 자체는 다른 호출자용으로 유지했다.
- **REVIEW_API-2**: 계획이 제시한 두 옵션 중 "retry 전용 strict matcher 분리" 쪽을 택했다. 기존 `Identity.Matches`는 develop matching(`MatchDevelopMilestone`)이 사용하고 `TestIdentityMatches`가 "비-key 필드(project 등)는 무시한다"를 명시적으로 보장하므로, `Matches`를 바꾸면 선행 PASS subtask의 develop-match 동작이 바뀐다. 그래서 `Identity.MatchesStrict`(provider, tenant, project, work item id, path 전부 비교)를 새로 추가하고 `ReconcileCreationCycle`에서만 사용했다. develop match/parser 동작은 변경하지 않았으므로 해당 기존 테스트도 그대로 통과한다.
- **REVIEW_API-2 conflict 처리**: strict match 실패 시 단순히 `IdentityKept=false`로 두고 fresh resume하면 tenant/project mismatch가 조용히 새 cycle로 처리된다. 리뷰 의도(다른 workspace/project를 같은 identity로 재사용 금지)에 맞춰, `Existing.Found`이지만 strict match가 아니면 `ActionConflict`(`reasonIdentityKeyMismatch`)를 반환하도록 했다.
- **`./bin/sqlc` 검증 명령**: 계획대로 실행했으나, 계획·첫 리뷰가 예고한 대로 기존 `queries/workspace_slots.sql:19`의 `project_sync_setting_id is ambiguous`로 실패했다(내 신규 query 2개와 무관, sqlc가 첫 package 에러에서 중단). exact 출력을 아래 검증 결과에 남기고, generated DB 코드는 기존 sqlc v1.31.1 스타일로 수동 갱신했다. 이 ambiguity 수정은 이번 follow-up 범위(Required 이슈)에 포함되지 않아 건드리지 않았다.
## 주요 설계 결정
- **원자적 conflict 보호 (read-then-write 제거)**: `UpsertRoadmapSyncIdentityByWorkItem`은 work item key로 `ON CONFLICT ... DO UPDATE`하되, `WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path`로 기존 path와 incoming path가 같을 때만 update한다. SET 목록에서 path를 제거해 기존 path는 절대 덮어쓰지 않는다.
- 같은 work item + 다른 path 동시 재처리 → `DO UPDATE`가 매칭 row 없음 → `RETURNING` empty → `pgx.ErrNoRows` → store가 `ErrRoadmapSyncIdentityConflict`로 매핑. 두 동시 재처리 중 누구도 기존 identity를 새 path로 재바인딩할 수 없다.
- 다른 work item + 기존 path 재사용 → path unique index(SQLSTATE `23505`) 위반 → `pgconn.PgError.Code == "23505"`를 `ErrRoadmapSyncIdentityConflict`로 매핑(raw pg error 노출 안 함).
- 그 외 DB 에러는 그대로 전파(conflict로 삼키지 않음). 단위 테스트로 4개 경로(성공+conditional shape, no-row conflict, unique-violation conflict, generic error 전파)를 고정했다. 실제 multi-process DB race e2e는 환경상 제외하고 query shape/error mapping unit test로 대체했다.
- **strict retry identity key**: retry decision은 `Identity.MatchesStrict`로 provider+tenant+project+work item id+path를 모두 비교한다. 이는 DB의 provider-side unique key(`provider, tenant, project, work_item_id`)와 정확히 일치하므로, project-scoped sync 계약을 위반하는 cross-workspace/project identity 재사용을 막는다. loose `Matches`는 develop matching 전용으로 남겨 기존 동작을 보존했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- identity upsert가 provider work item conflict에서 다른 Milestone path로 재바인딩하지 않는지 확인한다.
- path unique constraint 위반과 conditional update no-row가 typed conflict로 변환되는지 확인한다.
- retry identity comparison이 tenant/project mismatch를 같은 identity로 취급하지 않는지 확인한다.
- `./bin/sqlc` 재생성 성공 또는 차단 증거가 검증 결과에 정확히 기록되었는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/storage
ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s
```
### REVIEW_API-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/roadmapsync
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
```
### 최종 검증
```bash
$ cd services/core && ./bin/sqlc
# package db
queries/workspace_slots.sql:19:11: column reference "project_sync_setting_id" is ambiguous
exit status 1
# (차단됨) 기존 workspace_slots query의 ambiguity로 실패. 신규
# roadmap_sync_identities.sql / roadmap_sync_steps.sql 는 에러를 내지 않았고
# sqlc 가 첫 package 에러에서 중단함. 이번 follow-up Required 범위 밖이라
# 미수정. generated DB 코드는 기존 sqlc v1.31.1 스타일로 수동 갱신했다.
$ cd services/core && go test -count=1 ./internal/storage ./internal/roadmapsync ./internal/adapters/plane
ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.008s
$ cd services/core && go test ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.005s
? 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 0.009s
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 0.013s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.004s
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 0.004s
? 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 0.019s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s
ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached)
ok github.com/nomadcode/nomadcode-core/internal/storage 0.008s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.007s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.007s
$ git diff --check
(출력 없음, exit 0)
$ cd services/core && go vet ./internal/roadmapsync ./internal/storage ./internal/db && gofmt -l internal/roadmapsync internal/storage internal/db
(출력 없음, vet exit 0 / gofmt 차이 없음)
```
---
> **[IMPLEMENTING AGENT - BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,47 @@
# Complete - m-milestone-work-item-creation-sync/05+03,04_idempotency_retry
## 완료 일시
2026-06-13
## 요약
Identity map idempotency/retry follow-up loop 2에서 atomic identity conflict handling과 strict retry identity key를 보완하고 최종 PASS로 종결했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | identity upsert rebind race와 retry identity tenant/project mismatch 문제로 follow-up 작성 |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | conditional upsert conflict mapping과 strict retry identity tests 확인 |
## 구현/정리 내용
- `UpsertRoadmapSyncIdentityByWorkItem`의 `DO UPDATE`를 기존 Milestone path와 incoming path가 같을 때만 실행하도록 바꾸고, path 재바인딩 no-row와 unique violation을 `ErrRoadmapSyncIdentityConflict`로 변환했다.
- store의 비원자적 선조회 기반 conflict check를 제거하고 SQL statement 단위 conflict handling으로 정리했다.
- `Identity.MatchesStrict`를 추가하고 `ReconcileCreationCycle`에서 provider, tenant, project, work item id, Milestone path를 모두 비교하도록 보완했다.
- rebind/no-row, path unique violation, generic DB error propagation, tenant/project mismatch 테스트를 추가했다.
## 최종 검증
- `cd services/core && ./bin/sqlc` - BLOCKED; 기존 `queries/workspace_slots.sql:19:11` ambiguity로 실패, 이번 신규 query와 follow-up 범위 밖 차단으로 기록됨.
- `cd services/core && go test -count=1 ./internal/storage ./internal/roadmapsync ./internal/adapters/plane` - PASS; storage, roadmapsync, Plane adapter targeted tests 통과.
- `cd services/core && go test ./...` - PASS; core 전체 Go test 통과.
- `cd services/core && go vet ./internal/roadmapsync ./internal/storage ./internal/db` - PASS; 출력 없음.
- `cd services/core && gofmt -l internal/roadmapsync internal/storage internal/db` - PASS; 출력 없음.
- `git diff --check` - PASS; whitespace 오류 없음.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Completed task ids:
- `cycle-idempotency`: PASS; evidence=`agent-task/archive/2026/06/m-milestone-work-item-creation-sync/05+03,04_idempotency_retry/plan_cloud_G07_1.log`, `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/05+03,04_idempotency_retry/code_review_cloud_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/storage ./internal/roadmapsync ./internal/adapters/plane`, `cd services/core && go test ./...`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,94 @@
<!-- task=m-milestone-work-item-creation-sync/05+03,04_idempotency_retry plan=1 tag=REVIEW_API -->
# Plan - Review API Idempotency Retry Fix
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 사용자 전용 결정이나 외부 환경 차단은 review stub의 `사용자 리뷰 요청`에 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하지 않는다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Task ids:
- `cycle-idempotency`: 같은 Plane 티켓 또는 같은 Milestone path 재처리 시 중복 생성 없이 남은 단계만 재시도한다.
- Completion mode: check-on-pass
## 배경
첫 리뷰(`code_review_cloud_G07_0.log`)는 identity map/retry slice를 FAIL로 판정했다. 선조회 후 upsert가 동시 재처리에서 같은 provider work item을 다른 Milestone path로 조용히 재바인딩할 수 있고, retry identity comparison이 `tenant`/`project`를 identity key로 비교하지 않아 project-scoped sync 계약과 어긋난다.
## 범위 결정 근거
- 이번 follow-up은 `code_review_cloud_G07_0.log`의 Required 이슈만 닫는다.
- Plane Todo projection, formatter, adapter body projection은 선행 subtask PASS 범위이므로 동작 변경하지 않는다.
- `./bin/sqlc`는 현재 `queries/workspace_slots.sql`의 기존 ambiguity로 실패할 수 있다. 재생성이 성공하면 산출물을 반영하고, 실패하면 정확한 출력과 함께 generated DB 코드가 수동 갱신되었음을 검증 결과에 남긴다.
## 구현 체크리스트
- [ ] `UpsertRoadmapSyncIdentityByWorkItem`가 기존 provider work item row의 Milestone path가 다르면 update하지 않고 typed conflict로 반환하게 SQL/query/store/tests를 보완한다.
- [ ] path unique race/raw unique violation을 `ErrRoadmapSyncIdentityConflict`로 변환하고 테스트를 추가한다.
- [ ] retry identity comparison이 provider, tenant, project, work item id, Milestone path를 같은 identity key로 비교하도록 보완하고 mismatch 테스트를 추가한다.
- [ ] `./bin/sqlc` 재생성 또는 차단 증거, `go test -count=1 ./internal/storage ./internal/roadmapsync ./internal/adapters/plane`, `go test ./...`, `git diff --check` 검증 결과를 남긴다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_API-1] Atomic Identity Conflict Handling
### 문제
`services/core/queries/roadmap_sync_identities.sql`의 upsert는 provider work item conflict 시 `roadmap_milestone_path`를 무조건 `EXCLUDED` 값으로 갱신한다. `services/core/internal/storage/roadmap_sync_identities.go`의 선조회는 원자적이지 않아, 같은 work item에 서로 다른 path가 동시에 들어오면 나중 upsert가 기존 identity를 재바인딩할 수 있다.
### 해결 방법
- upsert SQL의 `DO UPDATE`에 기존 `roadmap_milestone_path`가 incoming path와 같을 때만 update하는 조건을 둔다.
- 조건 불일치로 `RETURNING` row가 없을 때 `ErrRoadmapSyncIdentityConflict`로 변환한다.
- path unique constraint 위반도 raw pg error 대신 `ErrRoadmapSyncIdentityConflict`로 변환한다.
- mock 기반 단위 테스트로 query shape, no-row conflict mapping, unique violation mapping을 고정한다. 실제 DB race e2e가 없으면 생략 사유를 검증 결과에 남긴다.
### 수정 파일 후보
- `services/core/queries/roadmap_sync_identities.sql`
- `services/core/internal/db/roadmap_sync_identities.sql.go`
- `services/core/internal/storage/roadmap_sync_identities.go`
- `services/core/internal/storage/roadmap_sync_identities_test.go`
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/storage
```
## [REVIEW_API-2] Strict Retry Identity Key
### 문제
`roadmapsync.Identity.Matches`는 provider, work item id, Milestone path만 비교한다. 이번 plan과 DB unique key는 provider, tenant, project, work item id를 provider-side identity key로 저장하므로 retry decision도 다른 Plane workspace/project를 같은 identity로 재사용하면 안 된다.
### 해결 방법
- retry decision에서 쓰는 identity comparison이 `provider`, `tenant`, `project`, `work_item_id`, `roadmap_milestone_path`를 모두 비교하도록 보완한다.
- 기존 broader matching이 다른 call site에 필요하면 retry 전용 strict matcher를 분리하고 `ReconcileCreationCycle`에서만 strict matcher를 사용한다.
- tenant/project mismatch가 `ActionConflict`가 되는 테스트를 추가한다. develop match 동작을 함께 바꾸는 경우 기존 parser/match 테스트도 업데이트한다.
### 수정 파일 후보
- `services/core/internal/roadmapsync/identity.go`
- `services/core/internal/roadmapsync/retry.go`
- `services/core/internal/roadmapsync/identity_test.go`
- `services/core/internal/roadmapsync/retry_test.go`
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/roadmapsync
```
## 최종 검증
```bash
cd services/core && ./bin/sqlc
cd services/core && go test -count=1 ./internal/storage ./internal/roadmapsync ./internal/adapters/plane
cd services/core && go test ./...
git diff --check
```
`./bin/sqlc`가 기존 `workspace_slots` query ambiguity로 실패하면, exact stdout/stderr를 `CODE_REVIEW-cloud-G07.md`에 남기고 신규 generated 파일의 수동 변경 근거를 함께 기록한다.

View file

@ -1,92 +0,0 @@
<!-- task=m-milestone-work-item-creation-sync/04+03_plane_todo_projection 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/04+03_plane_todo_projection, 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-plane-todo`: 원본 Plane 본문을 `사용자 요청:` 댓글로 보존하고, `develop`의 Milestone 내용으로 Plane 본문/제목을 갱신한 뒤 `Todo`로 이동한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Plane Body And Title Patch | [ ] |
| [API-2] Projection Service Ordering | [ ] |
| [API-3] Plane Body Formatter | [ ] |
## 구현 체크리스트
- [ ] Plane adapter에 work item body/title update capability를 additive로 추가한다.
- [ ] Projection service가 댓글 보존 -> body/title patch -> Todo status 이동 순서로 실행하고 중간 실패 시 다음 단계를 건너뛰게 한다.
- [ ] Milestone 내용을 Plane body로 변환하는 작은 formatter를 추가하고 제목은 `[milestone-id] 제목`으로 만든다.
- [ ] develop projection candidate가 없으면 no-op 또는 explicit not-ready error를 반환한다.
- [ ] `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>`이면 런타임 완료 이벤트 메타데이터를 보고한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- comment 보존 실패가 body/status mutation을 막는지 확인한다.
- Todo 이동이 develop projection candidate 없이 실행되지 않는지 확인한다.
- Plane custom field 전제를 넣지 않았는지 확인한다.
## 검증 결과
### API-1 중간 검증
```bash
$ cd services/core && go test ./internal/adapters/plane ./internal/workitem
(output)
```
### API-2 중간 검증
```bash
$ cd services/core && go test ./internal/roadmapsync ./internal/adapters/plane
(output)
```
### 최종 검증
```bash
$ cd services/core && go test ./...
$ git diff --check
(output)
```
---

View file

@ -1,92 +0,0 @@
<!-- task=m-milestone-work-item-creation-sync/05+03,04_idempotency_retry 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/05+03,04_idempotency_retry, 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-idempotency`: 같은 Plane 티켓 또는 같은 Milestone path 재처리 시 중복 생성 없이 남은 단계만 재시도한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Identity Map Persistence | [ ] |
| [API-2] Step Ledger | [ ] |
| [API-3] Retry Decision Service | [ ] |
## 구현 체크리스트
- [ ] Milestone path, provider, tenant, project, work item id, provider revision, roadmap revision을 저장하는 identity map migration/query/store를 추가한다.
- [ ] provider work item 또는 Milestone path 재처리 시 existing identity를 찾고 중복 Milestone 생성/authoring을 막는다.
- [ ] develop match, comment preserve, body/title update, Todo status move의 step ledger를 저장하고 성공한 step은 재시도하지 않는다.
- [ ] actor guard와 revision mismatch가 silent overwrite 대신 conflict/retryable result를 반환하게 한다.
- [ ] `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>`이면 런타임 완료 이벤트 메타데이터를 보고한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- provider work item id와 Milestone path 양쪽 unique path가 중복 생성을 막는지 확인한다.
- completed step이 재시도에서 다시 호출되지 않는지 확인한다.
- actor guard가 NomadCode self mutation을 새 user trigger로 처리하지 않는지 확인한다.
## 검증 결과
### API-1 중간 검증
```bash
$ cd services/core && go test ./internal/storage ./internal/db
(output)
```
### API-3 중간 검증
```bash
$ cd services/core && go test ./internal/roadmapsync ./internal/storage
(output)
```
### 최종 검증
```bash
$ cd services/core && go test ./...
$ git diff --check
(output)
```
---

View file

@ -260,7 +260,9 @@ func buildRequestMetadata(input model.GenerateInput) map[string]any {
merged[k] = v merged[k] = v
} }
if input.WorkspaceMetadata != nil { if input.WorkspaceMetadata != nil {
merged["workspace"] = input.WorkspaceMetadata if path := strings.TrimSpace(input.WorkspaceMetadata.Path); path != "" {
merged["workspace"] = path
}
} }
return merged return merged
} }

View file

@ -95,7 +95,7 @@ func TestGenerateCallsResponsesAPI(t *testing.T) {
} }
} }
func TestGenerateWorkspaceMetadataIsObject(t *testing.T) { func TestGenerateWorkspaceMetadataUsesFlatWorkspacePath(t *testing.T) {
var gotBody map[string]any var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -132,21 +132,12 @@ func TestGenerateWorkspaceMetadataIsObject(t *testing.T) {
if metadata["task_id"] != "task-abc" || metadata["source"] != "plane" { if metadata["task_id"] != "task-abc" || metadata["source"] != "plane" {
t.Fatalf("flat metadata not preserved: %#v", metadata) t.Fatalf("flat metadata not preserved: %#v", metadata)
} }
ws, ok := metadata["workspace"].(map[string]any) wsPath, ok := metadata["workspace"].(string)
if !ok { if !ok {
t.Fatalf("expected metadata.workspace object, got %T: %#v", metadata["workspace"], metadata["workspace"]) t.Fatalf("expected metadata.workspace string, got %T: %#v", metadata["workspace"], metadata["workspace"])
} }
if ws["path"] != "/home/user/workspace/nomadcode/slots/000" { if wsPath != "/home/user/workspace/nomadcode/slots/000" {
t.Fatalf("unexpected workspace.path: %#v", ws["path"]) t.Fatalf("unexpected workspace path: %#v", wsPath)
}
if ws["source_branch"] != "develop" {
t.Fatalf("unexpected workspace.source_branch: %#v", ws["source_branch"])
}
if ws["provider"] != "plane" {
t.Fatalf("unexpected workspace.provider: %#v", ws["provider"])
}
if ws["work_item_id"] != "NOMAD-42" {
t.Fatalf("unexpected workspace.work_item_id: %#v", ws["work_item_id"])
} }
} }

View file

@ -21,6 +21,7 @@ var (
_ workitem.Commenter = (*Client)(nil) _ workitem.Commenter = (*Client)(nil)
_ workitem.StatusProjector = (*Client)(nil) _ workitem.StatusProjector = (*Client)(nil)
_ workitem.Creator = (*Client)(nil) _ workitem.Creator = (*Client)(nil)
_ workitem.BodyProjector = (*Client)(nil)
) )
var ( var (
@ -75,6 +76,17 @@ type UpdateIssueStatusInput struct {
Status string Status string
} }
// UpdateWorkItemInput carries an additive PATCH of a Plane work item. Only
// non-empty fields are sent, so a blank Title/DescriptionHTML/Status never
// clears an existing value. Status maps to the Plane `state` field, the same
// field UpdateIssueStatus patches.
type UpdateWorkItemInput struct {
Ref WorkItemRef
Title string
DescriptionHTML string
Status string
}
func NewClient(cfg Config, logger *slog.Logger) *Client { func NewClient(cfg Config, logger *slog.Logger) *Client {
return &Client{cfg: cfg, http: http.DefaultClient, logger: logger} return &Client{cfg: cfg, http: http.DefaultClient, logger: logger}
} }
@ -96,6 +108,7 @@ func (c *Client) Capabilities() workitem.Capabilities {
workitem.CapabilityComment: true, workitem.CapabilityComment: true,
workitem.CapabilityStatusProjection: true, workitem.CapabilityStatusProjection: true,
workitem.CapabilityCreate: true, workitem.CapabilityCreate: true,
workitem.CapabilityBodyProjection: true,
workitem.CapabilityLabelProjection: false, workitem.CapabilityLabelProjection: false,
} }
} }
@ -145,6 +158,21 @@ func (c *Client) ProjectStatus(ctx context.Context, input workitem.StatusProject
}) })
} }
func (c *Client) ProjectBody(ctx context.Context, input workitem.BodyProjection) error {
planeRef, err := planeWorkItemRef(input.Ref)
if err != nil {
return err
}
if strings.TrimSpace(input.Title) == "" && strings.TrimSpace(input.DescriptionHTML) == "" {
return ErrInvalidInput
}
return c.UpdateWorkItem(ctx, UpdateWorkItemInput{
Ref: planeRef,
Title: input.Title,
DescriptionHTML: input.DescriptionHTML,
})
}
func (c *Client) CreateWorkItem(ctx context.Context, input workitem.CreateInput) (workitem.CreateResult, error) { func (c *Client) CreateWorkItem(ctx context.Context, input workitem.CreateInput) (workitem.CreateResult, error) {
workspace := strings.TrimSpace(input.Tenant) workspace := strings.TrimSpace(input.Tenant)
project := strings.TrimSpace(input.Project) project := strings.TrimSpace(input.Project)
@ -240,8 +268,29 @@ func (c *Client) AddComment(ctx context.Context, input AddCommentInput) error {
} }
func (c *Client) UpdateIssueStatus(ctx context.Context, input UpdateIssueStatusInput) error { func (c *Client) UpdateIssueStatus(ctx context.Context, input UpdateIssueStatusInput) error {
body := map[string]string{ return c.UpdateWorkItem(ctx, UpdateWorkItemInput{
"state": input.Status, Ref: input.Ref,
Status: input.Status,
})
}
// UpdateWorkItem PATCHes the work item with only the non-empty fields in input.
// name, description_html, and state are sent independently so a body/title
// update never disturbs the state, and a status move never disturbs the body.
// An input with no non-empty field is an invalid no-op patch.
func (c *Client) UpdateWorkItem(ctx context.Context, input UpdateWorkItemInput) error {
body := map[string]string{}
if title := strings.TrimSpace(input.Title); title != "" {
body["name"] = title
}
if descHTML := strings.TrimSpace(input.DescriptionHTML); descHTML != "" {
body["description_html"] = descHTML
}
if status := strings.TrimSpace(input.Status); status != "" {
body["state"] = status
}
if len(body) == 0 {
return ErrInvalidInput
} }
return c.do(ctx, http.MethodPatch, workItemPath(input.Ref)+"/", body, nil) return c.do(ctx, http.MethodPatch, workItemPath(input.Ref)+"/", body, nil)
} }

View file

@ -150,11 +150,107 @@ func TestClientImplementsWorkItemFacets(t *testing.T) {
if !caps.Supports(workitem.CapabilityCreate) { if !caps.Supports(workitem.CapabilityCreate) {
t.Error("expected create capability") t.Error("expected create capability")
} }
if !caps.Supports(workitem.CapabilityBodyProjection) {
t.Error("expected body projection capability")
}
if caps.Supports(workitem.CapabilityLabelProjection) { if caps.Supports(workitem.CapabilityLabelProjection) {
t.Error("label projection should be unsupported") t.Error("label projection should be unsupported")
} }
} }
func TestUpdateWorkItemPatchesOnlyNonEmptyFields(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Fatalf("method: got %s", r.Method)
}
if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1/" {
t.Fatalf("path: got %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["name"] != "New title" {
t.Errorf("name: got %q", body["name"])
}
if body["description_html"] != "<p>new body</p>" {
t.Errorf("description_html: got %q", body["description_html"])
}
if _, ok := body["state"]; ok {
t.Errorf("state should not be present when blank: %#v", body)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil)
err := client.UpdateWorkItem(context.Background(), UpdateWorkItemInput{
Ref: WorkItemRef{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
},
Title: "New title",
DescriptionHTML: "<p>new body</p>",
})
if err != nil {
t.Fatalf("UpdateWorkItem returned error: %v", err)
}
}
func TestUpdateWorkItemRejectsEmptyPatch(t *testing.T) {
client := NewClient(Config{BaseURL: "https://example.com", Token: "test-token"}, nil)
err := client.UpdateWorkItem(context.Background(), UpdateWorkItemInput{
Ref: WorkItemRef{WorkspaceSlug: "general", ProjectID: "project-1", WorkItemID: "work-1"},
})
if err == nil || !strings.Contains(err.Error(), ErrInvalidInput.Error()) {
t.Fatalf("empty patch: expected ErrInvalidInput, got %v", err)
}
}
func TestProjectBodyPatchesTitleAndBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Fatalf("method: got %s", r.Method)
}
if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1/" {
t.Fatalf("path: got %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["name"] != "[m-slug] Title" {
t.Errorf("name: got %q", body["name"])
}
if body["description_html"] != "<p>milestone</p>" {
t.Errorf("description_html: got %q", body["description_html"])
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil)
err := client.ProjectBody(context.Background(), workitem.BodyProjection{
Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"},
Title: "[m-slug] Title",
DescriptionHTML: "<p>milestone</p>",
})
if err != nil {
t.Fatalf("ProjectBody returned error: %v", err)
}
}
func TestProjectBodyRejectsEmptyInput(t *testing.T) {
client := NewClient(Config{BaseURL: "https://example.com", Token: "test-token"}, nil)
err := client.ProjectBody(context.Background(), workitem.BodyProjection{
Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "project-1", ID: "work-1"},
})
if err == nil || !strings.Contains(err.Error(), ErrInvalidInput.Error()) {
t.Fatalf("empty body projection: expected ErrInvalidInput, got %v", err)
}
}
func TestCreateIssuePostsWorkItem(t *testing.T) { func TestCreateIssuePostsWorkItem(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {

View file

@ -24,6 +24,29 @@ type ProjectSyncSetting struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type RoadmapSyncIdentity struct {
ID int64 `json:"id"`
Shape string `json:"shape"`
RoadmapMilestonePath string `json:"roadmap_milestone_path"`
RoadmapItemID string `json:"roadmap_item_id"`
Provider string `json:"provider"`
Tenant string `json:"tenant"`
Project string `json:"project"`
WorkItemID string `json:"work_item_id"`
ParentWorkItemID string `json:"parent_work_item_id"`
ProviderRevision *string `json:"provider_revision"`
RoadmapRevision *string `json:"roadmap_revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RoadmapSyncStep struct {
ID int64 `json:"id"`
RoadmapSyncIdentityID int64 `json:"roadmap_sync_identity_id"`
Step string `json:"step"`
CompletedAt time.Time `json:"completed_at"`
}
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`

View file

@ -0,0 +1,138 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: roadmap_sync_identities.sql
package db
import (
"context"
)
const getRoadmapSyncIdentityByMilestonePath = `-- name: GetRoadmapSyncIdentityByMilestonePath :one
SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
FROM roadmap_sync_identities
WHERE roadmap_milestone_path = $1
`
func (q *Queries) GetRoadmapSyncIdentityByMilestonePath(ctx context.Context, roadmapMilestonePath string) (RoadmapSyncIdentity, error) {
row := q.db.QueryRow(ctx, getRoadmapSyncIdentityByMilestonePath, roadmapMilestonePath)
var i RoadmapSyncIdentity
err := row.Scan(
&i.ID,
&i.Shape,
&i.RoadmapMilestonePath,
&i.RoadmapItemID,
&i.Provider,
&i.Tenant,
&i.Project,
&i.WorkItemID,
&i.ParentWorkItemID,
&i.ProviderRevision,
&i.RoadmapRevision,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getRoadmapSyncIdentityByWorkItem = `-- name: GetRoadmapSyncIdentityByWorkItem :one
SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
FROM roadmap_sync_identities
WHERE provider = $1 AND tenant = $2 AND project = $3 AND work_item_id = $4
`
type GetRoadmapSyncIdentityByWorkItemParams struct {
Provider string `json:"provider"`
Tenant string `json:"tenant"`
Project string `json:"project"`
WorkItemID string `json:"work_item_id"`
}
func (q *Queries) GetRoadmapSyncIdentityByWorkItem(ctx context.Context, arg GetRoadmapSyncIdentityByWorkItemParams) (RoadmapSyncIdentity, error) {
row := q.db.QueryRow(ctx, getRoadmapSyncIdentityByWorkItem,
arg.Provider,
arg.Tenant,
arg.Project,
arg.WorkItemID,
)
var i RoadmapSyncIdentity
err := row.Scan(
&i.ID,
&i.Shape,
&i.RoadmapMilestonePath,
&i.RoadmapItemID,
&i.Provider,
&i.Tenant,
&i.Project,
&i.WorkItemID,
&i.ParentWorkItemID,
&i.ProviderRevision,
&i.RoadmapRevision,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertRoadmapSyncIdentityByWorkItem = `-- name: UpsertRoadmapSyncIdentityByWorkItem :one
INSERT INTO roadmap_sync_identities (
shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (provider, tenant, project, work_item_id)
DO UPDATE SET
shape = EXCLUDED.shape,
roadmap_item_id = EXCLUDED.roadmap_item_id,
parent_work_item_id = EXCLUDED.parent_work_item_id,
provider_revision = EXCLUDED.provider_revision,
roadmap_revision = EXCLUDED.roadmap_revision,
updated_at = now()
WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path
RETURNING id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
`
type UpsertRoadmapSyncIdentityByWorkItemParams struct {
Shape string `json:"shape"`
RoadmapMilestonePath string `json:"roadmap_milestone_path"`
RoadmapItemID string `json:"roadmap_item_id"`
Provider string `json:"provider"`
Tenant string `json:"tenant"`
Project string `json:"project"`
WorkItemID string `json:"work_item_id"`
ParentWorkItemID string `json:"parent_work_item_id"`
ProviderRevision *string `json:"provider_revision"`
RoadmapRevision *string `json:"roadmap_revision"`
}
func (q *Queries) UpsertRoadmapSyncIdentityByWorkItem(ctx context.Context, arg UpsertRoadmapSyncIdentityByWorkItemParams) (RoadmapSyncIdentity, error) {
row := q.db.QueryRow(ctx, upsertRoadmapSyncIdentityByWorkItem,
arg.Shape,
arg.RoadmapMilestonePath,
arg.RoadmapItemID,
arg.Provider,
arg.Tenant,
arg.Project,
arg.WorkItemID,
arg.ParentWorkItemID,
arg.ProviderRevision,
arg.RoadmapRevision,
)
var i RoadmapSyncIdentity
err := row.Scan(
&i.ID,
&i.Shape,
&i.RoadmapMilestonePath,
&i.RoadmapItemID,
&i.Provider,
&i.Tenant,
&i.Project,
&i.WorkItemID,
&i.ParentWorkItemID,
&i.ProviderRevision,
&i.RoadmapRevision,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View file

@ -0,0 +1,67 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: roadmap_sync_steps.sql
package db
import (
"context"
)
const listRoadmapSyncSteps = `-- name: ListRoadmapSyncSteps :many
SELECT id, roadmap_sync_identity_id, step, completed_at
FROM roadmap_sync_steps
WHERE roadmap_sync_identity_id = $1
ORDER BY id
`
func (q *Queries) ListRoadmapSyncSteps(ctx context.Context, roadmapSyncIdentityID int64) ([]RoadmapSyncStep, error) {
rows, err := q.db.Query(ctx, listRoadmapSyncSteps, roadmapSyncIdentityID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RoadmapSyncStep
for rows.Next() {
var i RoadmapSyncStep
if err := rows.Scan(
&i.ID,
&i.RoadmapSyncIdentityID,
&i.Step,
&i.CompletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markRoadmapSyncStepCompleted = `-- name: MarkRoadmapSyncStepCompleted :one
INSERT INTO roadmap_sync_steps (roadmap_sync_identity_id, step)
VALUES ($1, $2)
ON CONFLICT (roadmap_sync_identity_id, step)
DO UPDATE SET roadmap_sync_identity_id = EXCLUDED.roadmap_sync_identity_id
RETURNING id, roadmap_sync_identity_id, step, completed_at
`
type MarkRoadmapSyncStepCompletedParams struct {
RoadmapSyncIdentityID int64 `json:"roadmap_sync_identity_id"`
Step string `json:"step"`
}
func (q *Queries) MarkRoadmapSyncStepCompleted(ctx context.Context, arg MarkRoadmapSyncStepCompletedParams) (RoadmapSyncStep, error) {
row := q.db.QueryRow(ctx, markRoadmapSyncStepCompleted, arg.RoadmapSyncIdentityID, arg.Step)
var i RoadmapSyncStep
err := row.Scan(
&i.ID,
&i.RoadmapSyncIdentityID,
&i.Step,
&i.CompletedAt,
)
return i, err
}

View file

@ -9,10 +9,10 @@ type Client interface {
Generate(ctx context.Context, input GenerateInput) (GenerateResult, error) Generate(ctx context.Context, input GenerateInput) (GenerateResult, error)
} }
// WorkspaceMetadata carries workspace-bound authoring context passed as // WorkspaceMetadata carries workspace-bound authoring context. On the wire,
// metadata.workspace in the /v1/responses payload. Fields here map directly // only the Path is serialized as a flat string to metadata.workspace in the
// to IOP Edge workspace routing fields so the receiving agent can resolve the // /v1/responses payload. Other fields are used on the client-side/authoring
// correct slot checkout without CLI-level invocation by NomadCode. // logic.
type WorkspaceMetadata struct { type WorkspaceMetadata struct {
Path string `json:"path"` Path string `json:"path"`
SourceBranch string `json:"source_branch,omitempty"` SourceBranch string `json:"source_branch,omitempty"`

View file

@ -102,6 +102,30 @@ func (i Identity) Matches(other Identity) bool {
left.RoadmapMilestonePath == right.RoadmapMilestonePath left.RoadmapMilestonePath == right.RoadmapMilestonePath
} }
// MatchesStrict reports whether two identities refer to the same item using the
// full provider-side identity key persisted by the identity map: provider,
// tenant, project, work item id, plus the Milestone path. Unlike Matches it
// also compares tenant and project, so a re-process in a different Plane
// workspace/project is never treated as the same identity. The retry decision
// uses this so it cannot reuse an identity across project-scoped sync targets.
// Callers must be able to normalize both sides; an un-normalizable identity
// never matches.
func (i Identity) MatchesStrict(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.Tenant == right.Tenant &&
left.Project == right.Project &&
left.WorkItemID == right.WorkItemID &&
left.RoadmapMilestonePath == right.RoadmapMilestonePath
}
// Revision captures the branch and revision context that a scan observed, so a // 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 // projection decision can record what develop state it was based on. Milestone
// sync defaults Branch to develop per the contract note. // sync defaults Branch to develop per the contract note.

View file

@ -88,6 +88,36 @@ func TestIdentityMatches(t *testing.T) {
} }
} }
func TestIdentityMatchesStrict(t *testing.T) {
base := Identity{
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
Provider: "plane",
Tenant: "general",
Project: "NOMAD",
WorkItemID: "NOMAD-13",
}
if !base.MatchesStrict(base) {
t.Errorf("identical identity must strict-match")
}
differentTenant := base
differentTenant.Tenant = "other-workspace"
if base.MatchesStrict(differentTenant) {
t.Errorf("different tenant must not strict-match")
}
differentProject := base
differentProject.Project = "OTHER"
if base.MatchesStrict(differentProject) {
t.Errorf("different project must not strict-match")
}
// Matches (loose) ignores tenant/project; MatchesStrict must not.
if !base.Matches(differentTenant) {
t.Errorf("sanity: loose Matches should still ignore tenant")
}
}
func TestRevisionNormalizeDefaultsDevelop(t *testing.T) { func TestRevisionNormalizeDefaultsDevelop(t *testing.T) {
got := Revision{}.Normalize() got := Revision{}.Normalize()
if got.Branch != DefaultBranch { if got.Branch != DefaultBranch {

View file

@ -0,0 +1,148 @@
package roadmapsync
import (
"bufio"
"fmt"
"html"
"path"
"strings"
)
// PlaneProjection is the provider-neutral, HTML-safe view of a develop
// Milestone that should be written into the Plane work item. Title follows the
// `[milestone-slug] Milestone Title` form (Milestone doc line 103); BodyHTML is
// the Milestone goal/scope rendered as escaped HTML. No provider secret or
// token is ever placed here.
type PlaneProjection struct {
Title string
BodyHTML string
}
// MilestoneSlug derives the milestone id used in the Plane title from the
// Milestone file path: the file name without its .md extension
// (agent-roadmap/phase/<phase>/milestones/<slug>.md -> <slug>). It returns ""
// when the path is empty or not a .md file so a caller never builds a
// `[] Title` prefix from a bad path.
func MilestoneSlug(milestonePath string) string {
cleaned := normalizeRepoPath(milestonePath)
if cleaned == "" {
return ""
}
base := path.Base(cleaned)
if !strings.HasSuffix(base, ".md") {
return ""
}
return strings.TrimSuffix(base, ".md")
}
// FormatPlaneProjection turns a develop Milestone Markdown document into the
// Plane title/body projection. milestonePath supplies the slug used in the
// title prefix. The body carries the Milestone title, the "## 목표"/"## 범위"
// sections when present, and is HTML-escaped so untrusted Markdown text never
// injects markup into Plane. The formatter is deliberately minimal: it does not
// attempt full Markdown rendering, only a safe, readable summary.
func FormatPlaneProjection(milestonePath, markdown string) PlaneProjection {
slug := MilestoneSlug(milestonePath)
title := extractMilestoneTitle(markdown)
displayTitle := title
if slug != "" {
shown := title
if strings.TrimSpace(shown) == "" {
shown = slug
}
displayTitle = fmt.Sprintf("[%s] %s", slug, shown)
}
return PlaneProjection{
Title: strings.TrimSpace(displayTitle),
BodyHTML: buildBodyHTML(title, markdown),
}
}
// extractMilestoneTitle returns the text of the first level-1 heading
// ("# Title") in the document, or "" when there is none.
func extractMilestoneTitle(markdown string) string {
scanner := bufio.NewScanner(strings.NewReader(markdown))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "#"))
}
}
return ""
}
// sectionLabels are the Milestone section headings whose body lines are carried
// into the Plane projection, in output order. Matched case-insensitively after
// stripping heading markers.
var sectionLabels = []string{"목표", "범위"}
// buildBodyHTML renders the Milestone title plus the goal/scope sections as
// escaped HTML paragraphs. Lines under a tracked section are emitted until the
// next heading. Everything is HTML-escaped, so neither Markdown text nor any
// stray provider metadata is rendered as live markup.
func buildBodyHTML(title, markdown string) string {
var b strings.Builder
if t := strings.TrimSpace(title); t != "" {
b.WriteString("<h2>")
b.WriteString(html.EscapeString(t))
b.WriteString("</h2>")
}
sections := collectSections(markdown)
for _, label := range sectionLabels {
lines, ok := sections[label]
if !ok || len(lines) == 0 {
continue
}
b.WriteString("<h3>")
b.WriteString(html.EscapeString(label))
b.WriteString("</h3>")
for _, line := range lines {
b.WriteString("<p>")
b.WriteString(html.EscapeString(line))
b.WriteString("</p>")
}
}
return b.String()
}
// collectSections groups non-empty body lines under each tracked section
// heading. A heading line that matches one of sectionLabels (after trimming
// `#`) opens that section; any heading closes the current one. Keys are the
// matched label.
func collectSections(markdown string) map[string][]string {
want := map[string]bool{}
for _, l := range sectionLabels {
want[l] = true
}
sections := map[string][]string{}
current := ""
scanner := bufio.NewScanner(strings.NewReader(markdown))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
if want[heading] {
current = heading
} else {
current = ""
}
continue
}
if current == "" {
continue
}
text := strings.TrimSpace(strings.TrimLeft(line, "-*"))
if text == "" {
continue
}
sections[current] = append(sections[current], text)
}
return sections
}

View file

@ -0,0 +1,73 @@
package roadmapsync
import (
"strings"
"testing"
)
const sampleMilestone = `# Milestone Work Item Creation Sync
## 목표
- Plane 티켓을 develop Milestone과 연결한다.
## 범위
- Plane 본문/제목 갱신과 Todo 이동.
## Provider identity
- provider: plane
- work item id: NOMAD-13
`
func TestFormatPlaneProjectionTitleUsesSlugPrefix(t *testing.T) {
proj := FormatPlaneProjection(
"agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md",
sampleMilestone,
)
want := "[milestone-work-item-creation-sync] Milestone Work Item Creation Sync"
if proj.Title != want {
t.Fatalf("title: got %q want %q", proj.Title, want)
}
}
func TestFormatPlaneProjectionBodyIncludesGoalAndIsEscaped(t *testing.T) {
proj := FormatPlaneProjection(
"agent-roadmap/phase/p/milestones/m.md",
"# T\n\n## 목표\n\n- 목표 <b>raw</b> 본문\n",
)
if !strings.Contains(proj.BodyHTML, "목표") {
t.Errorf("body should include goal section: %q", proj.BodyHTML)
}
// Raw markup in the Milestone text must be escaped, not rendered live.
if strings.Contains(proj.BodyHTML, "<b>raw</b>") {
t.Errorf("body must escape untrusted markup: %q", proj.BodyHTML)
}
if !strings.Contains(proj.BodyHTML, "&lt;b&gt;raw&lt;/b&gt;") {
t.Errorf("body should contain escaped markup: %q", proj.BodyHTML)
}
}
func TestFormatPlaneProjectionExcludesProviderIdentityBlock(t *testing.T) {
proj := FormatPlaneProjection("agent-roadmap/phase/p/milestones/m.md", sampleMilestone)
// Provider/work item id metadata is not a body section, so it must not leak
// into the Plane body.
if strings.Contains(proj.BodyHTML, "NOMAD-13") || strings.Contains(proj.BodyHTML, "provider") {
t.Errorf("body must not include provider identity metadata: %q", proj.BodyHTML)
}
}
func TestMilestoneSlug(t *testing.T) {
cases := map[string]string{
"agent-roadmap/phase/p/milestones/m-foo.md": "m-foo",
"m-foo.md": "m-foo",
"": "",
"agent-roadmap/x.txt": "",
}
for in, want := range cases {
if got := MilestoneSlug(in); got != want {
t.Errorf("MilestoneSlug(%q): got %q want %q", in, got, want)
}
}
}

View file

@ -0,0 +1,102 @@
package roadmapsync
import (
"context"
"errors"
"strings"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
// ErrNotReady is returned when the develop projection candidate is not ready,
// so a caller never silently projects a Plane Todo for an unmatched or unpushed
// Milestone.
var ErrNotReady = errors.New("develop projection candidate is not ready")
// ErrInvalidProjectionInput is returned when a required projection input
// (provider ref, original body, todo state, or milestone content) is missing.
var ErrInvalidProjectionInput = errors.New("invalid plane todo projection input")
// preserveCommentPrefix opens the comment that preserves the original Plane
// body before the develop content replaces it. Kept as a stable prefix so the
// preserved request is recognizable in the Plane timeline.
const preserveCommentPrefix = "사용자 요청:"
// PlaneTodoProvider is the minimal set of provider facets the Todo projection
// drives, in execution order: preserve the original body as a comment, replace
// title/body, then move the work item to the Todo state. It is satisfied by the
// Plane adapter Client and by test fakes.
type PlaneTodoProvider interface {
workitem.Commenter
workitem.BodyProjector
workitem.StatusProjector
}
// ProjectPlaneTodoInput carries everything ProjectPlaneTodo needs without
// reaching into provider DTOs: the develop match decision, the original Plane
// body to preserve, the Todo state id to move to, and the develop Milestone
// markdown to project. Ref is the provider work item the projection targets.
type ProjectPlaneTodoInput struct {
Candidate ProjectionCandidate
Ref workitem.Ref
OriginalBody string
TodoStateID string
// MilestoneMarkdown is the develop Milestone content used to build the new
// Plane title/body via FormatPlaneProjection.
MilestoneMarkdown string
// ExternalSource/ExternalID tag the preservation comment so a re-run can be
// recognized as nomadcode-authored rather than a user request.
ExternalSource string
ExternalID string
}
// ProjectPlaneTodo runs the Plane Todo projection in the order the Milestone
// requires (doc lines 57/102): preserve the original body as a `사용자 요청:`
// comment, then replace the title/body with the develop Milestone content, then
// move the work item to the Todo state. A failure at any step stops the
// sequence so a later step never runs on an unpreserved or unupdated ticket.
//
// When the develop projection candidate is not ready it returns ErrNotReady
// without calling the provider, so a Plane Todo is never produced for an
// unmatched or unpushed Milestone.
func ProjectPlaneTodo(ctx context.Context, p PlaneTodoProvider, in ProjectPlaneTodoInput) error {
if !in.Candidate.Ready {
return ErrNotReady
}
if p == nil ||
strings.TrimSpace(string(in.Ref.Provider)) == "" ||
strings.TrimSpace(in.Ref.ID) == "" ||
strings.TrimSpace(in.OriginalBody) == "" ||
strings.TrimSpace(in.TodoStateID) == "" ||
strings.TrimSpace(in.MilestoneMarkdown) == "" {
return ErrInvalidProjectionInput
}
projection := FormatPlaneProjection(in.Candidate.Identity.RoadmapMilestonePath, in.MilestoneMarkdown)
// 1. Preserve the original Plane body as a comment before anything mutates it.
if err := p.AppendComment(ctx, workitem.CommentInput{
Ref: in.Ref,
Body: preserveCommentPrefix + " " + in.OriginalBody,
Format: workitem.CommentFormatHTML,
ExternalSource: in.ExternalSource,
ExternalID: in.ExternalID,
}); err != nil {
return err
}
// 2. Replace title/body with the develop Milestone projection.
if err := p.ProjectBody(ctx, workitem.BodyProjection{
Ref: in.Ref,
Title: projection.Title,
DescriptionHTML: projection.BodyHTML,
}); err != nil {
return err
}
// 3. Move the work item to the Todo state.
return p.ProjectStatus(ctx, workitem.StatusProjection{
Ref: in.Ref,
ProviderState: in.TodoStateID,
})
}

View file

@ -0,0 +1,127 @@
package roadmapsync
import (
"context"
"errors"
"strings"
"testing"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
type fakeProvider struct {
calls []string
commentBody string
commentErr error
bodyErr error
statusErr error
bodyProjection workitem.BodyProjection
statusState string
}
func (f *fakeProvider) AppendComment(_ context.Context, in workitem.CommentInput) error {
f.calls = append(f.calls, "comment")
f.commentBody = in.Body
return f.commentErr
}
func (f *fakeProvider) ProjectBody(_ context.Context, in workitem.BodyProjection) error {
f.calls = append(f.calls, "body")
f.bodyProjection = in
return f.bodyErr
}
func (f *fakeProvider) ProjectStatus(_ context.Context, in workitem.StatusProjection) error {
f.calls = append(f.calls, "status")
f.statusState = in.ProviderState
return f.statusErr
}
func readyInput() ProjectPlaneTodoInput {
return ProjectPlaneTodoInput{
Candidate: ProjectionCandidate{
Ready: true,
Identity: Identity{
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m-foo.md",
},
},
Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "p", ID: "work-1"},
OriginalBody: "<p>original request</p>",
TodoStateID: "todo-state",
MilestoneMarkdown: "# Foo Milestone\n\n## 목표\n\n- 목표\n",
ExternalSource: "nomadcode",
ExternalID: "task-1",
}
}
func TestProjectPlaneTodoSuccessOrderAndPreservation(t *testing.T) {
f := &fakeProvider{}
if err := ProjectPlaneTodo(context.Background(), f, readyInput()); err != nil {
t.Fatalf("ProjectPlaneTodo returned error: %v", err)
}
if got := strings.Join(f.calls, ","); got != "comment,body,status" {
t.Fatalf("call order: got %q", got)
}
if !strings.HasPrefix(f.commentBody, preserveCommentPrefix) {
t.Errorf("comment must preserve original body with prefix: %q", f.commentBody)
}
if !strings.Contains(f.commentBody, "original request") {
t.Errorf("comment must contain original body: %q", f.commentBody)
}
if f.statusState != "todo-state" {
t.Errorf("status: got %q", f.statusState)
}
if !strings.HasPrefix(f.bodyProjection.Title, "[m-foo]") {
t.Errorf("body title should use slug prefix: %q", f.bodyProjection.Title)
}
}
func TestProjectPlaneTodoStopsWhenCommentFails(t *testing.T) {
f := &fakeProvider{commentErr: errors.New("comment boom")}
err := ProjectPlaneTodo(context.Background(), f, readyInput())
if err == nil || !strings.Contains(err.Error(), "comment boom") {
t.Fatalf("expected comment error, got %v", err)
}
if got := strings.Join(f.calls, ","); got != "comment" {
t.Fatalf("only comment should run: got %q", got)
}
}
func TestProjectPlaneTodoStopsWhenBodyFails(t *testing.T) {
f := &fakeProvider{bodyErr: errors.New("body boom")}
err := ProjectPlaneTodo(context.Background(), f, readyInput())
if err == nil || !strings.Contains(err.Error(), "body boom") {
t.Fatalf("expected body error, got %v", err)
}
if got := strings.Join(f.calls, ","); got != "comment,body" {
t.Fatalf("status must not run after body failure: got %q", got)
}
}
func TestProjectPlaneTodoNotReadyDoesNotCallProvider(t *testing.T) {
f := &fakeProvider{}
in := readyInput()
in.Candidate.Ready = false
err := ProjectPlaneTodo(context.Background(), f, in)
if !errors.Is(err, ErrNotReady) {
t.Fatalf("expected ErrNotReady, got %v", err)
}
if len(f.calls) != 0 {
t.Fatalf("provider must not be called when not ready: %v", f.calls)
}
}
func TestProjectPlaneTodoRejectsMissingInput(t *testing.T) {
f := &fakeProvider{}
in := readyInput()
in.OriginalBody = " "
err := ProjectPlaneTodo(context.Background(), f, in)
if !errors.Is(err, ErrInvalidProjectionInput) {
t.Fatalf("expected ErrInvalidProjectionInput, got %v", err)
}
if len(f.calls) != 0 {
t.Fatalf("provider must not be called on invalid input: %v", f.calls)
}
}

View file

@ -0,0 +1,173 @@
package roadmapsync
import "strings"
// Step is the provider-neutral creation-cycle step the retry decision resumes
// from. The values mirror the persisted step ledger so a caller can read the
// ledger and pass the completed set in without re-mapping strings.
type Step string
const (
StepDevelopMatched Step = "develop_matched"
StepOriginalCommentPreserved Step = "original_comment_preserved"
StepPlaneBodyUpdated Step = "plane_body_updated"
StepPlaneTodoMoved Step = "plane_todo_moved"
)
// OrderedSteps is the creation-cycle order. A reconcile resumes at the first
// step not present in the completed set.
var OrderedSteps = []Step{
StepDevelopMatched,
StepOriginalCommentPreserved,
StepPlaneBodyUpdated,
StepPlaneTodoMoved,
}
// Action is the next thing a creation cycle should do after reconciling the
// persisted identity and step ledger against the current trigger.
type Action string
const (
// ActionResume runs the remaining steps starting at NextStep.
ActionResume Action = "resume"
// ActionComplete means every step is already recorded; nothing to do.
ActionComplete Action = "complete"
// ActionConflict means the trigger disagrees with the persisted identity
// (revision mismatch) and a human/upstream decision is required instead of a
// silent overwrite.
ActionConflict Action = "conflict"
// ActionSkipSelfMutation means the trigger was NomadCode's own mutation, so
// it must not be treated as a new authoring trigger.
ActionSkipSelfMutation Action = "skip_self_mutation"
)
// ExistingIdentity is the provider-neutral view of a persisted identity row the
// reconcile needs: the stored identity plus the revisions it was last written
// with. Found is false when no identity is persisted yet.
type ExistingIdentity struct {
Found bool
Identity Identity
ProviderRevision string
RoadmapRevision string
}
// ReconcileInput carries the current trigger and the persisted state to
// reconcile it against. Existing and CompletedSteps come from the identity map
// and step ledger; an empty CompletedSteps means a fresh cycle.
type ReconcileInput struct {
// Identity is the trigger's provider-neutral identity (the Plane ticket or
// Milestone path being re-processed).
Identity Identity
// ProviderRevision / RoadmapRevision are the revisions the current trigger
// observed. A mismatch against the persisted revisions is a conflict.
ProviderRevision string
RoadmapRevision string
// Actor is who caused this trigger. When it equals SelfActor the trigger is
// NomadCode's own mutation and is not a new authoring trigger.
Actor string
SelfActor string
Existing ExistingIdentity
CompletedSteps map[Step]bool
}
// ReconcileResult is the typed decision: what to do next, the resume step when
// ActionResume, whether an existing identity was reused, and a reason that is
// always populated so a not-resume decision is never silent.
type ReconcileResult struct {
Action Action
NextStep Step
IdentityKept bool
Reason string
}
// Reasons for a reconcile decision. Stable strings so a caller can branch on
// the cause.
const (
reasonInvalidReconcileIdentity = "trigger identity is invalid or incomplete"
reasonIdentityKeyMismatch = "persisted identity key does not match trigger (provider/tenant/project/work item/path)"
reasonSelfMutation = "trigger actor is NomadCode self mutation, not a new authoring trigger"
reasonProviderRevMismatch = "trigger provider revision does not match persisted identity"
reasonRoadmapRevMismatch = "trigger roadmap revision does not match persisted identity"
reasonAllStepsComplete = "all creation cycle steps already completed"
reasonResumeRemaining = "resuming creation cycle at first incomplete step"
)
// ReconcileCreationCycle decides the next action for a (possibly re-processed)
// creation cycle. It is the idempotency join described by plan 05: it reuses an
// existing identity when the same Plane ticket or Milestone path is
// re-processed, refuses to overwrite on a revision mismatch, skips NomadCode's
// own mutations, and otherwise resumes at the first step not yet in the ledger.
//
// The function reads no DB itself; callers supply the persisted identity and
// completed-step set so the decision stays provider-neutral and unit-testable.
func ReconcileCreationCycle(in ReconcileInput) ReconcileResult {
identity, err := in.Identity.Normalize()
if err != nil {
return ReconcileResult{Action: ActionConflict, Reason: reasonInvalidReconcileIdentity}
}
// Actor guard: a mutation NomadCode itself made (e.g. the body/title rewrite
// it just projected) must not be re-interpreted as a fresh user trigger.
self := strings.TrimSpace(in.SelfActor)
if self != "" && strings.TrimSpace(in.Actor) == self {
return ReconcileResult{Action: ActionSkipSelfMutation, Reason: reasonSelfMutation}
}
// Strict match: when a persisted identity exists it must agree on the full
// provider-side key (provider, tenant, project, work item id, Milestone
// path). A found-but-not-strictly-matching row means the trigger disagrees
// with what is stored (e.g. a different Plane workspace/project for the same
// work item id/path), which is a conflict rather than a silent fresh resume.
if in.Existing.Found && !in.Existing.Identity.MatchesStrict(identity) {
return ReconcileResult{Action: ActionConflict, Reason: reasonIdentityKeyMismatch}
}
identityKept := in.Existing.Found
// Revision guard: when an identity already exists for this work item/path,
// the trigger must observe the same revisions, otherwise resuming would
// silently overwrite work projected from a different develop state.
if identityKept {
if mismatch(in.Existing.ProviderRevision, in.ProviderRevision) {
return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonProviderRevMismatch}
}
if mismatch(in.Existing.RoadmapRevision, in.RoadmapRevision) {
return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonRoadmapRevMismatch}
}
}
next, ok := firstIncompleteStep(in.CompletedSteps)
if !ok {
return ReconcileResult{Action: ActionComplete, IdentityKept: identityKept, Reason: reasonAllStepsComplete}
}
return ReconcileResult{
Action: ActionResume,
NextStep: next,
IdentityKept: identityKept,
Reason: reasonResumeRemaining,
}
}
// firstIncompleteStep returns the earliest ordered step not present in the
// completed set. ok is false when every step is complete.
func firstIncompleteStep(completed map[Step]bool) (Step, bool) {
for _, step := range OrderedSteps {
if !completed[step] {
return step, true
}
}
return "", false
}
// mismatch reports whether a persisted revision and a trigger revision disagree.
// Empty values are treated as "unknown" and never conflict, so a first cycle
// that has not recorded a revision is not blocked.
func mismatch(persisted, trigger string) bool {
p := strings.TrimSpace(persisted)
t := strings.TrimSpace(trigger)
if p == "" || t == "" {
return false
}
return p != t
}

View file

@ -0,0 +1,184 @@
package roadmapsync
import (
"testing"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
func reconcileIdentity() Identity {
return Identity{
Shape: ShapeMilestone,
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
Provider: workitem.ProviderID("plane"),
Tenant: "general",
Project: "NOMAD",
WorkItemID: "NOMAD-13",
}
}
func TestReconcile_SamePlaneTicketReusesIdentity(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: id},
CompletedSteps: map[Step]bool{
StepDevelopMatched: true,
},
})
if res.Action != ActionResume {
t.Fatalf("action: got %q, want resume (%s)", res.Action, res.Reason)
}
if !res.IdentityKept {
t.Error("expected identity reuse for the same Plane ticket")
}
if res.NextStep != StepOriginalCommentPreserved {
t.Errorf("next step: got %q, want original_comment_preserved", res.NextStep)
}
}
func TestReconcile_SameMilestonePathReusesIdentity(t *testing.T) {
id := reconcileIdentity()
// Existing row found via the Milestone path: same path + provider work item.
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: id},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume || !res.IdentityKept {
t.Fatalf("expected resume with identity kept, got %q kept=%v (%s)", res.Action, res.IdentityKept, res.Reason)
}
if res.NextStep != StepDevelopMatched {
t.Errorf("fresh ledger should resume at develop_matched, got %q", res.NextStep)
}
}
func TestReconcile_ProviderRevisionMismatchConflicts(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
ProviderRevision: "rev-2",
Existing: ExistingIdentity{
Found: true, Identity: id, ProviderRevision: "rev-1",
},
})
if res.Action != ActionConflict {
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
}
if !res.IdentityKept {
t.Error("a conflict on a matched identity should still report the identity as kept")
}
}
func TestReconcile_RoadmapRevisionMismatchConflicts(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
RoadmapRevision: "abc",
Existing: ExistingIdentity{
Found: true, Identity: id, RoadmapRevision: "def",
},
})
if res.Action != ActionConflict {
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
}
}
func TestReconcile_NomadCodeActorIsNotNewTrigger(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Actor: "nomadcode",
SelfActor: "nomadcode",
Existing: ExistingIdentity{Found: true, Identity: id},
})
if res.Action != ActionSkipSelfMutation {
t.Fatalf("action: got %q, want skip_self_mutation (%s)", res.Action, res.Reason)
}
}
func TestReconcile_DifferentTenantConflicts(t *testing.T) {
id := reconcileIdentity()
existing := id
existing.Tenant = "other-workspace"
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: existing},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionConflict {
t.Fatalf("tenant mismatch should conflict, got %q (%s)", res.Action, res.Reason)
}
if res.IdentityKept {
t.Error("a non-matching persisted identity must not be reported as kept")
}
}
func TestReconcile_DifferentProjectConflicts(t *testing.T) {
id := reconcileIdentity()
existing := id
existing.Project = "OTHER"
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: existing},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionConflict {
t.Fatalf("project mismatch should conflict, got %q (%s)", res.Action, res.Reason)
}
}
func TestReconcile_AllStepsCompleteReturnsComplete(t *testing.T) {
id := reconcileIdentity()
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
Existing: ExistingIdentity{Found: true, Identity: id},
CompletedSteps: map[Step]bool{
StepDevelopMatched: true,
StepOriginalCommentPreserved: true,
StepPlaneBodyUpdated: true,
StepPlaneTodoMoved: true,
},
})
if res.Action != ActionComplete {
t.Fatalf("action: got %q, want complete (%s)", res.Action, res.Reason)
}
}
func TestReconcile_InvalidIdentityConflicts(t *testing.T) {
res := ReconcileCreationCycle(ReconcileInput{
Identity: Identity{Provider: workitem.ProviderID("plane")}, // missing path + work item id
})
if res.Action != ActionConflict {
t.Fatalf("action: got %q, want conflict (%s)", res.Action, res.Reason)
}
}
func TestReconcile_FreshCycleNoExistingResumesAtFirstStep(t *testing.T) {
res := ReconcileCreationCycle(ReconcileInput{
Identity: reconcileIdentity(),
Existing: ExistingIdentity{Found: false},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume || res.NextStep != StepDevelopMatched {
t.Fatalf("fresh cycle should resume at develop_matched, got %q step=%q", res.Action, res.NextStep)
}
if res.IdentityKept {
t.Error("no existing identity should report IdentityKept=false")
}
}
func TestReconcile_FirstCycleRevisionUnknownDoesNotConflict(t *testing.T) {
id := reconcileIdentity()
// Existing identity has no recorded revision yet; a trigger that carries one
// must not be blocked.
res := ReconcileCreationCycle(ReconcileInput{
Identity: id,
ProviderRevision: "rev-1",
Existing: ExistingIdentity{Found: true, Identity: id, ProviderRevision: ""},
CompletedSteps: map[Step]bool{},
})
if res.Action != ActionResume {
t.Fatalf("unknown persisted revision should not conflict, got %q (%s)", res.Action, res.Reason)
}
}

View file

@ -0,0 +1,84 @@
package storage
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/nomadcode/nomadcode-core/internal/db"
)
// pgUniqueViolation is the SQLSTATE code Postgres returns for a unique
// constraint violation.
const pgUniqueViolation = "23505"
// ErrRoadmapSyncIdentityNotFound is returned when no identity row matches the
// lookup key.
var ErrRoadmapSyncIdentityNotFound = errors.New("roadmap sync identity not found")
// ErrRoadmapSyncIdentityConflict is returned when the incoming provider work
// item and Milestone path do not agree with an existing identity row: either
// the Milestone path is already bound to a different provider work item, or the
// provider work item is already bound to a different Milestone path. Returning a
// typed conflict keeps a re-process from silently rebinding one identity onto
// another instead of hitting a raw unique-index violation.
var ErrRoadmapSyncIdentityConflict = errors.New("roadmap sync identity conflict")
// GetRoadmapSyncIdentityByWorkItem returns the identity bound to a provider work
// item, or ErrRoadmapSyncIdentityNotFound when none exists.
func (s *Store) GetRoadmapSyncIdentityByWorkItem(ctx context.Context, args db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
row, err := s.queries.GetRoadmapSyncIdentityByWorkItem(ctx, args)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityNotFound
}
return db.RoadmapSyncIdentity{}, err
}
return row, nil
}
// GetRoadmapSyncIdentityByMilestonePath returns the identity bound to a roadmap
// Milestone path, or ErrRoadmapSyncIdentityNotFound when none exists.
func (s *Store) GetRoadmapSyncIdentityByMilestonePath(ctx context.Context, milestonePath string) (db.RoadmapSyncIdentity, error) {
row, err := s.queries.GetRoadmapSyncIdentityByMilestonePath(ctx, milestonePath)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityNotFound
}
return db.RoadmapSyncIdentity{}, err
}
return row, nil
}
// UpsertRoadmapSyncIdentity persists an identity keyed on the provider work
// item, reusing the existing row when the same Plane ticket or the same
// Milestone path is re-processed.
//
// Conflict protection is atomic in a single statement rather than a non-atomic
// read-then-write: the upsert's DO UPDATE keeps the stored roadmap_milestone_path
// and only fires when it already equals the incoming path. So a re-process of
// the same provider work item with a different Milestone path matches no row on
// conflict and returns pgx.ErrNoRows, which maps to ErrRoadmapSyncIdentityConflict
// instead of silently rebinding the ticket to a new path. A different work item
// re-using an existing Milestone path trips the path unique index (SQLSTATE
// 23505), which also maps to ErrRoadmapSyncIdentityConflict instead of a raw pg
// error. Two concurrent re-processes therefore cannot both win.
func (s *Store) UpsertRoadmapSyncIdentity(ctx context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
row, err := s.queries.UpsertRoadmapSyncIdentityByWorkItem(ctx, args)
if err != nil {
// Work item already bound to a different Milestone path: the conditional
// DO UPDATE matched no row, so RETURNING produced nothing.
if errors.Is(err, pgx.ErrNoRows) {
return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityConflict
}
// Different work item re-using an existing Milestone path: the path unique
// index rejected the INSERT.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation {
return db.RoadmapSyncIdentity{}, ErrRoadmapSyncIdentityConflict
}
return db.RoadmapSyncIdentity{}, err
}
return row, nil
}

View file

@ -0,0 +1,249 @@
package storage
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/nomadcode/nomadcode-core/internal/db"
)
// identityRouteResult is the row/err a routed QueryRow should produce.
type identityRouteResult struct {
row db.RoadmapSyncIdentity
err error
}
// identityMockDBTX routes QueryRow calls by a substring found in the SQL so a
// single mock can answer the by-path lookup, the by-work-item lookup, and the
// upsert with different rows.
type identityMockDBTX struct {
byPath identityRouteResult
byWork identityRouteResult
upsert identityRouteResult
queryLog []string
}
func (m *identityMockDBTX) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
return pgconn.NewCommandTag("OK"), nil
}
func (m *identityMockDBTX) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
return nil, errors.New("unexpected Query call")
}
func (m *identityMockDBTX) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
m.queryLog = append(m.queryLog, sql)
switch {
case strings.Contains(sql, "INSERT INTO roadmap_sync_identities"):
return &identityMockRow{result: m.upsert}
case strings.Contains(sql, "WHERE roadmap_milestone_path = $1"):
return &identityMockRow{result: m.byPath}
case strings.Contains(sql, "WHERE provider = $1"):
return &identityMockRow{result: m.byWork}
default:
return &identityMockRow{result: identityRouteResult{err: errors.New("unrouted sql: " + sql)}}
}
}
type identityMockRow struct {
result identityRouteResult
}
func (r *identityMockRow) Scan(dest ...interface{}) error {
if r.result.err != nil {
return r.result.err
}
scanRoadmapSyncIdentity(r.result.row, dest)
return nil
}
// scanRoadmapSyncIdentity fills scan destinations in the column order used by
// the generated roadmap_sync_identities queries.
func scanRoadmapSyncIdentity(row db.RoadmapSyncIdentity, dest []interface{}) {
if len(dest) < 13 {
return
}
assign(dest[0], row.ID)
assign(dest[1], row.Shape)
assign(dest[2], row.RoadmapMilestonePath)
assign(dest[3], row.RoadmapItemID)
assign(dest[4], row.Provider)
assign(dest[5], row.Tenant)
assign(dest[6], row.Project)
assign(dest[7], row.WorkItemID)
assign(dest[8], row.ParentWorkItemID)
if p, ok := dest[9].(**string); ok {
*p = row.ProviderRevision
}
if p, ok := dest[10].(**string); ok {
*p = row.RoadmapRevision
}
if p, ok := dest[11].(*time.Time); ok {
*p = row.CreatedAt
}
if p, ok := dest[12].(*time.Time); ok {
*p = row.UpdatedAt
}
}
func assign[T any](dst interface{}, v T) {
if p, ok := dst.(*T); ok {
*p = v
}
}
func newIdentityStore(m *identityMockDBTX) *Store {
return NewStoreWithQueries(db.New(m))
}
func sampleUpsertParams() db.UpsertRoadmapSyncIdentityByWorkItemParams {
return db.UpsertRoadmapSyncIdentityByWorkItemParams{
Shape: "milestone",
RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md",
Provider: "plane",
Tenant: "general",
Project: "NOMAD",
WorkItemID: "NOMAD-13",
}
}
// ---- UpsertRoadmapSyncIdentity ----
func TestUpsertRoadmapSyncIdentity_ByWorkItemReturnsRow(t *testing.T) {
args := sampleUpsertParams()
m := &identityMockDBTX{
upsert: identityRouteResult{row: db.RoadmapSyncIdentity{
ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath,
Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
}},
}
store := newIdentityStore(m)
row, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if row.ID != 7 || row.WorkItemID != "NOMAD-13" || row.RoadmapMilestonePath != args.RoadmapMilestonePath {
t.Errorf("identity not preserved: %+v", row)
}
if !sqlLogContains(m.queryLog, "ON CONFLICT (provider, tenant, project, work_item_id)") {
t.Errorf("expected upsert query shape, log=%v", m.queryLog)
}
// The conflict path must keep the stored Milestone path and only update when
// the incoming path already matches, so the rebind race is closed in SQL.
if !sqlLogContains(m.queryLog, "WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path") {
t.Errorf("expected conditional path guard in DO UPDATE, log=%v", m.queryLog)
}
if sqlLogContains(m.queryLog, "roadmap_milestone_path = EXCLUDED.roadmap_milestone_path,") {
t.Errorf("DO UPDATE must not overwrite roadmap_milestone_path, log=%v", m.queryLog)
}
}
func TestUpsertRoadmapSyncIdentity_SamePathSameWorkItemReuses(t *testing.T) {
args := sampleUpsertParams()
existing := db.RoadmapSyncIdentity{
ID: 7, Shape: "milestone", RoadmapMilestonePath: args.RoadmapMilestonePath,
Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
}
m := &identityMockDBTX{upsert: identityRouteResult{row: existing}}
store := newIdentityStore(m)
row, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if row.ID != 7 {
t.Errorf("expected reuse of row 7, got %+v", row)
}
}
// Same provider work item arriving with a different Milestone path: the
// conditional DO UPDATE matches no row, RETURNING is empty, and the store maps
// pgx.ErrNoRows to a typed conflict instead of rebinding the ticket.
func TestUpsertRoadmapSyncIdentity_WorkItemRebindToDifferentPathConflicts(t *testing.T) {
args := sampleUpsertParams()
m := &identityMockDBTX{upsert: identityRouteResult{err: pgx.ErrNoRows}}
store := newIdentityStore(m)
_, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
if !errors.Is(err, ErrRoadmapSyncIdentityConflict) {
t.Fatalf("expected conflict, got %v", err)
}
}
// A different provider work item re-using an existing Milestone path trips the
// path unique index (SQLSTATE 23505); the store maps it to a typed conflict
// rather than surfacing a raw pg error.
func TestUpsertRoadmapSyncIdentity_PathUniqueViolationConflicts(t *testing.T) {
args := sampleUpsertParams()
m := &identityMockDBTX{upsert: identityRouteResult{err: &pgconn.PgError{
Code: pgUniqueViolation,
ConstraintName: "ux_roadmap_sync_identities_milestone_path",
}}}
store := newIdentityStore(m)
_, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
if !errors.Is(err, ErrRoadmapSyncIdentityConflict) {
t.Fatalf("expected conflict, got %v", err)
}
}
// A non-conflict DB error must propagate unchanged, not be swallowed as a
// conflict.
func TestUpsertRoadmapSyncIdentity_OtherDBErrorPropagates(t *testing.T) {
args := sampleUpsertParams()
sentinel := errors.New("connection reset")
m := &identityMockDBTX{upsert: identityRouteResult{err: sentinel}}
store := newIdentityStore(m)
_, err := store.UpsertRoadmapSyncIdentity(context.Background(), args)
if !errors.Is(err, sentinel) {
t.Fatalf("expected sentinel error, got %v", err)
}
if errors.Is(err, ErrRoadmapSyncIdentityConflict) {
t.Error("generic DB error must not be reported as conflict")
}
}
// ---- Get lookups ----
func TestGetRoadmapSyncIdentityByWorkItem_NotFoundSentinel(t *testing.T) {
m := &identityMockDBTX{byWork: identityRouteResult{err: pgx.ErrNoRows}}
store := newIdentityStore(m)
_, err := store.GetRoadmapSyncIdentityByWorkItem(context.Background(), db.GetRoadmapSyncIdentityByWorkItemParams{
Provider: "plane", Tenant: "general", Project: "NOMAD", WorkItemID: "NOMAD-13",
})
if !errors.Is(err, ErrRoadmapSyncIdentityNotFound) {
t.Fatalf("expected not-found sentinel, got %v", err)
}
}
func TestGetRoadmapSyncIdentityByMilestonePath_ReturnsRow(t *testing.T) {
m := &identityMockDBTX{byPath: identityRouteResult{row: db.RoadmapSyncIdentity{
ID: 4, RoadmapMilestonePath: "agent-roadmap/phase/p/milestones/m.md", WorkItemID: "NOMAD-13",
}}}
store := newIdentityStore(m)
row, err := store.GetRoadmapSyncIdentityByMilestonePath(context.Background(), "agent-roadmap/phase/p/milestones/m.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if row.ID != 4 || row.WorkItemID != "NOMAD-13" {
t.Errorf("row not preserved: %+v", row)
}
}
func sqlLogContains(logs []string, sub string) bool {
for _, l := range logs {
if strings.Contains(l, sub) {
return true
}
}
return false
}

View file

@ -0,0 +1,72 @@
package storage
import (
"context"
"errors"
"github.com/nomadcode/nomadcode-core/internal/db"
)
// RoadmapSyncStep is the closed set of creation-cycle steps tracked in the
// ledger, in execution order. A completed step is skipped on re-process so a
// partially-failed cycle resumes only the remaining steps.
type RoadmapSyncStep string
const (
StepDevelopMatched RoadmapSyncStep = "develop_matched"
StepOriginalCommentPreserved RoadmapSyncStep = "original_comment_preserved"
StepPlaneBodyUpdated RoadmapSyncStep = "plane_body_updated"
StepPlaneTodoMoved RoadmapSyncStep = "plane_todo_moved"
)
// OrderedRoadmapSyncSteps lists the steps in the order the creation cycle runs
// them, so a resume can pick the first not-yet-completed step.
var OrderedRoadmapSyncSteps = []RoadmapSyncStep{
StepDevelopMatched,
StepOriginalCommentPreserved,
StepPlaneBodyUpdated,
StepPlaneTodoMoved,
}
// ErrUnknownRoadmapSyncStep is returned when a caller marks or queries a step
// outside the closed set, mirroring the DB check constraint so a typo never
// silently records an unknown step.
var ErrUnknownRoadmapSyncStep = errors.New("unknown roadmap sync step")
// Valid reports whether the step is part of the tracked closed set.
func (s RoadmapSyncStep) Valid() bool {
switch s {
case StepDevelopMatched, StepOriginalCommentPreserved, StepPlaneBodyUpdated, StepPlaneTodoMoved:
return true
default:
return false
}
}
// MarkStepCompleted records that a step finished for an identity. Re-marking an
// already-completed step is a no-op that returns the existing row, so a retry
// after a later-step failure never duplicates ledger rows. An unknown step is
// rejected before touching the DB.
func (s *Store) MarkStepCompleted(ctx context.Context, identityID int64, step RoadmapSyncStep) (db.RoadmapSyncStep, error) {
if !step.Valid() {
return db.RoadmapSyncStep{}, ErrUnknownRoadmapSyncStep
}
return s.queries.MarkRoadmapSyncStepCompleted(ctx, db.MarkRoadmapSyncStepCompletedParams{
RoadmapSyncIdentityID: identityID,
Step: string(step),
})
}
// CompletedSteps returns the set of steps already completed for an identity, so
// a retry decision can skip them.
func (s *Store) CompletedSteps(ctx context.Context, identityID int64) (map[RoadmapSyncStep]bool, error) {
rows, err := s.queries.ListRoadmapSyncSteps(ctx, identityID)
if err != nil {
return nil, err
}
done := make(map[RoadmapSyncStep]bool, len(rows))
for _, r := range rows {
done[RoadmapSyncStep(r.Step)] = true
}
return done, nil
}

View file

@ -0,0 +1,180 @@
package storage
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/nomadcode/nomadcode-core/internal/db"
)
// stepMockDBTX answers the ledger list (Query) and mark (QueryRow) calls.
type stepMockDBTX struct {
listRows []db.RoadmapSyncStep
listErr error
markRow db.RoadmapSyncStep
markErr error
queryLog []string
markArgs []interface{}
}
func (m *stepMockDBTX) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
return pgconn.NewCommandTag("OK"), nil
}
func (m *stepMockDBTX) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
m.queryLog = append(m.queryLog, sql)
if m.listErr != nil {
return nil, m.listErr
}
return &fakeStepRows{rows: m.listRows}, nil
}
func (m *stepMockDBTX) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
m.queryLog = append(m.queryLog, sql)
m.markArgs = args
return &stepMockRow{row: m.markRow, err: m.markErr}
}
type stepMockRow struct {
row db.RoadmapSyncStep
err error
}
func (r *stepMockRow) Scan(dest ...interface{}) error {
if r.err != nil {
return r.err
}
scanRoadmapSyncStep(r.row, dest)
return nil
}
type fakeStepRows struct {
rows []db.RoadmapSyncStep
idx int
}
func (r *fakeStepRows) Close() {}
func (r *fakeStepRows) Err() error { return nil }
func (r *fakeStepRows) CommandTag() pgconn.CommandTag { return pgconn.NewCommandTag("SELECT") }
func (r *fakeStepRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
func (r *fakeStepRows) Values() ([]interface{}, error) { return nil, nil }
func (r *fakeStepRows) RawValues() [][]byte { return nil }
func (r *fakeStepRows) Conn() *pgx.Conn { return nil }
func (r *fakeStepRows) Next() bool {
if r.idx >= len(r.rows) {
return false
}
r.idx++
return true
}
func (r *fakeStepRows) Scan(dest ...interface{}) error {
scanRoadmapSyncStep(r.rows[r.idx-1], dest)
return nil
}
func scanRoadmapSyncStep(row db.RoadmapSyncStep, dest []interface{}) {
if len(dest) < 4 {
return
}
assign(dest[0], row.ID)
assign(dest[1], row.RoadmapSyncIdentityID)
assign(dest[2], row.Step)
if p, ok := dest[3].(*time.Time); ok {
*p = row.CompletedAt
}
}
func newStepStore(m *stepMockDBTX) *Store {
return NewStoreWithQueries(db.New(m))
}
// ---- MarkStepCompleted ----
func TestMarkStepCompleted_KnownStepPersists(t *testing.T) {
completedAt := time.Now()
m := &stepMockDBTX{markRow: db.RoadmapSyncStep{
ID: 1, RoadmapSyncIdentityID: 7, Step: string(StepDevelopMatched), CompletedAt: completedAt,
}}
store := newStepStore(m)
row, err := store.MarkStepCompleted(context.Background(), 7, StepDevelopMatched)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if row.Step != string(StepDevelopMatched) || row.RoadmapSyncIdentityID != 7 {
t.Errorf("row not preserved: %+v", row)
}
if !sqlLogContains(m.queryLog, "ON CONFLICT (roadmap_sync_identity_id, step)") {
t.Errorf("expected idempotent upsert shape, log=%v", m.queryLog)
}
}
func TestMarkStepCompleted_CompletedStepReapplyIsNoOp(t *testing.T) {
// The DB upsert returns the original row (same completed_at) on re-mark; the
// store passes it through without error so a re-apply is a no-op.
original := time.Date(2026, 6, 13, 10, 0, 0, 0, time.UTC)
m := &stepMockDBTX{markRow: db.RoadmapSyncStep{
ID: 1, RoadmapSyncIdentityID: 7, Step: string(StepPlaneBodyUpdated), CompletedAt: original,
}}
store := newStepStore(m)
row, err := store.MarkStepCompleted(context.Background(), 7, StepPlaneBodyUpdated)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !row.CompletedAt.Equal(original) {
t.Errorf("re-apply must keep original completed_at, got %v", row.CompletedAt)
}
}
func TestMarkStepCompleted_UnknownStepRejected(t *testing.T) {
m := &stepMockDBTX{}
store := newStepStore(m)
_, err := store.MarkStepCompleted(context.Background(), 7, RoadmapSyncStep("plane_done_moved"))
if !errors.Is(err, ErrUnknownRoadmapSyncStep) {
t.Fatalf("expected unknown-step error, got %v", err)
}
if len(m.queryLog) != 0 {
t.Errorf("unknown step must not touch the DB, log=%v", m.queryLog)
}
}
// ---- CompletedSteps ----
func TestCompletedSteps_ReturnsSetForResume(t *testing.T) {
m := &stepMockDBTX{listRows: []db.RoadmapSyncStep{
{ID: 1, RoadmapSyncIdentityID: 7, Step: string(StepDevelopMatched)},
{ID: 2, RoadmapSyncIdentityID: 7, Step: string(StepOriginalCommentPreserved)},
}}
store := newStepStore(m)
done, err := store.CompletedSteps(context.Background(), 7)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !done[StepDevelopMatched] || !done[StepOriginalCommentPreserved] {
t.Errorf("expected first two steps completed, got %v", done)
}
if done[StepPlaneBodyUpdated] || done[StepPlaneTodoMoved] {
t.Errorf("uncompleted steps must be absent, got %v", done)
}
}
func TestCompletedSteps_PropagatesQueryError(t *testing.T) {
sentinel := errors.New("query failed")
m := &stepMockDBTX{listErr: sentinel}
store := newStepStore(m)
_, err := store.CompletedSteps(context.Background(), 7)
if !errors.Is(err, sentinel) {
t.Fatalf("expected sentinel error, got %v", err)
}
}

View file

@ -21,6 +21,7 @@ const (
CapabilityStatusProjection Capability = "status_projection" CapabilityStatusProjection Capability = "status_projection"
CapabilityLabelProjection Capability = "label_projection" CapabilityLabelProjection Capability = "label_projection"
CapabilityCreate Capability = "create" CapabilityCreate Capability = "create"
CapabilityBodyProjection Capability = "body_projection"
) )
type Capabilities map[Capability]bool type Capabilities map[Capability]bool
@ -50,6 +51,13 @@ type Creator interface {
CreateWorkItem(ctx context.Context, input CreateInput) (CreateResult, error) CreateWorkItem(ctx context.Context, input CreateInput) (CreateResult, error)
} }
// BodyProjector updates an existing work item's title and/or body. It is an
// additive projection capability separate from status projection so a body
// rewrite and a status move stay independent steps.
type BodyProjector interface {
ProjectBody(ctx context.Context, input BodyProjection) error
}
type LabelProjector interface { type LabelProjector interface {
ProjectLabels(ctx context.Context, input LabelProjection) error ProjectLabels(ctx context.Context, input LabelProjection) error
} }
@ -97,6 +105,12 @@ type StatusProjection struct {
ProviderState string `json:"provider_state"` ProviderState string `json:"provider_state"`
} }
type BodyProjection struct {
Ref Ref `json:"ref"`
Title string `json:"title,omitempty"`
DescriptionHTML string `json:"description_html,omitempty"`
}
type CreateInput struct { type CreateInput struct {
Provider ProviderID `json:"provider"` Provider ProviderID `json:"provider"`
Tenant string `json:"tenant"` Tenant string `json:"tenant"`

View file

@ -0,0 +1,34 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS roadmap_sync_identities (
id BIGSERIAL PRIMARY KEY,
shape TEXT NOT NULL DEFAULT 'milestone',
roadmap_milestone_path TEXT NOT NULL,
roadmap_item_id TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL,
tenant TEXT NOT NULL DEFAULT '',
project TEXT NOT NULL DEFAULT '',
work_item_id TEXT NOT NULL,
parent_work_item_id TEXT NOT NULL DEFAULT '',
provider_revision TEXT,
roadmap_revision TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT roadmap_sync_identities_shape_check
CHECK (shape IN ('milestone', 'task'))
);
-- The same provider work item maps to exactly one identity row, so re-processing
-- the same Plane ticket never creates a duplicate Milestone identity.
CREATE UNIQUE INDEX ux_roadmap_sync_identities_provider_work_item
ON roadmap_sync_identities (provider, tenant, project, work_item_id);
-- The same Milestone path maps to exactly one identity row, so re-processing the
-- same Milestone path never creates a duplicate identity.
CREATE UNIQUE INDEX ux_roadmap_sync_identities_milestone_path
ON roadmap_sync_identities (roadmap_milestone_path);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS roadmap_sync_identities;
-- +goose StatementEnd

View file

@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS roadmap_sync_steps (
id BIGSERIAL PRIMARY KEY,
roadmap_sync_identity_id BIGINT NOT NULL REFERENCES roadmap_sync_identities(id) ON DELETE CASCADE,
step TEXT NOT NULL,
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT roadmap_sync_steps_step_check
CHECK (step IN ('develop_matched', 'original_comment_preserved', 'plane_body_updated', 'plane_todo_moved'))
);
-- Each identity records a given step at most once, so re-applying a completed
-- step is idempotent rather than appending a duplicate ledger row.
CREATE UNIQUE INDEX ux_roadmap_sync_steps_identity_step
ON roadmap_sync_steps (roadmap_sync_identity_id, step);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS roadmap_sync_steps;
-- +goose StatementEnd

View file

@ -0,0 +1,25 @@
-- name: GetRoadmapSyncIdentityByWorkItem :one
SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
FROM roadmap_sync_identities
WHERE provider = $1 AND tenant = $2 AND project = $3 AND work_item_id = $4;
-- name: GetRoadmapSyncIdentityByMilestonePath :one
SELECT id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at
FROM roadmap_sync_identities
WHERE roadmap_milestone_path = $1;
-- name: UpsertRoadmapSyncIdentityByWorkItem :one
INSERT INTO roadmap_sync_identities (
shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (provider, tenant, project, work_item_id)
DO UPDATE SET
shape = EXCLUDED.shape,
roadmap_item_id = EXCLUDED.roadmap_item_id,
parent_work_item_id = EXCLUDED.parent_work_item_id,
provider_revision = EXCLUDED.provider_revision,
roadmap_revision = EXCLUDED.roadmap_revision,
updated_at = now()
WHERE roadmap_sync_identities.roadmap_milestone_path = EXCLUDED.roadmap_milestone_path
RETURNING id, shape, roadmap_milestone_path, roadmap_item_id, provider, tenant, project, work_item_id, parent_work_item_id, provider_revision, roadmap_revision, created_at, updated_at;

View file

@ -0,0 +1,12 @@
-- name: ListRoadmapSyncSteps :many
SELECT id, roadmap_sync_identity_id, step, completed_at
FROM roadmap_sync_steps
WHERE roadmap_sync_identity_id = $1
ORDER BY id;
-- name: MarkRoadmapSyncStepCompleted :one
INSERT INTO roadmap_sync_steps (roadmap_sync_identity_id, step)
VALUES ($1, $2)
ON CONFLICT (roadmap_sync_identity_id, step)
DO UPDATE SET roadmap_sync_identity_id = EXCLUDED.roadmap_sync_identity_id
RETURNING id, roadmap_sync_identity_id, step, completed_at;