feat: plane integration and workflow updates

- Add plane adapter client with test coverage
- Add task external refs migration
- Update workflow service with tests
- Add HTTP handler tests
- Update router and middleware tests
- Update database models and queries
- Update scheduler jobs
- Update storage store
- Update server main and docker-compose
- Update roadmaps and documentation
This commit is contained in:
toki 2026-05-22 13:37:47 +09:00
parent 8148b53ca1
commit 424de604ee
25 changed files with 820 additions and 98 deletions

View file

@ -112,7 +112,7 @@ Keep detailed roadmap state in `agent-ops/roadmap/` and detailed domain rules in
| Core | `MODEL_BASE_URL`, `MODEL_API_KEY`, `MODEL_NAME`, `MODEL_CONTEXT_SIZE`, `MODEL_TIMEOUT_SEC` | No | Configure the OpenAI-compatible model endpoint used by worker execution. | | Core | `MODEL_BASE_URL`, `MODEL_API_KEY`, `MODEL_NAME`, `MODEL_CONTEXT_SIZE`, `MODEL_TIMEOUT_SEC` | No | Configure the OpenAI-compatible model endpoint used by worker execution. |
| Core | `A2A_EDGE_URL`, `A2A_AGENT_URL`, `A2A_TOKEN`, `A2A_TIMEOUT_SEC` | No | Configure the A2A-compatible agent call path. | | Core | `A2A_EDGE_URL`, `A2A_AGENT_URL`, `A2A_TOKEN`, `A2A_TIMEOUT_SEC` | No | Configure the A2A-compatible agent call path. |
| Core | `MATTERMOST_BASE_URL`, `MATTERMOST_TOKEN` | No | Reserved for Mattermost adapter integration. | | Core | `MATTERMOST_BASE_URL`, `MATTERMOST_TOKEN` | No | Reserved for Mattermost adapter integration. |
| Core | `PLANE_BASE_URL`, `PLANE_TOKEN` | No | Reserved for Plane adapter integration. | | Core | `PLANE_BASE_URL`, `PLANE_TOKEN` | No | Configure Plane work item lookup, comment, and state update integration. |
| Web | `VITE_NOMADCODE_API_BASE_URL` | No | The web client falls back to `http://localhost:8080`; `apps/web/.env.example` documents the same local value. | | Web | `VITE_NOMADCODE_API_BASE_URL` | No | The web client falls back to `http://localhost:8080`; `apps/web/.env.example` documents the same local value. |
## Reference Documents ## Reference Documents

View file

@ -4,14 +4,14 @@
NomadCode는 모바일 앱, 웹 콘솔, core 서비스, 공유 계약, agent-operation 규칙을 하나의 원레포로 묶어 AI-assisted development workflow를 조율하는 프로젝트다. NomadCode는 모바일 앱, 웹 콘솔, core 서비스, 공유 계약, agent-operation 규칙을 하나의 원레포로 묶어 AI-assisted development workflow를 조율하는 프로젝트다.
현재 로드맵은 기존 `services/core/README.md`의 단계별 다음 작업을 `agent-ops/roadmap/` 구조로 옮긴 것이다. 범위는 우선 core 백엔드 오케스트레이션의 서버 골격을 바탕으로 Plane issue 기반의 얇은 e2e cycle을 먼저 닫고, 그 실제 흐름을 기준으로 workflow 안정화와 외부 통합 확장을 이어가는 데 둔다. 현재 로드맵은 기존 `services/core/README.md`의 단계별 다음 작업을 `agent-ops/roadmap/` 구조로 옮긴 것이다. 범위는 우선 core 백엔드 오케스트레이션의 서버 골격을 바탕으로 Plane과의 실제 통신 기반을 안정화하고, 그 뒤에 task pipeline과 workflow 안정화를 설계하는 데 둔다.
## Phase 흐름 ## Phase 흐름
- Server Skeleton: 서버 실행 골격, task 저장 구조, 로컬 모델 호출 기반 비동기 job 실행, Adapter stub을 구성한다. - Server Skeleton: 서버 실행 골격, task 저장 구조, 로컬 모델 호출 기반 비동기 job 실행, Adapter stub을 구성한다.
- Plane Thin E2E: Plane issue를 core task와 연결하고 실행 결과를 Plane comment/status로 되돌리는 최소 제품 e2e cycle을 완성한다. - Plane Communication Foundation: Plane self-hosted 인스턴스와 통신하기 위한 인증, API client, 외부 참조 저장, smoke 검증 토대를 만든다.
- Workflow Core: Plane thin e2e cycle에서 관찰되는 실제 상태 변화를 기준으로 task 상태 전이, retry, timeout, notification event의 기본 구조를 안정화한다. - Workflow Core: Plane 통신 토대 위에서 task pipeline 설계가 정리된 뒤 상태 전이, retry, timeout, notification event의 기본 구조를 안정화한다.
- External Integration: Plane thin e2e 이후 Mattermost, Agent Integrator, IOP 연결을 실제 통합 흐름으로 확장한다. - External Integration: Plane 통신 토대와 workflow core 이후 Mattermost, Agent Integrator, IOP 연결을 실제 통합 흐름으로 확장한다.
## Milestone 목록 ## Milestone 목록
@ -19,9 +19,9 @@ NomadCode는 모바일 앱, 웹 콘솔, core 서비스, 공유 계약, agent-ope
- [Server Skeleton](milestones/server-skeleton.md) - 상태: 완료; 목표: core 서버 실행 골격과 task 저장, 비동기 job, 외부 Adapter stub을 구성한다. - [Server Skeleton](milestones/server-skeleton.md) - 상태: 완료; 목표: core 서버 실행 골격과 task 저장, 비동기 job, 외부 Adapter stub을 구성한다.
### Plane Thin E2E ### Plane Communication Foundation
- [Plane Thin E2E Loop](milestones/plane-thin-e2e-loop.md) - 상태: 진행 중; 목표: Plane issue에서 core task 실행 결과가 다시 Plane에 남는 최소 e2e cycle을 닫는다. - [Plane Communication Foundation](milestones/plane-thin-e2e-loop.md) - 상태: 진행 중; 목표: Plane 인스턴스와의 인증/API 통신, 외부 참조 저장, 수동 smoke 검증 토대를 만든다.
### Workflow Core ### Workflow Core

View file

@ -2,7 +2,7 @@
## 활성 Milestone ## 활성 Milestone
- Plane Thin E2E Loop: agent-ops/roadmap/milestones/plane-thin-e2e-loop.md - Plane Communication Foundation: agent-ops/roadmap/milestones/plane-thin-e2e-loop.md
## 선택 규칙 ## 선택 규칙

View file

@ -2,7 +2,7 @@
## 목표 ## 목표
Plane thin e2e 이후 남은 Plane 확장, Mattermost, Agent Integrator, IOP 연결을 stub 또는 호환 호출 경로에서 실제 통합 흐름으로 확장한다. Plane 통신 토대와 workflow core 이후 남은 Plane 확장, Mattermost, Agent Integrator, IOP 연결을 stub 또는 호환 호출 경로에서 실제 통합 흐름으로 확장한다.
## 단계 ## 단계
@ -49,5 +49,5 @@ External Integration
- 이전 출처: `services/core/README.md``## 단계별 다음 작업` - 이전 출처: `services/core/README.md``## 단계별 다음 작업`
- 주요 작업 영역: `services/core/internal/adapters/`, `services/core/internal/scheduler/`, `services/core/internal/workflow/` - 주요 작업 영역: `services/core/internal/adapters/`, `services/core/internal/scheduler/`, `services/core/internal/workflow/`
- 선행 작업: Plane Thin E2E Loop, Workflow Core - 선행 작업: Plane Communication Foundation, Workflow Core
- 확인 필요: IOP native protocol과 Agent Integrator의 최종 계약은 구현 전 별도 확인이 필요하다. - 확인 필요: IOP native protocol과 Agent Integrator의 최종 계약은 구현 전 별도 확인이 필요하다.

View file

@ -1,12 +1,12 @@
# Plane Thin E2E Loop # Plane Communication Foundation
## 목표 ## 목표
Plane issue를 core task의 외부 출처로 연결하고, task 실행 결과를 Plane comment/status로 되돌려보내는 최소 e2e cycle을 완성한다. Workflow Core의 retry, timeout, notification event 정책은 이 실제 loop가 관찰된 뒤 안정화한다. core가 실제 Plane 인스턴스와 안정적으로 통신할 수 있는 최소 기반을 만든다. 현재 단계는 파이프라인 설계나 자동 실행 흐름을 확정하지 않고, 인증, endpoint 계약, work item 조회, comment/status update, 외부 참조 저장, 수동 smoke 검증에 집중한다.
## 단계 ## 단계
Plane Thin E2E Plane Communication Foundation
## 상태 ## 상태
@ -14,42 +14,66 @@ Plane Thin E2E
## 범위 ## 범위
- Plane issue 기반 작업 입력과 core task 생성 경로 - Plane self-hosted 인스턴스 연결 정보와 API 계약 확인
- core task와 Plane issue의 외부 참조 연결 - Plane API token 생성/설정 방식 정리
- task 완료와 실패 결과의 Plane comment/status 반영 - Plane work item 조회, comment 생성, status update adapter 구현
- 최소 e2e 검증 절차와 운영 설정 문서화 - core task와 Plane work item을 연결할 외부 참조 저장 토대 구성
- 실제 Plane 인스턴스를 대상으로 한 수동 smoke 검증 절차 정리
## 필수 기능 ## 필수 기능
- [ ] Plane issue를 core task로 연결하는 최소 입력 계약을 정한다. - [ ] Plane 연결 설정과 인증 계약을 정한다.
- [ ] task에 `provider`, `external_id`, `external_url` 등 외부 참조를 저장하는 방식을 정한다. - [ ] `PLANE_BASE_URL=https://plane.toki-labs.com` 기준 endpoint prefix를 확인한다.
- [ ] Plane issue title/description/comment 중 어떤 값을 task title/payload로 매핑할지 정한다. - [ ] Plane API key를 `PLANE_TOKEN`으로 주입하고 `X-Api-Key` header로 호출한다.
- [ ] Plane adapter에서 issue comment와 status update 실제 호출을 구현한다. - [ ] token 생성/보관/교체 방식은 운영 문서에 남기고 secret 값은 repository에 기록하지 않는다.
- [ ] `PLANE_BASE_URL`, `PLANE_TOKEN` 설정과 adapter client 동작을 README 또는 운영 문서와 맞춘다. - [ ] Plane adapter의 최소 통신 기능을 구현한다.
- [ ] task 완료/실패 시 Plane에 결과를 남기는 발행 지점을 workflow 또는 scheduler에 연결한다. - [ ] work item detail 조회
- [ ] completed 결과는 Plane comment로 남긴다. - [ ] work item comment 생성
- [ ] failed 결과는 Plane comment 또는 status로 관찰 가능하게 남긴다. - [ ] work item state update
- [ ] Plane issue에서 core task 실행 결과가 Plane에 다시 남는 e2e 검증 시나리오를 정리한다. - [ ] non-2xx 응답과 설정 누락 오류를 진단 가능하게 반환
- [ ] core task에 외부 참조 저장 토대를 둔다.
- [ ] `external_provider`, `external_id`, `external_url`, `external_metadata` 등 provider-neutral 필드를 둔다.
- [ ] Plane workspace/project/work item/state id를 metadata에 보존할 수 있게 한다.
- [ ] 실제 Plane 통신 smoke를 검증한다.
- [ ] 인증 성공/실패를 구분해 확인한다.
- [ ] 테스트용 Plane work item에 comment를 남길 수 있다.
- [ ] 테스트용 Plane work item state를 변경할 수 있다.
- [ ] 문서에 현재 통신 토대의 사용법과 한계를 정리한다.
## 완료 기준 ## 완료 기준
- [ ] Plane issue 하나를 기준으로 core task를 만들고 enqueue할 수 있다. - [ ] 유효한 `PLANE_TOKEN`으로 `https://plane.toki-labs.com/api/v1/...` work item API가 200 응답을 반환한다.
- [ ] core task의 completed 결과가 Plane issue comment 또는 status에 반영된다. - [ ] core Plane adapter 테스트가 request path, `X-Api-Key`, request body, error response를 검증한다.
- [ ] core task의 failed 결과가 Plane issue에서 관찰 가능하게 남는다. - [ ] core task가 Plane work item id와 관련 metadata를 저장할 수 있다.
- [ ] e2e loop 검증 명령이나 수동 절차가 문서에 남아 있다. - [ ] 실제 Plane work item 하나에 comment 생성과 state update smoke가 성공한다.
- [ ] Workflow Core에서 후속으로 정리할 retry, timeout, notification event TODO가 실제 loop 기준으로 드러난다. - [ ] README 또는 운영 문서에 Plane 설정, smoke 절차, secret 취급 방식이 남아 있다.
## 범위 제외 ## 범위 제외
- task pipeline 설계와 agent 실행 orchestration 확정
- Plane issue에서 core task를 자동 생성하고 enqueue하는 제품 흐름 확정
- task 완료/실패 결과를 Plane으로 자동 발행하는 정책 확정
- retry, timeout, notification event 정책
- Plane webhook 기반 자동 수집
- Plane 전체 양방향 동기화 - Plane 전체 양방향 동기화
- Plane webhook 기반 자동 수집의 완전한 운영화
- Mattermost 메시지 발송 구현 - Mattermost 메시지 발송 구현
- Agent Integrator와 IOP의 추가 통합 - Agent Integrator와 IOP의 추가 통합
- 복잡한 workflow DSL - 복잡한 workflow DSL
## 작업 컨텍스트 ## 작업 컨텍스트
- 관련 경로: `services/core/internal/adapters/plane/`, `services/core/internal/workflow/`, `services/core/internal/scheduler/`, `services/core/internal/storage/` - 관련 경로: `services/core/internal/adapters/plane/`, `services/core/internal/storage/`, `services/core/internal/workflow/`, `services/core/migrations/`, `services/core/queries/`, `services/core/README.md`
- 선행 작업: Server Skeleton - 선행 작업: Server Skeleton
- 후속 작업: Workflow Core - 후속 작업 후보: Plane Task Pipeline Design, Workflow Core
- 확인 필요: Plane issue 상태 값과 comment API의 실제 계약은 구현 전에 확인이 필요하다. - 확인된 dev Plane:
- Base URL: `https://plane.toki-labs.com`
- workspace slug: `general`
- NomadCode project id: `a6beb42f-7a8a-410c-b50f-ea3ca94828f3`
- Done state id: `ea2e5b48-8bf1-4723-b749-de7723be41e9`
- Cancelled state id: `f29c06c2-d70c-4b56-a83c-fccc4db60ae4`
## 메모
- 이 마일스톤은 Plane과의 통신 경계와 데이터 토대만 안정화한다.
- pipeline 설계가 정리되기 전까지 scheduler/workflow에 Plane 자동 발행 정책을 더 넣지 않는다.
- 이미 구현된 코드가 이 범위를 넘어간 경우, 리뷰 단계에서 유지/축소/후속 분리 여부를 판단한다.

View file

@ -52,5 +52,5 @@ Server Skeleton
- 이전 출처: `services/core/README.md``## 단계별 다음 작업` - 이전 출처: `services/core/README.md``## 단계별 다음 작업`
- 주요 작업 영역: `services/core/` - 주요 작업 영역: `services/core/`
- 후속 작업: Plane Thin E2E Loop - 후속 작업: Plane Communication Foundation
- 검증 기준: core 변경은 `go test ./...` 또는 `bin/test`를 우선 사용한다. - 검증 기준: core 변경은 `go test ./...` 또는 `bin/test`를 우선 사용한다.

View file

@ -2,7 +2,7 @@
## 목표 ## 목표
Plane thin e2e cycle에서 관찰되는 실제 상태 변화를 기준으로 task 상태 전이를 정리하고 enqueue / running / completed / failed 흐름을 안정화한다. retry, timeout, notification event의 기본 구조를 추가해 core workflow의 운영 기준을 만든다. Plane 통신 토대와 별도 pipeline 설계가 정리된 뒤 실제 상태 변화를 기준으로 task 상태 전이를 정리하고 enqueue / running / completed / failed 흐름을 안정화한다. retry, timeout, notification event의 기본 구조를 추가해 core workflow의 운영 기준을 만든다.
## 단계 ## 단계
@ -18,7 +18,7 @@ Workflow Core
- enqueue / running / completed / failed 흐름 안정화 - enqueue / running / completed / failed 흐름 안정화
- retry / timeout 기본 구조 추가 - retry / timeout 기본 구조 추가
- notification event 구조 정리 - notification event 구조 정리
- Plane thin e2e loop에서 확인된 상태 변화와 실패 케이스 반영 - Plane 통신 토대와 후속 pipeline 설계에서 확인된 상태 변화와 실패 케이스 반영
## 필수 기능 ## 필수 기능
@ -46,7 +46,6 @@ Workflow Core
- 이전 출처: `services/core/README.md``## 단계별 다음 작업` - 이전 출처: `services/core/README.md``## 단계별 다음 작업`
- 주요 작업 영역: `services/core/internal/workflow/`, `services/core/internal/scheduler/`, `services/core/internal/notification/` - 주요 작업 영역: `services/core/internal/workflow/`, `services/core/internal/scheduler/`, `services/core/internal/notification/`
- 선행 작업: Plane Thin E2E Loop - 선행 작업: Plane Communication Foundation, Plane Task Pipeline Design
- 후속 작업: External Integration - 후속 작업: External Integration
- 검증 기준: scheduler, persistence, adapter behavior를 크게 바꾸면 관련 테스트를 함께 갱신한다. - 검증 기준: scheduler, persistence, adapter behavior를 크게 바꾸면 관련 테스트를 함께 갱신한다.

View file

@ -15,13 +15,12 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를
- River task job - River task job
- IOP Edge/OpenAI-compatible 모델 호출 경로(Ollama direct fallback 가능) - IOP Edge/OpenAI-compatible 모델 호출 경로(Ollama direct fallback 가능)
- IOP Edge/A2A-compatible agent 호출 인터페이스 - IOP Edge/A2A-compatible agent 호출 인터페이스
- Plane Adapter stub - Plane work item lookup, comment, and state update adapter
- Mattermost Adapter stub - Mattermost Adapter stub
- 선택적 Docker Compose 실행 환경(PostgreSQL, Redis) - 선택적 Docker Compose 실행 환경(PostgreSQL, Redis)
## 현재 구현하지 않은 범위 ## 현재 구현하지 않은 범위
- 실제 Plane API 호출
- 실제 Mattermost 메시지 발송 - 실제 Mattermost 메시지 발송
- IOP native protocol 연동 - IOP native protocol 연동
- Agent Integrator 연동 - Agent Integrator 연동
@ -34,7 +33,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를
## 실행 방법 ## 실행 방법
로컬 실행은 현재 개발 호스트의 Go와 `code-server` compose에 붙은 PostgreSQL/Redis가 있다는 전제로 진행합니다. local 기본 `DATABASE_URL``postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable` 이고, local 기본 `REDIS_URL``redis://code-server-redis:6379/3`, `REDIS_KEY_PREFIX``nomadcode-core:local` 입니다. dev 배포는 Docker Compose로 실행하며, 같은 `code-server-postgres` Postgres와 `code-server-redis` Redis를 사용합니다. dev DB명은 `nomad-core-dev` 이고, dev 기본 `REDIS_URL``redis://code-server-redis:6379/4`, `REDIS_KEY_PREFIX``nomadcode-core:dev` 입니다. `AUTH_PASSWORD`를 설정하면 `/readyz``/api/*`에 HTTP Basic Auth가 적용됩니다. 모델 호출의 장기 대상은 IOP Edge의 OpenAI-compatible input surface이며, 현재 core model client는 Responses API 형식을 사용합니다. fallback 기본값은 direct Ollama `MODEL_BASE_URL=http://192.168.0.91:11434`, `MODEL_NAME=qwen3.6:35b-a3b-bf16`, `MODEL_CONTEXT_SIZE=262144`, `MODEL_TIMEOUT_SEC=300` 입니다. A2A agent 호출 endpoint는 `A2A_EDGE_URL`로 설정하고, 기존 `A2A_AGENT_URL`도 fallback alias로 받습니다. bearer token은 `A2A_TOKEN`, timeout은 `A2A_TIMEOUT_SEC`로 설정합니다. 로컬 실행은 현재 개발 호스트의 Go와 `code-server` compose에 붙은 PostgreSQL/Redis가 있다는 전제로 진행합니다. local 기본 `DATABASE_URL``postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable` 이고, local 기본 `REDIS_URL``redis://code-server-redis:6379/3`, `REDIS_KEY_PREFIX``nomadcode-core:local` 입니다. dev 배포는 Docker Compose로 실행하며, 같은 `code-server-postgres` Postgres와 `code-server-redis` Redis를 사용합니다. dev DB명은 `nomad-core-dev` 이고, dev 기본 `REDIS_URL``redis://code-server-redis:6379/4`, `REDIS_KEY_PREFIX``nomadcode-core:dev` 입니다. `AUTH_PASSWORD`를 설정하면 `/readyz``/api/*`에 HTTP Basic Auth가 적용됩니다. 모델 호출의 장기 대상은 IOP Edge의 OpenAI-compatible input surface이며, 현재 core model client는 Responses API 형식을 사용합니다. fallback 기본값은 direct Ollama `MODEL_BASE_URL=http://192.168.0.91:11434`, `MODEL_NAME=qwen3.6:35b-a3b-bf16`, `MODEL_CONTEXT_SIZE=262144`, `MODEL_TIMEOUT_SEC=300` 입니다. A2A agent 호출 endpoint는 `A2A_EDGE_URL`로 설정하고, 기존 `A2A_AGENT_URL`도 fallback alias로 받습니다. bearer token은 `A2A_TOKEN`, timeout은 `A2A_TIMEOUT_SEC`로 설정합니다. Plane 연동은 `PLANE_BASE_URL`, `PLANE_TOKEN`으로 설정하며, toki-labs dev Plane 기본 URL은 `https://plane.toki-labs.com` 입니다.
`code-server` PostgreSQL 컨테이너에 DB를 생성하는 예시: `code-server` PostgreSQL 컨테이너에 DB를 생성하는 예시:
@ -83,6 +82,30 @@ MODEL_TIMEOUT_SEC="300" \
A2A agent 호출 인터페이스는 JSON-RPC 2.0 `message/send`, `tasks/get`, `tasks/cancel`을 우선 지원합니다. `A2A_EDGE_URL`을 설정하면 worker는 A2A `message/send`를 blocking 호출하고, 완료된 task/message 응답만 local task completion으로 반영합니다. 기존 `A2A_AGENT_URL`도 fallback alias로 받습니다. 설정하지 않으면 기존 OpenAI-compatible 모델 호출 경로를 사용합니다. A2A agent 호출 인터페이스는 JSON-RPC 2.0 `message/send`, `tasks/get`, `tasks/cancel`을 우선 지원합니다. `A2A_EDGE_URL`을 설정하면 worker는 A2A `message/send`를 blocking 호출하고, 완료된 task/message 응답만 local task completion으로 반영합니다. 기존 `A2A_AGENT_URL`도 fallback alias로 받습니다. 설정하지 않으면 기존 OpenAI-compatible 모델 호출 경로를 사용합니다.
Plane 연동은 Plane API key를 `PLANE_TOKEN`으로 받고 `X-Api-Key` header로 호출합니다. 현재 toki-labs dev Plane은 `https://plane.toki-labs.com`에 있고, 예시 workspace/project 값은 `workspace_slug=general`, `project_id=a6beb42f-7a8a-410c-b50f-ea3ca94828f3` 입니다.
```bash
PLANE_BASE_URL="https://plane.toki-labs.com" \
PLANE_TOKEN="<plane-api-key>" \
./bin/run
```
Plane work item에서 pending task를 수동 생성하는 예시:
```bash
curl -X POST localhost:8080/api/integrations/plane/tasks \
-H 'Content-Type: application/json' \
-d '{
"workspace_slug": "general",
"project_id": "a6beb42f-7a8a-410c-b50f-ea3ca94828f3",
"work_item_id": "8f3861ff-49e4-4944-8ab3-7a7a4e0b95d1",
"external_url": "https://plane.toki-labs.com/general/projects/a6beb42f-7a8a-410c-b50f-ea3ca94828f3/issues/NOMAD-11",
"comment": "NomadCode core thin e2e smoke"
}'
```
현재 endpoint는 Plane work item 조회와 external ref 저장만 검증합니다. 자동 enqueue와 완료/실패 결과 발행은 pipeline 설계 이후 별도 마일스톤에서 다룹니다.
테스트 실행: 테스트 실행:
```bash ```bash

View file

@ -13,6 +13,7 @@ import (
agenta2a "github.com/nomadcode/nomadcode-core/internal/adapters/a2a" agenta2a "github.com/nomadcode/nomadcode-core/internal/adapters/a2a"
"github.com/nomadcode/nomadcode-core/internal/adapters/mattermost" "github.com/nomadcode/nomadcode-core/internal/adapters/mattermost"
modelopenai "github.com/nomadcode/nomadcode-core/internal/adapters/openai" modelopenai "github.com/nomadcode/nomadcode-core/internal/adapters/openai"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane"
"github.com/nomadcode/nomadcode-core/internal/agent" "github.com/nomadcode/nomadcode-core/internal/agent"
"github.com/nomadcode/nomadcode-core/internal/config" "github.com/nomadcode/nomadcode-core/internal/config"
"github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/db"
@ -48,6 +49,10 @@ func run(logger *slog.Logger) error {
BaseURL: cfg.MattermostBaseURL, BaseURL: cfg.MattermostBaseURL,
Token: cfg.MattermostToken, Token: cfg.MattermostToken,
}, logger) }, logger)
planeClient := plane.NewClient(plane.Config{
BaseURL: cfg.PlaneBaseURL,
Token: cfg.PlaneToken,
}, logger)
notificationService := notification.NewService(mattermostClient, logger) notificationService := notification.NewService(mattermostClient, logger)
modelClient := modelopenai.NewClient(modelopenai.Config{ modelClient := modelopenai.NewClient(modelopenai.Config{
BaseURL: cfg.ModelBaseURL, BaseURL: cfg.ModelBaseURL,
@ -79,7 +84,7 @@ func run(logger *slog.Logger) error {
} }
workflowService := workflow.NewService(store, taskScheduler, logger) workflowService := workflow.NewService(store, taskScheduler, logger)
handler := apphttp.NewHandler(pool, workflowService, logger) handler := apphttp.NewHandler(pool, workflowService, planeClient, logger)
server := &http.Server{ server := &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,
Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}), Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}),

View file

@ -22,8 +22,8 @@ services:
A2A_TIMEOUT_SEC: ${A2A_TIMEOUT_SEC:-300} A2A_TIMEOUT_SEC: ${A2A_TIMEOUT_SEC:-300}
MATTERMOST_BASE_URL: "" MATTERMOST_BASE_URL: ""
MATTERMOST_TOKEN: "" MATTERMOST_TOKEN: ""
PLANE_BASE_URL: "" PLANE_BASE_URL: ${PLANE_BASE_URL:-}
PLANE_TOKEN: "" PLANE_TOKEN: ${PLANE_TOKEN:-}
ports: ports:
- "8080:8080" - "8080:8080"
networks: networks:

View file

@ -1,12 +1,23 @@
package plane package plane
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"io"
"log/slog" "log/slog"
"net/http"
"net/url"
"strings"
) )
var ErrNotImplemented = errors.New("plane adapter not implemented") var (
ErrNotImplemented = errors.New("plane adapter method is not implemented")
ErrMissingConfig = errors.New("plane adapter requires base URL and token")
ErrInvalidInput = errors.New("invalid plane adapter input")
)
type Config struct { type Config struct {
BaseURL string BaseURL string
@ -15,26 +26,51 @@ type Config struct {
type Client struct { type Client struct {
cfg Config cfg Config
http *http.Client
logger *slog.Logger logger *slog.Logger
} }
type WorkItemRef struct {
WorkspaceSlug string
ProjectID string
WorkItemID string
}
type WorkItem struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
DescriptionHTML string `json:"description_html"`
DescriptionStripped string `json:"description_stripped"`
}
type CreateIssueInput struct { type CreateIssueInput struct {
Title string Title string
Description string Description string
} }
type AddCommentInput struct { type AddCommentInput struct {
IssueID string Ref WorkItemRef
Body string CommentHTML string
Access string
ExternalSource string
ExternalID string
} }
type UpdateIssueStatusInput struct { type UpdateIssueStatusInput struct {
IssueID string Ref WorkItemRef
Status string Status string
} }
func NewClient(cfg Config, logger *slog.Logger) *Client { func NewClient(cfg Config, logger *slog.Logger) *Client {
return &Client{cfg: cfg, logger: logger} return &Client{cfg: cfg, http: http.DefaultClient, logger: logger}
}
func NewClientWithHTTPClient(cfg Config, httpClient *http.Client, logger *slog.Logger) *Client {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Client{cfg: cfg, http: httpClient, logger: logger}
} }
func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) error { func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) error {
@ -42,14 +78,113 @@ func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) error
return ErrNotImplemented return ErrNotImplemented
} }
func (c *Client) GetWorkItem(ctx context.Context, ref WorkItemRef) (WorkItem, error) {
var output WorkItem
if err := c.do(ctx, http.MethodGet, workItemPath(ref), nil, &output); err != nil {
return WorkItem{}, err
}
return output, nil
}
func (c *Client) AddComment(ctx context.Context, input AddCommentInput) error { func (c *Client) AddComment(ctx context.Context, input AddCommentInput) error {
c.log(ctx, "plane add comment skipped", "issue_id", input.IssueID) body := map[string]any{
return ErrNotImplemented "comment_html": input.CommentHTML,
}
if input.Access != "" {
body["access"] = input.Access
}
if input.ExternalSource != "" {
body["external_source"] = input.ExternalSource
}
if input.ExternalID != "" {
body["external_id"] = input.ExternalID
}
return c.do(ctx, http.MethodPost, workItemPath(input.Ref)+"/comments/", body, nil)
} }
func (c *Client) UpdateIssueStatus(ctx context.Context, input UpdateIssueStatusInput) error { func (c *Client) UpdateIssueStatus(ctx context.Context, input UpdateIssueStatusInput) error {
c.log(ctx, "plane update issue status skipped", "issue_id", input.IssueID, "status", input.Status) body := map[string]string{
return ErrNotImplemented "state": input.Status,
}
return c.do(ctx, http.MethodPatch, workItemPath(input.Ref)+"/", body, nil)
}
func (c *Client) do(ctx context.Context, method, path string, input any, output any) error {
baseURL, token, err := c.config()
if err != nil {
return err
}
if strings.TrimSpace(path) == "" {
return ErrInvalidInput
}
endpoint := baseURL + path
var body io.Reader
if input != nil {
raw, err := json.Marshal(input)
if err != nil {
return err
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
req.Header.Set("X-Api-Key", token)
if input != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("plane API request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
if output == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, output); err != nil {
return fmt.Errorf("decode plane API response: %w", err)
}
return nil
}
func (c *Client) config() (string, string, error) {
baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
token := strings.TrimSpace(c.cfg.Token)
if baseURL == "" || token == "" {
return "", "", ErrMissingConfig
}
if _, err := url.ParseRequestURI(baseURL); err != nil {
return "", "", err
}
return baseURL, token, nil
}
func workItemPath(ref WorkItemRef) string {
workspaceSlug := strings.TrimSpace(ref.WorkspaceSlug)
projectID := strings.TrimSpace(ref.ProjectID)
workItemID := strings.TrimSpace(ref.WorkItemID)
if workspaceSlug == "" || projectID == "" || workItemID == "" {
return ""
}
return fmt.Sprintf(
"/api/v1/workspaces/%s/projects/%s/work-items/%s",
url.PathEscape(workspaceSlug),
url.PathEscape(projectID),
url.PathEscape(workItemID),
)
} }
func (c *Client) log(ctx context.Context, message string, args ...any) { func (c *Client) log(ctx context.Context, message string, args ...any) {

View file

@ -0,0 +1,143 @@
package plane
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetWorkItemUsesWorkItemsEndpointAndAPIKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("method: got %s", r.Method)
}
if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1" {
t.Fatalf("path: got %s", r.URL.Path)
}
if r.Header.Get("X-Api-Key") != "test-token" {
t.Fatalf("missing API key header: %#v", r.Header)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "work-1",
"name": "Fix issue",
"description_stripped": "Do the thing",
})
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil)
item, err := client.GetWorkItem(context.Background(), WorkItemRef{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
})
if err != nil {
t.Fatalf("GetWorkItem returned error: %v", err)
}
if item.ID != "work-1" || item.Name != "Fix issue" || item.DescriptionStripped != "Do the thing" {
t.Fatalf("unexpected item: %#v", item)
}
}
func TestAddCommentPostsHTMLAndExternalFields(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method: got %s", r.Method)
}
if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1/comments/" {
t.Fatalf("path: got %s", r.URL.Path)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Fatalf("content type: got %s", r.Header.Get("Content-Type"))
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["comment_html"] != "<p>done</p>" || body["external_source"] != "nomadcode" || body["external_id"] != "task-1" {
t.Fatalf("unexpected body: %#v", body)
}
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil)
err := client.AddComment(context.Background(), AddCommentInput{
Ref: WorkItemRef{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
},
CommentHTML: "<p>done</p>",
ExternalSource: "nomadcode",
ExternalID: "task-1",
})
if err != nil {
t.Fatalf("AddComment returned error: %v", err)
}
}
func TestUpdateIssueStatusPatchesState(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Fatalf("method: got %s", r.Method)
}
if r.URL.Path != "/api/v1/workspaces/general/projects/project-1/work-items/work-1/" {
t.Fatalf("path: got %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["state"] != "state-1" {
t.Fatalf("unexpected body: %#v", body)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil)
err := client.UpdateIssueStatus(context.Background(), UpdateIssueStatusInput{
Ref: WorkItemRef{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
},
Status: "state-1",
})
if err != nil {
t.Fatalf("UpdateIssueStatus returned error: %v", err)
}
}
func TestClientReturnsResponseBodyOnError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not allowed", http.StatusForbidden)
}))
defer server.Close()
client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil)
_, err := client.GetWorkItem(context.Background(), WorkItemRef{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
})
if err == nil || !strings.Contains(err.Error(), "status 403") || !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestClientRequiresBaseURLAndToken(t *testing.T) {
client := NewClient(Config{}, nil)
_, err := client.GetWorkItem(context.Background(), WorkItemRef{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
})
if err == nil || !strings.Contains(err.Error(), ErrMissingConfig.Error()) {
t.Fatalf("expected missing config error, got %v", err)
}
}

View file

@ -10,13 +10,17 @@ import (
) )
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Source string `json:"source"` Source string `json:"source"`
Status string `json:"status"` Status string `json:"status"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
Result json.RawMessage `json:"result"` Result json.RawMessage `json:"result"`
Error *string `json:"error"` Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
ExternalProvider *string `json:"external_provider"`
ExternalID *string `json:"external_id"`
ExternalUrl *string `json:"external_url"`
ExternalMetadata json.RawMessage `json:"external_metadata"`
} }

View file

@ -14,7 +14,7 @@ const completeTask = `-- name: CompleteTask :one
UPDATE tasks UPDATE tasks
SET status = 'completed', result = $2, error = NULL, updated_at = now() SET status = 'completed', result = $2, error = NULL, updated_at = now()
WHERE id::text = $1 WHERE id::text = $1
RETURNING id, title, source, status, payload, result, error, created_at, updated_at RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
` `
type CompleteTaskParams struct { type CompleteTaskParams struct {
@ -35,24 +35,40 @@ func (q *Queries) CompleteTask(ctx context.Context, arg CompleteTaskParams) (Tas
&i.Error, &i.Error,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ExternalProvider,
&i.ExternalID,
&i.ExternalUrl,
&i.ExternalMetadata,
) )
return i, err return i, err
} }
const createTask = `-- name: CreateTask :one const createTask = `-- name: CreateTask :one
INSERT INTO tasks (title, source, payload) INSERT INTO tasks (title, source, payload, external_provider, external_id, external_url, external_metadata)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, title, source, status, payload, result, error, created_at, updated_at RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
` `
type CreateTaskParams struct { type CreateTaskParams struct {
Title string `json:"title"` Title string `json:"title"`
Source string `json:"source"` Source string `json:"source"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
ExternalProvider *string `json:"external_provider"`
ExternalID *string `json:"external_id"`
ExternalUrl *string `json:"external_url"`
ExternalMetadata json.RawMessage `json:"external_metadata"`
} }
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
row := q.db.QueryRow(ctx, createTask, arg.Title, arg.Source, arg.Payload) row := q.db.QueryRow(ctx, createTask,
arg.Title,
arg.Source,
arg.Payload,
arg.ExternalProvider,
arg.ExternalID,
arg.ExternalUrl,
arg.ExternalMetadata,
)
var i Task var i Task
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
@ -64,6 +80,10 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, e
&i.Error, &i.Error,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ExternalProvider,
&i.ExternalID,
&i.ExternalUrl,
&i.ExternalMetadata,
) )
return i, err return i, err
} }
@ -72,7 +92,7 @@ const failTask = `-- name: FailTask :one
UPDATE tasks UPDATE tasks
SET status = 'failed', error = $2, updated_at = now() SET status = 'failed', error = $2, updated_at = now()
WHERE id::text = $1 WHERE id::text = $1
RETURNING id, title, source, status, payload, result, error, created_at, updated_at RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
` `
type FailTaskParams struct { type FailTaskParams struct {
@ -93,12 +113,16 @@ func (q *Queries) FailTask(ctx context.Context, arg FailTaskParams) (Task, error
&i.Error, &i.Error,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ExternalProvider,
&i.ExternalID,
&i.ExternalUrl,
&i.ExternalMetadata,
) )
return i, err return i, err
} }
const getTask = `-- name: GetTask :one const getTask = `-- name: GetTask :one
SELECT id, title, source, status, payload, result, error, created_at, updated_at SELECT id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
FROM tasks FROM tasks
WHERE id::text = $1 WHERE id::text = $1
` `
@ -116,12 +140,16 @@ func (q *Queries) GetTask(ctx context.Context, id string) (Task, error) {
&i.Error, &i.Error,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ExternalProvider,
&i.ExternalID,
&i.ExternalUrl,
&i.ExternalMetadata,
) )
return i, err return i, err
} }
const listTasks = `-- name: ListTasks :many const listTasks = `-- name: ListTasks :many
SELECT id, title, source, status, payload, result, error, created_at, updated_at SELECT id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
FROM tasks FROM tasks
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 LIMIT $1
@ -146,6 +174,10 @@ func (q *Queries) ListTasks(ctx context.Context, limit int32) ([]Task, error) {
&i.Error, &i.Error,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ExternalProvider,
&i.ExternalID,
&i.ExternalUrl,
&i.ExternalMetadata,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -161,7 +193,7 @@ const updateTaskStatus = `-- name: UpdateTaskStatus :one
UPDATE tasks UPDATE tasks
SET status = $2, updated_at = now() SET status = $2, updated_at = now()
WHERE id::text = $1 WHERE id::text = $1
RETURNING id, title, source, status, payload, result, error, created_at, updated_at RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
` `
type UpdateTaskStatusParams struct { type UpdateTaskStatusParams struct {
@ -182,6 +214,10 @@ func (q *Queries) UpdateTaskStatus(ctx context.Context, arg UpdateTaskStatusPara
&i.Error, &i.Error,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ExternalProvider,
&i.ExternalID,
&i.ExternalUrl,
&i.ExternalMetadata,
) )
return i, err return i, err
} }

View file

@ -7,25 +7,33 @@ import (
"log/slog" "log/slog"
stdhttp "net/http" stdhttp "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane"
"github.com/nomadcode/nomadcode-core/internal/workflow" "github.com/nomadcode/nomadcode-core/internal/workflow"
) )
type PlaneWorkItemClient interface {
GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error)
}
type Handler struct { type Handler struct {
db *pgxpool.Pool db *pgxpool.Pool
workflow *workflow.Service workflow *workflow.Service
plane PlaneWorkItemClient
logger *slog.Logger logger *slog.Logger
} }
func NewHandler(pool *pgxpool.Pool, workflowService *workflow.Service, logger *slog.Logger) *Handler { func NewHandler(pool *pgxpool.Pool, workflowService *workflow.Service, planeClient PlaneWorkItemClient, logger *slog.Logger) *Handler {
return &Handler{ return &Handler{
db: pool, db: pool,
workflow: workflowService, workflow: workflowService,
plane: planeClient,
logger: logger, logger: logger,
} }
} }
@ -60,8 +68,64 @@ func (h *Handler) CreateTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
} }
writeJSON(w, stdhttp.StatusCreated, map[string]string{ writeJSON(w, stdhttp.StatusCreated, map[string]string{
"id": task.ID, "id": task.ID,
"status": task.Status, "status": task.Status,
"external_provider": stringValue(task.ExternalProvider),
"external_id": stringValue(task.ExternalID),
})
}
type createPlaneTaskRequest struct {
WorkspaceSlug string `json:"workspace_slug"`
ProjectID string `json:"project_id"`
WorkItemID string `json:"work_item_id"`
ExternalURL string `json:"external_url"`
Comment string `json:"comment"`
}
func (h *Handler) CreatePlaneTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if h.plane == nil {
writeError(w, stdhttp.StatusServiceUnavailable, "plane client is not configured")
return
}
var input createPlaneTaskRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, stdhttp.StatusBadRequest, "invalid JSON body")
return
}
ref, err := input.workItemRef()
if err != nil {
writeError(w, stdhttp.StatusBadRequest, err.Error())
return
}
workItem, err := h.plane.GetWorkItem(r.Context(), ref)
if err != nil {
h.writeServiceError(w, err)
return
}
createInput, err := buildPlaneCreateTaskInput(input, workItem)
if err != nil {
writeError(w, stdhttp.StatusBadRequest, err.Error())
return
}
task, err := h.workflow.CreateTask(r.Context(), createInput)
if err != nil {
h.writeServiceError(w, err)
return
}
status := stdhttp.StatusCreated
writeJSON(w, status, map[string]string{
"id": task.ID,
"status": task.Status,
"external_provider": stringValue(task.ExternalProvider),
"external_id": stringValue(task.ExternalID),
}) })
} }
@ -108,6 +172,79 @@ func (h *Handler) EnqueueTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
}) })
} }
func (input createPlaneTaskRequest) workItemRef() (plane.WorkItemRef, error) {
ref := plane.WorkItemRef{
WorkspaceSlug: strings.TrimSpace(input.WorkspaceSlug),
ProjectID: strings.TrimSpace(input.ProjectID),
WorkItemID: strings.TrimSpace(input.WorkItemID),
}
if ref.WorkspaceSlug == "" || ref.ProjectID == "" || ref.WorkItemID == "" {
return plane.WorkItemRef{}, errors.New("workspace_slug, project_id, and work_item_id are required")
}
return ref, nil
}
func buildPlaneCreateTaskInput(input createPlaneTaskRequest, workItem plane.WorkItem) (workflow.CreateTaskInput, error) {
ref, err := input.workItemRef()
if err != nil {
return workflow.CreateTaskInput{}, err
}
title := strings.TrimSpace(workItem.Name)
if title == "" {
title = ref.WorkItemID
}
message := firstNonEmpty(input.Comment, workItem.DescriptionStripped, workItem.Description, workItem.DescriptionHTML, title)
metadata := map[string]string{
"workspace_slug": ref.WorkspaceSlug,
"project_id": ref.ProjectID,
"work_item_id": ref.WorkItemID,
"external_url": strings.TrimSpace(input.ExternalURL),
}
payload := map[string]any{
"message": message,
"plane": metadata,
}
rawPayload, err := json.Marshal(payload)
if err != nil {
return workflow.CreateTaskInput{}, err
}
rawMetadata, err := json.Marshal(metadata)
if err != nil {
return workflow.CreateTaskInput{}, err
}
return workflow.CreateTaskInput{
Title: title,
Source: "plane",
Payload: rawPayload,
External: &workflow.ExternalRefInput{
Provider: "plane",
ID: ref.WorkItemID,
URL: strings.TrimSpace(input.ExternalURL),
Metadata: rawMetadata,
},
}, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func (h *Handler) writeServiceError(w stdhttp.ResponseWriter, err error) { func (h *Handler) writeServiceError(w stdhttp.ResponseWriter, err error) {
switch { switch {
case errors.Is(err, workflow.ErrInvalidTaskInput): case errors.Is(err, workflow.ErrInvalidTaskInput):

View file

@ -0,0 +1,69 @@
package http
import (
"encoding/json"
"testing"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane"
)
func TestBuildPlaneCreateTaskInputUsesCommentBeforeDescription(t *testing.T) {
input, err := buildPlaneCreateTaskInput(createPlaneTaskRequest{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
Comment: "operator instruction",
}, plane.WorkItem{
ID: "work-1",
Name: "Fix issue",
DescriptionStripped: "plane description",
})
if err != nil {
t.Fatalf("buildPlaneCreateTaskInput returned error: %v", err)
}
if input.Title != "Fix issue" {
t.Fatalf("unexpected title: %q", input.Title)
}
if input.Source != "plane" {
t.Fatalf("unexpected source: %q", input.Source)
}
var payload map[string]any
if err := json.Unmarshal(input.Payload, &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["message"] != "operator instruction" {
t.Fatalf("unexpected message: %#v", payload)
}
}
func TestBuildPlaneCreateTaskInputStoresExternalMetadata(t *testing.T) {
input, err := buildPlaneCreateTaskInput(createPlaneTaskRequest{
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
ExternalURL: "https://plane.example/work-1",
}, plane.WorkItem{
ID: "work-1",
Name: "Fix issue",
})
if err != nil {
t.Fatalf("buildPlaneCreateTaskInput returned error: %v", err)
}
if input.External == nil {
t.Fatal("expected external ref")
}
if input.External.Provider != "plane" || input.External.ID != "work-1" {
t.Fatalf("unexpected external ref: %#v", input.External)
}
var metadata map[string]string
if err := json.Unmarshal(input.External.Metadata, &metadata); err != nil {
t.Fatalf("decode metadata: %v", err)
}
if metadata["workspace_slug"] != "general" ||
metadata["project_id"] != "project-1" ||
metadata["work_item_id"] != "work-1" {
t.Fatalf("unexpected metadata: %#v", metadata)
}
}

View file

@ -44,7 +44,7 @@ func TestBasicAuthMiddlewareAcceptsValidCredentials(t *testing.T) {
} }
func TestRouterKeepsHealthzPublicAndProtectsAPI(t *testing.T) { func TestRouterKeepsHealthzPublicAndProtectsAPI(t *testing.T) {
router := NewRouter(NewHandler(nil, nil, nil), nil, AuthConfig{ router := NewRouter(NewHandler(nil, nil, nil, nil), nil, AuthConfig{
Username: "nomadcode", Username: "nomadcode",
Password: "secret", Password: "secret",
}) })

View file

@ -27,6 +27,7 @@ func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig) stdhttp.H
protected.Get("/readyz", handler.Readyz) protected.Get("/readyz", handler.Readyz)
protected.Route("/api", func(r chi.Router) { protected.Route("/api", func(r chi.Router) {
r.Post("/integrations/plane/tasks", handler.CreatePlaneTask)
r.Post("/tasks", handler.CreateTask) r.Post("/tasks", handler.CreateTask)
r.Get("/tasks", handler.ListTasks) r.Get("/tasks", handler.ListTasks)
r.Get("/tasks/{id}", handler.GetTask) r.Get("/tasks/{id}", handler.GetTask)

View file

@ -299,7 +299,10 @@ func (w *TaskWorker) markFailed(taskID string, err error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if _, failErr := w.Store.FailTask(ctx, taskID, err.Error()); failErr != nil && w.Logger != nil { _, failErr := w.Store.FailTask(ctx, taskID, err.Error())
if failErr != nil && w.Logger != nil {
w.Logger.Error("failed to mark task failed", "task_id", taskID, "error", failErr) w.Logger.Error("failed to mark task failed", "task_id", taskID, "error", failErr)
return
} }
} }

View file

@ -10,6 +10,16 @@ import (
type Task = db.Task type Task = db.Task
type CreateTaskInput struct {
Title string
Source string
Payload json.RawMessage
ExternalProvider *string
ExternalID *string
ExternalURL *string
ExternalMetadata json.RawMessage
}
type Store struct { type Store struct {
queries *db.Queries queries *db.Queries
} }
@ -18,11 +28,20 @@ func NewStore(pool *pgxpool.Pool) *Store {
return &Store{queries: db.New(pool)} return &Store{queries: db.New(pool)}
} }
func (s *Store) CreateTask(ctx context.Context, title, source string, payload json.RawMessage) (db.Task, error) { func (s *Store) CreateTask(ctx context.Context, input CreateTaskInput) (db.Task, error) {
externalMetadata := input.ExternalMetadata
if len(externalMetadata) == 0 || string(externalMetadata) == "null" {
externalMetadata = json.RawMessage(`{}`)
}
return s.queries.CreateTask(ctx, db.CreateTaskParams{ return s.queries.CreateTask(ctx, db.CreateTaskParams{
Title: title, Title: input.Title,
Source: source, Source: input.Source,
Payload: payload, Payload: input.Payload,
ExternalProvider: input.ExternalProvider,
ExternalID: input.ExternalID,
ExternalUrl: input.ExternalURL,
ExternalMetadata: externalMetadata,
}) })
} }

View file

@ -14,7 +14,15 @@ const (
) )
type CreateTaskInput struct { type CreateTaskInput struct {
Title string `json:"title"` Title string `json:"title"`
Source string `json:"source"` Source string `json:"source"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
External *ExternalRefInput `json:"external,omitempty"`
}
type ExternalRefInput struct {
Provider string `json:"provider"`
ID string `json:"id"`
URL string `json:"url"`
Metadata json.RawMessage `json:"metadata"`
} }

View file

@ -48,13 +48,68 @@ func (s *Service) CreateTask(ctx context.Context, input CreateTaskInput) (storag
return storage.Task{}, ErrInvalidTaskInput return storage.Task{}, ErrInvalidTaskInput
} }
return s.store.CreateTask(ctx, title, source, payload) external, err := NormalizeExternalRef(input.External)
if err != nil {
return storage.Task{}, err
}
return s.store.CreateTask(ctx, storage.CreateTaskInput{
Title: title,
Source: source,
Payload: payload,
ExternalProvider: external.Provider,
ExternalID: external.ID,
ExternalURL: external.URL,
ExternalMetadata: external.Metadata,
})
} }
func (s *Service) GetTask(ctx context.Context, id string) (storage.Task, error) { func (s *Service) GetTask(ctx context.Context, id string) (storage.Task, error) {
return s.store.GetTask(ctx, id) return s.store.GetTask(ctx, id)
} }
type NormalizedExternalRef struct {
Provider *string
ID *string
URL *string
Metadata json.RawMessage
}
func NormalizeExternalRef(input *ExternalRefInput) (NormalizedExternalRef, error) {
if input == nil {
return NormalizedExternalRef{Metadata: json.RawMessage(`{}`)}, nil
}
provider := strings.TrimSpace(input.Provider)
id := strings.TrimSpace(input.ID)
externalURL := strings.TrimSpace(input.URL)
metadata := input.Metadata
if len(metadata) == 0 || string(metadata) == "null" {
metadata = json.RawMessage(`{}`)
}
if !json.Valid(metadata) {
return NormalizedExternalRef{}, ErrInvalidTaskInput
}
if provider == "" {
return NormalizedExternalRef{}, ErrInvalidTaskInput
}
return NormalizedExternalRef{
Provider: optionalString(provider),
ID: optionalString(id),
URL: optionalString(externalURL),
Metadata: metadata,
}, nil
}
func optionalString(value string) *string {
if value == "" {
return nil
}
return &value
}
func (s *Service) ListTasks(ctx context.Context, limit int32) ([]storage.Task, error) { func (s *Service) ListTasks(ctx context.Context, limit int32) ([]storage.Task, error) {
if limit <= 0 { if limit <= 0 {
limit = 20 limit = 20

View file

@ -0,0 +1,48 @@
package workflow
import (
"encoding/json"
"testing"
)
func TestNormalizeExternalRefDefaultsMetadata(t *testing.T) {
ref, err := NormalizeExternalRef(&ExternalRefInput{
Provider: " plane ",
ID: " work-item-1 ",
URL: " https://plane.example/work-item-1 ",
})
if err != nil {
t.Fatalf("NormalizeExternalRef returned error: %v", err)
}
if ref.Provider == nil || *ref.Provider != "plane" {
t.Fatalf("unexpected provider: %#v", ref.Provider)
}
if ref.ID == nil || *ref.ID != "work-item-1" {
t.Fatalf("unexpected id: %#v", ref.ID)
}
if ref.URL == nil || *ref.URL != "https://plane.example/work-item-1" {
t.Fatalf("unexpected url: %#v", ref.URL)
}
if string(ref.Metadata) != "{}" {
t.Fatalf("unexpected metadata: %s", ref.Metadata)
}
}
func TestNormalizeExternalRefRejectsInvalidMetadata(t *testing.T) {
_, err := NormalizeExternalRef(&ExternalRefInput{
Provider: "plane",
Metadata: json.RawMessage(`{"bad"`),
})
if err == nil {
t.Fatal("expected invalid metadata error")
}
}
func TestNormalizeExternalRefRequiresProvider(t *testing.T) {
_, err := NormalizeExternalRef(&ExternalRefInput{
ID: "work-item-1",
})
if err == nil {
t.Fatal("expected provider error")
}
}

View file

@ -0,0 +1,13 @@
-- +goose Up
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS external_provider text NULL,
ADD COLUMN IF NOT EXISTS external_id text NULL,
ADD COLUMN IF NOT EXISTS external_url text NULL,
ADD COLUMN IF NOT EXISTS external_metadata jsonb NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE tasks
DROP COLUMN IF EXISTS external_metadata,
DROP COLUMN IF EXISTS external_url,
DROP COLUMN IF EXISTS external_id,
DROP COLUMN IF EXISTS external_provider;

View file

@ -1,15 +1,15 @@
-- name: CreateTask :one -- name: CreateTask :one
INSERT INTO tasks (title, source, payload) INSERT INTO tasks (title, source, payload, external_provider, external_id, external_url, external_metadata)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, title, source, status, payload, result, error, created_at, updated_at; RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata;
-- name: GetTask :one -- name: GetTask :one
SELECT id, title, source, status, payload, result, error, created_at, updated_at SELECT id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
FROM tasks FROM tasks
WHERE id::text = $1; WHERE id::text = $1;
-- name: ListTasks :many -- name: ListTasks :many
SELECT id, title, source, status, payload, result, error, created_at, updated_at SELECT id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata
FROM tasks FROM tasks
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1; LIMIT $1;
@ -18,16 +18,16 @@ LIMIT $1;
UPDATE tasks UPDATE tasks
SET status = $2, updated_at = now() SET status = $2, updated_at = now()
WHERE id::text = $1 WHERE id::text = $1
RETURNING id, title, source, status, payload, result, error, created_at, updated_at; RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata;
-- name: CompleteTask :one -- name: CompleteTask :one
UPDATE tasks UPDATE tasks
SET status = 'completed', result = $2, error = NULL, updated_at = now() SET status = 'completed', result = $2, error = NULL, updated_at = now()
WHERE id::text = $1 WHERE id::text = $1
RETURNING id, title, source, status, payload, result, error, created_at, updated_at; RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata;
-- name: FailTask :one -- name: FailTask :one
UPDATE tasks UPDATE tasks
SET status = 'failed', error = $2, updated_at = now() SET status = 'failed', error = $2, updated_at = now()
WHERE id::text = $1 WHERE id::text = $1
RETURNING id, title, source, status, payload, result, error, created_at, updated_at; RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata;