update plane integration and core http handlers
This commit is contained in:
parent
38cf931fdf
commit
3504e2837a
7 changed files with 450 additions and 25 deletions
|
|
@ -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`
|
||||
|
||||
## 메모
|
||||
|
||||
|
|
|
|||
|
|
@ -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
7
services/core/bin/plane-smoke
Executable 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 "$@"
|
||||
173
services/core/cmd/plane-smoke/main.go
Normal file
173
services/core/cmd/plane-smoke/main.go
Normal 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>")
|
||||
}
|
||||
194
services/core/cmd/plane-smoke/main_test.go
Normal file
194
services/core/cmd/plane-smoke/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue