update plane integration and core http handlers

This commit is contained in:
toki 2026-05-23 11:45:50 +09:00
parent 38cf931fdf
commit 3504e2837a
7 changed files with 450 additions and 25 deletions

View file

@ -15,38 +15,40 @@ Plane Communication Foundation
## 범위
- Plane self-hosted 인스턴스 연결 정보와 API 계약 확인
- Plane API token 생성/설정 방식 정리
- Plane API token 주입 방식 정리
- Plane work item 조회, comment 생성, status update adapter 구현
- core task와 Plane work item을 연결할 외부 참조 저장 토대 구성
- 실제 Plane 인스턴스를 대상으로 한 수동 smoke 검증 절차 정리
## 필수 기능
- [ ] 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를 변경할 수 있다.
- [ ] 문서에 현재 통신 토대의 사용법과 한계를 정리한다.
- [x] Plane 연결 설정과 인증 계약을 정한다.
- [x] `PLANE_BASE_URL=https://plane.toki-labs.com` 기준 endpoint prefix를 확인한다.
- [x] Plane API key를 `PLANE_TOKEN`으로 주입하고 `X-Api-Key` header로 호출한다.
- [x] Plane adapter의 최소 통신 기능을 구현한다.
- [x] work item detail 조회
- [x] work item comment 생성
- [x] work item state update
- [x] non-2xx 응답과 설정 누락 오류를 진단 가능하게 반환
- [x] core task에 외부 참조 저장 토대를 둔다.
- [x] `external_provider`, `external_id`, `external_url`, `external_metadata` 등 provider-neutral 필드를 둔다.
- [x] Plane workspace/project/work item id와 external URL을 metadata에 보존할 수 있게 한다.
- [x] Plane state id를 metadata에 보존할 수 있게 한다.
- [x] 실제 Plane 통신 smoke를 검증한다.
- [x] 인증 성공/실패를 구분해 확인한다.
- [x] 테스트용 Plane work item에 comment를 남길 수 있다.
- [x] 테스트용 Plane work item state를 변경할 수 있다.
- [x] 문서에 현재 통신 토대의 사용법과 한계를 정리한다.
- [x] `services/core/README.md`에 Plane 설정, 수동 task 생성 예시, 현재 endpoint 한계를 정리한다.
- [x] 실제 smoke 절차를 운영 문서에 보강한다.
## 완료 기준
- [ ] 유효한 `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 취급 방식이 남아 있다.
- [x] 유효한 `PLANE_TOKEN`으로 `https://plane.toki-labs.com/api/v1/...` work item API가 200 응답을 반환한다.
- [x] core Plane adapter 테스트가 request path, `X-Api-Key`, request body, error response를 검증한다.
- [x] core task가 Plane work item id와 관련 metadata를 저장할 수 있다.
- [x] 실제 Plane work item 하나에 comment 생성과 state update smoke가 성공한다.
- [x] README 또는 운영 문서에 Plane 설정과 smoke 절차가 남아 있다.
## 범위 제외
@ -59,6 +61,7 @@ Plane Communication Foundation
- Mattermost 메시지 발송 구현
- Agent Integrator와 IOP의 추가 통합
- 복잡한 workflow DSL
- Plane token 생성/보관/교체 운영 절차
## 작업 컨텍스트
@ -71,6 +74,17 @@ Plane Communication Foundation
- 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 adapter는 work item 조회, comment 생성, state update, `X-Api-Key`, non-2xx/error body, 설정 누락 오류 테스트를 포함한다.
- core task는 provider-neutral external ref 컬럼과 Plane workspace/project/work item metadata 저장 경로를 포함한다.
- HTTP endpoint는 Plane work item 조회 후 pending task와 external ref를 수동 생성하는 얇은 경로만 제공한다.
- 확인 필요:
- 실제 `PLANE_TOKEN`으로 dev Plane work item 조회, comment 생성, state update smoke를 수행한다.
- Plane state id를 task metadata에 저장할 입력/저장 경로가 필요한지 결정한다.
- smoke 절차를 운영 문서에 보강한다.
- 작업 계획:
- state id metadata 반영: `agent-task/plane_foundation/01_state_metadata/PLAN-local-G04.md`
- 실제 Plane smoke 검증: `agent-task/plane_foundation/02+01_plane_smoke/PLAN-cloud-G07.md`
## 메모

View file

@ -99,12 +99,44 @@ curl -X POST localhost:8080/api/integrations/plane/tasks \
"workspace_slug": "general",
"project_id": "a6beb42f-7a8a-410c-b50f-ea3ca94828f3",
"work_item_id": "8f3861ff-49e4-4944-8ab3-7a7a4e0b95d1",
"state_id": "ea2e5b48-8bf1-4723-b749-de7723be41e9",
"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 설계 이후 별도 마일스톤에서 다룹니다.
현재 endpoint는 Plane work item 조회와 `state_id`를 포함한 external ref 저장만 검증합니다. 실제 Plane 상의 state update는 smoke 작업 단계에서 별도로 검증합니다. 자동 enqueue와 완료/실패 결과 발행은 pipeline 설계 이후 별도 마일스톤에서 다룹니다.
### Plane Smoke 테스트
실제 Plane API와의 통신(조회, 코멘트 추가, 상태 업데이트)을 검증하기 위한 툴을 제공합니다.
#### 1. 조회 Smoke 테스트 (Read-Only)
```bash
PLANE_BASE_URL="https://plane.toki-labs.com" \
PLANE_TOKEN="<plane-api-key>" \
PLANE_WORKSPACE_SLUG="general" \
PLANE_PROJECT_ID="a6beb42f-7a8a-410c-b50f-ea3ca94828f3" \
PLANE_WORK_ITEM_ID="<test-work-item-id>" \
./bin/plane-smoke
```
#### 2. 쓰기 Smoke 테스트 (Write-Apply)
실제 코멘트 작성 및 상태 변경을 유발하므로 테스트용 work item에서만 수행해야 합니다.
```bash
PLANE_BASE_URL="https://plane.toki-labs.com" \
PLANE_TOKEN="<plane-api-key>" \
PLANE_WORKSPACE_SLUG="general" \
PLANE_PROJECT_ID="a6beb42f-7a8a-410c-b50f-ea3ca94828f3" \
PLANE_WORK_ITEM_ID="<test-work-item-id>" \
PLANE_SMOKE_COMMENT="NomadCode core Plane smoke" \
PLANE_SMOKE_STATE_ID="ea2e5b48-8bf1-4723-b749-de7723be41e9" \
PLANE_SMOKE_APPLY=1 \
./bin/plane-smoke
```
테스트 실행:

7
services/core/bin/plane-smoke Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
exec go run ./cmd/plane-smoke "$@"

View file

@ -0,0 +1,173 @@
package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane"
)
type EnvConfig struct {
BaseURL string
Token string
WorkspaceSlug string
ProjectID string
WorkItemID string
SmokeApply bool
SmokeComment string
SmokeStateID string
}
func LoadConfig(getenv func(string) string) (EnvConfig, error) {
var cfg EnvConfig
cfg.BaseURL = strings.TrimSpace(getenv("PLANE_BASE_URL"))
cfg.Token = strings.TrimSpace(getenv("PLANE_TOKEN"))
cfg.WorkspaceSlug = strings.TrimSpace(getenv("PLANE_WORKSPACE_SLUG"))
cfg.ProjectID = strings.TrimSpace(getenv("PLANE_PROJECT_ID"))
cfg.WorkItemID = strings.TrimSpace(getenv("PLANE_WORK_ITEM_ID"))
var missing []string
if cfg.BaseURL == "" {
missing = append(missing, "PLANE_BASE_URL")
}
if cfg.Token == "" {
missing = append(missing, "PLANE_TOKEN")
}
if cfg.WorkspaceSlug == "" {
missing = append(missing, "PLANE_WORKSPACE_SLUG")
}
if cfg.ProjectID == "" {
missing = append(missing, "PLANE_PROJECT_ID")
}
if cfg.WorkItemID == "" {
missing = append(missing, "PLANE_WORK_ITEM_ID")
}
if len(missing) > 0 {
return cfg, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
}
applyStr := getenv("PLANE_SMOKE_APPLY")
cfg.SmokeApply = applyStr == "1" || strings.ToLower(applyStr) == "true"
if cfg.SmokeApply {
cfg.SmokeComment = strings.TrimSpace(getenv("PLANE_SMOKE_COMMENT"))
cfg.SmokeStateID = strings.TrimSpace(getenv("PLANE_SMOKE_STATE_ID"))
var missingSmoke []string
if cfg.SmokeComment == "" {
missingSmoke = append(missingSmoke, "PLANE_SMOKE_COMMENT")
}
if cfg.SmokeStateID == "" {
missingSmoke = append(missingSmoke, "PLANE_SMOKE_STATE_ID")
}
if len(missingSmoke) > 0 {
return cfg, fmt.Errorf("PLANE_SMOKE_APPLY is enabled but missing required smoke variables: %s", strings.Join(missingSmoke, ", "))
}
}
return cfg, nil
}
type PlaneClient interface {
GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error)
AddComment(ctx context.Context, input plane.AddCommentInput) error
UpdateIssueStatus(ctx context.Context, input plane.UpdateIssueStatusInput) error
}
func RunSmoke(ctx context.Context, cfg EnvConfig, client PlaneClient, out io.Writer) error {
ref := plane.WorkItemRef{
WorkspaceSlug: cfg.WorkspaceSlug,
ProjectID: cfg.ProjectID,
WorkItemID: cfg.WorkItemID,
}
item, err := client.GetWorkItem(ctx, ref)
if err != nil {
return fmt.Errorf("failed to lookup work item: %w", err)
}
fmt.Fprintf(out, "lookup ok: id=%s name=%s\n", item.ID, item.Name)
if !cfg.SmokeApply {
fmt.Fprintln(out, "write smoke skipped: set PLANE_SMOKE_APPLY=1")
return nil
}
err = client.AddComment(ctx, plane.AddCommentInput{
Ref: ref,
CommentHTML: fmt.Sprintf("<p>%s</p>", cfg.SmokeComment),
ExternalSource: "nomadcode",
ExternalID: "plane-smoke-test",
})
if err != nil {
return fmt.Errorf("failed to add comment: %w", err)
}
fmt.Fprintln(out, "comment ok")
err = client.UpdateIssueStatus(ctx, plane.UpdateIssueStatusInput{
Ref: ref,
Status: cfg.SmokeStateID,
})
if err != nil {
return fmt.Errorf("failed to update state: %w", err)
}
fmt.Fprintf(out, "state update ok: state_id=%s\n", cfg.SmokeStateID)
return nil
}
func main() {
cfg, err := LoadConfig(os.Getenv)
if err != nil {
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
fmt.Fprintln(os.Stderr, "\nUsage:")
fmt.Fprintln(os.Stderr, " Required environment variables:")
fmt.Fprintln(os.Stderr, " PLANE_BASE_URL - Base URL of Plane instance")
fmt.Fprintln(os.Stderr, " PLANE_TOKEN - API Token (will be redacted in logs)")
fmt.Fprintln(os.Stderr, " PLANE_WORKSPACE_SLUG - Workspace slug (e.g. general)")
fmt.Fprintln(os.Stderr, " PLANE_PROJECT_ID - Project UUID")
fmt.Fprintln(os.Stderr, " PLANE_WORK_ITEM_ID - Work Item UUID")
fmt.Fprintln(os.Stderr, " Optional variables for write operations:")
fmt.Fprintln(os.Stderr, " PLANE_SMOKE_APPLY - Set to 1 to enable write smoke tests")
fmt.Fprintln(os.Stderr, " PLANE_SMOKE_COMMENT - HTML comment content to post")
fmt.Fprintln(os.Stderr, " PLANE_SMOKE_STATE_ID - State UUID to transition to")
os.Exit(1)
}
safeURL := cfg.BaseURL
safeToken := "<redacted>"
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger.Info("starting plane smoke test",
"base_url", safeURL,
"token", safeToken,
"workspace", cfg.WorkspaceSlug,
"project_id", cfg.ProjectID,
"work_item_id", cfg.WorkItemID,
"apply", cfg.SmokeApply,
)
planeClient := plane.NewClient(plane.Config{
BaseURL: cfg.BaseURL,
Token: cfg.Token,
}, logger)
if err := RunSmoke(context.Background(), cfg, planeClient, os.Stdout); err != nil {
logger.Error("smoke test failed", "error", redactToken(err.Error(), cfg.Token))
os.Exit(2)
}
logger.Info("plane smoke test finished successfully")
}
func redactToken(s, token string) string {
if token == "" {
return s
}
return strings.ReplaceAll(s, token, "<redacted>")
}

View file

@ -0,0 +1,194 @@
package main
import (
"context"
"strings"
"testing"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane"
)
type mockPlaneClient struct {
getItemFunc func(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error)
addCommentFunc func(ctx context.Context, input plane.AddCommentInput) error
updateFunc func(ctx context.Context, input plane.UpdateIssueStatusInput) error
}
func (m *mockPlaneClient) GetWorkItem(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) {
return m.getItemFunc(ctx, ref)
}
func (m *mockPlaneClient) AddComment(ctx context.Context, input plane.AddCommentInput) error {
return m.addCommentFunc(ctx, input)
}
func (m *mockPlaneClient) UpdateIssueStatus(ctx context.Context, input plane.UpdateIssueStatusInput) error {
return m.updateFunc(ctx, input)
}
func TestLoadConfig(t *testing.T) {
tests := []struct {
name string
env map[string]string
wantErr bool
errMsg string
}{
{
name: "valid read-only config",
env: map[string]string{
"PLANE_BASE_URL": "https://plane.example.com",
"PLANE_TOKEN": "secret-token",
"PLANE_WORKSPACE_SLUG": "general",
"PLANE_PROJECT_ID": "proj-1",
"PLANE_WORK_ITEM_ID": "item-1",
},
wantErr: false,
},
{
name: "missing required fields",
env: map[string]string{
"PLANE_BASE_URL": "https://plane.example.com",
},
wantErr: true,
errMsg: "missing required environment variables",
},
{
name: "valid write config",
env: map[string]string{
"PLANE_BASE_URL": "https://plane.example.com",
"PLANE_TOKEN": "secret-token",
"PLANE_WORKSPACE_SLUG": "general",
"PLANE_PROJECT_ID": "proj-1",
"PLANE_WORK_ITEM_ID": "item-1",
"PLANE_SMOKE_APPLY": "1",
"PLANE_SMOKE_COMMENT": "test comment",
"PLANE_SMOKE_STATE_ID": "state-done",
},
wantErr: false,
},
{
name: "missing write fields when apply=1",
env: map[string]string{
"PLANE_BASE_URL": "https://plane.example.com",
"PLANE_TOKEN": "secret-token",
"PLANE_WORKSPACE_SLUG": "general",
"PLANE_PROJECT_ID": "proj-1",
"PLANE_WORK_ITEM_ID": "item-1",
"PLANE_SMOKE_APPLY": "1",
},
wantErr: true,
errMsg: "missing required smoke variables",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
getenv := func(key string) string {
return tt.env[key]
}
cfg, err := LoadConfig(getenv)
if (err != nil) != tt.wantErr {
t.Fatalf("LoadConfig() error = %v, wantErr = %v", err, tt.wantErr)
}
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Fatalf("expected error containing %q, got %q", tt.errMsg, err.Error())
}
if err == nil {
if cfg.BaseURL != tt.env["PLANE_BASE_URL"] {
t.Errorf("expected BaseURL %q, got %q", tt.env["PLANE_BASE_URL"], cfg.BaseURL)
}
if cfg.Token != tt.env["PLANE_TOKEN"] {
t.Errorf("expected Token %q, got %q", tt.env["PLANE_TOKEN"], cfg.Token)
}
}
})
}
}
func TestRunSmoke(t *testing.T) {
cfg := EnvConfig{
BaseURL: "https://plane.example.com",
Token: "secret-token",
WorkspaceSlug: "general",
ProjectID: "proj-1",
WorkItemID: "item-1",
}
t.Run("read-only mode", func(t *testing.T) {
var builder strings.Builder
client := &mockPlaneClient{
getItemFunc: func(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) {
return plane.WorkItem{ID: "item-1", Name: "Test Issue"}, nil
},
}
err := RunSmoke(context.Background(), cfg, client, &builder)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := builder.String()
if !strings.Contains(out, "lookup ok: id=item-1 name=Test Issue") {
t.Errorf("unexpected output: %q", out)
}
if !strings.Contains(out, "write smoke skipped") {
t.Errorf("expected write smoke skipped message, got %q", out)
}
})
t.Run("write apply mode", func(t *testing.T) {
writeCfg := cfg
writeCfg.SmokeApply = true
writeCfg.SmokeComment = "test comment"
writeCfg.SmokeStateID = "state-done"
var builder strings.Builder
var commentCalled, stateCalled bool
client := &mockPlaneClient{
getItemFunc: func(ctx context.Context, ref plane.WorkItemRef) (plane.WorkItem, error) {
return plane.WorkItem{ID: "item-1", Name: "Test Issue"}, nil
},
addCommentFunc: func(ctx context.Context, input plane.AddCommentInput) error {
commentCalled = true
if input.CommentHTML != "<p>test comment</p>" {
t.Errorf("unexpected comment body: %q", input.CommentHTML)
}
return nil
},
updateFunc: func(ctx context.Context, input plane.UpdateIssueStatusInput) error {
stateCalled = true
if input.Status != "state-done" {
t.Errorf("unexpected status: %q", input.Status)
}
return nil
},
}
err := RunSmoke(context.Background(), writeCfg, client, &builder)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !commentCalled || !stateCalled {
t.Errorf("expected write methods to be called: comment=%t, state=%t", commentCalled, stateCalled)
}
out := builder.String()
if !strings.Contains(out, "lookup ok") || !strings.Contains(out, "comment ok") || !strings.Contains(out, "state update ok") {
t.Errorf("unexpected output: %q", out)
}
})
}
func TestRedactToken(t *testing.T) {
token := "super-secret-key-123"
errStr := "api error with token super-secret-key-123 failed"
redacted := redactToken(errStr, token)
if strings.Contains(redacted, token) {
t.Errorf("token was not redacted: %q", redacted)
}
if !strings.Contains(redacted, "<redacted>") {
t.Errorf("redacted string should contain <redacted>: %q", redacted)
}
}

View file

@ -79,6 +79,7 @@ type createPlaneTaskRequest struct {
WorkspaceSlug string `json:"workspace_slug"`
ProjectID string `json:"project_id"`
WorkItemID string `json:"work_item_id"`
StateID string `json:"state_id"`
ExternalURL string `json:"external_url"`
Comment string `json:"comment"`
}
@ -195,11 +196,13 @@ func buildPlaneCreateTaskInput(input createPlaneTaskRequest, workItem plane.Work
title = ref.WorkItemID
}
stateID := strings.TrimSpace(input.StateID)
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,
"state_id": stateID,
"external_url": strings.TrimSpace(input.ExternalURL),
}
payload := map[string]any{

View file

@ -42,6 +42,7 @@ func TestBuildPlaneCreateTaskInputStoresExternalMetadata(t *testing.T) {
WorkspaceSlug: "general",
ProjectID: "project-1",
WorkItemID: "work-1",
StateID: "state-1",
ExternalURL: "https://plane.example/work-1",
}, plane.WorkItem{
ID: "work-1",
@ -63,7 +64,8 @@ func TestBuildPlaneCreateTaskInputStoresExternalMetadata(t *testing.T) {
}
if metadata["workspace_slug"] != "general" ||
metadata["project_id"] != "project-1" ||
metadata["work_item_id"] != "work-1" {
metadata["work_item_id"] != "work-1" ||
metadata["state_id"] != "state-1" {
t.Fatalf("unexpected metadata: %#v", metadata)
}
}