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:
parent
8148b53ca1
commit
424de604ee
25 changed files with 820 additions and 98 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
## 선택 규칙
|
||||
|
||||
|
|
|
|||
|
|
@ -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의 최종 계약은 구현 전 별도 확인이 필요하다.
|
||||
|
|
|
|||
|
|
@ -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 자동 발행 정책을 더 넣지 않는다.
|
||||
- 이미 구현된 코드가 이 범위를 넘어간 경우, 리뷰 단계에서 유지/축소/후속 분리 여부를 판단한다.
|
||||
|
|
|
|||
|
|
@ -52,5 +52,5 @@ Server Skeleton
|
|||
|
||||
- 이전 출처: `services/core/README.md`의 `## 단계별 다음 작업`
|
||||
- 주요 작업 영역: `services/core/`
|
||||
- 후속 작업: Plane Thin E2E Loop
|
||||
- 후속 작업: Plane Communication Foundation
|
||||
- 검증 기준: core 변경은 `go test ./...` 또는 `bin/test`를 우선 사용한다.
|
||||
|
|
|
|||
|
|
@ -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를 크게 바꾸면 관련 테스트를 함께 갱신한다.
|
||||
벼
|
||||
|
|
@ -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="<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
|
||||
|
|
|
|||
|
|
@ -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}),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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) {
|
||||
|
|
|
|||
143
services/core/internal/adapters/plane/client_test.go
Normal file
143
services/core/internal/adapters/plane/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,4 +19,8 @@ type Task struct {
|
|||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +70,62 @@ func (h *Handler) CreateTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|||
writeJSON(w, stdhttp.StatusCreated, map[string]string{
|
||||
"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):
|
||||
|
|
|
|||
69
services/core/internal/http/handlers_test.go
Normal file
69
services/core/internal/http/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,4 +17,12 @@ type CreateTaskInput struct {
|
|||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
services/core/internal/workflow/service_test.go
Normal file
48
services/core/internal/workflow/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
13
services/core/migrations/00002_add_task_external_refs.sql
Normal file
13
services/core/migrations/00002_add_task_external_refs.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue