From 5f72fd14738cf7d5e7d2a340829c502f9f37ff3b Mon Sep 17 00:00:00 2001 From: polidog Date: Thu, 2 Jul 2026 15:58:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Vercel=20Custom=20Environment=EF=BC=88s?= =?UTF-8?q?taging=20=E7=AD=89=EF=BC=89=E3=82=92=20environments=20=E3=81=AB?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E5=8F=AF=E8=83=BD=E3=81=AB=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - production/preview/development 以外の environments 値を Custom Environment slug として許容 - GET /v9/projects/{id}/custom-environments で slug→ID を解決し customEnvironmentIds で送信 - 標準環境と Custom Environment の混在指定に対応(target と customEnvironmentIds を同居) - 存在しない slug は利用可能一覧付きの i18n エラー(en/ja) - custom slug 未使用ターゲットでは API を呼ばず後方互換を維持 - README / README.ja / env-sync.yaml に指定方法を追記 Co-Authored-By: Claude Fable 5 --- README.ja.md | 2 + README.md | 2 + env-sync.yaml | 3 + internal/i18n/catalog_en.go | 45 ++-- internal/i18n/catalog_ja.go | 45 ++-- internal/i18n/keys.go | 11 +- internal/provider/vercel/vercel.go | 153 +++++++++++- internal/provider/vercel/vercel_test.go | 305 +++++++++++++++++++++++- 8 files changed, 503 insertions(+), 63 deletions(-) diff --git a/README.ja.md b/README.ja.md index b888d36..e9235c0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -396,6 +396,8 @@ variables: `environments` には `production` / `preview` / `development` を指定。省略時(空)は `production` と `preview` をデフォルト適用。 +`environments` には Custom Environment の slug(例 `staging`)も指定可。Custom Environment は Vercel 側で事前作成が必要で、存在しない slug はエラー。標準環境と混在指定も可能(例 `[production, staging]`)。 + #### GitHub Actions における各フィールドの意味 | `secret` | 登録先 | 説明 | diff --git a/README.md b/README.md index ffde856..1189047 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,8 @@ variables: `environments` accepts `production` / `preview` / `development`. Defaults to `production` and `preview` when omitted. +`environments` also accepts custom environment slugs (e.g. `staging`). Custom environments must be pre-created in the Vercel project; a non-existent slug results in an error. Standard and custom values can be mixed (e.g. `[production, staging]`). + #### Field Meanings for GitHub Actions | `secret` | Target | Description | diff --git a/env-sync.yaml b/env-sync.yaml index 4f6e364..125a4e2 100644 --- a/env-sync.yaml +++ b/env-sync.yaml @@ -14,6 +14,9 @@ # # environments: [] 登録先環境の配列 # Vercel: production|preview|development(空なら production と preview) +# または Custom Environment の slug(例: staging) +# ※ Custom Environment は Vercel 側で事前作成が必要。存在しない slug はエラー。 +# ※ 標準環境と Custom Environment は混在指定可(例: [production, staging]) # GitHub: named environment 名(空なら repo レベル) # ※ GitHub の named environment は事前に作成が必要 # diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index ea4cb1d..a18f839 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -218,27 +218,30 @@ YAML schema (definition file env-sync.yaml): "# Add projects[] / repos[] (for monorepo multi-target) manually.\n", // ----- Vercel Provider ----- - MsgVercelTokenMissing: "VERCEL_TOKEN is not set (set VERCEL_TOKEN env var or vercel.token in config file)", - MsgVercelTokenMissingProject: "VERCEL_TOKEN is not set (project %q: set VERCEL_TOKEN env var or token in config file)", - MsgVercelTokenSkipProject: "✗ project %q: VERCEL_TOKEN is not set (skipping this target and continuing)\n", - MsgVercelExistingKeysFetchWarn: "Warning: failed to fetch existing keys, skipping new/update classification: %s\n", - MsgVercelTargetProject: "Target project: %s (env: %s, def: %s)\n", - MsgVercelEntriesUpsert: "%d entries (existing will be overwritten by upsert):\n", - MsgVercelConfirmMulti: "Register the above to %d Vercel projects (existing will be overwritten). Continue? (y/N) ", - MsgVercelConfirmSingle: "Register the above to Vercel (existing will be overwritten). Continue? (y/N) ", - MsgVercelProjectSeparator: "\n--- project: %s ---\n", - MsgVercelURLBuildFailOut: "✗ failed to build URL: %s\n", - MsgVercelRequestCreateFailOut: "✗ %s -> failed to create request: %s\n", - MsgVercelSendFailOut: "✗ %s -> failed to send: %s\n", - MsgVercelProjectJSONReadFail: "failed to read .vercel/project.json: %s", - MsgVercelProjectJSONParseFail: "failed to parse .vercel/project.json: %s", - MsgVercelProjectIDMissing: "VERCEL_PROJECT_ID is not set and .vercel/project.json not found (run vercel link first or specify the project ID)", - MsgVercelProjectNotDefined: "%s: vercel_project is specified but vercel.projects is not defined in config (use vercel_project together with vercel.projects[])", - MsgVercelProjectInvalidConfig: "%s: vercel_project %q does not exist in config vercel.projects (defined: %s)", - MsgVercelInvalidEnvironment: "%s: invalid environments value %q (must be production / preview / development)", - MsgVercelURLBuildFailInternal: "failed to build URL", - MsgVercelExistingKeyFetchFail: "failed to fetch existing keys", - MsgVercelExistingKeyParseFail: "failed to parse existing keys response", + MsgVercelTokenMissing: "VERCEL_TOKEN is not set (set VERCEL_TOKEN env var or vercel.token in config file)", + MsgVercelTokenMissingProject: "VERCEL_TOKEN is not set (project %q: set VERCEL_TOKEN env var or token in config file)", + MsgVercelTokenSkipProject: "✗ project %q: VERCEL_TOKEN is not set (skipping this target and continuing)\n", + MsgVercelExistingKeysFetchWarn: "Warning: failed to fetch existing keys, skipping new/update classification: %s\n", + MsgVercelTargetProject: "Target project: %s (env: %s, def: %s)\n", + MsgVercelEntriesUpsert: "%d entries (existing will be overwritten by upsert):\n", + MsgVercelConfirmMulti: "Register the above to %d Vercel projects (existing will be overwritten). Continue? (y/N) ", + MsgVercelConfirmSingle: "Register the above to Vercel (existing will be overwritten). Continue? (y/N) ", + MsgVercelProjectSeparator: "\n--- project: %s ---\n", + MsgVercelURLBuildFailOut: "✗ failed to build URL: %s\n", + MsgVercelRequestCreateFailOut: "✗ %s -> failed to create request: %s\n", + MsgVercelSendFailOut: "✗ %s -> failed to send: %s\n", + MsgVercelProjectJSONReadFail: "failed to read .vercel/project.json: %s", + MsgVercelProjectJSONParseFail: "failed to parse .vercel/project.json: %s", + MsgVercelProjectIDMissing: "VERCEL_PROJECT_ID is not set and .vercel/project.json not found (run vercel link first or specify the project ID)", + MsgVercelProjectNotDefined: "%s: vercel_project is specified but vercel.projects is not defined in config (use vercel_project together with vercel.projects[])", + MsgVercelProjectInvalidConfig: "%s: vercel_project %q does not exist in config vercel.projects (defined: %s)", + MsgVercelCustomEnvNotFound: "custom environment %q does not exist in project %s (available: %s)", + MsgVercelCustomEnvFetchFail: "failed to fetch custom environments", + MsgVercelCustomEnvParseFail: "failed to parse custom environments response", + MsgVercelCustomEnvNoneAvailable: "(none — please create a Custom Environment in the Vercel dashboard first)", + MsgVercelURLBuildFailInternal: "failed to build URL", + MsgVercelExistingKeyFetchFail: "failed to fetch existing keys", + MsgVercelExistingKeyParseFail: "failed to parse existing keys response", // ----- GitHub Provider ----- MsgGitHubTokenMissing: "GITHUB_TOKEN is not set (set GITHUB_TOKEN env var or github.token in config file)", diff --git a/internal/i18n/catalog_ja.go b/internal/i18n/catalog_ja.go index b890fa1..a17d2d1 100644 --- a/internal/i18n/catalog_ja.go +++ b/internal/i18n/catalog_ja.go @@ -218,27 +218,30 @@ YAML スキーマ(定義ファイル env-sync.yaml): "# projects[] / repos[](モノレポ向け複数ターゲット)は手で追記してください。\n", // ----- Vercel Provider ----- - MsgVercelTokenMissing: "VERCEL_TOKEN が未設定です(環境変数 VERCEL_TOKEN または config ファイルの vercel.token で指定してください)", - MsgVercelTokenMissingProject: "VERCEL_TOKEN が未設定です(プロジェクト %q: 環境変数 VERCEL_TOKEN または config ファイルの token で指定してください)", - MsgVercelTokenSkipProject: "✗ プロジェクト %q: VERCEL_TOKEN が未設定です(このターゲットをスキップして残りを継続します)\n", - MsgVercelExistingKeysFetchWarn: "警告: 既存 key の取得に失敗したため新規/更新の分類をスキップします: %s\n", - MsgVercelTargetProject: "対象プロジェクト: %s (env: %s, def: %s)\n", - MsgVercelEntriesUpsert: "登録対象 %d 件 (既存は upsert で上書き):\n", - MsgVercelConfirmMulti: "上記を Vercel の %d プロジェクトに登録します(既存は上書き)。続行しますか? (y/N) ", - MsgVercelConfirmSingle: "上記を Vercel に登録します(既存は上書き)。続行しますか? (y/N) ", - MsgVercelProjectSeparator: "\n--- プロジェクト: %s ---\n", - MsgVercelURLBuildFailOut: "✗ URL の組み立てに失敗: %s\n", - MsgVercelRequestCreateFailOut: "✗ %s -> リクエスト生成失敗: %s\n", - MsgVercelSendFailOut: "✗ %s -> 送信失敗: %s\n", - MsgVercelProjectJSONReadFail: ".vercel/project.json の読み込みに失敗: %s", - MsgVercelProjectJSONParseFail: ".vercel/project.json の JSON パースに失敗: %s", - MsgVercelProjectIDMissing: "VERCEL_PROJECT_ID が未設定で .vercel/project.json もありません(先に vercel link するか指定してください)", - MsgVercelProjectNotDefined: "%s: vercel_project が指定されていますが config に vercel.projects が定義されていません(vercel_project は vercel.projects[] と組み合わせて使用してください)", - MsgVercelProjectInvalidConfig: "%s: vercel_project %q は config の vercel.projects に存在しません(定義済み: %s)", - MsgVercelInvalidEnvironment: "%s: 不正な environments %q(production / preview / development)", - MsgVercelURLBuildFailInternal: "URL 組み立て失敗", - MsgVercelExistingKeyFetchFail: "既存 key 取得失敗", - MsgVercelExistingKeyParseFail: "既存 key レスポンスのパース失敗", + MsgVercelTokenMissing: "VERCEL_TOKEN が未設定です(環境変数 VERCEL_TOKEN または config ファイルの vercel.token で指定してください)", + MsgVercelTokenMissingProject: "VERCEL_TOKEN が未設定です(プロジェクト %q: 環境変数 VERCEL_TOKEN または config ファイルの token で指定してください)", + MsgVercelTokenSkipProject: "✗ プロジェクト %q: VERCEL_TOKEN が未設定です(このターゲットをスキップして残りを継続します)\n", + MsgVercelExistingKeysFetchWarn: "警告: 既存 key の取得に失敗したため新規/更新の分類をスキップします: %s\n", + MsgVercelTargetProject: "対象プロジェクト: %s (env: %s, def: %s)\n", + MsgVercelEntriesUpsert: "登録対象 %d 件 (既存は upsert で上書き):\n", + MsgVercelConfirmMulti: "上記を Vercel の %d プロジェクトに登録します(既存は上書き)。続行しますか? (y/N) ", + MsgVercelConfirmSingle: "上記を Vercel に登録します(既存は上書き)。続行しますか? (y/N) ", + MsgVercelProjectSeparator: "\n--- プロジェクト: %s ---\n", + MsgVercelURLBuildFailOut: "✗ URL の組み立てに失敗: %s\n", + MsgVercelRequestCreateFailOut: "✗ %s -> リクエスト生成失敗: %s\n", + MsgVercelSendFailOut: "✗ %s -> 送信失敗: %s\n", + MsgVercelProjectJSONReadFail: ".vercel/project.json の読み込みに失敗: %s", + MsgVercelProjectJSONParseFail: ".vercel/project.json の JSON パースに失敗: %s", + MsgVercelProjectIDMissing: "VERCEL_PROJECT_ID が未設定で .vercel/project.json もありません(先に vercel link するか指定してください)", + MsgVercelProjectNotDefined: "%s: vercel_project が指定されていますが config に vercel.projects が定義されていません(vercel_project は vercel.projects[] と組み合わせて使用してください)", + MsgVercelProjectInvalidConfig: "%s: vercel_project %q は config の vercel.projects に存在しません(定義済み: %s)", + MsgVercelCustomEnvNotFound: "Custom Environment %q がプロジェクト %s に存在しません(利用可能な Custom Environment: %s)", + MsgVercelCustomEnvFetchFail: "Custom Environment 一覧の取得に失敗", + MsgVercelCustomEnvParseFail: "Custom Environment 一覧のパースに失敗", + MsgVercelCustomEnvNoneAvailable: "(なし — Vercel ダッシュボードで事前に作成してください)", + MsgVercelURLBuildFailInternal: "URL 組み立て失敗", + MsgVercelExistingKeyFetchFail: "既存 key 取得失敗", + MsgVercelExistingKeyParseFail: "既存 key レスポンスのパース失敗", // ----- GitHub Provider ----- MsgGitHubTokenMissing: "GITHUB_TOKEN が未設定です(環境変数 GITHUB_TOKEN または config ファイルの github.token で指定してください)", diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index f593abb..6537c87 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -204,8 +204,15 @@ const ( MsgVercelProjectNotDefined MsgKey = "vercel.project_not_defined" // MsgVercelProjectInvalidConfig は vercel_project が config に存在しないエラー(書式: キー名, プロジェクト名, 定義済みリスト)。 MsgVercelProjectInvalidConfig MsgKey = "vercel.project_invalid_config" - // MsgVercelInvalidEnvironment は不正な environment 値エラー(書式: キー名, 値)。 - MsgVercelInvalidEnvironment MsgKey = "vercel.invalid_environment" + // MsgVercelCustomEnvNotFound は指定した Custom Environment slug がプロジェクトに存在しないエラー + // (書式引数: slug, プロジェクトラベル, 利用可能 slug 一覧)。 + MsgVercelCustomEnvNotFound MsgKey = "vercel.custom_env_not_found" + // MsgVercelCustomEnvFetchFail は Custom Environment 一覧取得失敗ラベル(%w ラップ用)。 + MsgVercelCustomEnvFetchFail MsgKey = "vercel.custom_env_fetch_fail" + // MsgVercelCustomEnvParseFail は Custom Environment 一覧レスポンスのパース失敗ラベル(%w ラップ用)。 + MsgVercelCustomEnvParseFail MsgKey = "vercel.custom_env_parse_fail" + // MsgVercelCustomEnvNoneAvailable はプロジェクトに Custom Environment が 1 件もない場合の「利用可能なし」ラベル。 + MsgVercelCustomEnvNoneAvailable MsgKey = "vercel.custom_env_none_available" // MsgVercelURLBuildFailInternal は vercelFetchExistingKeys の URL 組み立て失敗ラベル(%w ラップ用)。 MsgVercelURLBuildFailInternal MsgKey = "vercel.url_build_fail_internal" // MsgVercelExistingKeyFetchFail は既存 key 取得失敗ラベル(%w ラップ用)。 diff --git a/internal/provider/vercel/vercel.go b/internal/provider/vercel/vercel.go index 695875f..5996b4d 100644 --- a/internal/provider/vercel/vercel.go +++ b/internal/provider/vercel/vercel.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os" + "sort" "strings" "time" @@ -99,6 +100,14 @@ func (v *vercelProvider) Sync(opts provider.Options, entries []provider.Entry) e continue } + // Custom Environment slug を ID に解決する(dry-run 時はスキップ)。 + // custom slug が 1 件もないターゲットでは API を呼ばない。 + if !opts.DryRun && tgt.Token != "" { + if err := applyCustomEnvironments(client, tgt, items); err != nil { + return err + } + } + // 既存 key を問い合わせて新規/更新を分類(結果を保存して後で再利用) var classified []classifiedVercelItem if tgt.Token != "" { @@ -126,7 +135,9 @@ func (v *vercelProvider) Sync(opts provider.Options, entries []provider.Entry) e fmt.Print(i18n.T(i18n.MsgVercelEntriesUpsert, len(items))) } for j, it := range items { - tj, _ := json.Marshal(it.Target) + // 一覧表示では標準環境(Target)と Custom Environment slug(customEnvSlugs)を結合して表示 + allEnvs := append(append([]string{}, it.Target...), it.customEnvSlugs...) + tj, _ := json.Marshal(allEnvs) secretLabel := "secret=true" if it.Type == "plain" { secretLabel = "secret=false" @@ -384,6 +395,109 @@ func vercelFetchExistingKeys(client *http.Client, token, projectID, teamID strin return existing, nil } +// vercelFetchCustomEnvironments は GET /v9/projects/{id}/custom-environments で +// slug → ID("env_*" 形式)のマップを返す。 +func vercelFetchCustomEnvironments(client *http.Client, token, projectID, teamID string) (map[string]string, error) { + u, err := url.Parse(fmt.Sprintf("%s/v9/projects/%s/custom-environments", apiBase, url.PathEscape(projectID))) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T(i18n.MsgVercelURLBuildFailInternal), err) + } + q := u.Query() + if teamID != "" { + q.Set("teamId", teamID) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T(i18n.MsgRequestCreateFail), err) + } + req.Header.Set("Authorization", "Bearer "+token) + + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T(i18n.MsgVercelCustomEnvFetchFail), err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + msg := fmt.Sprintf("HTTP %d", res.StatusCode) + if detail := parseErrorBody(res.Body); detail != "" { + msg += ": " + detail + } + return nil, fmt.Errorf("%s: %s", i18n.T(i18n.MsgVercelCustomEnvFetchFail), msg) + } + + var resp struct { + Environments []struct { + ID string `json:"id"` + Slug string `json:"slug"` + } `json:"environments"` + } + if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T(i18n.MsgVercelCustomEnvParseFail), err) + } + + m := make(map[string]string, len(resp.Environments)) + for _, e := range resp.Environments { + m[e.Slug] = e.ID + } + return m, nil +} + +// applyCustomEnvironments は items 内の customEnvSlugs を解決して CustomEnvironmentIDs を埋める。 +// custom slug が 1 件もないターゲットでは custom-environments API を一切呼ばない。 +func applyCustomEnvironments(client *http.Client, tgt config.VercelTarget, items []item) error { + // 必要な slug を集約する(重複排除) + slugSet := make(map[string]bool) + for _, it := range items { + for _, s := range it.customEnvSlugs { + slugSet[s] = true + } + } + if len(slugSet) == 0 { + return nil + } + + customEnvs, err := vercelFetchCustomEnvironments(client, tgt.Token, tgt.ProjectID, tgt.TeamID) + if err != nil { + return err + } + + // プロジェクトラベルは Name != "" ? Name : ProjectID + projectLabel := tgt.ProjectID + if tgt.Name != "" { + projectLabel = tgt.Name + } + + for i := range items { + // 同一 item 内での CustomEnvironmentIDs 重複を防ぐ + addedIDs := make(map[string]bool) + for _, slug := range items[i].customEnvSlugs { + id, ok := customEnvs[slug] + if !ok { + available := make([]string, 0, len(customEnvs)) + for s := range customEnvs { + available = append(available, s) + } + sort.Strings(available) // エラーメッセージを決定的にする + var availableStr string + if len(available) == 0 { + availableStr = i18n.T(i18n.MsgVercelCustomEnvNoneAvailable) + } else { + availableStr = strings.Join(available, ", ") + } + return fmt.Errorf("%s", i18n.T(i18n.MsgVercelCustomEnvNotFound, slug, projectLabel, availableStr)) + } + if !addedIDs[id] { + addedIDs[id] = true + items[i].CustomEnvironmentIDs = append(items[i].CustomEnvironmentIDs, id) + } + } + } + return nil +} + // classifyVercelItems は items を既存 key セットと照合して新規/更新に分類する純粋関数。 func classifyVercelItems(items []item, existingKeys map[string]bool) []classifiedVercelItem { result := make([]classifiedVercelItem, len(items)) @@ -412,7 +526,8 @@ func countClassified(classified []classifiedVercelItem, total int) (newCount, up // entriesToVercelItems は Entry スライスを Vercel API 用の item スライスに変換する純粋関数。 // Secret=true → type:"sensitive"、false → type:"plain" // Environments が空なら [production, preview] をデフォルト適用。 -// Environments の値が production/preview/development 以外なら error を返す。 +// production/preview/development は item.Target へ、それ以外は item.customEnvSlugs へ振り分ける。 +// Custom Environment slug の存在確認は applyCustomEnvironments が担うため、この関数はエラーを返さない。 func entriesToVercelItems(entries []provider.Entry) ([]item, error) { validTargets := map[string]bool{ "production": true, @@ -427,28 +542,40 @@ func entriesToVercelItems(entries []provider.Entry) ([]item, error) { typ = "plain" } - target := e.Environments - if len(target) == 0 { - target = []string{"production", "preview"} + envs := e.Environments + if len(envs) == 0 { + envs = []string{"production", "preview"} } - for _, t := range target { - if !validTargets[t] { - return nil, fmt.Errorf("%s", i18n.T(i18n.MsgVercelInvalidEnvironment, e.Key, t)) + var target []string + var customSlugs []string + for _, env := range envs { + if validTargets[env] { + target = append(target, env) + } else { + customSlugs = append(customSlugs, env) } } - items = append(items, item{Key: e.Key, Value: e.Value, Type: typ, Target: target}) + items = append(items, item{ + Key: e.Key, + Value: e.Value, + Type: typ, + Target: target, + customEnvSlugs: customSlugs, + }) } return items, nil } // item は Vercel へ送信する 1 件の環境変数を表す。 type item struct { - Key string `json:"key"` - Value string `json:"value"` - Type string `json:"type"` - Target []string `json:"target"` + Key string `json:"key"` + Value string `json:"value"` + Type string `json:"type"` + Target []string `json:"target,omitempty"` + CustomEnvironmentIDs []string `json:"customEnvironmentIds,omitempty"` + customEnvSlugs []string // 非公開: JSON化しない。表示・ID解決の中間保持用 } // projectJSON は .vercel/project.json の必要フィールド。 diff --git a/internal/provider/vercel/vercel_test.go b/internal/provider/vercel/vercel_test.go index e6d7955..c2de6c7 100644 --- a/internal/provider/vercel/vercel_test.go +++ b/internal/provider/vercel/vercel_test.go @@ -166,16 +166,44 @@ func TestEntriesToVercelItems_EmptyEnvironments_DefaultTarget(t *testing.T) { } } -func TestEntriesToVercelItems_InvalidEnvironment_Error(t *testing.T) { +func TestEntriesToVercelItems_CustomSlug_GoesToCustomEnvSlugs(t *testing.T) { + // staging は validTargets に含まれないため customEnvSlugs に振り分けられ、Target には含まれない。 + // entriesToVercelItems 自体はエラーを返さない(slug の存在確認は applyCustomEnvironments が担う)。 entries := []provider.Entry{ {Key: "FOO", Value: "bar", Secret: true, Environments: []string{"staging"}}, } - _, err := entriesToVercelItems(entries) - if err == nil { - t.Fatal("不正な environments でエラーを期待したが nil") + items, err := entriesToVercelItems(entries) + if err != nil { + t.Fatalf("entriesToVercelItems エラーなしを期待したが: %v", err) } - if !strings.Contains(err.Error(), "staging") { - t.Errorf("エラーメッセージに staging が含まれていない: %v", err) + if len(items) != 1 { + t.Fatalf("items len = %d, want 1", len(items)) + } + if len(items[0].Target) != 0 { + t.Errorf("staging は Target に含まれてはいけない: %v", items[0].Target) + } + if len(items[0].customEnvSlugs) != 1 || items[0].customEnvSlugs[0] != "staging" { + t.Errorf("customEnvSlugs = %v, want [staging]", items[0].customEnvSlugs) + } +} + +func TestEntriesToVercelItems_MixedStandardAndCustom(t *testing.T) { + // production(標準)と staging(カスタム)の混在指定。 + entries := []provider.Entry{ + {Key: "FOO", Value: "bar", Secret: true, Environments: []string{"production", "staging"}}, + } + items, err := entriesToVercelItems(entries) + if err != nil { + t.Fatalf("entriesToVercelItems エラーなしを期待したが: %v", err) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1", len(items)) + } + if len(items[0].Target) != 1 || items[0].Target[0] != "production" { + t.Errorf("Target = %v, want [production]", items[0].Target) + } + if len(items[0].customEnvSlugs) != 1 || items[0].customEnvSlugs[0] != "staging" { + t.Errorf("customEnvSlugs = %v, want [staging]", items[0].customEnvSlugs) } } @@ -388,6 +416,271 @@ func newVercelTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Serve return srv } +// --- vercelFetchCustomEnvironments のテスト --- + +func TestVercelFetchCustomEnvironments_Success(t *testing.T) { + var gotPath, gotQuery, gotMethod, gotAuth string + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + resp := map[string]interface{}{ + "environments": []map[string]interface{}{ + {"id": "env_staging", "slug": "staging", "type": "custom"}, + {"id": "env_qa", "slug": "qa", "type": "custom"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + m, err := vercelFetchCustomEnvironments(&http.Client{}, "test-token", "my-project", "my-team") + if err != nil { + t.Fatalf("vercelFetchCustomEnvironments 失敗: %v", err) + } + + if gotMethod != http.MethodGet { + t.Errorf("メソッド = %q, want GET", gotMethod) + } + if want := "/v9/projects/my-project/custom-environments"; gotPath != want { + t.Errorf("パス = %q, want %q", gotPath, want) + } + if !strings.Contains(gotQuery, "teamId=my-team") { + t.Errorf("クエリ = %q, want teamId=my-team を含む", gotQuery) + } + if want := "Bearer test-token"; gotAuth != want { + t.Errorf("Authorization = %q, want %q", gotAuth, want) + } + if m["staging"] != "env_staging" { + t.Errorf("m[staging] = %q, want env_staging", m["staging"]) + } + if m["qa"] != "env_qa" { + t.Errorf("m[qa] = %q, want env_qa", m["qa"]) + } +} + +func TestVercelFetchCustomEnvironments_HTTPError(t *testing.T) { + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":{"message":"project not found"}}`)) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + _, err := vercelFetchCustomEnvironments(&http.Client{}, "token", "bad-project", "") + if err == nil { + t.Fatal("HTTP 404 のときエラーを期待したが nil") + } +} + +func TestVercelFetchCustomEnvironments_ParseFail(t *testing.T) { + // レスポンスが不正 JSON のとき MsgVercelCustomEnvParseFail 経路でエラーを返す。 + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("not valid json")) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + _, err := vercelFetchCustomEnvironments(&http.Client{}, "token", "project", "") + if err == nil { + t.Fatal("不正 JSON のときエラーを期待したが nil") + } +} + +func TestVercelFetchCustomEnvironments_NoTeamID(t *testing.T) { + var gotQuery string + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"environments": []interface{}{}}) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + if _, err := vercelFetchCustomEnvironments(&http.Client{}, "token", "project", ""); err != nil { + t.Fatalf("vercelFetchCustomEnvironments 失敗: %v", err) + } + if gotQuery != "" { + t.Errorf("teamID 空のときクエリは空のはず, got %q", gotQuery) + } +} + +// --- applyCustomEnvironments のテスト --- + +func TestApplyCustomEnvironments_NoCustomSlugs_NoAPICall(t *testing.T) { + // custom slug が 1 件もない場合、custom-environments API は呼ばれない。 + apiCalled := false + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "custom-environments") { + apiCalled = true + } + w.WriteHeader(http.StatusOK) + }) + withVercelAPIBase(t, srv.URL) + + items := []item{ + {Key: "FOO", Target: []string{"production"}, customEnvSlugs: nil}, + } + tgt := config.VercelTarget{Token: "tok", ProjectID: "pid"} + if err := applyCustomEnvironments(&http.Client{}, tgt, items); err != nil { + t.Fatalf("applyCustomEnvironments エラー: %v", err) + } + if apiCalled { + t.Error("custom slug がないのに custom-environments API が呼ばれた") + } +} + +func TestApplyCustomEnvironments_SlugNotFound_Error(t *testing.T) { + // 存在しない slug を指定したとき、slug 名を含むエラーが返る。 + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "environments": []map[string]interface{}{ + {"id": "env_qa", "slug": "qa"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + items := []item{ + {Key: "FOO", customEnvSlugs: []string{"nonexistent"}}, + } + tgt := config.VercelTarget{Token: "tok", ProjectID: "pid"} + err := applyCustomEnvironments(&http.Client{}, tgt, items) + if err == nil { + t.Fatal("存在しない slug でエラーを期待したが nil") + } + if !strings.Contains(err.Error(), "nonexistent") { + t.Errorf("エラーメッセージに slug 名 'nonexistent' が含まれていない: %v", err) + } +} + +func TestApplyCustomEnvironments_SlugNotFound_AvailableSorted(t *testing.T) { + // エラーメッセージ内の利用可能 slug 一覧がソートされていること(決定的な出力)。 + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "environments": []map[string]interface{}{ + {"id": "env_z", "slug": "zzz"}, + {"id": "env_a", "slug": "aaa"}, + {"id": "env_m", "slug": "mmm"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + items := []item{{Key: "FOO", customEnvSlugs: []string{"nonexistent"}}} + tgt := config.VercelTarget{Token: "tok", ProjectID: "pid"} + err := applyCustomEnvironments(&http.Client{}, tgt, items) + if err == nil { + t.Fatal("エラーを期待したが nil") + } + msg := err.Error() + // "aaa" が "mmm" より前、"mmm" が "zzz" より前に現れることを確認 + idxA := strings.Index(msg, "aaa") + idxM := strings.Index(msg, "mmm") + idxZ := strings.Index(msg, "zzz") + if idxA < 0 || idxM < 0 || idxZ < 0 { + t.Fatalf("エラーメッセージに slug 名が含まれない: %v", msg) + } + if !(idxA < idxM && idxM < idxZ) { + t.Errorf("available 一覧がソートされていない: %v", msg) + } +} + +func TestApplyCustomEnvironments_DuplicateSlug_NoDuplicateID(t *testing.T) { + // 同一 slug を複数指定した場合でも CustomEnvironmentIDs に重複 ID が入らない。 + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "environments": []map[string]interface{}{ + {"id": "env_stg", "slug": "staging"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + items := []item{ + {Key: "FOO", customEnvSlugs: []string{"staging", "staging"}}, + } + tgt := config.VercelTarget{Token: "tok", ProjectID: "pid"} + if err := applyCustomEnvironments(&http.Client{}, tgt, items); err != nil { + t.Fatalf("applyCustomEnvironments エラー: %v", err) + } + if len(items[0].CustomEnvironmentIDs) != 1 { + t.Errorf("重複排除後の CustomEnvironmentIDs len = %d, want 1: %v", len(items[0].CustomEnvironmentIDs), items[0].CustomEnvironmentIDs) + } +} + +func TestApplyCustomEnvironments_NoAvailable_Message(t *testing.T) { + // プロジェクトに Custom Environment が 1 件も無い場合のエラーメッセージに「なし」ラベルが含まれる。 + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "environments": []map[string]interface{}{}, // 0 件 + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + }) + withVercelAPIBase(t, srv.URL) + + items := []item{{Key: "FOO", customEnvSlugs: []string{"staging"}}} + tgt := config.VercelTarget{Token: "tok", ProjectID: "pid"} + err := applyCustomEnvironments(&http.Client{}, tgt, items) + if err == nil { + t.Fatal("エラーを期待したが nil") + } + // available が空のとき末尾が空文字列にならず「なし」ラベルが入る + if strings.Contains(err.Error(), "available: )") { + t.Errorf("available 空のとき末尾が空になっている: %v", err) + } +} + +func TestApplyCustomEnvironments_Mixed_JSONBody(t *testing.T) { + // [production, staging] 混在時の POST ボディが target と customEnvironmentIds を含む。 + srv := newVercelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "custom-environments") { + resp := map[string]interface{}{ + "environments": []map[string]interface{}{ + {"id": "env_stg123", "slug": "staging"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + return + } + w.WriteHeader(http.StatusCreated) + }) + withVercelAPIBase(t, srv.URL) + + items := []item{ + {Key: "FOO", Value: "bar", Type: "sensitive", Target: []string{"production"}, customEnvSlugs: []string{"staging"}}, + } + tgt := config.VercelTarget{Token: "tok", ProjectID: "pid"} + if err := applyCustomEnvironments(&http.Client{}, tgt, items); err != nil { + t.Fatalf("applyCustomEnvironments エラー: %v", err) + } + + // applyCustomEnvironments 後に CustomEnvironmentIDs が埋まっていること + if len(items[0].CustomEnvironmentIDs) != 1 || items[0].CustomEnvironmentIDs[0] != "env_stg123" { + t.Errorf("CustomEnvironmentIDs = %v, want [env_stg123]", items[0].CustomEnvironmentIDs) + } + + // JSON に target と customEnvironmentIds が共存する + body, _ := json.Marshal(items[0]) + var m map[string]interface{} + json.Unmarshal(body, &m) //nolint:errcheck + + if targets, ok := m["target"].([]interface{}); !ok || len(targets) == 0 { + t.Errorf("JSON に target フィールドがない: %s", string(body)) + } + if ids, ok := m["customEnvironmentIds"].([]interface{}); !ok || len(ids) == 0 { + t.Errorf("JSON に customEnvironmentIds フィールドがない: %s", string(body)) + } +} + // classifyVercelItems のテスト(分類結果の再利用確認のため) func TestClassifyVercelItems_NewAndUpdate(t *testing.T) {