feat: plane integration and workflow updates

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

View file

@ -112,7 +112,7 @@ Keep detailed roadmap state in `agent-ops/roadmap/` and detailed domain rules in
| Core | `MODEL_BASE_URL`, `MODEL_API_KEY`, `MODEL_NAME`, `MODEL_CONTEXT_SIZE`, `MODEL_TIMEOUT_SEC` | No | Configure the OpenAI-compatible model endpoint used by worker execution. |
| Core | `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

View file

@ -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

View file

@ -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
## 선택 규칙

View file

@ -2,7 +2,7 @@
## 목표
Plane thin e2e 이후 남은 Plane 확장, Mattermost, Agent Integrator, IOP 연결을 stub 또는 호환 호출 경로에서 실제 통합 흐름으로 확장한다.
Plane 통신 토대와 workflow core 이후 남은 Plane 확장, Mattermost, Agent Integrator, IOP 연결을 stub 또는 호환 호출 경로에서 실제 통합 흐름으로 확장한다.
## 단계
@ -49,5 +49,5 @@ External Integration
- 이전 출처: `services/core/README.md``## 단계별 다음 작업`
- 주요 작업 영역: `services/core/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의 최종 계약은 구현 전 별도 확인이 필요하다.

View file

@ -1,12 +1,12 @@
# Plane Thin E2E Loop
# Plane Communication Foundation
## 목표
Plane issue를 core task의 외부 출처로 연결하고, task 실행 결과를 Plane comment/status로 되돌려보내는 최소 e2e cycle을 완성한다. Workflow Core의 retry, timeout, notification event 정책은 이 실제 loop가 관찰된 뒤 안정화한다.
core가 실제 Plane 인스턴스와 안정적으로 통신할 수 있는 최소 기반을 만든다. 현재 단계는 파이프라인 설계나 자동 실행 흐름을 확정하지 않고, 인증, endpoint 계약, work item 조회, comment/status update, 외부 참조 저장, 수동 smoke 검증에 집중한다.
## 단계
Plane Thin E2E
Plane Communication Foundation
## 상태
@ -14,42 +14,66 @@ Plane Thin E2E
## 범위
- Plane issue 기반 작업 입력과 core task 생성 경로
- 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 자동 발행 정책을 더 넣지 않는다.
- 이미 구현된 코드가 이 범위를 넘어간 경우, 리뷰 단계에서 유지/축소/후속 분리 여부를 판단한다.

View file

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

View file

@ -2,7 +2,7 @@
## 목표
Plane thin e2e cycle에서 관찰되는 실제 상태 변화를 기준으로 task 상태 전이를 정리하고 enqueue / running / completed / failed 흐름을 안정화한다. retry, timeout, notification event의 기본 구조를 추가해 core workflow의 운영 기준을 만든다.
Plane 통신 토대와 별도 pipeline 설계가 정리된 뒤 실제 상태 변화를 기준으로 task 상태 전이를 정리하고 enqueue / running / completed / failed 흐름을 안정화한다. retry, timeout, notification event의 기본 구조를 추가해 core workflow의 운영 기준을 만든다.
## 단계
@ -18,7 +18,7 @@ Workflow Core
- enqueue / running / completed / failed 흐름 안정화
- 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를 크게 바꾸면 관련 테스트를 함께 갱신한다.

View file

@ -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

View file

@ -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}),

View file

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

View file

@ -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) {

View file

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

View file

@ -10,13 +10,17 @@ import (
)
type Task struct {
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"`
}

View file

@ -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
}

View file

@ -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):

View file

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

View file

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

View file

@ -27,6 +27,7 @@ func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig) stdhttp.H
protected.Get("/readyz", handler.Readyz)
protected.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)

View file

@ -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
}
}

View file

@ -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,
})
}

View file

@ -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"`
}

View file

@ -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

View file

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

View file

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

View file

@ -1,15 +1,15 @@
-- name: CreateTask :one
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;