feat(sync): roadmap identity backfill 기능과 git backfiller를 추가한다

roadmap sync 과정에서 origin 프로젝트 간행본의 identity
정보를 채우는 backfill 파이프라인을 구현한다.

- git_backfiller: git 레이블/태그 간행본의 identity 백필
- migration: backfill 완료 단계 테이블 스키마 추가
- pipeline/service: backfill 호출 흐름 연동
- identity_write: backfill 결과 쓰기 통합
This commit is contained in:
toki 2026-07-03 04:19:10 +09:00
parent f768130b2d
commit 0d619b446c
18 changed files with 1696 additions and 169 deletions

View file

@ -0,0 +1,204 @@
<!-- task=m-agent-origin-milestone-creation-sync/02+01_identity_backfill plan=0 tag=API -->
# Code Review Reference - 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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-07-02
task=m-agent-origin-milestone-creation-sync/02+01_identity_backfill, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
- Task ids:
- `identity-backfill`: Plane 생성 직후 Milestone markdown에 `Provider identity` block을 주입하고 Core-authored backfill commit을 `develop`에 push한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 판정 append, active 파일 log archive, PASS 시 `complete.log` 작성과 task directory archive까지 끝난 상태를 의미합니다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Durable Backfill Step | [x] |
| [API-2] Identity Backfill Writer And Commit Scope | [x] |
| [API-3] Scheduler Wiring And Review Evidence | [x] |
## 구현 체크리스트
- [x] `identity-backfill` step을 storage/domain ordered step set에 추가하고 DB check constraint migration/query tests를 갱신한다.
- [x] missing-create Plane create + identity upsert 직후 Milestone markdown에 `Provider identity` block을 주입하는 pipeline seam을 추가한다.
- [x] backfill writer가 identity block 외 unrelated roadmap 변경을 commit하지 않는 git scope check를 구현/테스트한다. 검증: backfill commit에는 identity block 외의 unrelated roadmap 변경이 포함되지 않는다.
- [x] push 성공 뒤에만 `identity_backfilled` ledger step을 mark하고, 실패 시 retry가 create를 반복하지 않고 backfill부터 재개하게 한다.
- [x] `git diff --check`, focused `go test -count=1`, focused `go vet`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/`로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
- `NewService` 시그니처는 변경하지 않고 `Service.SetBackfiller(Backfiller)` post-construction setter를 추가했다 (기존 `workflowService.SetEnqueuer` 패턴과 동일). 기존 모든 `NewService` 호출부(`main.go`, `service_test.go`의 다수 테스트)가 그대로 유지된다.
- Plan의 API-2 "After" 코드는 missing-create 최초 생성 경로만 보여줬지만, 실제로는 "이미 identity가 생성됐지만 backfill push가 아직 안 끝난 채로 같은 missing-create 트리거가 재전달"되는 재시도 경로를 별도로 처리해야 했다(체크리스트 항목 4 "실패 시 retry가 create를 반복하지 않고 backfill부터 재개"). `SyncCreation` 최상단에서 트리거의 원본 `Expected.WorkItemID`/`Ref.ID`가 둘 다 빈 값인지(`missingCreateShaped`)를 found-infill 이전에 캡처해 두고, `if found` 블록에서 `existingRow.WorkItemID != "" && !completed[identity_backfilled]`이면 재생성 없이 backfill만 재개하도록 분기를 추가했다. 이 게이팅을 `Expected.WorkItemID`가 채워진 일반 candidate-match 충돌 테스트들(`readyInput()` 기반)과 분리해, 기존 conflict/revision-mismatch 동작에 영향을 주지 않게 했다.
- `roadmapsync.OrderedSteps`/`storage.OrderedRoadmapSyncSteps`에 `identity_backfilled`를 `develop_matched` 바로 다음 순서로 추가했으므로, candidate-matched 일반 흐름(`runRemainingSteps`)도 이 step을 순회한다. 이 흐름에서는 develop scan이 이미 identity가 박힌 markdown을 매칭한 상태이므로 실제 backfill 액션이 불필요해, `runStep`에서 `StepIdentityBackfilled`를 `StepDevelopMatched`와 동일하게 no-op으로 처리하고 원장에는 마킹만 한다. 실제 git backfill 호출은 missing-create 최초/재시도 경로에서만 발생한다.
- `authoring/identity_write.go`의 identity block 렌더링을 `RenderProviderIdentityBlock` exported 함수로 추출했다(체크리스트 항목 "reuse identity block rendering without dirty-worktree assumptions"). 출력 포맷은 기존과 동일해 `identity_write_test.go`는 수정 없이 그대로 통과한다.
- Plan은 `queries/roadmap_sync_steps.sql`이나 sqlc 재생성 언급이 없었고, 이번 마이그레이션은 CHECK 제약만 갱신하므로 컬럼 스키마 변경이 없어 `db` 패키지 재생성이 필요 없음을 확인했다(코드 인스펙션 근거).
## 주요 설계 결정
- Core-authored backfill 커밋 스코프 검증은 두 단계로 구성했다: (1) `git diff --cached --name-only`로 staged 파일이 대상 Milestone 경로 단 하나인지 확인, (2) `git diff --cached -- <path>`의 모든 `+`/`-` 라인을 검사해 추가된 라인이 렌더링된 identity block과 정확히 일치하고 삭제된 라인이 전혀 없는지 확인한다. 두 조건 중 하나라도 위반되면 커밋하지 않고 에러를 반환한다.
- `GitBackfiller.BackfillIdentity`는 대상 파일에 이미 일치하는 identity가 있으면(이전 시도에서 push는 성공했지만 ledger mark가 실패한 재시도 케이스) 아무 것도 하지 않고 `nil`을 반환해 멱등성을 보장한다. 기존 identity가 있는데 값이 다르면 `ErrIdentityBackfillMismatch`를 반환해 다른 provider identity를 조용히 덮어쓰지 않는다.
- backfill 커밋 메시지에 `NomadCode-Identity-Backfill: true` marker 라인을 남겨, 이후 self-loop(03+01,02 subtask) 단계에서 재전달된 Gito webhook이 Core 자신의 backfill 커밋인지 식별할 수 있는 여지를 남겼다(이번 plan 범위는 아니며 marker만 기록).
- `Service.backfiller`가 `nil`인 상태에서 missing-create/backfill-resume 경로에 도달하면 명시적 에러를 반환한다(`identity backfiller not configured`). `GITO_DEVELOP_REPO_PATH`가 설정되지 않으면 `main.go`에서 아예 backfiller를 wiring하지 않으므로, 이 상황이 실제 misconfiguration 신호가 된다.
- `roadmap_sync_jobs.go`와 `scheduler.New` 시그니처는 변경하지 않았다. `Backfiller`는 `roadmapsyncpipeline.Service` 내부 의존성이며 River job args나 `CreationSyncRunner` 인터페이스에는 노출되지 않으므로, 스케줄러 wiring 코드는 그대로 두고 `main.go`의 구성 지점에서만 `SetBackfiller`를 호출했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Implementation waited for predecessor `01_scan_create_ledger` completion.
- `identity_backfilled` is durable and ordered after `develop_matched`.
- Backfill commit stages only the target Milestone identity block and records ledger only after push success.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```sh
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s
```
### API-2 중간 검증
```sh
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/authoring
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.265s
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.093s
```
### API-3 중간 검증
```sh
$ cd services/core && go test -count=1 ./internal/scheduler ./cmd/server ./internal/roadmapsyncpipeline
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.037s
ok github.com/nomadcode/nomadcode-core/cmd/server 0.005s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.298s
```
### 최종 검증
```sh
$ git diff --check
(no output, exit 0)
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/authoring ./internal/roadmapsyncpipeline ./internal/scheduler ./cmd/server
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.003s
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.139s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.340s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.033s
ok github.com/nomadcode/nomadcode-core/cmd/server 0.005s
$ cd services/core && go vet ./internal/roadmapsync ./internal/storage ./internal/authoring ./internal/roadmapsyncpipeline ./internal/scheduler ./cmd/server
(no output, exit 0)
```
### 추가 회귀 확인 (plan 범위 밖, 안전을 위해 추가 실행)
```sh
$ cd services/core && go build ./... && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.004s
ok github.com/nomadcode/nomadcode-core/cmd/server 0.007s
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.009s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.012s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.011s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.013s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.013s
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.328s
ok github.com/nomadcode/nomadcode-core/internal/config 0.003s
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.005s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.405s
ok github.com/nomadcode/nomadcode-core/internal/http 0.010s
ok github.com/nomadcode/nomadcode-core/internal/notification 0.003s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.005s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.020s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.354s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.041s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s
```
### Git 커밋 스코프 증거 (temp repo)
```sh
$ go test -count=1 -run TestGitBackfiller ./internal/roadmapsyncpipeline -v
=== RUN TestGitBackfillerCommitsAndPushesIdentityOnly
--- PASS: TestGitBackfillerCommitsAndPushesIdentityOnly (0.13s)
=== RUN TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted
--- PASS: TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted (0.10s)
=== RUN TestGitBackfillerRejectsMismatchedExistingIdentity
--- PASS: TestGitBackfillerRejectsMismatchedExistingIdentity (0.08s)
PASS
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.312s
```
`TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted`는 backfill 대상 파일에 identity block과 무관한 dirty edit이 섞여 있으면 scope check가 커밋 자체를 거부하고 `origin/develop`이 최초 커밋에 그대로 머무는지 확인한다(unrelated roadmap 변경이 포함되지 않는다는 요구사항의 직접 증거).
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Pass
- Code quality: Pass
- Implementation deviation: Fail
- Verification trust: Warn
- Spec conformance: Fail
- 발견된 문제:
- Required: `services/core/internal/roadmapsyncpipeline/git_backfiller.go:73`에서 worktree에 존재하는 matching identity block만 보고 `nil`을 반환한다. 하지만 같은 함수가 `verifyScopedDiff` 실패 또는 `git push` 실패 후에도 worktree/local commit에 identity block을 남길 수 있어, 다음 retry가 remote `develop` push 성공 없이 `identity_backfilled` ledger를 mark할 수 있다. `BackfillIdentity`는 remote target branch 또는 성공적으로 pushed commit에 matching identity가 있음을 확인한 경우에만 no-op으로 성공해야 하며, pre-commit 실패 시 staged/worktree 변경을 원상복구하거나 retry가 false success로 흐르지 않게 해야 한다. push 실패 후 retry는 pending commit을 다시 push하거나 remote 미반영 상태를 에러로 유지해야 한다.
- Required: `services/core/internal/roadmapsyncpipeline/git_backfiller.go:66`가 verified `origin/develop` revision이 아니라 현재 working tree/`HEAD`의 `RepoPath/path`를 읽어 backfill commit을 만든다. `BranchRevisionScanner`는 `services/core/internal/gitosync/scanner.go:124`와 `services/core/internal/gitosync/scanner.go:174`에서 remote ref를 fetch/`git show`로 검증할 뿐 local branch checkout을 업데이트하지 않으므로, 정상 missing-create 이벤트에서도 새 Milestone 파일이 worktree에 없거나 stale `HEAD` 위에서 `HEAD:develop` push가 시도될 수 있다. Backfiller에 verified roadmap revision을 전달하고, 쓰기 전에 local branch/working tree가 그 revision을 기준으로 깨끗한지 보장하거나, remote revision에서 별도 worktree를 만들어 identity-only commit을 생성하도록 고쳐야 한다.
- 다음 단계: FAIL follow-up PLAN/CODE_REVIEW를 같은 task directory에 작성한다. `USER_REVIEW.md`는 생성하지 않는다.

View file

@ -0,0 +1,248 @@
<!-- task=m-agent-origin-milestone-creation-sync/02+01_identity_backfill 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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-02
task=m-agent-origin-milestone-creation-sync/02+01_identity_backfill, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
- Task ids:
- `identity-backfill`: Plane 생성 직후 Milestone markdown에 `Provider identity` block을 주입하고 Core-authored backfill commit을 `develop`에 push한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior logs:
- `agent-task/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/plan_cloud_G08_0.log`
- `agent-task/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/code_review_cloud_G08_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/roadmapsyncpipeline/git_backfiller.go:73` treats a matching identity in local worktree as success, so retry after scope-check or push failure can mark `identity_backfilled` without a successful remote push.
- `services/core/internal/roadmapsyncpipeline/git_backfiller.go:66` reads current working tree/`HEAD`, while `services/core/internal/gitosync/scanner.go:124` and `services/core/internal/gitosync/scanner.go:174` verify remote `origin/develop` without updating local checkout.
- Suggested/Nit summary: 없음.
- Affected files: `services/core/internal/roadmapsyncpipeline/git_backfiller.go`, `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`, `services/core/internal/roadmapsyncpipeline/service.go`, `services/core/internal/roadmapsyncpipeline/service_test.go`, optional `services/core/cmd/server/main.go`.
- Verification evidence from failed loop: `git diff --check`, focused `go test -count=1`, and focused `go vet` passed, but tests did not cover retry false-success or stale checkout base.
- Allowed prior-context reread: only the two prior log files listed above if exact review wording is needed.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G08.md` -> `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` -> `plan_cloud_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Remote Push Success And Retry Contract | [x] |
| [REVIEW_API-2] Verified Develop Revision Base | [x] |
## 구현 체크리스트
- [x] `GitBackfiller`가 local worktree/local commit identity만으로 성공하지 않고, remote target branch 또는 성공적으로 pushed commit에 matching identity가 확인될 때만 no-op 성공하도록 수정한다.
- [x] Backfill commit이 scanner가 검증한 develop revision을 기준으로 생성되도록 `BackfillInput`/service 경로에 revision을 전달하고, dirty/stale checkout은 파일 mutation 전에 명시적으로 실패시키거나 안전한 임시 worktree로 처리한다.
- [x] scope-check 실패 retry, push 실패 retry, local HEAD behind `origin/develop`, mismatched milestone id identity block에 대한 regression tests를 추가한다.
- [x] `git diff --check`, focused `go test -count=1`, focused `go vet`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/`를 `agent-task/archive/YYYY/MM/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-origin-milestone-creation-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- `cmd/server/main.go`의 backfiller wiring은 기존 구현 범위의 변경으로 유지했다. 이번 follow-up은 `NewGitBackfiller` 시그니처를 바꾸지 않고 verified revision을 `BackfillInput.RoadmapRevision`으로 전달하는 방식이라, 해당 wiring을 추가 수정하지 않았다.
- REVIEW_API-2의 두 옵션(임시 worktree vs. verified remote ref로 clean checkout/reset) 중 후자를 택했다. 기존 `TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted`가 dirty checkout을 명시적 실패로 기대하므로, primary develop checkout에서 dirty guard 후 `git checkout -B <branch> <remoteRev>`로 verified revision에 재기준하는 방식이 기존 계약과 일치한다.
- 검증 명령은 계획의 고정 계약 그대로 실행했다. 대체 없음.
## 주요 설계 결정
- 성공/mismatch 판정을 전부 **remote develop 상태 기준**으로 옮겼다. `BackfillIdentity`는 먼저 `git fetch --prune`으로 remote branch를 당긴 뒤 `origin/develop`의 revision을 `git show <remoteRev>:<path>`로 읽고, 그 내용에서 identity를 파싱한다. matching이면 no-op 성공, 다른 identity면 `ErrIdentityBackfillMismatch`, 없으면 backfill을 진행한다. 이로써 local worktree/local commit만 identity를 가진 상태는 절대 성공으로 취급되지 않는다 (REVIEW_API-1).
- Commit base를 verified remote revision으로 고정했다. dirty guard(`git status --porcelain`) 통과 후 `git checkout -B <branch> <remoteRev>`로 로컬 브랜치를 remote tip에 재기준하고, 그 위에 identity-only 변경을 commit한다. 로컬 HEAD가 `origin/develop`보다 뒤처져 있어도 commit parent는 항상 verified revision이다 (REVIEW_API-2).
- `BackfillInput`에 `RoadmapRevision`을 추가하고 service의 두 backfill 경로(fresh missing-create, redelivered resume)에서 `in.Scan.Revision.Normalize().Revision`을 전달한다. backfiller는 이 값이 remote tip과 다르면 파일 mutation 전에 실패시켜, scanner가 검증하지 않은 revision 위에 commit하지 않는다.
- pre-push 실패(write/add/scope-check/commit)는 `cleanup`으로 `git reset --hard <remoteRev>`을 수행해 staged/worktree 잔여물을 제거하고 원인 에러를 보존한다. push 실패는 로컬 commit을 남기되 에러를 반환하며, 다음 retry가 remote revision으로 재기준(`checkout -B`)해 재생성/재push한다. ledger step은 호출자(`backfillIdentityAndMark`)가 nil 반환 후에만 mark하므로 실패한 push는 ledger를 전진시키지 않는다.
- `identityMatches`에 canonical milestone id(`MilestoneID()`) 비교를 추가해, 같은 provider/tenant/project/work item id를 다른 milestone id로 재사용하는 블록을 mismatch로 판정한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Retry after scope-check failure or push failure cannot mark `identity_backfilled` until remote develop contains the identity commit.
- Backfill commit is based on the verified develop revision observed by the scanner, not stale local `HEAD`.
- Existing identity mismatch includes canonical milestone id, not only provider/work item id.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_API-1 중간 검증
```sh
$ cd services/core && go test -count=1 -run 'TestGitBackfiller' ./internal/roadmapsyncpipeline -v
=== RUN TestGitBackfillerCommitsAndPushesIdentityOnly
--- PASS: TestGitBackfillerCommitsAndPushesIdentityOnly (0.26s)
=== RUN TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted
--- PASS: TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted (0.11s)
=== RUN TestGitBackfillerRejectsMismatchedExistingIdentity
--- PASS: TestGitBackfillerRejectsMismatchedExistingIdentity (0.16s)
=== RUN TestGitBackfillerRejectsMismatchedMilestoneID
--- PASS: TestGitBackfillerRejectsMismatchedMilestoneID (0.16s)
=== RUN TestGitBackfillerRetriesAfterPushFailure
--- PASS: TestGitBackfillerRetriesAfterPushFailure (0.26s)
=== RUN TestGitBackfillerCommitsOnVerifiedRemoteRevisionWhenLocalBehind
--- PASS: TestGitBackfillerCommitsOnVerifiedRemoteRevisionWhenLocalBehind (0.19s)
=== RUN TestGitBackfillerRejectsMovedRemoteRevision
--- PASS: TestGitBackfillerRejectsMovedRemoteRevision (0.15s)
PASS
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 1.287s
```
### REVIEW_API-2 중간 검증
```sh
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/gitosync
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 2.037s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.409s
```
### 최종 검증
```sh
$ git diff --check
(exit 0, no output)
$ cd services/core && go test -count=1 -run 'TestGitBackfiller' ./internal/roadmapsyncpipeline -v
=== RUN TestGitBackfillerCommitsAndPushesIdentityOnly
--- PASS: TestGitBackfillerCommitsAndPushesIdentityOnly (0.28s)
=== RUN TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted
--- PASS: TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted (0.12s)
=== RUN TestGitBackfillerRejectsMismatchedExistingIdentity
--- PASS: TestGitBackfillerRejectsMismatchedExistingIdentity (0.16s)
=== RUN TestGitBackfillerRejectsMismatchedMilestoneID
--- PASS: TestGitBackfillerRejectsMismatchedMilestoneID (0.18s)
=== RUN TestGitBackfillerRetriesAfterPushFailure
--- PASS: TestGitBackfillerRetriesAfterPushFailure (0.32s)
=== RUN TestGitBackfillerCommitsOnVerifiedRemoteRevisionWhenLocalBehind
--- PASS: TestGitBackfillerCommitsOnVerifiedRemoteRevisionWhenLocalBehind (0.36s)
=== RUN TestGitBackfillerRejectsMovedRemoteRevision
--- PASS: TestGitBackfillerRejectsMovedRemoteRevision (0.25s)
PASS
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 1.672s
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 2.352s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.421s
ok github.com/nomadcode/nomadcode-core/cmd/server 0.010s
$ cd services/core && go vet ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server
(exit 0, no output)
```
보조 확인(계획의 테스트 환경 규칙에 명시된 관련 패키지, 계약 명령 아님):
```sh
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/authoring ./internal/scheduler
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.006s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.007s
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.208s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.040s
```
---
> **[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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 `complete.log`를 작성하고 active task directory를 `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/`로 이동한다. `m-agent-origin-milestone-creation-sync` 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 호출은 수행하지 않는다.
리뷰 메모:
- SDD S04(`identity-backfill`) Evidence Map과 일치한다. `GitBackfiller`는 remote `origin/develop` 상태를 fetch/read하고, scan-verified revision이 움직였으면 mutation 전에 실패한다.
- retry false-success 경로가 닫혀 있다. scope-check/dirty checkout은 성공으로 전환되지 않고, push 실패 후 retry도 remote에 identity가 실제 반영된 뒤에만 nil을 반환한다.
- `identity_backfilled` ledger step은 storage/domain enum, migration, service orchestration, regression tests에 반영됐다.
- 재검증:
- `git diff --check` PASS
- `cd services/core && go test -count=1 -run 'TestGitBackfiller' ./internal/roadmapsyncpipeline -v` PASS
- `cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server` PASS
- `cd services/core && go vet ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server` PASS
- `cd services/core && go test ./...` PASS
- `cd services/core && go vet ./...` PASS

View file

@ -0,0 +1,49 @@
# Complete - m-agent-origin-milestone-creation-sync/02+01_identity_backfill
## 완료 일시
2026-07-02
## 요약
Agent-origin Milestone creation sync의 `identity-backfill` task를 2회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | local-only identity success, stale checkout base 문제가 남아 follow-up 필요 |
| `plan_cloud_G08_1.log` | `code_review_cloud_G08_1.log` | PASS | remote push success predicate, verified revision base, retry regression tests 충족 |
## 구현/정리 내용
- `identity_backfilled` ledger step을 storage/domain ordered step set, DB check constraint migration, service orchestration에 추가했다.
- `GitBackfiller`를 추가해 scanner가 검증한 remote develop revision 위에서 provider identity block만 commit/push하도록 했다.
- retry false-success를 막기 위해 already-satisfied/mismatch 판정을 local worktree가 아니라 remote target branch 기준으로 수행한다.
- `BackfillInput.RoadmapRevision`을 service/backfiller 경로에 전달해 stale remote revision은 mutation 전에 실패시킨다.
- identity block rendering을 `authoring.RenderProviderIdentityBlock`으로 공유해 authoring writer와 Core backfill writer가 같은 Markdown shape를 사용한다.
- scope-check failure, push failure retry, local HEAD behind `origin/develop`, moved remote revision, mismatched milestone id regression tests를 추가했다.
## 최종 검증
- `git diff --check` - PASS; output 없음
- `cd services/core && go test -count=1 -run 'TestGitBackfiller' ./internal/roadmapsyncpipeline -v` - PASS; GitBackfiller regression tests 통과
- `cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server` - PASS
- `cd services/core && go vet ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server` - PASS; output 없음
- `cd services/core && go test ./...` - PASS
- `cd services/core && go vet ./...` - PASS; output 없음
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
- Completed task ids:
- `identity-backfill`: PASS; evidence=`agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/plan_cloud_G08_1.log`, `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/code_review_cloud_G08_1.log`; verification=`git diff --check`, `cd services/core && go test -count=1 -run 'TestGitBackfiller' ./internal/roadmapsyncpipeline -v`, `cd services/core && go test ./...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,265 @@
<!-- task=m-agent-origin-milestone-creation-sync/02+01_identity_backfill plan=1 tag=REVIEW_API -->
# Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
구현 후 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 반드시 실제 구현 내용과 검증 출력으로 채운다. 검증을 실행하고, active 파일은 그대로 둔 뒤 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 스킬 전용이다.
선택된 Milestone의 `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service 준비, 일반 범위 조정, 검증 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
이 follow-up은 `plan_cloud_G08_0.log` / `code_review_cloud_G08_0.log`의 FAIL 판정을 수습한다. 기존 구현은 identity block이 local worktree나 local commit에만 남아 있어도 backfill 성공으로 간주할 수 있고, scanner가 검증한 `origin/develop` revision이 아니라 현재 checkout `HEAD` 위에서 backfill commit을 만들 수 있다. S04는 "identity-only commit이 develop에 push된 뒤에만 ledger step을 mark"해야 하므로, git retry와 revision anchoring을 다시 닫는다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 Milestone lock decision만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 prompt는 금지이며, code-review가 정당성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
- Task ids:
- `identity-backfill`: Plane 생성 직후 Milestone markdown에 `Provider identity` block을 주입하고 Core-authored backfill commit을 `develop`에 push한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior logs:
- `agent-task/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/plan_cloud_G08_0.log`
- `agent-task/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/code_review_cloud_G08_0.log`
- Verdict: FAIL
- Required summary:
- `services/core/internal/roadmapsyncpipeline/git_backfiller.go:73` treats a matching identity in local worktree as success, so retry after scope-check or push failure can mark `identity_backfilled` without a successful remote push.
- `services/core/internal/roadmapsyncpipeline/git_backfiller.go:66` reads current working tree/`HEAD`, while `services/core/internal/gitosync/scanner.go:124` and `services/core/internal/gitosync/scanner.go:174` verify remote `origin/develop` without updating local checkout.
- Suggested/Nit summary: 없음.
- Affected files: `services/core/internal/roadmapsyncpipeline/git_backfiller.go`, `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`, `services/core/internal/roadmapsyncpipeline/service.go`, `services/core/internal/roadmapsyncpipeline/service_test.go`, optional `services/core/cmd/server/main.go`.
- Verification evidence from failed loop: `git diff --check`, focused `go test -count=1`, and focused `go vet` passed, but tests did not cover retry false-success or stale checkout base.
- Allowed prior-context reread: only the two prior log files listed above if exact review wording is needed.
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-contract/index.md`
- `agent-contract/outer/gito-branch-webhook-consumer-v1.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
- `agent-roadmap/sdd/agent-ops-mcp-control-plane/agent-origin-milestone-creation-sync/SDD.md`
- `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/01_scan_create_ledger/complete.log`
- `agent-task/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/plan_cloud_G08_0.log`
- `agent-task/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/code_review_cloud_G08_0.log`
- `services/core/internal/roadmapsyncpipeline/service.go`
- `services/core/internal/roadmapsyncpipeline/service_test.go`
- `services/core/internal/roadmapsyncpipeline/git_backfiller.go`
- `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`
- `services/core/internal/authoring/identity_write.go`
- `services/core/internal/roadmapsync/identity.go`
- `services/core/internal/roadmapsync/milestone_parser.go`
- `services/core/internal/roadmapsync/develop_match.go`
- `services/core/internal/roadmapsync/retry.go`
- `services/core/internal/roadmapsync/retry_test.go`
- `services/core/internal/storage/roadmap_sync_steps.go`
- `services/core/internal/storage/roadmap_sync_steps_test.go`
- `services/core/internal/gitosync/scanner.go`
- `services/core/internal/gitosync/bridge.go`
- `services/core/internal/gitosync/bridge_test.go`
- `services/core/cmd/server/main.go`
- `services/core/cmd/server/main_test.go`
- `services/core/migrations/00007_create_roadmap_sync_steps.sql`
- `services/core/migrations/00010_add_roadmap_identity_backfilled_step.sql`
- `services/core/queries/roadmap_sync_steps.sql`
- `services/core/internal/config/config.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/agent-origin-milestone-creation-sync/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 해제, 사용자 리뷰 없음.
- Targeted scenario: S04=`identity-backfill`.
- Evidence Map row: identity backfill writer test and git commit scope check.
- 이 follow-up은 S04의 "identity-only backfill commit이 develop에 push되고 unrelated 변경은 포함되지 않는다"를 remote push 성공, retry idempotency, verified revision base까지 확장해 검증한다.
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다.
- 적용 명령: current checkout focused Go tests plus `git diff --check`; core-smoke baseline은 `services/core`에서 `go test ./...`, 보조로 `go vet ./...`를 요구한다.
- 현재 리뷰 재검증: `git diff --check`, `go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/authoring ./internal/roadmapsyncpipeline ./internal/scheduler ./cmd/server`, `go vet ./internal/roadmapsync ./internal/storage ./internal/authoring ./internal/roadmapsyncpipeline ./internal/scheduler ./cmd/server` 모두 exit 0.
- 이번 follow-up은 git temp repo 단위 테스트로 재현 가능하므로 remote service/secret preflight는 필요 없다.
### 테스트 커버리지 공백
- `TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted`는 첫 실패 후 remote가 안 변한 것만 확인하고, 두 번째 `BackfillIdentity` 호출이 false success가 되지 않는지 확인하지 않는다.
- push 실패 후 retry가 remote push 성공 전 `nil`을 반환하지 않는 테스트가 없다.
- local `HEAD`가 `origin/develop`보다 뒤처진 checkout에서 backfill commit이 verified revision을 기준으로 만들어지는지 확인하는 테스트가 없다.
- existing identity block이 같은 provider/work item이지만 다른 milestone id일 때 mismatch로 거부하는 테스트가 없다.
### 심볼 참조
- 기존 변경에서 새 symbol `BackfillInput`, `Backfiller`, `GitBackfiller`, `SetBackfiller`, `StepIdentityBackfilled`가 추가됐다.
- 이번 follow-up에서 `BackfillInput` 필드를 늘리거나 `GitBackfiller` helper를 추가하면 `rg --sort path "BackfillInput|BackfillIdentity|SetBackfiller|StepIdentityBackfilled" services/core`로 모든 call site를 갱신한다.
### 분할 판단
- Split decision policy evaluated before writing this follow-up.
- 기존 subtask는 `02+01_identity_backfill`이며 predecessor `01`은 `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/01_scan_create_ledger/complete.log`로 PASS 확인됐다.
- 이 follow-up은 같은 subtask 내부의 FAIL 수습이며, 두 Required가 같은 `GitBackfiller`/pipeline seam에 묶여 있어 별도 split은 오히려 retry contract를 중복 수정하게 만든다.
### 범위 결정 근거
- 포함: S04 backfill commit base, retry success criteria, ledger mark condition, git temp repo regression tests.
- 제외: self-loop webhook classification, Plane body/title update projection, field smoke, provider native API schema 일반화.
### 빌드 등급
- `cloud-G08`: git process/push semantics, persisted ledger step, remote revision anchoring, retry false-success 진단이 모두 필요하다.
## 의존 관계 및 구현 순서
`02+01_identity_backfill` depends on sibling predecessor `01_scan_create_ledger`; predecessor PASS is satisfied by `agent-task/archive/2026/07/m-agent-origin-milestone-creation-sync/01_scan_create_ledger/complete.log`.
## 구현 체크리스트
- [ ] `GitBackfiller`가 local worktree/local commit identity만으로 성공하지 않고, remote target branch 또는 성공적으로 pushed commit에 matching identity가 확인될 때만 no-op 성공하도록 수정한다.
- [ ] Backfill commit이 scanner가 검증한 develop revision을 기준으로 생성되도록 `BackfillInput`/service 경로에 revision을 전달하고, dirty/stale checkout은 파일 mutation 전에 명시적으로 실패시키거나 안전한 임시 worktree로 처리한다.
- [ ] scope-check 실패 retry, push 실패 retry, local HEAD behind `origin/develop`, mismatched milestone id identity block에 대한 regression tests를 추가한다.
- [ ] `git diff --check`, focused `go test -count=1`, focused `go vet`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_API-1] Remote Push Success And Retry Contract
### 문제
`services/core/internal/roadmapsyncpipeline/git_backfiller.go:73`은 target file에 matching identity block이 있으면 즉시 `nil`을 반환한다. 같은 함수의 `services/core/internal/roadmapsyncpipeline/git_backfiller.go:86` 이후 경로는 scope-check 실패나 push 실패 뒤 worktree/local commit에 identity block을 남길 수 있어, 다음 retry가 remote push 성공 없이 `identity_backfilled` ledger를 mark할 수 있다.
### 해결 방법
Remote target branch에서 matching identity가 확인된 경우에만 "already satisfied"로 성공한다. Pre-commit failure는 staged/worktree 변경을 cleanup하거나, cleanup 실패까지 에러에 포함해 retry가 false success가 되지 않게 한다. Push failure 뒤에는 remote 미반영 상태를 성공으로 해석하지 말고, 재시도 시 pending commit을 push하거나 remote 기준으로 안전하게 재생성한다.
Before:
```go
// services/core/internal/roadmapsyncpipeline/git_backfiller.go:73
existing, parseErr := roadmapsync.ParseMilestoneIdentity(path, content)
if parseErr == nil {
if identityMatches(existing, in.Identity) {
return nil
}
return ErrIdentityBackfillMismatch
}
```
After:
```go
// sketch; exact helper names are implementation-owned
if remoteHasMatchingIdentity(ctx, path, in.Identity, expectedRevision) {
return nil
}
if localHasIdentityButRemoteDoesNot(path, in.Identity) {
return errors.New("roadmapsyncpipeline: identity backfill is local-only and not pushed")
}
```
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller.go`: success predicate uses remote/pushed state, not only local file content.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller.go`: failed scope-check and failed push paths do not leave a future retry able to return nil before remote confirmation.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller.go`: identity match compares provider, tenant, project, work item id, and canonical milestone id.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`: second call after scope-check failure remains failing until dirty state is resolved and never advances remote.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`: push failure followed by retry does not return success unless remote branch contains the identity commit.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`: existing identity with a different milestone id returns `ErrIdentityBackfillMismatch`.
### 테스트 작성
Regression tests required. Use temp bare/working repos; do not depend on live remotes or secrets.
### 중간 검증
```sh
cd services/core && go test -count=1 -run 'TestGitBackfiller' ./internal/roadmapsyncpipeline -v
```
Expected: PASS, including tests for retry false-success and push failure.
## [REVIEW_API-2] Verified Develop Revision Base
### 문제
`services/core/internal/gitosync/scanner.go:124` fetches and verifies `refs/remotes/<remote>/<branch>`, and `services/core/internal/gitosync/scanner.go:174` reads milestone markdown with `git show <after>:<path>`. It does not update the local working tree. `services/core/internal/roadmapsyncpipeline/git_backfiller.go:66` then reads `filepath.Join(RepoPath, path)` and commits `HEAD:<branch>`, so the backfill can be based on a stale checkout instead of the verified `after` revision.
### 해결 방법
Pass the verified roadmap revision from `SyncCreationInput.Scan.Revision.Normalize().Revision` into the backfill path. Before writing, fetch/verify the remote ref still equals that revision and create the commit from that revision: either check out/reset a clean dedicated branch to the remote ref, or use a temporary worktree rooted at the verified commit. If the checkout is dirty or the remote ref has moved, fail before mutating files so the worker can retry from a fresh event.
Before:
```go
// services/core/internal/roadmapsyncpipeline/git_backfiller.go:66
fullPath := filepath.Join(b.RepoPath, path)
contentBytes, err := os.ReadFile(fullPath)
```
After:
```go
// sketch; exact helper names are implementation-owned
if err := b.prepareVerifiedDevelopBase(ctx, in.RoadmapRevision); err != nil {
return err
}
fullPath := filepath.Join(b.RepoPath, path)
```
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`: pass the verified scan revision into `BackfillInput` for initial missing-create and resume paths.
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`: fake backfiller asserts `BackfillInput` carries the scan revision.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller.go`: verify/fetch remote branch and base the commit on the verified revision before writing.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`: local branch behind `origin/develop` still produces a backfill commit on top of `origin/develop`.
- [ ] `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go`: remote ref mismatch or dirty checkout fails before file mutation.
### 테스트 작성
Regression tests required because this is the SDD S04 source-of-truth guarantee.
### 중간 검증
```sh
cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/gitosync
```
Expected: PASS; temp repo test proves commit base follows verified `origin/develop`.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/roadmapsyncpipeline/git_backfiller.go` | REVIEW_API-1, REVIEW_API-2 |
| `services/core/internal/roadmapsyncpipeline/git_backfiller_test.go` | REVIEW_API-1, REVIEW_API-2 |
| `services/core/internal/roadmapsyncpipeline/service.go` | REVIEW_API-2 |
| `services/core/internal/roadmapsyncpipeline/service_test.go` | REVIEW_API-2 |
| `services/core/cmd/server/main.go` | REVIEW_API-2, only if constructor wiring must pass new config into `GitBackfiller` |
## 최종 검증
```sh
git diff --check
cd services/core && go test -count=1 -run 'TestGitBackfiller' ./internal/roadmapsyncpipeline -v
cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server
cd services/core && go vet ./internal/roadmapsyncpipeline ./internal/gitosync ./cmd/server
```
Expected: all commands exit 0 with fresh `-count=1` test execution.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,127 +0,0 @@
<!-- task=m-agent-origin-milestone-creation-sync/02+01_identity_backfill plan=0 tag=API -->
# Code Review Reference - 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 selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-07-02
task=m-agent-origin-milestone-creation-sync/02+01_identity_backfill, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md`
- Task ids:
- `identity-backfill`: Plane 생성 직후 Milestone markdown에 `Provider identity` block을 주입하고 Core-authored backfill commit을 `develop`에 push한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 판정 append, active 파일 log archive, PASS 시 `complete.log` 작성과 task directory archive까지 끝난 상태를 의미합니다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Durable Backfill Step | [ ] |
| [API-2] Identity Backfill Writer And Commit Scope | [ ] |
| [API-3] Scheduler Wiring And Review Evidence | [ ] |
## 구현 체크리스트
- [ ] `identity-backfill` step을 storage/domain ordered step set에 추가하고 DB check constraint migration/query tests를 갱신한다.
- [ ] missing-create Plane create + identity upsert 직후 Milestone markdown에 `Provider identity` block을 주입하는 pipeline seam을 추가한다.
- [ ] backfill writer가 identity block 외 unrelated roadmap 변경을 commit하지 않는 git scope check를 구현/테스트한다. 검증: backfill commit에는 identity block 외의 unrelated roadmap 변경이 포함되지 않는다.
- [ ] push 성공 뒤에만 `identity_backfilled` ledger step을 mark하고, 실패 시 retry가 create를 반복하지 않고 backfill부터 재개하게 한다.
- [ ] `git diff --check`, focused `go test -count=1`, focused `go vet`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G08_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G08_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-origin-milestone-creation-sync/02+01_identity_backfill/`로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Implementation waited for predecessor `01_scan_create_ledger` completion.
- `identity_backfilled` is durable and ordered after `develop_matched`.
- Backfill commit stages only the target Milestone identity block and records ledger only after push success.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```sh
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage
(output)
```
### API-2 중간 검증
```sh
$ cd services/core && go test -count=1 ./internal/roadmapsyncpipeline ./internal/authoring
(output)
```
### API-3 중간 검증
```sh
$ cd services/core && go test -count=1 ./internal/scheduler ./cmd/server ./internal/roadmapsyncpipeline
(output)
```
### 최종 검증
```sh
$ git diff --check
(output)
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/authoring ./internal/roadmapsyncpipeline ./internal/scheduler ./cmd/server
(output)
$ cd services/core && go vet ./internal/roadmapsync ./internal/storage ./internal/authoring ./internal/roadmapsyncpipeline ./internal/scheduler ./cmd/server
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**

View file

@ -104,6 +104,18 @@ func run(logger *slog.Logger) error {
// self-actor guard is active only when PLANE_SELF_ACTOR_ID is configured. // self-actor guard is active only when PLANE_SELF_ACTOR_ID is configured.
creationSync := roadmapsyncpipeline.NewService(store, planeClient, cfg.PlaneSelfActorID) creationSync := roadmapsyncpipeline.NewService(store, planeClient, cfg.PlaneSelfActorID)
// The identity backfiller is only wired when a local develop checkout is
// configured; without it, a missing-create identity backfill fails
// explicitly (see Service.SetBackfiller) instead of silently completing
// with only the DB identity.
if cfg.GitoDevelopRepoPath != "" {
backfiller, err := roadmapsyncpipeline.NewGitBackfiller(cfg.GitoDevelopRepoPath, cfg.GitoRemoteName, cfg.GitoBranch)
if err != nil {
return err
}
creationSync.SetBackfiller(backfiller)
}
workflowService := workflow.NewService(store, nil, logger) workflowService := workflow.NewService(store, nil, logger)
identityWriter := authoring.NewLocalIdentityWriter() identityWriter := authoring.NewLocalIdentityWriter()

View file

@ -15,6 +15,7 @@ import (
"github.com/nomadcode/nomadcode-core/internal/gitosync" "github.com/nomadcode/nomadcode-core/internal/gitosync"
apphttp "github.com/nomadcode/nomadcode-core/internal/http" apphttp "github.com/nomadcode/nomadcode-core/internal/http"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/roadmapsync"
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
"github.com/nomadcode/nomadcode-core/internal/scheduler" "github.com/nomadcode/nomadcode-core/internal/scheduler"
"github.com/nomadcode/nomadcode-core/internal/workitem" "github.com/nomadcode/nomadcode-core/internal/workitem"
) )
@ -386,3 +387,23 @@ func TestGitoHTTPWebhookDeliveryOffTargetIgnored(t *testing.T) {
t.Fatalf("response status: got %q, want %q", resp["status"], "ignored") t.Fatalf("response status: got %q, want %q", resp["status"], "ignored")
} }
} }
// TestCreationSyncBackfillerWiredWhenGitoCheckoutConfigured exercises the same
// construction branch run() uses to wire roadmapsyncpipeline's identity
// backfiller (see the `cfg.GitoDevelopRepoPath != ""` block in run()): a
// configured local develop checkout must produce a usable GitBackfiller, and
// an empty checkout path must be rejected so a misconfigured deployment fails
// fast instead of silently running with no backfiller.
func TestCreationSyncBackfillerWiredWhenGitoCheckoutConfigured(t *testing.T) {
svc := roadmapsyncpipeline.NewService(nil, nil, "")
backfiller, err := roadmapsyncpipeline.NewGitBackfiller(t.TempDir(), "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller with configured checkout path: %v", err)
}
svc.SetBackfiller(backfiller)
if _, err := roadmapsyncpipeline.NewGitBackfiller("", "origin", "develop"); err == nil {
t.Fatal("expected NewGitBackfiller to reject an empty develop checkout path")
}
}

View file

@ -99,25 +99,7 @@ func (w *LocalIdentityWriter) EnsureProviderIdentity(ctx context.Context, in Ens
} }
if errors.Is(err, roadmapsync.ErrNoProviderIdentity) { if errors.Is(err, roadmapsync.ErrNoProviderIdentity) {
var sb strings.Builder newContent := content + RenderProviderIdentityBlock(in.Identity)
sb.WriteString("\n\n## Provider identity\n")
if in.Identity.Provider != "" {
sb.WriteString(fmt.Sprintf("- provider: %s\n", in.Identity.Provider))
}
if in.Identity.Tenant != "" {
sb.WriteString(fmt.Sprintf("- tenant: %s\n", in.Identity.Tenant))
}
if in.Identity.Project != "" {
sb.WriteString(fmt.Sprintf("- project: %s\n", in.Identity.Project))
}
if in.Identity.WorkItemID != "" {
sb.WriteString(fmt.Sprintf("- work item id: %s\n", in.Identity.WorkItemID))
}
if in.Identity.RoadmapItemID != "" {
sb.WriteString(fmt.Sprintf("- milestone id: %s\n", in.Identity.RoadmapItemID))
}
newContent := content + sb.String()
err = os.WriteFile(fullPath, []byte(newContent), 0644) err = os.WriteFile(fullPath, []byte(newContent), 0644)
if err != nil { if err != nil {
return EnsureProviderIdentityResult{}, fmt.Errorf("write file %s failed: %w", f, err) return EnsureProviderIdentityResult{}, fmt.Errorf("write file %s failed: %w", f, err)
@ -132,3 +114,28 @@ func (w *LocalIdentityWriter) EnsureProviderIdentity(ctx context.Context, in Ens
InjectedPaths: injected, InjectedPaths: injected,
}, nil }, nil
} }
// RenderProviderIdentityBlock renders the "## Provider identity" Markdown
// block for an identity, in the exact shape ParseMilestoneIdentity expects.
// It makes no dirty-worktree or git assumptions, so both the authoring
// workspace writer above and a Core-authored backfill commit can reuse it.
func RenderProviderIdentityBlock(identity roadmapsync.Identity) string {
var sb strings.Builder
sb.WriteString("\n\n## Provider identity\n")
if identity.Provider != "" {
sb.WriteString(fmt.Sprintf("- provider: %s\n", identity.Provider))
}
if identity.Tenant != "" {
sb.WriteString(fmt.Sprintf("- tenant: %s\n", identity.Tenant))
}
if identity.Project != "" {
sb.WriteString(fmt.Sprintf("- project: %s\n", identity.Project))
}
if identity.WorkItemID != "" {
sb.WriteString(fmt.Sprintf("- work item id: %s\n", identity.WorkItemID))
}
if identity.RoadmapItemID != "" {
sb.WriteString(fmt.Sprintf("- milestone id: %s\n", identity.RoadmapItemID))
}
return sb.String()
}

View file

@ -9,6 +9,7 @@ type Step string
const ( const (
StepDevelopMatched Step = "develop_matched" StepDevelopMatched Step = "develop_matched"
StepIdentityBackfilled Step = "identity_backfilled"
StepOriginalCommentPreserved Step = "original_comment_preserved" StepOriginalCommentPreserved Step = "original_comment_preserved"
StepPlaneBodyUpdated Step = "plane_body_updated" StepPlaneBodyUpdated Step = "plane_body_updated"
StepPlaneTodoMoved Step = "plane_todo_moved" StepPlaneTodoMoved Step = "plane_todo_moved"
@ -18,6 +19,7 @@ const (
// step not present in the completed set. // step not present in the completed set.
var OrderedSteps = []Step{ var OrderedSteps = []Step{
StepDevelopMatched, StepDevelopMatched,
StepIdentityBackfilled,
StepOriginalCommentPreserved, StepOriginalCommentPreserved,
StepPlaneBodyUpdated, StepPlaneBodyUpdated,
StepPlaneTodoMoved, StepPlaneTodoMoved,

View file

@ -32,8 +32,8 @@ func TestReconcile_SamePlaneTicketReusesIdentity(t *testing.T) {
if !res.IdentityKept { if !res.IdentityKept {
t.Error("expected identity reuse for the same Plane ticket") t.Error("expected identity reuse for the same Plane ticket")
} }
if res.NextStep != StepOriginalCommentPreserved { if res.NextStep != StepIdentityBackfilled {
t.Errorf("next step: got %q, want original_comment_preserved", res.NextStep) t.Errorf("next step: got %q, want identity_backfilled", res.NextStep)
} }
} }
@ -169,6 +169,7 @@ func TestReconcile_AllStepsCompleteReturnsComplete(t *testing.T) {
Existing: ExistingIdentity{Found: true, Identity: id}, Existing: ExistingIdentity{Found: true, Identity: id},
CompletedSteps: map[Step]bool{ CompletedSteps: map[Step]bool{
StepDevelopMatched: true, StepDevelopMatched: true,
StepIdentityBackfilled: true,
StepOriginalCommentPreserved: true, StepOriginalCommentPreserved: true,
StepPlaneBodyUpdated: true, StepPlaneBodyUpdated: true,
StepPlaneTodoMoved: true, StepPlaneTodoMoved: true,

View file

@ -0,0 +1,270 @@
package roadmapsyncpipeline
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/nomadcode/nomadcode-core/internal/authoring"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
)
// commitMarker tags a backfill commit as Core-authored so a later self-loop
// check can recognize its own commit and skip re-processing it as a fresh
// authoring trigger.
const commitMarker = "NomadCode-Identity-Backfill: true"
// ErrIdentityBackfillMismatch is returned when the target Milestone file
// already carries a provider identity block that disagrees with the identity
// being backfilled, so writing over it would silently rebind history.
var ErrIdentityBackfillMismatch = errors.New("roadmapsyncpipeline: existing provider identity does not match backfill target")
// GitBackfiller is the production Backfiller. It bases every backfill on the
// scan-verified remote develop revision, never a stale local checkout: it
// fetches the remote branch, injects the provider identity block into the
// target Milestone content as read at that revision, stages only that file,
// verifies the staged diff is scoped to the identity block, commits with the
// Core marker on top of the verified revision, and pushes to the configured
// remote/branch. The already-satisfied and mismatch decisions are made against
// the pushed remote state, so a retry after a failed scope-check or push never
// reports success until the identity block actually landed on the remote
// branch.
type GitBackfiller struct {
RepoPath string
RemoteName string
Branch string
}
// NewGitBackfiller builds a GitBackfiller. RepoPath is required; RemoteName
// and Branch default to "origin" and "develop" when empty.
func NewGitBackfiller(repoPath, remoteName, branch string) (*GitBackfiller, error) {
if strings.TrimSpace(repoPath) == "" {
return nil, errors.New("roadmapsyncpipeline: backfiller repo path is required")
}
if strings.TrimSpace(remoteName) == "" {
remoteName = "origin"
}
if strings.TrimSpace(branch) == "" {
branch = "develop"
}
return &GitBackfiller{RepoPath: repoPath, RemoteName: remoteName, Branch: branch}, nil
}
// BackfillIdentity injects the provider identity block into the target
// Milestone as read at the scan-verified remote develop revision, stages only
// that path, verifies the staged diff adds nothing but the identity block,
// commits with the Core marker on top of the verified revision, and pushes to
// develop. It fetches the remote branch first so the already-satisfied and
// mismatch decisions and the commit base all use the pushed remote state, and
// refuses to touch a dirty checkout or a revision that has moved since the scan.
func (b *GitBackfiller) BackfillIdentity(ctx context.Context, in BackfillInput) error {
path := strings.TrimSpace(in.MarkdownPath)
if path == "" {
path = in.Identity.RoadmapMilestonePath
}
if path == "" {
return errors.New("roadmapsyncpipeline: backfill markdown path is required")
}
remoteRef := b.RemoteName + "/" + b.Branch
// Fetch the remote target branch so every decision below observes the pushed
// develop state the scanner verified, never a stale local checkout or an
// unpushed local commit.
if err := b.run(ctx, "fetch", "--prune", b.RemoteName, b.Branch); err != nil {
return err
}
remoteRev, err := b.output(ctx, "rev-parse", remoteRef)
if err != nil {
return err
}
remoteRev = strings.TrimSpace(remoteRev)
if remoteRev == "" {
return fmt.Errorf("roadmapsyncpipeline: empty remote revision for %s", remoteRef)
}
// If the caller passed the scan-verified revision, the remote ref must still
// point at it. Otherwise develop moved since the scan and a fresh event
// should re-drive the sync rather than committing on top of a revision the
// scanner never checked.
if expected := strings.TrimSpace(in.RoadmapRevision); expected != "" && expected != remoteRev {
return fmt.Errorf("roadmapsyncpipeline: backfill target revision moved: %s is at %s, verified scan revision was %s", remoteRef, remoteRev, expected)
}
// Refuse to mutate a dirty checkout: an unrelated in-flight edit must never
// ride along in a Core-authored backfill commit, and resetting the base to
// the remote revision would otherwise silently discard that work.
dirty, err := b.output(ctx, "status", "--porcelain")
if err != nil {
return err
}
if strings.TrimSpace(dirty) != "" {
return fmt.Errorf("roadmapsyncpipeline: backfill checkout is dirty, refusing to mutate: %s", strings.TrimSpace(dirty))
}
// Read the milestone content at the verified remote revision. The
// already-satisfied and mismatch decisions are made against the pushed
// remote state, so a retry after a failed push never reports success until
// the identity block actually landed on the remote branch.
remoteContent, err := b.output(ctx, "show", remoteRev+":"+path)
if err != nil {
return fmt.Errorf("roadmapsyncpipeline: read milestone at %s failed: %w", remoteRev, err)
}
existing, parseErr := roadmapsync.ParseMilestoneIdentity(path, remoteContent)
if parseErr == nil {
if identityMatches(existing, in.Identity) {
// The identity block already exists on the remote develop branch;
// nothing left to commit (e.g. a prior push landed but the ledger
// mark failed).
return nil
}
return ErrIdentityBackfillMismatch
}
if !errors.Is(parseErr, roadmapsync.ErrNoProviderIdentity) {
return fmt.Errorf("roadmapsyncpipeline: parse milestone identity failed: %w", parseErr)
}
// Base the backfill commit on the verified remote revision, not the local
// HEAD, so a checkout that is behind origin/develop still commits on top of
// the revision the scanner verified. The dirty guard above already ensured
// this force-reset discards no uncommitted work.
if err := b.run(ctx, "checkout", "-B", b.Branch, remoteRev); err != nil {
return err
}
block := authoring.RenderProviderIdentityBlock(in.Identity)
fullPath := filepath.Join(b.RepoPath, path)
if err := os.WriteFile(fullPath, []byte(remoteContent+block), 0644); err != nil {
return b.cleanup(ctx, remoteRev, fmt.Errorf("roadmapsyncpipeline: write milestone file failed: %w", err))
}
if err := b.run(ctx, "add", "--", path); err != nil {
return b.cleanup(ctx, remoteRev, err)
}
if err := b.verifyScopedDiff(ctx, path, block); err != nil {
return b.cleanup(ctx, remoteRev, err)
}
commitMsg := fmt.Sprintf("chore(roadmap): backfill provider identity for %s\n\n%s", in.Identity.MilestoneID(), commitMarker)
if err := b.run(ctx, "commit", "-m", commitMsg); err != nil {
return b.cleanup(ctx, remoteRev, err)
}
if err := b.run(ctx, "push", b.RemoteName, "HEAD:"+b.Branch); err != nil {
// Leave the local commit in place and surface the error: the ledger step
// is only marked by the caller after this returns nil, so a failed push
// never advances the ledger, and a retry re-bases on the freshly fetched
// remote revision (checkout -B above) before pushing again.
return err
}
return nil
}
// cleanup restores the checkout to rev after a pre-push failure so a later
// retry re-bases from the remote revision instead of finding a staged or
// half-written identity block it might mistake for progress. The original
// cause is preserved; a cleanup failure is appended rather than hiding it.
func (b *GitBackfiller) cleanup(ctx context.Context, rev string, cause error) error {
if err := b.run(ctx, "reset", "--hard", rev); err != nil {
return fmt.Errorf("%w; additionally backfill cleanup failed: %v", cause, err)
}
return cause
}
// identityMatches reports whether an existing identity block is the same
// provider-side identity as the one being backfilled. It compares the full
// provider key — provider, tenant, project, work item id — plus the canonical
// milestone id, so a block that reuses the same work item under a different
// milestone id is treated as a mismatch rather than silently accepted.
func identityMatches(a, b roadmapsync.Identity) bool {
return a.Provider == b.Provider &&
a.Tenant == b.Tenant &&
a.Project == b.Project &&
a.WorkItemID == b.WorkItemID &&
a.MilestoneID() == b.MilestoneID()
}
// verifyScopedDiff ensures the staged change touches only the target path and
// that the path's diff is a pure addition matching the injected identity
// block, so an unrelated dirty-workspace roadmap edit never rides along in a
// Core-authored backfill commit.
func (b *GitBackfiller) verifyScopedDiff(ctx context.Context, path, block string) error {
staged, err := b.output(ctx, "diff", "--cached", "--name-only")
if err != nil {
return err
}
files := nonEmptyLines(staged)
if len(files) != 1 || files[0] != path {
return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: staged files=%v, want only %q", files, path)
}
diff, err := b.output(ctx, "diff", "--cached", "--", path)
if err != nil {
return err
}
return verifyIdentityOnlyDiff(diff, block)
}
// verifyIdentityOnlyDiff rejects a staged diff that removes any line or adds
// a line outside the rendered identity block.
func verifyIdentityOnlyDiff(diff, block string) error {
blockLines := strings.Split(strings.TrimRight(block, "\n"), "\n")
blockSet := make(map[string]bool, len(blockLines))
for _, l := range blockLines {
blockSet[l] = true
}
for _, line := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
continue
case strings.HasPrefix(line, "-"):
return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: unexpected removed line %q", line)
case strings.HasPrefix(line, "+"):
added := line[1:]
if !blockSet[added] {
return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: unexpected added line %q", added)
}
}
}
return nil
}
func (b *GitBackfiller) run(ctx context.Context, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = b.RepoPath
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("roadmapsyncpipeline: git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func (b *GitBackfiller) output(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = b.RepoPath
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("roadmapsyncpipeline: git %s failed: %w", strings.Join(args, " "), err)
}
return string(out), nil
}
func nonEmptyLines(s string) []string {
var out []string
for _, l := range strings.Split(s, "\n") {
l = strings.TrimSpace(l)
if l != "" {
out = append(out, l)
}
}
return out
}

View file

@ -0,0 +1,392 @@
package roadmapsyncpipeline
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
)
// setupBackfillRepos creates a bare "remote" repo and a working clone with an
// initial commit on branch so BackfillIdentity has something real to push to.
func setupBackfillRepos(t *testing.T, branch string) (workDir string) {
t.Helper()
workDir, _ = setupBackfillReposWithRemote(t, branch)
return workDir
}
// setupBackfillReposWithRemote is setupBackfillRepos but also returns the bare
// remote path, so tests that need to advance origin/develop or break the push
// URL can reach it.
func setupBackfillReposWithRemote(t *testing.T, branch string) (workDir, remoteDir string) {
t.Helper()
remoteDir, err := os.MkdirTemp("", "nomadcode-backfill-remote-*")
if err != nil {
t.Fatalf("failed to create remote dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(remoteDir) })
runGit(t, remoteDir, "init", "--bare")
workDir, err = os.MkdirTemp("", "nomadcode-backfill-work-*")
if err != nil {
t.Fatalf("failed to create work dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(workDir) })
runGit(t, workDir, "init", "-b", branch)
runGit(t, workDir, "config", "user.name", "test")
runGit(t, workDir, "config", "user.email", "test@test.com")
runGit(t, workDir, "remote", "add", "origin", remoteDir)
milestoneDir := filepath.Join(workDir, "agent-roadmap", "phase", "p1", "milestones")
if err := os.MkdirAll(milestoneDir, 0755); err != nil {
t.Fatalf("failed to create milestone dir: %v", err)
}
milestonePath := filepath.Join(milestoneDir, "m1.md")
if err := os.WriteFile(milestonePath, []byte("# Milestone\nSome description.\n"), 0644); err != nil {
t.Fatalf("failed to write milestone file: %v", err)
}
runGit(t, workDir, "add", "-A")
runGit(t, workDir, "commit", "-m", "initial")
runGit(t, workDir, "push", "origin", branch)
return workDir, remoteDir
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v: %s", strings.Join(args, " "), err, string(out))
}
}
// gitOut runs a git command and returns its trimmed stdout, failing the test on
// error. It is the read-side counterpart to runGit for revision assertions.
func gitOut(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
t.Fatalf("git %s failed: %v", strings.Join(args, " "), err)
}
return strings.TrimSpace(string(out))
}
// advanceDevelop appends marker to the milestone file, commits, and pushes so
// origin/develop moves forward, then returns the new origin/develop revision.
func advanceDevelop(t *testing.T, workDir, path, marker string) string {
t.Helper()
fullPath := filepath.Join(workDir, path)
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("failed to read milestone file: %v", err)
}
if err := os.WriteFile(fullPath, []byte(string(content)+marker), 0644); err != nil {
t.Fatalf("failed to advance milestone file: %v", err)
}
runGit(t, workDir, "add", "-A")
runGit(t, workDir, "commit", "-m", "advance develop")
runGit(t, workDir, "push", "origin", "develop")
return gitOut(t, workDir, "rev-parse", "origin/develop")
}
func testBackfillIdentity() roadmapsync.Identity {
return roadmapsync.Identity{
Provider: "plane",
WorkItemID: "NOMAD-1",
Tenant: "general",
Project: "nomad",
RoadmapItemID: "m1",
}
}
func TestGitBackfillerCommitsAndPushesIdentityOnly(t *testing.T) {
workDir := setupBackfillRepos(t, "develop")
b, err := NewGitBackfiller(workDir, "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller failed: %v", err)
}
path := "agent-roadmap/phase/p1/milestones/m1.md"
if err := b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
}); err != nil {
t.Fatalf("BackfillIdentity failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(workDir, path))
if err != nil {
t.Fatalf("failed to read milestone file: %v", err)
}
parsed, err := roadmapsync.ParseMilestoneIdentity(path, string(content))
if err != nil {
t.Fatalf("failed to parse injected identity: %v", err)
}
if parsed.Provider != "plane" || parsed.WorkItemID != "NOMAD-1" || parsed.Tenant != "general" || parsed.Project != "nomad" {
t.Errorf("unexpected parsed identity: %+v", parsed)
}
// Verify the push actually reached the bare remote.
out, err := exec.Command("git", "-C", workDir, "log", "origin/develop", "-1", "--format=%s%n%b").CombinedOutput()
if err != nil {
t.Fatalf("git log origin/develop failed: %v: %s", err, string(out))
}
if !strings.Contains(string(out), commitMarker) {
t.Errorf("expected pushed commit to carry Core marker, got: %s", string(out))
}
// A second call after a successful push must be a no-op (idempotent retry
// after a ledger-mark failure), not a duplicate commit.
headBefore, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD").Output()
if err != nil {
t.Fatalf("rev-parse failed: %v", err)
}
if err := b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
}); err != nil {
t.Fatalf("second BackfillIdentity call failed: %v", err)
}
headAfter, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD").Output()
if err != nil {
t.Fatalf("rev-parse failed: %v", err)
}
if string(headBefore) != string(headAfter) {
t.Errorf("expected no new commit on retry, HEAD moved from %s to %s", headBefore, headAfter)
}
}
// TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted proves the backfill
// commit stays scoped to the injected identity block: an unrelated dirty
// change elsewhere in the same milestone file must abort the backfill rather
// than being swept into the Core-authored commit.
func TestGitBackfillerLeavesUnrelatedDirtyChangeUncommitted(t *testing.T) {
workDir := setupBackfillRepos(t, "develop")
path := "agent-roadmap/phase/p1/milestones/m1.md"
fullPath := filepath.Join(workDir, path)
// Simulate an unrelated in-flight edit to the same file that must not
// ride along in the backfill commit.
original, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("failed to read milestone file: %v", err)
}
dirty := string(original) + "\nUnrelated roadmap edit.\n"
b, err := NewGitBackfiller(workDir, "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller failed: %v", err)
}
// Write the dirty content directly so BackfillIdentity's own write lands
// on top of it, exactly like a concurrently dirty workspace would.
if err := os.WriteFile(fullPath, []byte(dirty), 0644); err != nil {
t.Fatalf("failed to write dirty content: %v", err)
}
err = b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
})
if err == nil {
t.Fatal("expected scope check to reject the unrelated dirty change, got nil error")
}
// The unrelated edit must remain unstaged/uncommitted: no push happened.
out, lerr := exec.Command("git", "-C", workDir, "log", "origin/develop", "-1", "--format=%s").CombinedOutput()
if lerr != nil {
t.Fatalf("git log origin/develop failed: %v: %s", lerr, string(out))
}
if strings.TrimSpace(string(out)) != "initial" {
t.Errorf("expected origin/develop to still be at the initial commit, got %q", strings.TrimSpace(string(out)))
}
}
// TestGitBackfillerRejectsMismatchedExistingIdentity proves the mismatch guard
// is driven by the pushed remote state: a different work item id already on
// origin/develop must abort rather than being silently rebound.
func TestGitBackfillerRejectsMismatchedExistingIdentity(t *testing.T) {
workDir := setupBackfillRepos(t, "develop")
path := "agent-roadmap/phase/p1/milestones/m1.md"
fullPath := filepath.Join(workDir, path)
existing := "# Milestone\n\n## Provider identity\n- provider: plane\n- tenant: general\n- project: nomad\n- work item id: NOMAD-OTHER\n- milestone id: m1\n"
if err := os.WriteFile(fullPath, []byte(existing), 0644); err != nil {
t.Fatalf("failed to write existing identity: %v", err)
}
runGit(t, workDir, "add", "-A")
runGit(t, workDir, "commit", "-m", "existing identity")
// The mismatch must be recognized from the remote branch, not a local-only
// commit, so push the conflicting identity to origin/develop first.
runGit(t, workDir, "push", "origin", "develop")
b, err := NewGitBackfiller(workDir, "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller failed: %v", err)
}
err = b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
})
if !errors.Is(err, ErrIdentityBackfillMismatch) {
t.Fatalf("expected ErrIdentityBackfillMismatch, got %v", err)
}
}
// TestGitBackfillerRejectsMismatchedMilestoneID proves the mismatch guard
// includes the canonical milestone id: an identity that reuses the same
// provider/tenant/project/work item id under a different milestone id must be
// rejected rather than treated as already satisfied.
func TestGitBackfillerRejectsMismatchedMilestoneID(t *testing.T) {
workDir := setupBackfillRepos(t, "develop")
path := "agent-roadmap/phase/p1/milestones/m1.md"
fullPath := filepath.Join(workDir, path)
existing := "# Milestone\n\n## Provider identity\n- provider: plane\n- tenant: general\n- project: nomad\n- work item id: NOMAD-1\n- milestone id: m-different\n"
if err := os.WriteFile(fullPath, []byte(existing), 0644); err != nil {
t.Fatalf("failed to write existing identity: %v", err)
}
runGit(t, workDir, "add", "-A")
runGit(t, workDir, "commit", "-m", "existing identity, other milestone")
runGit(t, workDir, "push", "origin", "develop")
b, err := NewGitBackfiller(workDir, "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller failed: %v", err)
}
err = b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
})
if !errors.Is(err, ErrIdentityBackfillMismatch) {
t.Fatalf("expected ErrIdentityBackfillMismatch for milestone id mismatch, got %v", err)
}
}
// TestGitBackfillerRetriesAfterPushFailure proves a failed push never reports
// success: the first attempt fails to push and origin/develop stays without the
// identity, and only once the push can reach the remote does a retry land it.
func TestGitBackfillerRetriesAfterPushFailure(t *testing.T) {
workDir, remoteDir := setupBackfillReposWithRemote(t, "develop")
path := "agent-roadmap/phase/p1/milestones/m1.md"
b, err := NewGitBackfiller(workDir, "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller failed: %v", err)
}
// Break only the push URL so the fetch still succeeds but the push fails.
brokenPush := filepath.Join(t.TempDir(), "nonexistent-remote.git")
runGit(t, workDir, "remote", "set-url", "--push", "origin", brokenPush)
if err := b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
}); err == nil {
t.Fatal("expected push failure, got nil error")
}
// The remote must not carry the identity block yet.
pushed := gitOut(t, workDir, "show", "origin/develop:"+path)
if _, perr := roadmapsync.ParseMilestoneIdentity(path, pushed); perr == nil {
t.Fatal("expected origin/develop to lack the identity after a failed push")
}
// Restore the push URL and retry: now it must land on the remote.
runGit(t, workDir, "remote", "set-url", "--push", "origin", remoteDir)
if err := b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
}); err != nil {
t.Fatalf("retry after restoring push failed: %v", err)
}
pushed = gitOut(t, workDir, "show", "origin/develop:"+path)
if _, perr := roadmapsync.ParseMilestoneIdentity(path, pushed); perr != nil {
t.Fatalf("expected origin/develop to carry the identity after retry: %v", perr)
}
if out := gitOut(t, workDir, "log", "origin/develop", "-1", "--format=%b"); !strings.Contains(out, commitMarker) {
t.Errorf("expected pushed commit to carry Core marker, got: %s", out)
}
}
// TestGitBackfillerCommitsOnVerifiedRemoteRevisionWhenLocalBehind proves the
// backfill commit is based on origin/develop even when the local checkout is
// behind it: the commit's parent is the advanced remote revision and its
// content includes the advanced develop content, not the stale local HEAD.
func TestGitBackfillerCommitsOnVerifiedRemoteRevisionWhenLocalBehind(t *testing.T) {
workDir := setupBackfillRepos(t, "develop")
path := "agent-roadmap/phase/p1/milestones/m1.md"
// Advance origin/develop, then move the local branch behind it.
remoteRev := advanceDevelop(t, workDir, path, "\nDevelop moved ahead.\n")
runGit(t, workDir, "reset", "--hard", "HEAD~1")
b, err := NewGitBackfiller(workDir, "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller failed: %v", err)
}
if err := b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
RoadmapRevision: remoteRev,
}); err != nil {
t.Fatalf("BackfillIdentity failed: %v", err)
}
// The backfill commit must sit directly on top of the verified remote
// revision, not the stale local HEAD.
parent := gitOut(t, workDir, "rev-parse", "origin/develop^")
if parent != remoteRev {
t.Errorf("backfill commit parent = %s, want verified remote revision %s", parent, remoteRev)
}
pushed := gitOut(t, workDir, "show", "origin/develop:"+path)
if !strings.Contains(pushed, "Develop moved ahead.") {
t.Errorf("expected backfill to build on advanced develop content, got: %s", pushed)
}
if _, perr := roadmapsync.ParseMilestoneIdentity(path, pushed); perr != nil {
t.Errorf("expected identity block on pushed content: %v", perr)
}
}
// TestGitBackfillerRejectsMovedRemoteRevision proves a stale verified revision
// aborts before any file mutation: if origin/develop has moved past the
// scan-verified revision, the backfill fails and the remote gains no identity.
func TestGitBackfillerRejectsMovedRemoteRevision(t *testing.T) {
workDir := setupBackfillRepos(t, "develop")
path := "agent-roadmap/phase/p1/milestones/m1.md"
staleRev := gitOut(t, workDir, "rev-parse", "origin/develop")
// Move origin/develop forward so staleRev is no longer the tip.
advanceDevelop(t, workDir, path, "\nDevelop moved ahead.\n")
b, err := NewGitBackfiller(workDir, "origin", "develop")
if err != nil {
t.Fatalf("NewGitBackfiller failed: %v", err)
}
err = b.BackfillIdentity(context.Background(), BackfillInput{
Identity: testBackfillIdentity(),
MarkdownPath: path,
RoadmapRevision: staleRev,
})
if err == nil {
t.Fatal("expected error for moved remote revision, got nil")
}
pushed := gitOut(t, workDir, "show", "origin/develop:"+path)
if _, perr := roadmapsync.ParseMilestoneIdentity(path, pushed); perr == nil {
t.Error("expected no identity backfill after a revision-mismatch abort")
}
}

View file

@ -54,6 +54,27 @@ type Store interface {
MarkStepCompleted(ctx context.Context, identityID int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) MarkStepCompleted(ctx context.Context, identityID int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error)
} }
// Backfiller writes the provider identity block to the develop checkout,
// commits only that block, and pushes it to develop. SyncCreation calls it
// right after a Plane work item is created (or on resume after a prior push
// failure) and only marks the identity_backfilled ledger step once it
// returns nil, so a retry never records the step for a push that did not
// actually land.
type Backfiller interface {
BackfillIdentity(ctx context.Context, in BackfillInput) error
}
// BackfillInput carries the identity to inject, the develop-relative Milestone
// path to inject it into, and the scan-verified develop revision the backfill
// commit must be based on. RoadmapRevision is the revision the scanner verified
// on origin/develop; the backfiller refuses to commit on top of a revision that
// no longer matches it, so a Core-authored backfill never lands on stale state.
type BackfillInput struct {
Identity roadmapsync.Identity
MarkdownPath string
RoadmapRevision string
}
// Service drives the creation sync orchestration against a Store and the Plane // Service drives the creation sync orchestration against a Store and the Plane
// Todo provider facets. // Todo provider facets.
type Service struct { type Service struct {
@ -62,6 +83,11 @@ type Service struct {
// selfActor identifies NomadCode's own provider account so a mutation it made // selfActor identifies NomadCode's own provider account so a mutation it made
// is not re-interpreted as a fresh user trigger. // is not re-interpreted as a fresh user trigger.
selfActor string selfActor string
// backfiller performs the Core-authored identity backfill commit/push for
// missing-create identities. Nil means backfill is not configured; a
// missing-create cycle then fails explicitly instead of silently
// completing with only the DB identity.
backfiller Backfiller
} }
// NewService builds the orchestrator. provider is the Plane adapter (or a fake) // NewService builds the orchestrator. provider is the Plane adapter (or a fake)
@ -71,6 +97,13 @@ func NewService(store Store, provider roadmapsync.PlaneTodoProvider, selfActor s
return &Service{store: store, provider: provider, selfActor: selfActor} return &Service{store: store, provider: provider, selfActor: selfActor}
} }
// SetBackfiller configures the identity backfiller. It is a post-construction
// setter (like workflow.Service.SetEnqueuer) so callers that do not need
// missing-create identity backfill can keep using NewService unchanged.
func (s *Service) SetBackfiller(b Backfiller) {
s.backfiller = b
}
// SyncCreationInput carries everything one creation sync needs without reaching // SyncCreationInput carries everything one creation sync needs without reaching
// into provider DTOs: the develop scan result and the expected work item // into provider DTOs: the develop scan result and the expected work item
// identity (the develop match inputs), the Plane work item ref, the original // identity (the develop match inputs), the Plane work item ref, the original
@ -117,6 +150,13 @@ func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncC
var found bool var found bool
var err error var err error
// missingCreateShaped is computed from the trigger's original identity
// fields before the found-identity infill below overwrites them, so a
// redelivered missing-create job (no work item id/ref on the trigger) is
// still recognized as such even after an earlier cycle already created
// the Plane item and populated the persisted row.
missingCreateShaped := in.Expected.WorkItemID == "" && in.Ref.ID == ""
// Load existing identity row (without mutation) // Load existing identity row (without mutation)
if in.Expected.MilestoneID() != "" { if in.Expected.MilestoneID() != "" {
existingRow, completed, found, err = s.loadExistingByID(ctx, in.Expected.MilestoneID()) existingRow, completed, found, err = s.loadExistingByID(ctx, in.Expected.MilestoneID())
@ -155,7 +195,16 @@ func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncC
if in.Ref.ID == "" { if in.Ref.ID == "" {
in.Ref.ID = in.Expected.WorkItemID in.Ref.ID = in.Expected.WorkItemID
} }
} else if in.Expected.WorkItemID == "" && in.Ref.ID == "" {
// A prior cycle already created the Plane item and upserted the
// identity (existingRow.WorkItemID set), but the identity backfill
// commit/push never completed — resume the backfill directly instead
// of falling through to develop-match, which cannot succeed until the
// backfill push actually lands the identity block on develop.
if missingCreateShaped && existingRow.WorkItemID != "" && !completed[storage.StepIdentityBackfilled] {
return s.backfillIdentityAndMark(ctx, existingRow, existingFromRow(existingRow).Identity, in.Scan.Revision.Normalize().Revision)
}
} else if missingCreateShaped {
// Plane missing-create scenario: no existing work item id means the // Plane missing-create scenario: no existing work item id means the
// active milestone has no Plane ticket yet. Expected.Provider is set by // active milestone has no Plane ticket yet. Expected.Provider is set by
// the bridge (e.g. "plane"); fall back to "plane" only when the caller // the bridge (e.g. "plane"); fall back to "plane" only when the caller
@ -224,11 +273,7 @@ func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncC
return SyncCreationResult{}, err return SyncCreationResult{}, err
} }
return SyncCreationResult{ return s.backfillIdentityAndMark(ctx, identityRow, identity, in.Scan.Revision.Normalize().Revision)
Action: SyncActionProjected,
Reason: "plane work item created for missing active milestone",
NextStep: roadmapsync.StepDevelopMatched,
}, nil
} }
candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{ candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{
@ -420,6 +465,28 @@ func (s *Service) loadExistingByID(ctx context.Context, milestoneID string) (db.
return row, completed, true, nil return row, completed, true, nil
} }
// backfillIdentityAndMark runs the Core-authored identity backfill commit/push
// for identityRow and, only once it succeeds, marks the identity_backfilled
// ledger step. A missing backfiller fails explicitly rather than silently
// completing with only the DB identity, so a misconfigured deployment cannot
// mistake a DB-only write for a durable, provider-verifiable identity.
func (s *Service) backfillIdentityAndMark(ctx context.Context, identityRow db.RoadmapSyncIdentity, identity roadmapsync.Identity, roadmapRevision string) (SyncCreationResult, error) {
if s.backfiller == nil {
return SyncCreationResult{}, errors.New("roadmapsyncpipeline: identity backfiller not configured")
}
if err := s.backfiller.BackfillIdentity(ctx, BackfillInput{Identity: identity, MarkdownPath: identity.RoadmapMilestonePath, RoadmapRevision: roadmapRevision}); err != nil {
return SyncCreationResult{}, err
}
if _, err := s.store.MarkStepCompleted(ctx, identityRow.ID, toStorageStep(roadmapsync.StepIdentityBackfilled)); err != nil {
return SyncCreationResult{}, err
}
return SyncCreationResult{
Action: SyncActionProjected,
Reason: "provider identity backfilled to develop",
NextStep: roadmapsync.StepIdentityBackfilled,
}, nil
}
// runRemainingSteps executes the creation-cycle steps from nextStep onward, // runRemainingSteps executes the creation-cycle steps from nextStep onward,
// skipping any already in the completed set, and records a ledger mark right // skipping any already in the completed set, and records a ledger mark right
// after each provider call succeeds. A provider failure stops the sequence so a // after each provider call succeeds. A provider failure stops the sequence so a
@ -453,10 +520,15 @@ func (s *Service) runRemainingSteps(ctx context.Context, identityID int64, nextS
// runStep performs the provider side effect for a single creation-cycle step. // runStep performs the provider side effect for a single creation-cycle step.
// StepDevelopMatched has no provider side effect — it only records that the // StepDevelopMatched has no provider side effect — it only records that the
// develop match gate passed — so it is a no-op here and the caller still records // develop match gate passed — so it is a no-op here and the caller still records
// its ledger mark. // its ledger mark. StepIdentityBackfilled is likewise a no-op here: by the time
// this general resume loop reaches it, the develop scan already matched the
// candidate against a Milestone doc that carries the identity block, so the
// content backfill Core would otherwise perform is already satisfied; the
// dedicated missing-create/resume path in SyncCreation is the only place that
// actually calls the Backfiller.
func (s *Service) runStep(ctx context.Context, step roadmapsync.Step, in roadmapsync.ProjectPlaneTodoInput) error { func (s *Service) runStep(ctx context.Context, step roadmapsync.Step, in roadmapsync.ProjectPlaneTodoInput) error {
switch step { switch step {
case roadmapsync.StepDevelopMatched: case roadmapsync.StepDevelopMatched, roadmapsync.StepIdentityBackfilled:
return nil return nil
case roadmapsync.StepOriginalCommentPreserved: case roadmapsync.StepOriginalCommentPreserved:
return roadmapsync.PreserveOriginalComment(ctx, s.provider, in) return roadmapsync.PreserveOriginalComment(ctx, s.provider, in)

View file

@ -139,6 +139,23 @@ func (f *fakeStore) MarkStepCompleted(_ context.Context, _ int64, step storage.R
return db.RoadmapSyncStep{Step: string(step)}, nil return db.RoadmapSyncStep{Step: string(step)}, nil
} }
// fakeBackfiller records the calls BackfillIdentity received and can fail on
// demand, so tests can assert call order (create -> identity upsert ->
// develop_matched mark -> backfill -> identity_backfilled mark) and retry
// behavior without a real git checkout.
type fakeBackfiller struct {
calls []BackfillInput
err error
}
func (f *fakeBackfiller) BackfillIdentity(_ context.Context, in BackfillInput) error {
f.calls = append(f.calls, in)
if f.err != nil {
return f.err
}
return nil
}
func readyInput() SyncCreationInput { func readyInput() SyncCreationInput {
identity := roadmapsync.Identity{ identity := roadmapsync.Identity{
Shape: roadmapsync.ShapeMilestone, Shape: roadmapsync.ShapeMilestone,
@ -213,6 +230,7 @@ func TestSyncCreationReadyRunsStepsAndMarksLedger(t *testing.T) {
wantLedger := []storage.RoadmapSyncStep{ wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched, storage.StepDevelopMatched,
storage.StepIdentityBackfilled,
storage.StepOriginalCommentPreserved, storage.StepOriginalCommentPreserved,
storage.StepPlaneBodyUpdated, storage.StepPlaneBodyUpdated,
storage.StepPlaneTodoMoved, storage.StepPlaneTodoMoved,
@ -264,6 +282,7 @@ func TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) {
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
} }
wantLedger := []storage.RoadmapSyncStep{ wantLedger := []storage.RoadmapSyncStep{
storage.StepIdentityBackfilled,
storage.StepPlaneBodyUpdated, storage.StepPlaneBodyUpdated,
storage.StepPlaneTodoMoved, storage.StepPlaneTodoMoved,
} }
@ -288,9 +307,11 @@ func TestSyncCreationStopsBeforeLaterStepOnProviderError(t *testing.T) {
if !equalStrings(provider.calls, wantProviderOrder) { if !equalStrings(provider.calls, wantProviderOrder) {
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder) t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
} }
// develop_matched + original_comment_preserved recorded; body never marked. // develop_matched + identity_backfilled + original_comment_preserved
// recorded; body never marked.
wantLedger := []storage.RoadmapSyncStep{ wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched, storage.StepDevelopMatched,
storage.StepIdentityBackfilled,
storage.StepOriginalCommentPreserved, storage.StepOriginalCommentPreserved,
} }
if !equalSteps(store.marked, wantLedger) { if !equalSteps(store.marked, wantLedger) {
@ -423,7 +444,9 @@ func equalSteps(a, b []storage.RoadmapSyncStep) bool {
func TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger(t *testing.T) { func TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger(t *testing.T) {
store := newFakeStore() store := newFakeStore()
provider := &fakeProvider{} provider := &fakeProvider{}
backfiller := &fakeBackfiller{}
svc := NewService(store, provider, "") svc := NewService(store, provider, "")
svc.SetBackfiller(backfiller)
// 1. Prepare input representing missing active milestone (expected identity without provider details) // 1. Prepare input representing missing active milestone (expected identity without provider details)
in := readyInput() in := readyInput()
@ -445,6 +468,9 @@ func TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger(t *te
if res.Action != SyncActionProjected { if res.Action != SyncActionProjected {
t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason) t.Fatalf("expected projected action, got %s (%s)", res.Action, res.Reason)
} }
if res.NextStep != roadmapsync.StepIdentityBackfilled {
t.Fatalf("expected next step identity_backfilled, got %s", res.NextStep)
}
// Fake creator should be called once with "create" // Fake creator should be called once with "create"
wantCalls := []string{"create"} wantCalls := []string{"create"}
@ -452,6 +478,21 @@ func TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger(t *te
t.Fatalf("provider calls = %v, want %v", provider.calls, wantCalls) t.Fatalf("provider calls = %v, want %v", provider.calls, wantCalls)
} }
// Backfiller must run after the identity is upserted, carrying the
// created work item id and the develop-relative Milestone path.
if len(backfiller.calls) != 1 {
t.Fatalf("backfiller calls = %d, want 1", len(backfiller.calls))
}
if backfiller.calls[0].Identity.WorkItemID != "CREATED-123" {
t.Errorf("backfiller identity WorkItemID = %q, want CREATED-123", backfiller.calls[0].Identity.WorkItemID)
}
if backfiller.calls[0].MarkdownPath != testMilestonePath {
t.Errorf("backfiller MarkdownPath = %q, want %q", backfiller.calls[0].MarkdownPath, testMilestonePath)
}
if backfiller.calls[0].RoadmapRevision != testRevision {
t.Errorf("backfiller RoadmapRevision = %q, want %q", backfiller.calls[0].RoadmapRevision, testRevision)
}
// Title should be `[milestone-id] title` (in our case extract title should return Milestone from Markdown) // Title should be `[milestone-id] title` (in our case extract title should return Milestone from Markdown)
expectedTitle := "[m1] Milestone" expectedTitle := "[m1] Milestone"
if provider.createdInput.Title != expectedTitle { if provider.createdInput.Title != expectedTitle {
@ -471,22 +512,21 @@ func TestSyncCreationMissingActiveMilestoneCreatesWorkItemAndRecordsLedger(t *te
wantLedger := []storage.RoadmapSyncStep{ wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched, storage.StepDevelopMatched,
storage.StepIdentityBackfilled,
} }
if !equalSteps(store.marked, wantLedger) { if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
} }
if res.NextStep != roadmapsync.StepDevelopMatched {
t.Fatalf("expected next step to be develop_matched, got %s", res.NextStep)
}
} }
// TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate reprocesses the // TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate reprocesses the
// same milestone slug and revision after a prior cycle already persisted the // same milestone slug and revision after a prior cycle already persisted the
// identity from a successful create. The missing-create predicate keys off // identity from a successful create but the identity backfill commit/push had
// `found` (identity row exists), not off the trigger's empty WorkItemID/Ref, // not completed yet. The missing-create predicate keys off `found` (identity
// so a duplicate delivery of the same bridge-produced missing-create job must // row exists), not off the trigger's empty WorkItemID/Ref, so a duplicate
// not call CreateWorkItem a second time even though the trigger input still // delivery of the same bridge-produced missing-create job must not call
// looks like a missing-create job. // CreateWorkItem a second time even though the trigger input still looks like
// a missing-create job; instead it resumes the identity backfill directly.
func TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate(t *testing.T) { func TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate(t *testing.T) {
store := newFakeStore() store := newFakeStore()
store.found = true store.found = true
@ -504,7 +544,9 @@ func TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate(t *testing.T) {
store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepDevelopMatched] = true
provider := &fakeProvider{} provider := &fakeProvider{}
backfiller := &fakeBackfiller{}
svc := NewService(store, provider, "") svc := NewService(store, provider, "")
svc.SetBackfiller(backfiller)
// Same missing-create shaped trigger as the bridge would re-send: no // Same missing-create shaped trigger as the bridge would re-send: no
// WorkItemID/Ref, same milestone slug and develop revision. // WorkItemID/Ref, same milestone slug and develop revision.
@ -546,6 +588,25 @@ func TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate(t *testing.T) {
if res.Action == SyncActionProjected && res.NextStep == roadmapsync.StepDevelopMatched { if res.Action == SyncActionProjected && res.NextStep == roadmapsync.StepDevelopMatched {
t.Fatalf("expected no fresh develop_matched projection on duplicate retry, got %+v", res) t.Fatalf("expected no fresh develop_matched projection on duplicate retry, got %+v", res)
} }
// The retry must resume the backfill (not recreate the Plane item) and
// mark identity_backfilled once it succeeds.
if len(backfiller.calls) != 1 {
t.Fatalf("backfiller calls = %d, want 1", len(backfiller.calls))
}
if backfiller.calls[0].Identity.WorkItemID != "CREATED-123" {
t.Errorf("backfiller identity WorkItemID = %q, want CREATED-123", backfiller.calls[0].Identity.WorkItemID)
}
if backfiller.calls[0].RoadmapRevision != "bbbb" {
t.Errorf("backfiller RoadmapRevision = %q, want bbbb", backfiller.calls[0].RoadmapRevision)
}
wantLedger := []storage.RoadmapSyncStep{storage.StepIdentityBackfilled}
if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
}
if res.Action != SyncActionProjected || res.NextStep != roadmapsync.StepIdentityBackfilled {
t.Fatalf("expected projected/identity_backfilled, got %+v", res)
}
} }
// TestSyncCreationBridgeProducedMissingCreate tests the actual bridge-produced // TestSyncCreationBridgeProducedMissingCreate tests the actual bridge-produced
@ -555,7 +616,9 @@ func TestSyncCreationMissingCreateDuplicateRetryDoesNotRecreate(t *testing.T) {
func TestSyncCreationBridgeProducedMissingCreate(t *testing.T) { func TestSyncCreationBridgeProducedMissingCreate(t *testing.T) {
store := newFakeStore() store := newFakeStore()
provider := &fakeProvider{} provider := &fakeProvider{}
backfiller := &fakeBackfiller{}
svc := NewService(store, provider, "") svc := NewService(store, provider, "")
svc.SetBackfiller(backfiller)
// Build input exactly as bridge.enqueueDoc does for missing-create: // Build input exactly as bridge.enqueueDoc does for missing-create:
// Provider="plane", Tenant/Project set from config, WorkItemID/Ref empty. // Provider="plane", Tenant/Project set from config, WorkItemID/Ref empty.
@ -627,10 +690,17 @@ func TestSyncCreationBridgeProducedMissingCreate(t *testing.T) {
// Verify ledger mark. // Verify ledger mark.
wantLedger := []storage.RoadmapSyncStep{ wantLedger := []storage.RoadmapSyncStep{
storage.StepDevelopMatched, storage.StepDevelopMatched,
storage.StepIdentityBackfilled,
} }
if !equalSteps(store.marked, wantLedger) { if !equalSteps(store.marked, wantLedger) {
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger) t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
} }
if len(backfiller.calls) != 1 {
t.Fatalf("backfiller calls = %d, want 1", len(backfiller.calls))
}
if backfiller.calls[0].RoadmapRevision != "bbbb" {
t.Errorf("backfiller RoadmapRevision = %q, want bbbb", backfiller.calls[0].RoadmapRevision)
}
} }
// TestSyncCreationMissingCreateRequiresTenantAndProject ensures that a // TestSyncCreationMissingCreateRequiresTenantAndProject ensures that a
@ -840,6 +910,7 @@ func TestSyncCreationUpdateAttributesBeforeCompleteNoOp(t *testing.T) {
} }
// All steps complete // All steps complete
store.completed[storage.StepDevelopMatched] = true store.completed[storage.StepDevelopMatched] = true
store.completed[storage.StepIdentityBackfilled] = true
store.completed[storage.StepOriginalCommentPreserved] = true store.completed[storage.StepOriginalCommentPreserved] = true
store.completed[storage.StepPlaneBodyUpdated] = true store.completed[storage.StepPlaneBodyUpdated] = true
store.completed[storage.StepPlaneTodoMoved] = true store.completed[storage.StepPlaneTodoMoved] = true

View file

@ -14,6 +14,7 @@ type RoadmapSyncStep string
const ( const (
StepDevelopMatched RoadmapSyncStep = "develop_matched" StepDevelopMatched RoadmapSyncStep = "develop_matched"
StepIdentityBackfilled RoadmapSyncStep = "identity_backfilled"
StepOriginalCommentPreserved RoadmapSyncStep = "original_comment_preserved" StepOriginalCommentPreserved RoadmapSyncStep = "original_comment_preserved"
StepPlaneBodyUpdated RoadmapSyncStep = "plane_body_updated" StepPlaneBodyUpdated RoadmapSyncStep = "plane_body_updated"
StepPlaneTodoMoved RoadmapSyncStep = "plane_todo_moved" StepPlaneTodoMoved RoadmapSyncStep = "plane_todo_moved"
@ -23,6 +24,7 @@ const (
// them, so a resume can pick the first not-yet-completed step. // them, so a resume can pick the first not-yet-completed step.
var OrderedRoadmapSyncSteps = []RoadmapSyncStep{ var OrderedRoadmapSyncSteps = []RoadmapSyncStep{
StepDevelopMatched, StepDevelopMatched,
StepIdentityBackfilled,
StepOriginalCommentPreserved, StepOriginalCommentPreserved,
StepPlaneBodyUpdated, StepPlaneBodyUpdated,
StepPlaneTodoMoved, StepPlaneTodoMoved,
@ -36,7 +38,7 @@ var ErrUnknownRoadmapSyncStep = errors.New("unknown roadmap sync step")
// Valid reports whether the step is part of the tracked closed set. // Valid reports whether the step is part of the tracked closed set.
func (s RoadmapSyncStep) Valid() bool { func (s RoadmapSyncStep) Valid() bool {
switch s { switch s {
case StepDevelopMatched, StepOriginalCommentPreserved, StepPlaneBodyUpdated, StepPlaneTodoMoved: case StepDevelopMatched, StepIdentityBackfilled, StepOriginalCommentPreserved, StepPlaneBodyUpdated, StepPlaneTodoMoved:
return true return true
default: default:
return false return false

View file

@ -134,6 +134,22 @@ func TestMarkStepCompleted_CompletedStepReapplyIsNoOp(t *testing.T) {
} }
} }
func TestMarkStepCompleted_IdentityBackfilledStepPersists(t *testing.T) {
completedAt := time.Now()
m := &stepMockDBTX{markRow: db.RoadmapSyncStep{
ID: 2, RoadmapSyncIdentityID: 7, Step: string(StepIdentityBackfilled), CompletedAt: completedAt,
}}
store := newStepStore(m)
row, err := store.MarkStepCompleted(context.Background(), 7, StepIdentityBackfilled)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if row.Step != string(StepIdentityBackfilled) {
t.Errorf("row not preserved: %+v", row)
}
}
func TestMarkStepCompleted_UnknownStepRejected(t *testing.T) { func TestMarkStepCompleted_UnknownStepRejected(t *testing.T) {
m := &stepMockDBTX{} m := &stepMockDBTX{}
store := newStepStore(m) store := newStepStore(m)
@ -152,7 +168,8 @@ func TestMarkStepCompleted_UnknownStepRejected(t *testing.T) {
func TestCompletedSteps_ReturnsSetForResume(t *testing.T) { func TestCompletedSteps_ReturnsSetForResume(t *testing.T) {
m := &stepMockDBTX{listRows: []db.RoadmapSyncStep{ m := &stepMockDBTX{listRows: []db.RoadmapSyncStep{
{ID: 1, RoadmapSyncIdentityID: 7, Step: string(StepDevelopMatched)}, {ID: 1, RoadmapSyncIdentityID: 7, Step: string(StepDevelopMatched)},
{ID: 2, RoadmapSyncIdentityID: 7, Step: string(StepOriginalCommentPreserved)}, {ID: 2, RoadmapSyncIdentityID: 7, Step: string(StepIdentityBackfilled)},
{ID: 3, RoadmapSyncIdentityID: 7, Step: string(StepOriginalCommentPreserved)},
}} }}
store := newStepStore(m) store := newStepStore(m)
@ -160,8 +177,8 @@ func TestCompletedSteps_ReturnsSetForResume(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if !done[StepDevelopMatched] || !done[StepOriginalCommentPreserved] { if !done[StepDevelopMatched] || !done[StepIdentityBackfilled] || !done[StepOriginalCommentPreserved] {
t.Errorf("expected first two steps completed, got %v", done) t.Errorf("expected first three steps completed, got %v", done)
} }
if done[StepPlaneBodyUpdated] || done[StepPlaneTodoMoved] { if done[StepPlaneBodyUpdated] || done[StepPlaneTodoMoved] {
t.Errorf("uncompleted steps must be absent, got %v", done) t.Errorf("uncompleted steps must be absent, got %v", done)

View file

@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE roadmap_sync_steps
DROP CONSTRAINT roadmap_sync_steps_step_check;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE roadmap_sync_steps
ADD CONSTRAINT roadmap_sync_steps_step_check
CHECK (step IN ('develop_matched', 'identity_backfilled', 'original_comment_preserved', 'plane_body_updated', 'plane_todo_moved'));
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE roadmap_sync_steps
DROP CONSTRAINT roadmap_sync_steps_step_check;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE roadmap_sync_steps
ADD CONSTRAINT roadmap_sync_steps_step_check
CHECK (step IN ('develop_matched', 'original_comment_preserved', 'plane_body_updated', 'plane_todo_moved'));
-- +goose StatementEnd