From 424de604ee24944970611a6648573726e1a74d14 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 22 May 2026 13:37:47 +0900 Subject: [PATCH] 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 --- README.md | 2 +- agent-ops/roadmap/ROADMAP.md | 12 +- agent-ops/roadmap/current.md | 2 +- .../milestones/external-integration.md | 4 +- .../roadmap/milestones/plane-thin-e2e-loop.md | 74 ++++++--- .../roadmap/milestones/server-skeleton.md | 2 +- agent-ops/roadmap/milestones/workflow-core.md | 7 +- services/core/README.md | 29 +++- services/core/cmd/server/main.go | 7 +- services/core/docker-compose.yml | 4 +- .../core/internal/adapters/plane/client.go | 155 ++++++++++++++++-- .../internal/adapters/plane/client_test.go | 143 ++++++++++++++++ services/core/internal/db/models.go | 22 ++- services/core/internal/db/tasks.sql.go | 60 +++++-- services/core/internal/http/handlers.go | 143 +++++++++++++++- services/core/internal/http/handlers_test.go | 69 ++++++++ .../core/internal/http/middleware_test.go | 2 +- services/core/internal/http/router.go | 1 + services/core/internal/scheduler/jobs.go | 5 +- services/core/internal/storage/store.go | 27 ++- services/core/internal/workflow/model.go | 14 +- services/core/internal/workflow/service.go | 57 ++++++- .../core/internal/workflow/service_test.go | 48 ++++++ .../00002_add_task_external_refs.sql | 13 ++ services/core/queries/tasks.sql | 16 +- 25 files changed, 820 insertions(+), 98 deletions(-) create mode 100644 services/core/internal/adapters/plane/client_test.go create mode 100644 services/core/internal/http/handlers_test.go create mode 100644 services/core/internal/workflow/service_test.go create mode 100644 services/core/migrations/00002_add_task_external_refs.sql diff --git a/README.md b/README.md index 95683be..211776f 100644 --- a/README.md +++ b/README.md @@ -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 | `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 | `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. | ## Reference Documents diff --git a/agent-ops/roadmap/ROADMAP.md b/agent-ops/roadmap/ROADMAP.md index 6eb980b..f6fd90e 100644 --- a/agent-ops/roadmap/ROADMAP.md +++ b/agent-ops/roadmap/ROADMAP.md @@ -4,14 +4,14 @@ 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 흐름 - Server Skeleton: 서버 실행 골격, task 저장 구조, 로컬 모델 호출 기반 비동기 job 실행, Adapter stub을 구성한다. -- Plane Thin E2E: Plane issue를 core task와 연결하고 실행 결과를 Plane comment/status로 되돌리는 최소 제품 e2e cycle을 완성한다. -- Workflow Core: Plane thin e2e cycle에서 관찰되는 실제 상태 변화를 기준으로 task 상태 전이, retry, timeout, notification event의 기본 구조를 안정화한다. -- External Integration: Plane thin e2e 이후 Mattermost, Agent Integrator, IOP 연결을 실제 통합 흐름으로 확장한다. +- Plane Communication Foundation: Plane self-hosted 인스턴스와 통신하기 위한 인증, API client, 외부 참조 저장, smoke 검증 토대를 만든다. +- Workflow Core: Plane 통신 토대 위에서 task pipeline 설계가 정리된 뒤 상태 전이, retry, timeout, notification event의 기본 구조를 안정화한다. +- External Integration: Plane 통신 토대와 workflow core 이후 Mattermost, Agent Integrator, IOP 연결을 실제 통합 흐름으로 확장한다. ## Milestone 목록 @@ -19,9 +19,9 @@ NomadCode는 모바일 앱, 웹 콘솔, core 서비스, 공유 계약, agent-ope - [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 diff --git a/agent-ops/roadmap/current.md b/agent-ops/roadmap/current.md index 19bfd19..3db85ad 100644 --- a/agent-ops/roadmap/current.md +++ b/agent-ops/roadmap/current.md @@ -2,7 +2,7 @@ ## 활성 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 ## 선택 규칙 diff --git a/agent-ops/roadmap/milestones/external-integration.md b/agent-ops/roadmap/milestones/external-integration.md index af888d6..76245fb 100644 --- a/agent-ops/roadmap/milestones/external-integration.md +++ b/agent-ops/roadmap/milestones/external-integration.md @@ -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/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의 최종 계약은 구현 전 별도 확인이 필요하다. diff --git a/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md b/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md index ceea445..e77cd4d 100644 --- a/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md +++ b/agent-ops/roadmap/milestones/plane-thin-e2e-loop.md @@ -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 생성 경로 -- core task와 Plane issue의 외부 참조 연결 -- task 완료와 실패 결과의 Plane comment/status 반영 -- 최소 e2e 검증 절차와 운영 설정 문서화 +- Plane self-hosted 인스턴스 연결 정보와 API 계약 확인 +- Plane API token 생성/설정 방식 정리 +- Plane work item 조회, comment 생성, status update adapter 구현 +- core task와 Plane work item을 연결할 외부 참조 저장 토대 구성 +- 실제 Plane 인스턴스를 대상으로 한 수동 smoke 검증 절차 정리 ## 필수 기능 -- [ ] Plane issue를 core task로 연결하는 최소 입력 계약을 정한다. - - [ ] task에 `provider`, `external_id`, `external_url` 등 외부 참조를 저장하는 방식을 정한다. - - [ ] Plane issue title/description/comment 중 어떤 값을 task title/payload로 매핑할지 정한다. -- [ ] Plane adapter에서 issue comment와 status update 실제 호출을 구현한다. - - [ ] `PLANE_BASE_URL`, `PLANE_TOKEN` 설정과 adapter client 동작을 README 또는 운영 문서와 맞춘다. -- [ ] task 완료/실패 시 Plane에 결과를 남기는 발행 지점을 workflow 또는 scheduler에 연결한다. - - [ ] completed 결과는 Plane comment로 남긴다. - - [ ] failed 결과는 Plane comment 또는 status로 관찰 가능하게 남긴다. -- [ ] Plane issue에서 core task 실행 결과가 Plane에 다시 남는 e2e 검증 시나리오를 정리한다. +- [ ] Plane 연결 설정과 인증 계약을 정한다. + - [ ] `PLANE_BASE_URL=https://plane.toki-labs.com` 기준 endpoint prefix를 확인한다. + - [ ] Plane API key를 `PLANE_TOKEN`으로 주입하고 `X-Api-Key` header로 호출한다. + - [ ] token 생성/보관/교체 방식은 운영 문서에 남기고 secret 값은 repository에 기록하지 않는다. +- [ ] Plane adapter의 최소 통신 기능을 구현한다. + - [ ] work item detail 조회 + - [ ] work item comment 생성 + - [ ] work item state update + - [ ] 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할 수 있다. -- [ ] core task의 completed 결과가 Plane issue comment 또는 status에 반영된다. -- [ ] core task의 failed 결과가 Plane issue에서 관찰 가능하게 남는다. -- [ ] e2e loop 검증 명령이나 수동 절차가 문서에 남아 있다. -- [ ] Workflow Core에서 후속으로 정리할 retry, timeout, notification event TODO가 실제 loop 기준으로 드러난다. +- [ ] 유효한 `PLANE_TOKEN`으로 `https://plane.toki-labs.com/api/v1/...` work item API가 200 응답을 반환한다. +- [ ] core Plane adapter 테스트가 request path, `X-Api-Key`, request body, error response를 검증한다. +- [ ] core task가 Plane work item id와 관련 metadata를 저장할 수 있다. +- [ ] 실제 Plane work item 하나에 comment 생성과 state update smoke가 성공한다. +- [ ] README 또는 운영 문서에 Plane 설정, smoke 절차, secret 취급 방식이 남아 있다. ## 범위 제외 +- task pipeline 설계와 agent 실행 orchestration 확정 +- Plane issue에서 core task를 자동 생성하고 enqueue하는 제품 흐름 확정 +- task 완료/실패 결과를 Plane으로 자동 발행하는 정책 확정 +- retry, timeout, notification event 정책 +- Plane webhook 기반 자동 수집 - Plane 전체 양방향 동기화 -- Plane webhook 기반 자동 수집의 완전한 운영화 - Mattermost 메시지 발송 구현 - Agent Integrator와 IOP의 추가 통합 - 복잡한 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 -- 후속 작업: Workflow Core -- 확인 필요: Plane issue 상태 값과 comment API의 실제 계약은 구현 전에 확인이 필요하다. +- 후속 작업 후보: Plane Task Pipeline Design, Workflow Core +- 확인된 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 자동 발행 정책을 더 넣지 않는다. +- 이미 구현된 코드가 이 범위를 넘어간 경우, 리뷰 단계에서 유지/축소/후속 분리 여부를 판단한다. diff --git a/agent-ops/roadmap/milestones/server-skeleton.md b/agent-ops/roadmap/milestones/server-skeleton.md index 1ea3c38..175a7e8 100644 --- a/agent-ops/roadmap/milestones/server-skeleton.md +++ b/agent-ops/roadmap/milestones/server-skeleton.md @@ -52,5 +52,5 @@ Server Skeleton - 이전 출처: `services/core/README.md`의 `## 단계별 다음 작업` - 주요 작업 영역: `services/core/` -- 후속 작업: Plane Thin E2E Loop +- 후속 작업: Plane Communication Foundation - 검증 기준: core 변경은 `go test ./...` 또는 `bin/test`를 우선 사용한다. diff --git a/agent-ops/roadmap/milestones/workflow-core.md b/agent-ops/roadmap/milestones/workflow-core.md index fb429c1..ef364ff 100644 --- a/agent-ops/roadmap/milestones/workflow-core.md +++ b/agent-ops/roadmap/milestones/workflow-core.md @@ -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 흐름 안정화 - retry / timeout 기본 구조 추가 - notification event 구조 정리 -- Plane thin e2e loop에서 확인된 상태 변화와 실패 케이스 반영 +- Plane 통신 토대와 후속 pipeline 설계에서 확인된 상태 변화와 실패 케이스 반영 ## 필수 기능 @@ -46,7 +46,6 @@ Workflow Core - 이전 출처: `services/core/README.md`의 `## 단계별 다음 작업` - 주요 작업 영역: `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 - 검증 기준: scheduler, persistence, adapter behavior를 크게 바꾸면 관련 테스트를 함께 갱신한다. -벼 \ No newline at end of file diff --git a/services/core/README.md b/services/core/README.md index 223c49a..8565cd9 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -15,13 +15,12 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 - River task job - IOP Edge/OpenAI-compatible 모델 호출 경로(Ollama direct fallback 가능) - IOP Edge/A2A-compatible agent 호출 인터페이스 -- Plane Adapter stub +- Plane work item lookup, comment, and state update adapter - Mattermost Adapter stub - 선택적 Docker Compose 실행 환경(PostgreSQL, Redis) ## 현재 구현하지 않은 범위 -- 실제 Plane API 호출 - 실제 Mattermost 메시지 발송 - IOP native protocol 연동 - 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를 생성하는 예시: @@ -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 모델 호출 경로를 사용합니다. +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="" \ +./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 diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index bae1e1f..e669f5e 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -13,6 +13,7 @@ import ( agenta2a "github.com/nomadcode/nomadcode-core/internal/adapters/a2a" "github.com/nomadcode/nomadcode-core/internal/adapters/mattermost" 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/config" "github.com/nomadcode/nomadcode-core/internal/db" @@ -48,6 +49,10 @@ func run(logger *slog.Logger) error { BaseURL: cfg.MattermostBaseURL, Token: cfg.MattermostToken, }, logger) + planeClient := plane.NewClient(plane.Config{ + BaseURL: cfg.PlaneBaseURL, + Token: cfg.PlaneToken, + }, logger) notificationService := notification.NewService(mattermostClient, logger) modelClient := modelopenai.NewClient(modelopenai.Config{ BaseURL: cfg.ModelBaseURL, @@ -79,7 +84,7 @@ func run(logger *slog.Logger) error { } workflowService := workflow.NewService(store, taskScheduler, logger) - handler := apphttp.NewHandler(pool, workflowService, logger) + handler := apphttp.NewHandler(pool, workflowService, planeClient, logger) server := &http.Server{ Addr: cfg.HTTPAddr, Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}), diff --git a/services/core/docker-compose.yml b/services/core/docker-compose.yml index 6e27682..4f5519a 100644 --- a/services/core/docker-compose.yml +++ b/services/core/docker-compose.yml @@ -22,8 +22,8 @@ services: A2A_TIMEOUT_SEC: ${A2A_TIMEOUT_SEC:-300} MATTERMOST_BASE_URL: "" MATTERMOST_TOKEN: "" - PLANE_BASE_URL: "" - PLANE_TOKEN: "" + PLANE_BASE_URL: ${PLANE_BASE_URL:-} + PLANE_TOKEN: ${PLANE_TOKEN:-} ports: - "8080:8080" networks: diff --git a/services/core/internal/adapters/plane/client.go b/services/core/internal/adapters/plane/client.go index cb66721..057b5e1 100644 --- a/services/core/internal/adapters/plane/client.go +++ b/services/core/internal/adapters/plane/client.go @@ -1,12 +1,23 @@ package plane import ( + "bytes" "context" + "encoding/json" "errors" + "fmt" + "io" "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 { BaseURL string @@ -15,26 +26,51 @@ type Config struct { type Client struct { cfg Config + http *http.Client 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 { Title string Description string } type AddCommentInput struct { - IssueID string - Body string + Ref WorkItemRef + CommentHTML string + Access string + ExternalSource string + ExternalID string } type UpdateIssueStatusInput struct { - IssueID string - Status string + Ref WorkItemRef + Status string } 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 { @@ -42,14 +78,113 @@ func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) error 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 { - c.log(ctx, "plane add comment skipped", "issue_id", input.IssueID) - return ErrNotImplemented + body := map[string]any{ + "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 { - c.log(ctx, "plane update issue status skipped", "issue_id", input.IssueID, "status", input.Status) - return ErrNotImplemented + body := map[string]string{ + "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) { diff --git a/services/core/internal/adapters/plane/client_test.go b/services/core/internal/adapters/plane/client_test.go new file mode 100644 index 0000000..ecdb96c --- /dev/null +++ b/services/core/internal/adapters/plane/client_test.go @@ -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"] != "

done

" || 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: "

done

", + 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) + } +} diff --git a/services/core/internal/db/models.go b/services/core/internal/db/models.go index 777c6aa..e94008b 100644 --- a/services/core/internal/db/models.go +++ b/services/core/internal/db/models.go @@ -10,13 +10,17 @@ import ( ) type Task struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - Status string `json:"status"` - Payload json.RawMessage `json:"payload"` - Result json.RawMessage `json:"result"` - Error *string `json:"error"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Status string `json:"status"` + Payload json.RawMessage `json:"payload"` + Result json.RawMessage `json:"result"` + Error *string `json:"error"` + CreatedAt time.Time `json:"created_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"` } diff --git a/services/core/internal/db/tasks.sql.go b/services/core/internal/db/tasks.sql.go index 4c8728d..b3f9925 100644 --- a/services/core/internal/db/tasks.sql.go +++ b/services/core/internal/db/tasks.sql.go @@ -14,7 +14,7 @@ const completeTask = `-- name: CompleteTask :one UPDATE tasks SET status = 'completed', result = $2, error = NULL, updated_at = now() 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 { @@ -35,24 +35,40 @@ func (q *Queries) CompleteTask(ctx context.Context, arg CompleteTaskParams) (Tas &i.Error, &i.CreatedAt, &i.UpdatedAt, + &i.ExternalProvider, + &i.ExternalID, + &i.ExternalUrl, + &i.ExternalMetadata, ) return i, err } const createTask = `-- name: CreateTask :one -INSERT INTO tasks (title, source, payload) -VALUES ($1, $2, $3) -RETURNING id, title, source, status, payload, result, error, created_at, updated_at +INSERT INTO tasks (title, source, payload, external_provider, external_id, external_url, external_metadata) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata ` type CreateTaskParams struct { - Title string `json:"title"` - Source string `json:"source"` - Payload json.RawMessage `json:"payload"` + Title string `json:"title"` + Source string `json:"source"` + 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) { - 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 err := row.Scan( &i.ID, @@ -64,6 +80,10 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, e &i.Error, &i.CreatedAt, &i.UpdatedAt, + &i.ExternalProvider, + &i.ExternalID, + &i.ExternalUrl, + &i.ExternalMetadata, ) return i, err } @@ -72,7 +92,7 @@ const failTask = `-- name: FailTask :one UPDATE tasks SET status = 'failed', error = $2, updated_at = now() 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 { @@ -93,12 +113,16 @@ func (q *Queries) FailTask(ctx context.Context, arg FailTaskParams) (Task, error &i.Error, &i.CreatedAt, &i.UpdatedAt, + &i.ExternalProvider, + &i.ExternalID, + &i.ExternalUrl, + &i.ExternalMetadata, ) return i, err } 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 WHERE id::text = $1 ` @@ -116,12 +140,16 @@ func (q *Queries) GetTask(ctx context.Context, id string) (Task, error) { &i.Error, &i.CreatedAt, &i.UpdatedAt, + &i.ExternalProvider, + &i.ExternalID, + &i.ExternalUrl, + &i.ExternalMetadata, ) return i, err } 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 ORDER BY created_at DESC LIMIT $1 @@ -146,6 +174,10 @@ func (q *Queries) ListTasks(ctx context.Context, limit int32) ([]Task, error) { &i.Error, &i.CreatedAt, &i.UpdatedAt, + &i.ExternalProvider, + &i.ExternalID, + &i.ExternalUrl, + &i.ExternalMetadata, ); err != nil { return nil, err } @@ -161,7 +193,7 @@ const updateTaskStatus = `-- name: UpdateTaskStatus :one UPDATE tasks SET status = $2, updated_at = now() 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 { @@ -182,6 +214,10 @@ func (q *Queries) UpdateTaskStatus(ctx context.Context, arg UpdateTaskStatusPara &i.Error, &i.CreatedAt, &i.UpdatedAt, + &i.ExternalProvider, + &i.ExternalID, + &i.ExternalUrl, + &i.ExternalMetadata, ) return i, err } diff --git a/services/core/internal/http/handlers.go b/services/core/internal/http/handlers.go index d098fbc..a33a039 100644 --- a/services/core/internal/http/handlers.go +++ b/services/core/internal/http/handlers.go @@ -7,25 +7,33 @@ import ( "log/slog" stdhttp "net/http" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "github.com/nomadcode/nomadcode-core/internal/adapters/plane" "github.com/nomadcode/nomadcode-core/internal/workflow" ) +type PlaneWorkItemClient interface { + GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) +} + type Handler struct { db *pgxpool.Pool workflow *workflow.Service + plane PlaneWorkItemClient 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{ db: pool, workflow: workflowService, + plane: planeClient, logger: logger, } } @@ -60,8 +68,64 @@ func (h *Handler) CreateTask(w stdhttp.ResponseWriter, r *stdhttp.Request) { } writeJSON(w, stdhttp.StatusCreated, map[string]string{ - "id": task.ID, - "status": task.Status, + "id": task.ID, + "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) { switch { case errors.Is(err, workflow.ErrInvalidTaskInput): diff --git a/services/core/internal/http/handlers_test.go b/services/core/internal/http/handlers_test.go new file mode 100644 index 0000000..c871a4b --- /dev/null +++ b/services/core/internal/http/handlers_test.go @@ -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) + } +} diff --git a/services/core/internal/http/middleware_test.go b/services/core/internal/http/middleware_test.go index c93a6de..d0581d6 100644 --- a/services/core/internal/http/middleware_test.go +++ b/services/core/internal/http/middleware_test.go @@ -44,7 +44,7 @@ func TestBasicAuthMiddlewareAcceptsValidCredentials(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", Password: "secret", }) diff --git a/services/core/internal/http/router.go b/services/core/internal/http/router.go index bf1c2ca..c1c104b 100644 --- a/services/core/internal/http/router.go +++ b/services/core/internal/http/router.go @@ -27,6 +27,7 @@ func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig) stdhttp.H protected.Get("/readyz", handler.Readyz) protected.Route("/api", func(r chi.Router) { + r.Post("/integrations/plane/tasks", handler.CreatePlaneTask) r.Post("/tasks", handler.CreateTask) r.Get("/tasks", handler.ListTasks) r.Get("/tasks/{id}", handler.GetTask) diff --git a/services/core/internal/scheduler/jobs.go b/services/core/internal/scheduler/jobs.go index 3e32f7b..9f1b5bd 100644 --- a/services/core/internal/scheduler/jobs.go +++ b/services/core/internal/scheduler/jobs.go @@ -299,7 +299,10 @@ func (w *TaskWorker) markFailed(taskID string, err error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 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) + return } + } diff --git a/services/core/internal/storage/store.go b/services/core/internal/storage/store.go index 857edc3..0c5feb5 100644 --- a/services/core/internal/storage/store.go +++ b/services/core/internal/storage/store.go @@ -10,6 +10,16 @@ import ( 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 { queries *db.Queries } @@ -18,11 +28,20 @@ func NewStore(pool *pgxpool.Pool) *Store { 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{ - Title: title, - Source: source, - Payload: payload, + Title: input.Title, + Source: input.Source, + Payload: input.Payload, + ExternalProvider: input.ExternalProvider, + ExternalID: input.ExternalID, + ExternalUrl: input.ExternalURL, + ExternalMetadata: externalMetadata, }) } diff --git a/services/core/internal/workflow/model.go b/services/core/internal/workflow/model.go index 759f4f1..da27fba 100644 --- a/services/core/internal/workflow/model.go +++ b/services/core/internal/workflow/model.go @@ -14,7 +14,15 @@ const ( ) type CreateTaskInput struct { - Title string `json:"title"` - Source string `json:"source"` - Payload json.RawMessage `json:"payload"` + Title string `json:"title"` + Source string `json:"source"` + 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"` } diff --git a/services/core/internal/workflow/service.go b/services/core/internal/workflow/service.go index 5ae3133..e4e0f32 100644 --- a/services/core/internal/workflow/service.go +++ b/services/core/internal/workflow/service.go @@ -48,13 +48,68 @@ func (s *Service) CreateTask(ctx context.Context, input CreateTaskInput) (storag 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) { 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) { if limit <= 0 { limit = 20 diff --git a/services/core/internal/workflow/service_test.go b/services/core/internal/workflow/service_test.go new file mode 100644 index 0000000..9ef4097 --- /dev/null +++ b/services/core/internal/workflow/service_test.go @@ -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") + } +} diff --git a/services/core/migrations/00002_add_task_external_refs.sql b/services/core/migrations/00002_add_task_external_refs.sql new file mode 100644 index 0000000..346e403 --- /dev/null +++ b/services/core/migrations/00002_add_task_external_refs.sql @@ -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; diff --git a/services/core/queries/tasks.sql b/services/core/queries/tasks.sql index 2ef3f11..4769a4a 100644 --- a/services/core/queries/tasks.sql +++ b/services/core/queries/tasks.sql @@ -1,15 +1,15 @@ -- name: CreateTask :one -INSERT INTO tasks (title, source, payload) -VALUES ($1, $2, $3) -RETURNING id, title, source, status, payload, result, error, created_at, updated_at; +INSERT INTO tasks (title, source, payload, external_provider, external_id, external_url, external_metadata) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata; -- 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 WHERE id::text = $1; -- 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 ORDER BY created_at DESC LIMIT $1; @@ -18,16 +18,16 @@ LIMIT $1; UPDATE tasks SET status = $2, updated_at = now() 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 UPDATE tasks SET status = 'completed', result = $2, error = NULL, updated_at = now() 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 UPDATE tasks SET status = 'failed', error = $2, updated_at = now() 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;