已合并
fix: round-6 P2 batch - completion env pin, release delete/list alignment, doc version gate #588
aflyingto创建于 9月30日
fix: round-6 P2 batch - completion env pin, release delete/list alignment, doc version gate #588
已合并
共 14 个文件变更+1081-60
| @@ -67,6 +67,15 @@ jobs: | |||
| 67 | run: | | 67 | run: | |
| 68 | bash scripts/test-release-version-validation.sh | 68 | bash scripts/test-release-version-validation.sh |
| 69 | bash scripts/sync-package-version.sh "$(cat VERSION)" --check | 69 | bash scripts/sync-package-version.sh "$(cat VERSION)" --check |
| 70 | + bash scripts/sync-docs-version.sh "$(cat VERSION)" --check | ||
| 71 | + | ||
| 72 | + - name: Validate docs version sync script | ||
| 73 | + # The sync mode relies on GNU sed -E -i (capture-group backwrites), | ||
| 74 | + # so it is exercised on Linux only; --check above is plain grep and | ||
| 75 | + # runs on every platform. | ||
| 76 | + if: matrix.os == 'ubuntu-latest' | ||
| 77 | + shell: bash | ||
| 78 | + run: bash scripts/test-sync-docs-version.sh | ||
| 70 | 79 | ||
| 71 | - name: Download dependencies | 80 | - name: Download dependencies |
| 72 | run: go mod download | 81 | run: go mod download |
| @@ -2062,13 +2062,21 @@ gc release create v1.0.0 -R infra-test/gctest1 --title "v1.0.0" --notes "Release | |||
| 2062 | # 列出所有 Releases | 2062 | # 列出所有 Releases |
| 2063 | gc release list -R infra-test/gctest1 | 2063 | gc release list -R infra-test/gctest1 |
| 2064 | 2064 | ||
| 2065 | +# 列出 150 个(跨 API 页自动累积) | ||
| 2066 | +gc release list -R infra-test/gctest1 --limit 150 | ||
| 2067 | + | ||
| 2068 | +# 只取第 2 页(单次请求) | ||
| 2069 | +gc release list -R infra-test/gctest1 --page 2 | ||
| 2070 | + | ||
| 2065 | # 输出 JSON | 2071 | # 输出 JSON |
| 2066 | gc release list -R infra-test/gctest1 --json | 2072 | gc release list -R infra-test/gctest1 --json |
| 2067 | ``` | 2073 | ``` |
| 2068 | 2074 | ||
| 2069 | 说明: | 2075 | 说明: |
| 2070 | - `--limit`(默认 30)返回最新 N 个 release:CLI 向 API 请求 `direction=desc`(最新优先),客户端再按 `published_at`(缺失时回退 `created_at`)降序排序。 | 2076 | - `--limit`(默认 30)返回最新 N 个 release:CLI 向 API 请求 `direction=desc`(最新优先),客户端再按 `published_at`(缺失时回退 `created_at`)降序排序。 |
| 2071 | -- 文本输出中只有最新一个正式 release 会标记为 `(latest)`。 | 2077 | +- API 单页上限 100:`--limit` 超过 100 时 CLI 自动跨页累积取满 N 个(不会静默截断),累积完成后统一按发布时间排序再截取。 |
| 2078 | +- `--page N`(默认 0)显式取第 N 页(单次请求,语义对齐 `issue list`/`pr list`);与大于 100 的 `--limit` 互斥,组合时报参数错误(退出码 2)。 | ||
| 2079 | +- 文本输出中只有最新一个正式 release 会标记为 `(latest)`(`--page` 翻页时指该页窗口内最新的正式 release,而非全仓库最新)。 | ||
| 2072 | - 其他正式 release 会标记为 `(published)`;草稿和预发布仍分别显示 `(draft)`、`(pre-release)`。 | 2080 | - 其他正式 release 会标记为 `(published)`;草稿和预发布仍分别显示 `(draft)`、`(pre-release)`。 |
| 2073 | 2081 | ||
| 2074 | ### release view - 查看 Release | 2082 | ### release view - 查看 Release |
| @@ -2165,6 +2173,7 @@ gc release delete v1.0.0 -R infra-test/gctest1 --dry-run | |||
| 2165 | 2173 | ||
| 2166 | 说明: | 2174 | 说明: |
| 2167 | - GitCode 官方 OpenAPI 当前没有 Release 删除接口,实际删除请求会返回 `405 Method Not Allowed`。 | 2175 | - GitCode 官方 OpenAPI 当前没有 Release 删除接口,实际删除请求会返回 `405 Method Not Allowed`。 |
| 2176 | +- 405 时命令错误信息指引到仓库 Release 页面完成删除(API 响应含 `html_url` 时附上网页链接,缺失时给无链接指引)。 | ||
| 2168 | - `--dry-run` 仅预览目标和参数,不执行删除;需要删除时请使用仓库 Release 页面。 | 2177 | - `--dry-run` 仅预览目标和参数,不执行删除;需要删除时请使用仓库 Release 页面。 |
| 2169 | 2178 | ||
| 2170 | ### release delete-asset - 删除 Release 附件 | 2179 | ### release delete-asset - 删除 Release 附件 |
| @@ -113,6 +113,7 @@ field/type checks whenever a command supports `--json`. | |||
| 113 | - 脚本不会读取真实 token,不会执行 `auth login --with-token`,也不会执行 `auth logout`。 | 113 | - 脚本不会读取真实 token,不会执行 `auth login --with-token`,也不会执行 `auth logout`。 |
| 114 | - 无认证错误路径会使用临时空 `GC_CONFIG_DIR` 和空环境变量执行单条命令,避免污染本地长期配置。 | 114 | - 无认证错误路径会使用临时空 `GC_CONFIG_DIR` 和空环境变量执行单条命令,避免污染本地长期配置。 |
| 115 | - `release delete` 的 dry-run 默认使用 `GC_REGRESSION_RELEASE_TAG`,未显式设置时默认取 `v0.0.1-test`。 | 115 | - `release delete` 的 dry-run 默认使用 `GC_REGRESSION_RELEASE_TAG`,未显式设置时默认取 `v0.0.1-test`。 |
| 116 | +- 真实删除请求当前被平台以 `405 Method Not Allowed` 拒绝,命令错误信息指引到仓库 Release 页面(API 响应含 `html_url` 时附上网页链接)。回归集只覆盖 `--dry-run`,不执行真实删除(平台将来启用删除 API 后也应如此,避免删除 infra-test 资源)。 | ||
| 116 | 117 | ||
| 117 | ## 可选写路径 | 118 | ## 可选写路径 |
| 118 | 119 | ||
| @@ -614,16 +614,16 @@ function installCompletions(bin, home) { | |||
| 614 | const installed = []; | 614 | const installed = []; |
| 615 | const skipped = []; | 615 | const skipped = []; |
| 616 | // Both command names get completions (matching the deb/rpm packages, | 616 | // Both command names get completions (matching the deb/rpm packages, |
| 617 | - // which ship gc and gitcode scripts): the second pass sets the command | 617 | + // which ship gc and gitcode scripts). Every pass pins the command name |
| 618 | - // name env so cobra's generator produces gitcode-named scripts. | 618 | + // env: resolveCommandName honors the environment over argv[0], so a |
| 619 | + // user-exported GITCODE_CLI_COMMAND_NAME would otherwise leak into the | ||
| 620 | + // gc-named scripts and silently break "gc <TAB>". | ||
| 619 | for (const [shell, commandName] of [ | 621 | for (const [shell, commandName] of [ |
| 620 | ["bash", "gc"], ["bash", "gitcode"], | 622 | ["bash", "gc"], ["bash", "gitcode"], |
| 621 | ["zsh", "gc"], ["zsh", "gitcode"], | 623 | ["zsh", "gc"], ["zsh", "gitcode"], |
| 622 | ["fish", "gc"], ["fish", "gitcode"], | 624 | ["fish", "gc"], ["fish", "gitcode"], |
| 623 | ]) { | 625 | ]) { |
| 624 | - const res = commandName === "gc" | 626 | + const res = runGc(bin, ["completion", shell], { GITCODE_CLI_COMMAND_NAME: commandName }); |
| 625 | - ? runGc(bin, ["completion", shell]) | ||
| 626 | - : runGc(bin, ["completion", shell], { GITCODE_CLI_COMMAND_NAME: commandName }); | ||
| 627 | if (res.status !== 0 || !res.stdout) { | 627 | if (res.status !== 0 || !res.stdout) { |
| 628 | skipped.push(`${shell} (${commandName}): completion command failed`); | 628 | skipped.push(`${shell} (${commandName}): completion command failed`); |
| 629 | continue; | 629 | continue; |
| @@ -2,6 +2,7 @@ | |||
| 2 | package delete | 2 | package delete |
| 3 | 3 | ||
| 4 | import ( | 4 | import ( |
| 5 | + "errors" | ||
| 5 | "fmt" | 6 | "fmt" |
| 6 | "net/http" | 7 | "net/http" |
| 7 | 8 | ||
| @@ -41,15 +42,20 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co | |||
| 41 | Long: heredoc.Doc(` | 42 | Long: heredoc.Doc(` |
| 42 | Delete a release from a repository. | 43 | Delete a release from a repository. |
| 43 | 44 | ||
| 44 | - This will delete the release but not the associated git tag. | 45 | + GitCode does not currently provide a release deletion API: the |
| 46 | + deletion request fails with HTTP 405 Method Not Allowed. Use | ||
| 47 | + --dry-run to preview the target, and delete the release from | ||
| 48 | + the repository's Releases page in the web UI. | ||
| 49 | + | ||
| 50 | + This would delete the release but not the associated git tag. | ||
| 45 | 51 | ||
| 46 | Non-interactive mode: Requires --yes to skip confirmation. | 52 | Non-interactive mode: Requires --yes to skip confirmation. |
| 47 | `), | 53 | `), |
| 48 | Example: heredoc.Doc(` | 54 | Example: heredoc.Doc(` |
| 49 | - # Delete a release | 55 | + # Preview a deletion (the platform has no delete API) |
| 50 | - $ gc release delete v1.0.0 -R owner/repo | 56 | + $ gc release delete v1.0.0 -R owner/repo --dry-run |
| 51 | 57 | ||
| 52 | - # Delete without confirmation | 58 | + # Attempt a deletion without confirmation |
| 53 | $ gc release delete v1.0.0 -R owner/repo --yes | 59 | $ gc release delete v1.0.0 -R owner/repo --yes |
| 54 | `), | 60 | `), |
| 55 | Args: cobra.ExactArgs(1), | 61 | Args: cobra.ExactArgs(1), |
| @@ -121,9 +127,19 @@ func deleteRun(opts *DeleteOptions) error { | |||
| 121 | // GetRelease call if the tag-based endpoint falls back to ID deletion. | 127 | // GetRelease call if the tag-based endpoint falls back to ID deletion. |
| 122 | err = api.DeleteReleaseByTagKnown(client, owner, repo, opts.TagName, release) | 128 | err = api.DeleteReleaseByTagKnown(client, owner, repo, opts.TagName, release) |
| 123 | if err != nil { | 129 | if err != nil { |
| 124 | - if err == api.ErrNoReleaseID { | 130 | + if errors.Is(err, api.ErrNoReleaseID) { |
| 125 | return fmt.Errorf("failed to delete release: %w; tag-based endpoint unavailable and GitCode omits release IDs in lookup responses", err) | 131 | return fmt.Errorf("failed to delete release: %w; tag-based endpoint unavailable and GitCode omits release IDs in lookup responses", err) |
| 126 | } | 132 | } |
| 133 | + var apiErr *api.APIError | ||
| 134 | + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusMethodNotAllowed { | ||
| 135 | + // GitCode has no release-deletion API (docs/COMMANDS.md, | ||
| 136 | + // "release delete"); point the user at the web UI instead. | ||
| 137 | + guide := "Delete the release from the web UI instead." | ||
| 138 | + if release.HTMLURL != "" { | ||
| 139 | + guide = fmt.Sprintf("Delete the release from the web UI instead:\n %s", release.HTMLURL) | ||
| 140 | + } | ||
| 141 | + return fmt.Errorf("failed to delete release: %w\n\nGitCode does not support deleting releases through the API (HTTP 405). %s", err, guide) | ||
| 142 | + } | ||
| 127 | return fmt.Errorf("failed to delete release: %w", err) | 143 | return fmt.Errorf("failed to delete release: %w", err) |
| 128 | } | 144 | } |
| 129 | 145 | ||
| @@ -1,9 +1,13 @@ | |||
| 1 | package delete | 1 | package delete |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "io" | ||
| 5 | + "net/http" | ||
| 6 | + "strings" | ||
| 4 | "testing" | 7 | "testing" |
| 5 | 8 | ||
| 6 | cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil" | 9 | cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil" |
| 10 | + "gitcode.com/gitcode-cli/cli/pkg/testutil" | ||
| 7 | ) | 11 | ) |
| 8 | 12 | ||
| 9 | func TestNewCmdDelete(t *testing.T) { | 13 | func TestNewCmdDelete(t *testing.T) { |
| @@ -61,3 +65,244 @@ func TestDeleteBaseRepoInjected(t *testing.T) { | |||
| 61 | t.Fatal("opts.BaseRepo is nil, want injected from f.BaseRepo") | 65 | t.Fatal("opts.BaseRepo is nil, want injected from f.BaseRepo") |
| 62 | } | 66 | } |
| 63 | } | 67 | } |
| 68 | + | ||
| 69 | +func TestDeleteRunDryRunMakesNoRequest(t *testing.T) { | ||
| 70 | + f := cmdutil.TestFactory() | ||
| 71 | + out := &strings.Builder{} | ||
| 72 | + f.IOStreams.Out = out | ||
| 73 | + | ||
| 74 | + opts := &DeleteOptions{ | ||
| 75 | + IO: f.IOStreams, | ||
| 76 | + Repository: "owner/repo", | ||
| 77 | + TagName: "v1.0.0", | ||
| 78 | + DryRun: true, | ||
| 79 | + HttpClient: func() (*http.Client, error) { | ||
| 80 | + return &http.Client{ | ||
| 81 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 82 | + t.Fatalf("unexpected request to %s", req.URL.String()) | ||
| 83 | + return nil, nil | ||
| 84 | + }), | ||
| 85 | + }, nil | ||
| 86 | + }, | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + if err := deleteRun(opts); err != nil { | ||
| 90 | + t.Fatalf("deleteRun() error = %v", err) | ||
| 91 | + } | ||
| 92 | + if !strings.Contains(out.String(), "Dry run: would delete release v1.0.0") { | ||
| 93 | + t.Fatalf("output = %q", out.String()) | ||
| 94 | + } | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +func TestDeleteRun405ReturnsWebGuidance(t *testing.T) { | ||
| 98 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 99 | + t.Setenv("GITCODE_TOKEN", "") | ||
| 100 | + f := cmdutil.TestFactory() | ||
| 101 | + out := &strings.Builder{} | ||
| 102 | + f.IOStreams.Out = out | ||
| 103 | + | ||
| 104 | + opts := &DeleteOptions{ | ||
| 105 | + IO: f.IOStreams, | ||
| 106 | + Repository: "owner/repo", | ||
| 107 | + TagName: "v1.0.0", | ||
| 108 | + Yes: true, | ||
| 109 | + HttpClient: func() (*http.Client, error) { | ||
| 110 | + return &http.Client{ | ||
| 111 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 112 | + if req.Method == "GET" { | ||
| 113 | + return &http.Response{ | ||
| 114 | + StatusCode: http.StatusOK, | ||
| 115 | + Header: make(http.Header), | ||
| 116 | + Body: io.NopCloser(strings.NewReader( | ||
| 117 | + `{"tag_name":"v1.0.0","name":"rel","html_url":"https://gitcode.com/owner/repo/-/releases/v1.0.0"}`)), | ||
| 118 | + }, nil | ||
| 119 | + } | ||
| 120 | + return &http.Response{ | ||
| 121 | + StatusCode: http.StatusMethodNotAllowed, | ||
| 122 | + Header: make(http.Header), | ||
| 123 | + Body: io.NopCloser(strings.NewReader(``)), | ||
| 124 | + }, nil | ||
| 125 | + }), | ||
| 126 | + }, nil | ||
| 127 | + }, | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + err := deleteRun(opts) | ||
| 131 | + if err == nil { | ||
| 132 | + t.Fatal("deleteRun() error = nil, want 405 error") | ||
| 133 | + } | ||
| 134 | + for _, want := range []string{"405", "web UI", "https://gitcode.com/owner/repo/-/releases/v1.0.0"} { | ||
| 135 | + if !strings.Contains(err.Error(), want) { | ||
| 136 | + t.Errorf("error = %q, want contains %q", err.Error(), want) | ||
| 137 | + } | ||
| 138 | + } | ||
| 139 | + if strings.Contains(out.String(), "Deleted release") { | ||
| 140 | + t.Errorf("output = %q, want no success line", out.String()) | ||
| 141 | + } | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +func TestDeleteRun405WithoutHTMLURLFallsBack(t *testing.T) { | ||
| 145 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 146 | + t.Setenv("GITCODE_TOKEN", "") | ||
| 147 | + f := cmdutil.TestFactory() | ||
| 148 | + f.IOStreams.Out = &strings.Builder{} | ||
| 149 | + | ||
| 150 | + opts := &DeleteOptions{ | ||
| 151 | + IO: f.IOStreams, | ||
| 152 | + Repository: "owner/repo", | ||
| 153 | + TagName: "v1.0.0", | ||
| 154 | + Yes: true, | ||
| 155 | + HttpClient: func() (*http.Client, error) { | ||
| 156 | + return &http.Client{ | ||
| 157 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 158 | + if req.Method == "GET" { | ||
| 159 | + return &http.Response{ | ||
| 160 | + StatusCode: http.StatusOK, | ||
| 161 | + Header: make(http.Header), | ||
| 162 | + Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0","name":"rel"}`)), | ||
| 163 | + }, nil | ||
| 164 | + } | ||
| 165 | + return &http.Response{ | ||
| 166 | + StatusCode: http.StatusMethodNotAllowed, | ||
| 167 | + Header: make(http.Header), | ||
| 168 | + Body: io.NopCloser(strings.NewReader(``)), | ||
| 169 | + }, nil | ||
| 170 | + }), | ||
| 171 | + }, nil | ||
| 172 | + }, | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + err := deleteRun(opts) | ||
| 176 | + if err == nil { | ||
| 177 | + t.Fatal("deleteRun() error = nil, want 405 error") | ||
| 178 | + } | ||
| 179 | + if !strings.Contains(err.Error(), "Delete the release from the web UI instead.") { | ||
| 180 | + t.Fatalf("error = %q, want fallback guidance without URL", err.Error()) | ||
| 181 | + } | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +// Success path: the platform does not support deletes today, but if it | ||
| 185 | +// ever enables the endpoint this is the only lock on the success output. | ||
| 186 | +func TestDeleteRunSucceeds(t *testing.T) { | ||
| 187 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 188 | + t.Setenv("GITCODE_TOKEN", "") | ||
| 189 | + f := cmdutil.TestFactory() | ||
| 190 | + out := &strings.Builder{} | ||
| 191 | + f.IOStreams.Out = out | ||
| 192 | + | ||
| 193 | + var deletePath string | ||
| 194 | + opts := &DeleteOptions{ | ||
| 195 | + IO: f.IOStreams, | ||
| 196 | + Repository: "owner/repo", | ||
| 197 | + TagName: "v1.0.0", | ||
| 198 | + Yes: true, | ||
| 199 | + HttpClient: func() (*http.Client, error) { | ||
| 200 | + return &http.Client{ | ||
| 201 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 202 | + if req.Method == "GET" { | ||
| 203 | + return &http.Response{ | ||
| 204 | + StatusCode: http.StatusOK, | ||
| 205 | + Header: make(http.Header), | ||
| 206 | + Body: io.NopCloser(strings.NewReader( | ||
| 207 | + `{"tag_name":"v1.0.0","name":"rel","html_url":"https://gitcode.com/owner/repo/-/releases/v1.0.0","id":123}`)), | ||
| 208 | + }, nil | ||
| 209 | + } | ||
| 210 | + deletePath = req.URL.Path | ||
| 211 | + return &http.Response{ | ||
| 212 | + StatusCode: http.StatusNoContent, | ||
| 213 | + Header: make(http.Header), | ||
| 214 | + Body: io.NopCloser(strings.NewReader(``)), | ||
| 215 | + }, nil | ||
| 216 | + }), | ||
| 217 | + }, nil | ||
| 218 | + }, | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + if err := deleteRun(opts); err != nil { | ||
| 222 | + t.Fatalf("deleteRun() error = %v", err) | ||
| 223 | + } | ||
| 224 | + if !strings.Contains(deletePath, "/releases/tags/v1.0.0") && !strings.Contains(deletePath, "/releases/") { | ||
| 225 | + t.Errorf("delete path = %q, want a release delete endpoint", deletePath) | ||
| 226 | + } | ||
| 227 | + if !strings.Contains(out.String(), "Deleted release v1.0.0") { | ||
| 228 | + t.Fatalf("output = %q, want success line", out.String()) | ||
| 229 | + } | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +func TestDeleteRunReleaseNotFoundIsWrapped(t *testing.T) { | ||
| 233 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 234 | + t.Setenv("GITCODE_TOKEN", "") | ||
| 235 | + f := cmdutil.TestFactory() | ||
| 236 | + f.IOStreams.Out = &strings.Builder{} | ||
| 237 | + | ||
| 238 | + opts := &DeleteOptions{ | ||
| 239 | + IO: f.IOStreams, | ||
| 240 | + Repository: "owner/repo", | ||
| 241 | + TagName: "v1.0.0", | ||
| 242 | + Yes: true, | ||
| 243 | + HttpClient: func() (*http.Client, error) { | ||
| 244 | + return &http.Client{ | ||
| 245 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 246 | + return &http.Response{ | ||
| 247 | + StatusCode: http.StatusNotFound, | ||
| 248 | + Header: make(http.Header), | ||
| 249 | + Body: io.NopCloser(strings.NewReader(``)), | ||
| 250 | + }, nil | ||
| 251 | + }), | ||
| 252 | + }, nil | ||
| 253 | + }, | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + err := deleteRun(opts) | ||
| 257 | + if err == nil { | ||
| 258 | + t.Fatal("deleteRun() error = nil, want not-found error") | ||
| 259 | + } | ||
| 260 | + if !strings.Contains(err.Error(), "release v1.0.0 not found in owner/repo") { | ||
| 261 | + t.Fatalf("error = %q, want wrapped not-found message", err.Error()) | ||
| 262 | + } | ||
| 263 | + if code := cmdutil.ExitCode(err); code != 3 { | ||
| 264 | + t.Errorf("exit code = %d, want 3 (not found)", code) | ||
| 265 | + } | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +func TestDeleteRunErrNoReleaseIDMessage(t *testing.T) { | ||
| 269 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 270 | + t.Setenv("GITCODE_TOKEN", "") | ||
| 271 | + f := cmdutil.TestFactory() | ||
| 272 | + f.IOStreams.Out = &strings.Builder{} | ||
| 273 | + | ||
| 274 | + opts := &DeleteOptions{ | ||
| 275 | + IO: f.IOStreams, | ||
| 276 | + Repository: "owner/repo", | ||
| 277 | + TagName: "v1.0.0", | ||
| 278 | + Yes: true, | ||
| 279 | + HttpClient: func() (*http.Client, error) { | ||
| 280 | + return &http.Client{ | ||
| 281 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 282 | + if req.Method == "GET" { | ||
| 283 | + return &http.Response{ | ||
| 284 | + StatusCode: http.StatusOK, | ||
| 285 | + Header: make(http.Header), | ||
| 286 | + Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0","name":"rel"}`)), | ||
| 287 | + }, nil | ||
| 288 | + } | ||
| 289 | + // Tag-based deletion 404s and the fallback needs a | ||
| 290 | + // release ID, which the lookup response omitted. | ||
| 291 | + return &http.Response{ | ||
| 292 | + StatusCode: http.StatusNotFound, | ||
| 293 | + Header: make(http.Header), | ||
| 294 | + Body: io.NopCloser(strings.NewReader(``)), | ||
| 295 | + }, nil | ||
| 296 | + }), | ||
| 297 | + }, nil | ||
| 298 | + }, | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + err := deleteRun(opts) | ||
| 302 | + if err == nil { | ||
| 303 | + t.Fatal("deleteRun() error = nil, want ErrNoReleaseID error") | ||
| 304 | + } | ||
| 305 | + if !strings.Contains(err.Error(), "tag-based endpoint unavailable") { | ||
| 306 | + t.Fatalf("error = %q, want tag-based endpoint unavailable", err.Error()) | ||
| 307 | + } | ||
| 308 | +} | ||
| @@ -23,6 +23,7 @@ type ListOptions struct { | |||
| 23 | // Flags | 23 | // Flags |
| 24 | Repository string | 24 | Repository string |
| 25 | Limit int | 25 | Limit int |
| 26 | + Page int | ||
| 26 | JSON bool | 27 | JSON bool |
| 27 | } | 28 | } |
| 28 | 29 | ||
| @@ -44,12 +45,15 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman | |||
| 44 | # List releases | 45 | # List releases |
| 45 | $ gc release list -R owner/repo | 46 | $ gc release list -R owner/repo |
| 46 | 47 | ||
| 47 | - # List releases in a specific repository | ||
| 48 | - $ gc release list -R owner/repo | ||
| 49 | - | ||
| 50 | # Limit the number of results | 48 | # Limit the number of results |
| 51 | $ gc release list -R owner/repo --limit 10 | 49 | $ gc release list -R owner/repo --limit 10 |
| 52 | 50 | ||
| 51 | + # List 150 releases (fetched across API pages) | ||
| 52 | + $ gc release list -R owner/repo --limit 150 | ||
| 53 | + | ||
| 54 | + # Fetch a single specific page | ||
| 55 | + $ gc release list -R owner/repo --page 2 | ||
| 56 | + | ||
| 53 | # Output as JSON | 57 | # Output as JSON |
| 54 | $ gc release list -R owner/repo --json | 58 | $ gc release list -R owner/repo --json |
| 55 | `), | 59 | `), |
| @@ -62,7 +66,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman | |||
| 62 | } | 66 | } |
| 63 | 67 | ||
| 64 | cmd.Flags().StringVarP(&opts.Repository, "repo", "R", "", "Repository (owner/repo)") | 68 | cmd.Flags().StringVarP(&opts.Repository, "repo", "R", "", "Repository (owner/repo)") |
| 65 | - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of releases to list") | 69 | + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of releases to list (fetched across API pages when greater than 100)") |
| 70 | + cmd.Flags().IntVar(&opts.Page, "page", 0, "Page number to fetch (single request; the API returns at most 100 per page)") | ||
| 66 | cmdutil.AddJSONFlag(cmd, &opts.JSON) | 71 | cmdutil.AddJSONFlag(cmd, &opts.JSON) |
| 67 | 72 | ||
| 68 | return cmd | 73 | return cmd |
| @@ -90,19 +95,28 @@ func listRun(opts *ListOptions) error { | |||
| 90 | if opts.Limit <= 0 { | 95 | if opts.Limit <= 0 { |
| 91 | return cmdutil.NewUsageError("--limit must be greater than 0") | 96 | return cmdutil.NewUsageError("--limit must be greater than 0") |
| 92 | } | 97 | } |
| 98 | + if opts.Page < 0 { | ||
| 99 | + return cmdutil.NewUsageError("--page must be greater than or equal to 0") | ||
| 100 | + } | ||
| 101 | + if opts.Page > 0 && opts.Limit > 100 { | ||
| 102 | + return cmdutil.NewUsageError("--page cannot be combined with --limit greater than 100 (the API returns at most 100 releases per page)") | ||
| 103 | + } | ||
| 93 | 104 | ||
| 94 | // List releases | 105 | // List releases |
| 95 | - releases, err := api.ListReleases(client, owner, repo, &api.ReleaseListOptions{ | 106 | + releases, err := fetchReleases(client, owner, repo, opts) |
| 96 | - PerPage: opts.Limit, | ||
| 97 | - Direction: "desc", | ||
| 98 | - }) | ||
| 99 | if err != nil { | 107 | if err != nil { |
| 100 | return fmt.Errorf("failed to list releases: %w", err) | 108 | return fmt.Errorf("failed to list releases: %w", err) |
| 101 | } | 109 | } |
| 102 | 110 | ||
| 103 | // Sort releases by published date descending (newest first). | 111 | // Sort releases by published date descending (newest first). |
| 104 | - // Falls back to created_at when published_at is nil. | 112 | + // Falls back to created_at when published_at is nil. Sort before the |
| 113 | + // limit trim: pages accumulate in created_at order, but the display | ||
| 114 | + // order (and the (latest) marker) is published_at order, so trimming | ||
| 115 | + // first could drop newer-published releases at a page boundary. | ||
| 105 | sortReleasesByDate(releases) | 116 | sortReleasesByDate(releases) |
| 117 | + if len(releases) > opts.Limit { | ||
| 118 | + releases = releases[:opts.Limit] | ||
| 119 | + } | ||
| 106 | 120 | ||
| 107 | if len(releases) == 0 { | 121 | if len(releases) == 0 { |
| 108 | if opts.JSON { | 122 | if opts.JSON { |
| @@ -153,6 +167,41 @@ func listRun(opts *ListOptions) error { | |||
| 153 | return nil | 167 | return nil |
| 154 | } | 168 | } |
| 155 | 169 | ||
| 170 | +// fetchReleases returns the releases for the requested window. The API | ||
| 171 | +// caps a single page at 100 releases, so without an explicit --page the | ||
| 172 | +// list accumulates consecutive pages until the limit is satisfied or a | ||
| 173 | +// short page marks the end — a --limit above 100 must not silently | ||
| 174 | +// truncate. An explicit --page fetches exactly that page instead. | ||
| 175 | +func fetchReleases(client *api.Client, owner, repo string, opts *ListOptions) ([]api.Release, error) { | ||
| 176 | + perPage := opts.Limit | ||
| 177 | + if perPage > 100 { | ||
| 178 | + perPage = 100 | ||
| 179 | + } | ||
| 180 | + if opts.Page > 0 { | ||
| 181 | + return api.ListReleases(client, owner, repo, &api.ReleaseListOptions{ | ||
| 182 | + PerPage: perPage, | ||
| 183 | + Page: opts.Page, | ||
| 184 | + Direction: "desc", | ||
| 185 | + }) | ||
| 186 | + } | ||
| 187 | + // Non-nil empty slice: an empty result must stay "[]" in JSON output. | ||
| 188 | + all := []api.Release{} | ||
| 189 | + for page := 1; ; page++ { | ||
| 190 | + releases, err := api.ListReleases(client, owner, repo, &api.ReleaseListOptions{ | ||
| 191 | + PerPage: perPage, | ||
| 192 | + Page: page, | ||
| 193 | + Direction: "desc", | ||
| 194 | + }) | ||
| 195 | + if err != nil { | ||
| 196 | + return nil, err | ||
| 197 | + } | ||
| 198 | + all = append(all, releases...) | ||
| 199 | + if len(all) >= opts.Limit || len(releases) < perPage { | ||
| 200 | + return all, nil | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | +} | ||
| 204 | + | ||
| 156 | // sortReleasesByDate sorts releases by published date in descending order (newest first). | 205 | // sortReleasesByDate sorts releases by published date in descending order (newest first). |
| 157 | // | 206 | // |
| 158 | // When PublishedAt is nil (e.g., draft releases or API omissions), the function | 207 | // When PublishedAt is nil (e.g., draft releases or API omissions), the function |
| @@ -1,13 +1,16 @@ | |||
| 1 | package list | 1 | package list |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | - "gitcode.com/gitcode-cli/cli/pkg/testutil" | 4 | + "encoding/json" |
| 5 | + "fmt" | ||
| 5 | "io" | 6 | "io" |
| 6 | "net/http" | 7 | "net/http" |
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| 10 | + "time" | ||
| 9 | 11 | ||
| 10 | cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil" | 12 | cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil" |
| 13 | + "gitcode.com/gitcode-cli/cli/pkg/testutil" | ||
| 11 | ) | 14 | ) |
| 12 | 15 | ||
| 13 | func TestNewCmdList(t *testing.T) { | 16 | func TestNewCmdList(t *testing.T) { |
| @@ -370,3 +373,386 @@ func TestListRunRequestsDirectionDesc(t *testing.T) { | |||
| 370 | t.Errorf("expected per_page=5 in query, got %q", gotQuery) | 373 | t.Errorf("expected per_page=5 in query, got %q", gotQuery) |
| 371 | } | 374 | } |
| 372 | } | 375 | } |
| 376 | + | ||
| 377 | +// releasePageJSON builds a page of count releases starting at index from | ||
| 378 | +// (r<from> .. r<from+count-1>) with strictly decreasing published_at, so | ||
| 379 | +// r1 is the newest and the display sort is deterministic. | ||
| 380 | +func releasePageJSON(from, count int) string { | ||
| 381 | + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | ||
| 382 | + entries := make([]string, 0, count) | ||
| 383 | + for i := 0; i < count; i++ { | ||
| 384 | + n := from + i | ||
| 385 | + stamp := base.Add(-time.Duration(n) * time.Minute).Format(time.RFC3339) | ||
| 386 | + entries = append(entries, fmt.Sprintf(`{"tag_name":"r%d","name":"r%d","published_at":%q}`, n, n, stamp)) | ||
| 387 | + } | ||
| 388 | + return "[" + strings.Join(entries, ",") + "]" | ||
| 389 | +} | ||
| 390 | + | ||
| 391 | +type releaseTagOnly struct { | ||
| 392 | + TagName string `json:"tag_name"` | ||
| 393 | +} | ||
| 394 | + | ||
| 395 | +func TestListRunPaginatesUntilLimit(t *testing.T) { | ||
| 396 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 397 | + f := cmdutil.TestFactory() | ||
| 398 | + out := &strings.Builder{} | ||
| 399 | + f.IOStreams.Out = out | ||
| 400 | + | ||
| 401 | + var gotPages []string | ||
| 402 | + err := listRun(&ListOptions{ | ||
| 403 | + IO: f.IOStreams, | ||
| 404 | + HttpClient: func() (*http.Client, error) { | ||
| 405 | + return &http.Client{ | ||
| 406 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 407 | + gotPages = append(gotPages, req.URL.Query().Get("page")) | ||
| 408 | + if req.URL.Query().Get("per_page") != "100" { | ||
| 409 | + t.Errorf("per_page = %q, want 100", req.URL.Query().Get("per_page")) | ||
| 410 | + } | ||
| 411 | + switch req.URL.Query().Get("page") { | ||
| 412 | + case "1": | ||
| 413 | + return listJSONResponse(releasePageJSON(1, 100)), nil | ||
| 414 | + case "2": | ||
| 415 | + return listJSONResponse(releasePageJSON(101, 100)), nil | ||
| 416 | + default: | ||
| 417 | + t.Fatalf("unexpected page %s", req.URL.RawQuery) | ||
| 418 | + return nil, nil | ||
| 419 | + } | ||
| 420 | + }), | ||
| 421 | + }, nil | ||
| 422 | + }, | ||
| 423 | + Repository: "owner/repo", | ||
| 424 | + Limit: 150, | ||
| 425 | + JSON: true, | ||
| 426 | + }) | ||
| 427 | + if err != nil { | ||
| 428 | + t.Fatalf("listRun() error = %v", err) | ||
| 429 | + } | ||
| 430 | + if len(gotPages) != 2 || gotPages[0] != "1" || gotPages[1] != "2" { | ||
| 431 | + t.Errorf("pages = %v, want exactly [1 2]", gotPages) | ||
| 432 | + } | ||
| 433 | + var releases []releaseTagOnly | ||
| 434 | + if err := json.Unmarshal([]byte(out.String()), &releases); err != nil { | ||
| 435 | + t.Fatalf("invalid JSON output: %v\n%s", err, out.String()) | ||
| 436 | + } | ||
| 437 | + if len(releases) != 150 { | ||
| 438 | + t.Fatalf("got %d releases, want 150", len(releases)) | ||
| 439 | + } | ||
| 440 | + if releases[0].TagName != "r1" || releases[149].TagName != "r150" { | ||
| 441 | + t.Errorf("trim kept wrong window: first %q last %q, want r1..r150", releases[0].TagName, releases[149].TagName) | ||
| 442 | + } | ||
| 443 | +} | ||
| 444 | + | ||
| 445 | +func TestListRunStopsOnShortPage(t *testing.T) { | ||
| 446 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 447 | + f := cmdutil.TestFactory() | ||
| 448 | + out := &strings.Builder{} | ||
| 449 | + f.IOStreams.Out = out | ||
| 450 | + | ||
| 451 | + var requestCount int | ||
| 452 | + err := listRun(&ListOptions{ | ||
| 453 | + IO: f.IOStreams, | ||
| 454 | + HttpClient: func() (*http.Client, error) { | ||
| 455 | + return &http.Client{ | ||
| 456 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 457 | + requestCount++ | ||
| 458 | + switch req.URL.Query().Get("page") { | ||
| 459 | + case "1": | ||
| 460 | + return listJSONResponse(releasePageJSON(1, 100)), nil | ||
| 461 | + case "2": | ||
| 462 | + return listJSONResponse(releasePageJSON(101, 30)), nil | ||
| 463 | + default: | ||
| 464 | + t.Fatalf("unexpected page %s", req.URL.RawQuery) | ||
| 465 | + return nil, nil | ||
| 466 | + } | ||
| 467 | + }), | ||
| 468 | + }, nil | ||
| 469 | + }, | ||
| 470 | + Repository: "owner/repo", | ||
| 471 | + Limit: 150, | ||
| 472 | + JSON: true, | ||
| 473 | + }) | ||
| 474 | + if err != nil { | ||
| 475 | + t.Fatalf("listRun() error = %v", err) | ||
| 476 | + } | ||
| 477 | + if requestCount != 2 { | ||
| 478 | + t.Errorf("requests = %d, want 2 (short page must end the loop)", requestCount) | ||
| 479 | + } | ||
| 480 | + var releases []releaseTagOnly | ||
| 481 | + if err := json.Unmarshal([]byte(out.String()), &releases); err != nil { | ||
| 482 | + t.Fatalf("invalid JSON output: %v", err) | ||
| 483 | + } | ||
| 484 | + if len(releases) != 130 { | ||
| 485 | + t.Fatalf("got %d releases, want 130", len(releases)) | ||
| 486 | + } | ||
| 487 | +} | ||
| 488 | + | ||
| 489 | +func TestListRunPageFlagSingleRequest(t *testing.T) { | ||
| 490 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 491 | + f := cmdutil.TestFactory() | ||
| 492 | + f.IOStreams.Out = &strings.Builder{} | ||
| 493 | + | ||
| 494 | + var gotQuery string | ||
| 495 | + var requestCount int | ||
| 496 | + err := listRun(&ListOptions{ | ||
| 497 | + IO: f.IOStreams, | ||
| 498 | + HttpClient: func() (*http.Client, error) { | ||
| 499 | + return &http.Client{ | ||
| 500 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 501 | + requestCount++ | ||
| 502 | + gotQuery = req.URL.RawQuery | ||
| 503 | + return listJSONResponse(`[]`), nil | ||
| 504 | + }), | ||
| 505 | + }, nil | ||
| 506 | + }, | ||
| 507 | + Repository: "owner/repo", | ||
| 508 | + Limit: 30, | ||
| 509 | + Page: 2, | ||
| 510 | + }) | ||
| 511 | + if err != nil { | ||
| 512 | + t.Fatalf("listRun() error = %v", err) | ||
| 513 | + } | ||
| 514 | + if requestCount != 1 { | ||
| 515 | + t.Errorf("requests = %d, want a single request for --page", requestCount) | ||
| 516 | + } | ||
| 517 | + for _, want := range []string{"page=2", "per_page=30", "direction=desc"} { | ||
| 518 | + if !strings.Contains(gotQuery, want) { | ||
| 519 | + t.Errorf("query = %q, want contains %q", gotQuery, want) | ||
| 520 | + } | ||
| 521 | + } | ||
| 522 | +} | ||
| 523 | + | ||
| 524 | +func TestListRunPageWithLimitAboveCapIsUsageError(t *testing.T) { | ||
| 525 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 526 | + f := cmdutil.TestFactory() | ||
| 527 | + f.IOStreams.Out = &strings.Builder{} | ||
| 528 | + | ||
| 529 | + err := listRun(&ListOptions{ | ||
| 530 | + IO: f.IOStreams, | ||
| 531 | + HttpClient: func() (*http.Client, error) { | ||
| 532 | + return &http.Client{ | ||
| 533 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 534 | + t.Fatalf("unexpected request to %s", req.URL.String()) | ||
| 535 | + return nil, nil | ||
| 536 | + }), | ||
| 537 | + }, nil | ||
| 538 | + }, | ||
| 539 | + Repository: "owner/repo", | ||
| 540 | + Limit: 150, | ||
| 541 | + Page: 2, | ||
| 542 | + }) | ||
| 543 | + if err == nil { | ||
| 544 | + t.Fatal("listRun() error = nil, want usage error") | ||
| 545 | + } | ||
| 546 | + if !strings.Contains(err.Error(), "--page cannot be combined") { | ||
| 547 | + t.Fatalf("error = %q, want --page cannot be combined", err.Error()) | ||
| 548 | + } | ||
| 549 | + if code := cmdutil.ExitCode(err); code != 2 { | ||
| 550 | + t.Errorf("exit code = %d, want 2", code) | ||
| 551 | + } | ||
| 552 | +} | ||
| 553 | + | ||
| 554 | +func TestListRunNegativePageIsUsageError(t *testing.T) { | ||
| 555 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 556 | + f := cmdutil.TestFactory() | ||
| 557 | + f.IOStreams.Out = &strings.Builder{} | ||
| 558 | + | ||
| 559 | + err := listRun(&ListOptions{ | ||
| 560 | + IO: f.IOStreams, | ||
| 561 | + HttpClient: func() (*http.Client, error) { | ||
| 562 | + return &http.Client{ | ||
| 563 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 564 | + t.Fatalf("unexpected request to %s", req.URL.String()) | ||
| 565 | + return nil, nil | ||
| 566 | + }), | ||
| 567 | + }, nil | ||
| 568 | + }, | ||
| 569 | + Repository: "owner/repo", | ||
| 570 | + Limit: 30, | ||
| 571 | + Page: -1, | ||
| 572 | + }) | ||
| 573 | + if err == nil { | ||
| 574 | + t.Fatal("listRun() error = nil, want usage error") | ||
| 575 | + } | ||
| 576 | + if !strings.Contains(err.Error(), "--page must be greater than or equal to 0") { | ||
| 577 | + t.Fatalf("error = %q, want --page must be greater than or equal to 0", err.Error()) | ||
| 578 | + } | ||
| 579 | + if code := cmdutil.ExitCode(err); code != 2 { | ||
| 580 | + t.Errorf("exit code = %d, want 2", code) | ||
| 581 | + } | ||
| 582 | +} | ||
| 583 | + | ||
| 584 | +func TestListRunDefaultLimitSingleRequest(t *testing.T) { | ||
| 585 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 586 | + f := cmdutil.TestFactory() | ||
| 587 | + out := &strings.Builder{} | ||
| 588 | + f.IOStreams.Out = out | ||
| 589 | + | ||
| 590 | + var gotQuery string | ||
| 591 | + var requestCount int | ||
| 592 | + err := listRun(&ListOptions{ | ||
| 593 | + IO: f.IOStreams, | ||
| 594 | + HttpClient: func() (*http.Client, error) { | ||
| 595 | + return &http.Client{ | ||
| 596 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 597 | + requestCount++ | ||
| 598 | + gotQuery = req.URL.RawQuery | ||
| 599 | + return listJSONResponse(releasePageJSON(1, 5)), nil | ||
| 600 | + }), | ||
| 601 | + }, nil | ||
| 602 | + }, | ||
| 603 | + Repository: "owner/repo", | ||
| 604 | + Limit: 30, | ||
| 605 | + JSON: true, | ||
| 606 | + }) | ||
| 607 | + if err != nil { | ||
| 608 | + t.Fatalf("listRun() error = %v", err) | ||
| 609 | + } | ||
| 610 | + if requestCount != 1 { | ||
| 611 | + t.Errorf("requests = %d, want 1 for a default limit", requestCount) | ||
| 612 | + } | ||
| 613 | + if !strings.Contains(gotQuery, "per_page=30") { | ||
| 614 | + t.Errorf("query = %q, want per_page=30", gotQuery) | ||
| 615 | + } | ||
| 616 | + var releases []releaseTagOnly | ||
| 617 | + if err := json.Unmarshal([]byte(out.String()), &releases); err != nil { | ||
| 618 | + t.Fatalf("invalid JSON output: %v", err) | ||
| 619 | + } | ||
| 620 | + if len(releases) != 5 { | ||
| 621 | + t.Fatalf("got %d releases, want 5", len(releases)) | ||
| 622 | + } | ||
| 623 | +} | ||
| 624 | + | ||
| 625 | +func TestListRunEmptyResultJSONIsEmptyArray(t *testing.T) { | ||
| 626 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 627 | + f := cmdutil.TestFactory() | ||
| 628 | + out := &strings.Builder{} | ||
| 629 | + f.IOStreams.Out = out | ||
| 630 | + | ||
| 631 | + err := listRun(&ListOptions{ | ||
| 632 | + IO: f.IOStreams, | ||
| 633 | + HttpClient: func() (*http.Client, error) { | ||
| 634 | + return &http.Client{ | ||
| 635 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 636 | + return listJSONResponse(`[]`), nil | ||
| 637 | + }), | ||
| 638 | + }, nil | ||
| 639 | + }, | ||
| 640 | + Repository: "owner/repo", | ||
| 641 | + Limit: 30, | ||
| 642 | + JSON: true, | ||
| 643 | + }) | ||
| 644 | + if err != nil { | ||
| 645 | + t.Fatalf("listRun() error = %v", err) | ||
| 646 | + } | ||
| 647 | + if got := strings.TrimSpace(out.String()); got != "[]" { | ||
| 648 | + t.Fatalf("output = %q, want []", got) | ||
| 649 | + } | ||
| 650 | +} | ||
| 651 | + | ||
| 652 | +// releasePageJSONWithOverride is releasePageJSON with one release's | ||
| 653 | +// published_at replaced by a stamp newer than every default stamp, for | ||
| 654 | +// locking the sort-before-trim order. | ||
| 655 | +func releasePageJSONWithOverride(from, count, overrideIndex int) string { | ||
| 656 | + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) | ||
| 657 | + newest := base.Add(10 * time.Minute).Format(time.RFC3339) | ||
| 658 | + entries := make([]string, 0, count) | ||
| 659 | + for i := 0; i < count; i++ { | ||
| 660 | + n := from + i | ||
| 661 | + stamp := base.Add(-time.Duration(n) * time.Minute).Format(time.RFC3339) | ||
| 662 | + if n == overrideIndex { | ||
| 663 | + stamp = newest | ||
| 664 | + } | ||
| 665 | + entries = append(entries, fmt.Sprintf(`{"tag_name":"r%d","name":"r%d","published_at":%q}`, n, n, stamp)) | ||
| 666 | + } | ||
| 667 | + return "[" + strings.Join(entries, ",") + "]" | ||
| 668 | +} | ||
| 669 | + | ||
| 670 | +// The accumulated pool arrives in created_at order, but the display window | ||
| 671 | +// is published_at order: a release beyond the limit window in accumulation | ||
| 672 | +// order must still survive when it is newer-published than the boundary | ||
| 673 | +// entries. Trim-before-sort would drop it silently. | ||
| 674 | +func TestListRunSortsBeforeTrimmingAcrossPages(t *testing.T) { | ||
| 675 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 676 | + f := cmdutil.TestFactory() | ||
| 677 | + out := &strings.Builder{} | ||
| 678 | + f.IOStreams.Out = out | ||
| 679 | + | ||
| 680 | + err := listRun(&ListOptions{ | ||
| 681 | + IO: f.IOStreams, | ||
| 682 | + HttpClient: func() (*http.Client, error) { | ||
| 683 | + return &http.Client{ | ||
| 684 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 685 | + switch req.URL.Query().Get("page") { | ||
| 686 | + case "1": | ||
| 687 | + return listJSONResponse(releasePageJSON(1, 100)), nil | ||
| 688 | + case "2": | ||
| 689 | + // r160 sits past the 150-entry accumulation window | ||
| 690 | + // but is the newest release by published_at. | ||
| 691 | + return listJSONResponse(releasePageJSONWithOverride(101, 100, 160)), nil | ||
| 692 | + default: | ||
| 693 | + t.Fatalf("unexpected page %s", req.URL.RawQuery) | ||
| 694 | + return nil, nil | ||
| 695 | + } | ||
| 696 | + }), | ||
| 697 | + }, nil | ||
| 698 | + }, | ||
| 699 | + Repository: "owner/repo", | ||
| 700 | + Limit: 150, | ||
| 701 | + JSON: true, | ||
| 702 | + }) | ||
| 703 | + if err != nil { | ||
| 704 | + t.Fatalf("listRun() error = %v", err) | ||
| 705 | + } | ||
| 706 | + var releases []releaseTagOnly | ||
| 707 | + if err := json.Unmarshal([]byte(out.String()), &releases); err != nil { | ||
| 708 | + t.Fatalf("invalid JSON output: %v", err) | ||
| 709 | + } | ||
| 710 | + if len(releases) != 150 { | ||
| 711 | + t.Fatalf("got %d releases, want 150", len(releases)) | ||
| 712 | + } | ||
| 713 | + if releases[0].TagName != "r160" { | ||
| 714 | + t.Errorf("first release = %q, want r160 (newest published must head the window)", releases[0].TagName) | ||
| 715 | + } | ||
| 716 | + if releases[149].TagName != "r149" { | ||
| 717 | + t.Errorf("last release = %q, want r149 (oldest kept at the trim boundary)", releases[149].TagName) | ||
| 718 | + } | ||
| 719 | +} | ||
| 720 | + | ||
| 721 | +// --page with --limit exactly at the API cap is the allowed boundary of | ||
| 722 | +// the mutual-exclusion guard (the guard rejects only limit > 100). | ||
| 723 | +func TestListRunPageWithLimitAtCapSucceeds(t *testing.T) { | ||
| 724 | + t.Setenv("GC_TOKEN", "test-token") | ||
| 725 | + f := cmdutil.TestFactory() | ||
| 726 | + f.IOStreams.Out = &strings.Builder{} | ||
| 727 | + | ||
| 728 | + var gotQuery string | ||
| 729 | + err := listRun(&ListOptions{ | ||
| 730 | + IO: f.IOStreams, | ||
| 731 | + HttpClient: func() (*http.Client, error) { | ||
| 732 | + return &http.Client{ | ||
| 733 | + Transport: testutil.NewRoundTripFunc(func(req *http.Request) (*http.Response, error) { | ||
| 734 | + gotQuery = req.URL.RawQuery | ||
| 735 | + return listJSONResponse(`[]`), nil | ||
| 736 | + }), | ||
| 737 | + }, nil | ||
| 738 | + }, | ||
| 739 | + Repository: "owner/repo", | ||
| 740 | + Limit: 100, | ||
| 741 | + Page: 2, | ||
| 742 | + }) | ||
| 743 | + if err != nil { | ||
| 744 | + t.Fatalf("listRun() error = %v", err) | ||
| 745 | + } | ||
| 746 | + if !strings.Contains(gotQuery, "per_page=100") || !strings.Contains(gotQuery, "page=2") { | ||
| 747 | + t.Errorf("query = %q, want per_page=100 and page=2", gotQuery) | ||
| 748 | + } | ||
| 749 | +} | ||
| 750 | + | ||
| 751 | +func listJSONResponse(body string) *http.Response { | ||
| 752 | + return &http.Response{ | ||
| 753 | + StatusCode: http.StatusOK, | ||
| 754 | + Status: http.StatusText(http.StatusOK), | ||
| 755 | + Header: make(http.Header), | ||
| 756 | + Body: io.NopCloser(strings.NewReader(body)), | ||
| 757 | + } | ||
| 758 | +} | ||
| @@ -1,34 +1,41 @@ | |||
| 1 | #!/usr/bin/env bash | 1 | #!/usr/bin/env bash |
| 2 | -# Sync release version strings in README.md and docs/PACKAGING.md. | 2 | +# Sync release version strings in README.md, docs/PACKAGING.md and |
| 3 | +# docs/AI-GUIDE.md. | ||
| 3 | # | 4 | # |
| 4 | -# These two files are committed, user-facing docs whose download URLs and | 5 | +# These files are committed, user-facing docs whose download URLs and |
| 5 | # examples carry a pinned release version. Unlike the build-time version | 6 | # examples carry a pinned release version. Unlike the build-time version |
| 6 | # sync in scripts/package.sh and .github/workflows/release.yml (which cover | 7 | # sync in scripts/package.sh and .github/workflows/release.yml (which cover |
| 7 | # nfpm-*.yaml / pyproject.toml / gc_cli/__init__.py), these docs are not | 8 | # nfpm-*.yaml / pyproject.toml / gc_cli/__init__.py), these docs are not |
| 8 | # synced automatically, so they lag behind every release (see #314). | 9 | # synced automatically, so they lag behind every release (see #314). |
| 9 | # | 10 | # |
| 10 | # This script derives the target version from a single input, auto-detects | 11 | # This script derives the target version from a single input, auto-detects |
| 11 | -# the version currently referenced in the docs, and replaces it. Run it as | 12 | +# the version each file currently references (per file, so a partially |
| 13 | +# synced docs tree self-heals in a single run), and replaces it. Run it as | ||
| 12 | # a release step and commit the result. | 14 | # a release step and commit the result. |
| 13 | # | 15 | # |
| 14 | # Usage: | 16 | # Usage: |
| 15 | -# ./scripts/sync-docs-version.sh <version> [--dry-run] | 17 | +# ./scripts/sync-docs-version.sh <version> [--dry-run|--check] |
| 16 | # | 18 | # |
| 17 | # <version> Target release version, e.g. v0.7.0 or 0.7.0 | 19 | # <version> Target release version, e.g. v0.7.0 or 0.7.0 |
| 18 | # --dry-run Print the planned changes without writing files | 20 | # --dry-run Print the planned changes without writing files |
| 21 | +# --check Verify every doc references the target version's download | ||
| 22 | +# URL; exit 1 with a fix hint otherwise. Writes nothing. | ||
| 19 | # | 23 | # |
| 20 | # Exit codes: | 24 | # Exit codes: |
| 21 | -# 0 success (or already at target version, no-op) | 25 | +# 0 success (or every doc already at target version / check passed) |
| 22 | -# 1 invalid input / detection failure / residual old version after replace | 26 | +# 1 invalid input / detection failure / residual old version after |
| 27 | +# replace / --check failed | ||
| 23 | # 2 usage error | 28 | # 2 usage error |
| 24 | 29 | ||
| 25 | set -euo pipefail | 30 | set -euo pipefail |
| 26 | 31 | ||
| 27 | dry_run=0 | 32 | dry_run=0 |
| 33 | +check_mode=0 | ||
| 28 | target="" | 34 | target="" |
| 29 | -for arg in "${1:-}" "${2:-}"; do | 35 | +for arg in "$@"; do |
| 30 | case "${arg}" in | 36 | case "${arg}" in |
| 31 | --dry-run) dry_run=1 ;; | 37 | --dry-run) dry_run=1 ;; |
| 38 | + --check) check_mode=1 ;; | ||
| 32 | "") ;; | 39 | "") ;; |
| 33 | -*) echo "Unknown option: ${arg}" >&2; exit 2 ;; | 40 | -*) echo "Unknown option: ${arg}" >&2; exit 2 ;; |
| 34 | *) target="${arg}" ;; | 41 | *) target="${arg}" ;; |
| @@ -36,11 +43,16 @@ for arg in "${1:-}" "${2:-}"; do | |||
| 36 | done | 43 | done |
| 37 | 44 | ||
| 38 | if [[ -z "${target}" ]]; then | 45 | if [[ -z "${target}" ]]; then |
| 39 | - echo "Usage: $0 <version> [--dry-run]" >&2 | 46 | + echo "Usage: $0 <version> [--dry-run|--check]" >&2 |
| 40 | echo "Example: $0 v0.7.0" >&2 | 47 | echo "Example: $0 v0.7.0" >&2 |
| 41 | exit 2 | 48 | exit 2 |
| 42 | fi | 49 | fi |
| 43 | 50 | ||
| 51 | +if [[ ${check_mode} -eq 1 && ${dry_run} -eq 1 ]]; then | ||
| 52 | + echo "--check cannot be combined with --dry-run" >&2 | ||
| 53 | + exit 2 | ||
| 54 | +fi | ||
| 55 | + | ||
| 44 | if [[ ! "${target}" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then | 56 | if [[ ! "${target}" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then |
| 45 | echo "Invalid version: ${target}" >&2 | 57 | echo "Invalid version: ${target}" >&2 |
| 46 | echo "Expected vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH (no prerelease)" >&2 | 58 | echo "Expected vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH (no prerelease)" >&2 |
| @@ -54,50 +66,89 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |||
| 54 | repo_root="$(cd "${script_dir}/.." && pwd)" | 66 | repo_root="$(cd "${script_dir}/.." && pwd)" |
| 55 | readme="${repo_root}/README.md" | 67 | readme="${repo_root}/README.md" |
| 56 | packaging="${repo_root}/docs/PACKAGING.md" | 68 | packaging="${repo_root}/docs/PACKAGING.md" |
| 69 | +ai_guide="${repo_root}/docs/AI-GUIDE.md" | ||
| 57 | 70 | ||
| 58 | -for f in "${readme}" "${packaging}"; do | 71 | +for f in "${readme}" "${packaging}" "${ai_guide}"; do |
| 59 | if [[ ! -f "${f}" ]]; then | 72 | if [[ ! -f "${f}" ]]; then |
| 60 | echo "Missing file: ${f}" >&2 | 73 | echo "Missing file: ${f}" >&2 |
| 61 | exit 1 | 74 | exit 1 |
| 62 | fi | 75 | fi |
| 63 | done | 76 | done |
| 64 | 77 | ||
| 65 | -# Detect the version currently referenced in README via the first | 78 | +# Check mode: every doc must reference the target version's download URL |
| 66 | -# releases/download/vX.Y.Z/ occurrence. | 79 | +# directory. Used by the release preflight and CI as a drift gate. |
| 67 | -old_num="$(grep -oE 'releases/download/v[0-9]+\.[0-9]+\.[0-9]+/' "${readme}" | head -1 | sed -E 's#releases/download/v([0-9]+\.[0-9]+\.[0-9]+)/#\1#')" | 80 | +if [[ ${check_mode} -eq 1 ]]; then |
| 68 | -if [[ -z "${old_num}" ]]; then | 81 | + failed=0 |
| 69 | - echo "Could not detect current doc version in ${readme}" >&2 | 82 | + for f in "${readme}" "${packaging}" "${ai_guide}"; do |
| 70 | - echo "Expected a 'releases/download/vX.Y.Z/' URL" >&2 | 83 | + if ! grep -qF "releases/download/v${new_num}/" "${f}"; then |
| 71 | - exit 1 | 84 | + echo "ERROR: ${f} does not reference ${new_tag}" >&2 |
| 72 | -fi | 85 | + echo " expected a 'releases/download/v${new_num}/' download URL" >&2 |
| 73 | -old_tag="v${old_num}" | 86 | + failed=1 |
| 74 | - | 87 | + fi |
| 75 | -if [[ "${old_num}" == "${new_num}" ]]; then | 88 | + done |
| 76 | - echo "Docs already reference ${new_tag}; nothing to do." | 89 | + if [[ ${failed} -ne 0 ]]; then |
| 90 | + echo "Fix: bash scripts/sync-docs-version.sh ${new_tag} && commit the updated docs" >&2 | ||
| 91 | + exit 1 | ||
| 92 | + fi | ||
| 93 | + echo "Docs version check passed: README.md, docs/PACKAGING.md, docs/AI-GUIDE.md all reference ${new_tag}" | ||
| 77 | exit 0 | 94 | exit 0 |
| 78 | fi | 95 | fi |
| 79 | 96 | ||
| 80 | -echo "Syncing doc version: ${old_tag} -> ${new_tag}" | 97 | +# Detect the version a doc currently references via its first |
| 98 | +# releases/download/vX.Y.Z/ occurrence. Detection is per file: a partially | ||
| 99 | +# synced docs tree (one file bumped, another lagging) still self-heals in a | ||
| 100 | +# single run instead of no-op'ing on the first file's version. | ||
| 101 | +detect_doc_version() { | ||
| 102 | + grep -oE 'releases/download/v[0-9]+\.[0-9]+\.[0-9]+/' "$1" | head -1 | sed -E 's#releases/download/v([0-9]+\.[0-9]+\.[0-9]+)/#\1#' | ||
| 103 | +} | ||
| 104 | + | ||
| 81 | # Escape regex metachars (dots) for LHS; new is RHS literal. | 105 | # Escape regex metachars (dots) for LHS; new is RHS literal. |
| 82 | -old_re="${old_num//./\\.}" | ||
| 83 | new_re="${new_num//./\\.}" | 106 | new_re="${new_num//./\\.}" |
| 84 | -# Boundary-aware: version followed by non-digit or EOL, so 0.6.1 is not | 107 | +# Boundary-aware on both sides: the version must not be preceded or |
| 85 | -# falsely matched inside 0.6.10 (residual check) and vice versa. | 108 | +# followed by a digit, so 0.6.1 is not falsely matched inside 0.6.10, |
| 86 | -old_pat="v?${old_re}([^0-9]|$)" | 109 | +# 10.6.1 or v10.6.1. The same boundary guards the replace below: an |
| 87 | -new_pat="v?${new_re}([^0-9]|$)" | 110 | +# unbounded prefix replace would corrupt a longer version string |
| 111 | +# (0.6.1 -> 0.7.0 would turn a v10.6.1 mention into v10.7.0) and the | ||
| 112 | +# residual check would count the embedded old version inside the new | ||
| 113 | +# one (0.6.1 -> 10.6.1 stays "residual" forever). | ||
| 114 | +new_pat="(^|[^0-9])v?${new_re}([^0-9]|$)" | ||
| 88 | # Two-pass placeholder replace: move old (v-prefixed + bare) to distinct | 115 | # Two-pass placeholder replace: move old (v-prefixed + bare) to distinct |
| 89 | # placeholders, then placeholders to new. Prevents corrupting the new | 116 | # placeholders, then placeholders to new. Prevents corrupting the new |
| 90 | # version when old_num is a string prefix of new_num (e.g. 0.6.1 -> 0.6.10 | 117 | # version when old_num is a string prefix of new_num (e.g. 0.6.1 -> 0.6.10 |
| 91 | # would otherwise turn v0.6.10 into v0.6.100). | 118 | # would otherwise turn v0.6.10 into v0.6.100). |
| 119 | +# | ||
| 120 | +# Known limitation of the consumed-boundary form: two occurrences of the | ||
| 121 | +# same version separated by a single boundary character (e.g. "0.6.1 | ||
| 122 | +# 0.6.1") leave the second unmatched, because the first match consumes the | ||
| 123 | +# separating character. The residual check below then fails loudly (exit 1) | ||
| 124 | +# instead of silently missing it; docs never carry back-to-back same- | ||
| 125 | +# version tokens in practice (URLs and filenames keep other characters | ||
| 126 | +# between them). | ||
| 92 | ptag="__GCDOC_VER_TAG__" | 127 | ptag="__GCDOC_VER_TAG__" |
| 93 | pnum="__GCDOC_VER_NUM__" | 128 | pnum="__GCDOC_VER_NUM__" |
| 94 | -for f in "${readme}" "${packaging}"; do | 129 | + |
| 130 | +changed=0 | ||
| 131 | +for f in "${readme}" "${packaging}" "${ai_guide}"; do | ||
| 132 | + old_num="$(detect_doc_version "${f}" || true)" | ||
| 133 | + if [[ -z "${old_num}" ]]; then | ||
| 134 | + echo "Could not detect current doc version in ${f}" >&2 | ||
| 135 | + echo "Expected a 'releases/download/vX.Y.Z/' URL" >&2 | ||
| 136 | + exit 1 | ||
| 137 | + fi | ||
| 138 | + old_tag="v${old_num}" | ||
| 139 | + if [[ "${old_num}" == "${new_num}" ]]; then | ||
| 140 | + echo " $(basename "${f}"): already references ${new_tag}" | ||
| 141 | + continue | ||
| 142 | + fi | ||
| 143 | + old_re="${old_num//./\\.}" | ||
| 144 | + old_pat="(^|[^0-9])v?${old_re}([^0-9]|$)" | ||
| 95 | before=$(grep -c -E "${old_pat}" "${f}" || true) | 145 | before=$(grep -c -E "${old_pat}" "${f}" || true) |
| 146 | + echo " $(basename "${f}"): ${old_tag} -> ${new_tag} (${before} line(s))" | ||
| 96 | if [[ ${dry_run} -eq 1 ]]; then | 147 | if [[ ${dry_run} -eq 1 ]]; then |
| 97 | echo " [dry-run] would touch ${before} line(s) in $(basename "${f}")" | 148 | echo " [dry-run] would touch ${before} line(s) in $(basename "${f}")" |
| 98 | continue | 149 | continue |
| 99 | fi | 150 | fi |
| 100 | - sed -i "s/v${old_re}/${ptag}/g; s/${old_re}/${pnum}/g" "${f}" | 151 | + sed -E -i "s/(^|[^0-9])v${old_re}([^0-9]|$)/\1${ptag}\2/g; s/(^|[^0-9])${old_re}([^0-9]|$)/\1${pnum}\2/g" "${f}" |
| 101 | sed -i "s/${ptag}/v${new_num}/g; s/${pnum}/${new_num}/g" "${f}" | 152 | sed -i "s/${ptag}/v${new_num}/g; s/${pnum}/${new_num}/g" "${f}" |
| 102 | after_old=$(grep -c -E "${old_pat}" "${f}" || true) | 153 | after_old=$(grep -c -E "${old_pat}" "${f}" || true) |
| 103 | after_new=$(grep -c -E "${new_pat}" "${f}" || true) | 154 | after_new=$(grep -c -E "${new_pat}" "${f}" || true) |
| @@ -106,10 +157,13 @@ for f in "${readme}" "${packaging}"; do | |||
| 106 | echo "ERROR: residual ${old_tag} in $(basename "${f}") after replace" >&2 | 157 | echo "ERROR: residual ${old_tag} in $(basename "${f}") after replace" >&2 |
| 107 | exit 1 | 158 | exit 1 |
| 108 | fi | 159 | fi |
| 160 | + changed=1 | ||
| 109 | done | 161 | done |
| 110 | 162 | ||
| 111 | if [[ ${dry_run} -eq 1 ]]; then | 163 | if [[ ${dry_run} -eq 1 ]]; then |
| 112 | echo "[dry-run] no files written." | 164 | echo "[dry-run] no files written." |
| 165 | +elif [[ ${changed} -eq 0 ]]; then | ||
| 166 | + echo "Docs already reference ${new_tag}; nothing to do." | ||
| 113 | else | 167 | else |
| 114 | - echo "Done. Verify with: git diff --stat README.md docs/PACKAGING.md" | 168 | + echo "Done. Verify with: git diff --stat README.md docs/PACKAGING.md docs/AI-GUIDE.md" |
| 115 | fi | 169 | fi |
| @@ -104,6 +104,29 @@ function assertVersion(entrypoint, expected, env) { | |||
| 104 | if (actual !== expected) throw new Error(`${entrypoint} reported ${actual}, expected ${expected}`); | 104 | if (actual !== expected) throw new Error(`${entrypoint} reported ${actual}, expected ${expected}`); |
| 105 | } | 105 | } |
| 106 | 106 | ||
| 107 | +// Each completion file must carry its own command name in its generated | ||
| 108 | +// header. The patterns anchor the name — padded header line for bash/fish, | ||
| 109 | +// exact line for zsh — so one command's header can never satisfy the | ||
| 110 | +// other's check. | ||
| 111 | +function assertCompletionFiles(home) { | ||
| 112 | + const checks = [ | ||
| 113 | + [".local/share/bash-completion/completions/gc", /^# bash completion V2 for gc( |$)/], | ||
| 114 | + [".local/share/bash-completion/completions/gitcode", /^# bash completion V2 for gitcode( |$)/], | ||
| 115 | + [".zsh/completions/_gc", /^#compdef gc$/], | ||
| 116 | + [".zsh/completions/_gitcode", /^#compdef gitcode$/], | ||
| 117 | + [".config/fish/completions/gc.fish", /^# fish completion for gc( |$)/], | ||
| 118 | + [".config/fish/completions/gitcode.fish", /^# fish completion for gitcode( |$)/], | ||
| 119 | + ]; | ||
| 120 | + for (const [relative, pattern] of checks) { | ||
| 121 | + const target = path.join(home, relative); | ||
| 122 | + if (!fs.existsSync(target)) throw new Error(`completion file missing after install: ${target}`); | ||
| 123 | + const firstLine = fs.readFileSync(target, "utf8").split("\n", 1)[0]; | ||
| 124 | + if (!pattern.test(firstLine)) { | ||
| 125 | + throw new Error(`completion header mismatch at ${target}:\n${firstLine}`); | ||
| 126 | + } | ||
| 127 | + } | ||
| 128 | +} | ||
| 129 | + | ||
| 107 | function main() { | 130 | function main() { |
| 108 | const tarball = buildPackage(); | 131 | const tarball = buildPackage(); |
| 109 | fs.mkdirSync(prefix, { recursive: true }); | 132 | fs.mkdirSync(prefix, { recursive: true }); |
| @@ -140,8 +163,21 @@ function main() { | |||
| 140 | throw new Error(`doctor did not diagnose npm shadowing: ${doctor.stdout}`); | 163 | throw new Error(`doctor did not diagnose npm shadowing: ${doctor.stdout}`); |
| 141 | } | 164 | } |
| 142 | 165 | ||
| 166 | + // Regression (#608): a user-exported GITCODE_CLI_COMMAND_NAME must not | ||
| 167 | + // leak into the gc-named completion files written by the bootstrap | ||
| 168 | + // install (the CLI resolves the command name env-first, overriding | ||
| 169 | + // argv[0]). Install under an isolated HOME with the name exported as | ||
| 170 | + // the opposite command and verify every file matches its own name. | ||
| 171 | + const completionHome = fs.mkdtempSync(path.join(os.tmpdir(), "gc-completion-home-")); | ||
| 172 | + const completionEnv = process.platform === "win32" | ||
| 173 | + ? {} | ||
| 174 | + : { HOME: completionHome, GITCODE_CLI_COMMAND_NAME: "gitcode" }; | ||
| 143 | run(process.execPath, [path.join(packageDir, "bin", "gc.js"), "install", "--target-dir", bootstrap], { | 175 | run(process.execPath, [path.join(packageDir, "bin", "gc.js"), "install", "--target-dir", bootstrap], { |
| 144 | - env: { PATH: process.env.PATH || "", Path: process.env.Path || process.env.PATH || "" }, | 176 | + env: { |
| 177 | + PATH: process.env.PATH || "", | ||
| 178 | + Path: process.env.Path || process.env.PATH || "", | ||
| 179 | + ...completionEnv, | ||
| 180 | + }, | ||
| 145 | }); | 181 | }); |
| 146 | const bootstrapEntry = path.join(bootstrap, process.platform === "win32" ? "gitcode.exe" : "gitcode"); | 182 | const bootstrapEntry = path.join(bootstrap, process.platform === "win32" ? "gitcode.exe" : "gitcode"); |
| 147 | assertVersion(bootstrapEntry, TEST_VERSION); | 183 | assertVersion(bootstrapEntry, TEST_VERSION); |
| @@ -149,6 +185,7 @@ function main() { | |||
| 149 | if (manifest.distribution !== "npm-bootstrap" || !manifest.sha256 || !fs.existsSync(manifest.helper)) { | 185 | if (manifest.distribution !== "npm-bootstrap" || !manifest.sha256 || !fs.existsSync(manifest.helper)) { |
| 150 | throw new Error(`invalid bootstrap manifest: ${JSON.stringify(manifest)}`); | 186 | throw new Error(`invalid bootstrap manifest: ${JSON.stringify(manifest)}`); |
| 151 | } | 187 | } |
| 188 | + if (process.platform !== "win32") assertCompletionFiles(completionHome); | ||
| 152 | 189 | ||
| 153 | process.stdout.write(`npm upgrade smoke passed: ${OLD_VERSION} -> ${TEST_VERSION} on ${process.platform}/${process.arch}\n`); | 190 | process.stdout.write(`npm upgrade smoke passed: ${OLD_VERSION} -> ${TEST_VERSION} on ${process.platform}/${process.arch}\n`); |
| 154 | } | 191 | } |
| @@ -0,0 +1,214 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# Test scripts/sync-docs-version.sh: version detection, boundary-aware | ||
| 3 | +# replacement, per-file self-heal, the --check drift gate, and argument | ||
| 4 | +# validation. The script resolves the repo root from its own location, so | ||
| 5 | +# each case runs a throwaway copy inside a temp dir against fixture docs. | ||
| 6 | + | ||
| 7 | +set -euo pipefail | ||
| 8 | + | ||
| 9 | +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 10 | +sync_script="${script_dir}/sync-docs-version.sh" | ||
| 11 | +failures=0 | ||
| 12 | +case_dir="" | ||
| 13 | + | ||
| 14 | +fail() { | ||
| 15 | + echo "FAIL: $*" >&2 | ||
| 16 | + failures=$((failures + 1)) | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +cleanup() { | ||
| 20 | + if [[ -n "${case_dir}" ]]; then | ||
| 21 | + rm -rf "${case_dir}" | ||
| 22 | + fi | ||
| 23 | +} | ||
| 24 | +trap cleanup EXIT | ||
| 25 | + | ||
| 26 | +write_doc() { | ||
| 27 | + local path="$1" version="$2" | ||
| 28 | + mkdir -p "$(dirname "${path}")" | ||
| 29 | + cat > "${path}" <<EOF | ||
| 30 | +# Fixture doc | ||
| 31 | +Download: https://gitcode.com/atomgit-cli/cli/releases/download/v${version}/gc_${version}_amd64.deb | ||
| 32 | +Checksums: gc_${version}_checksums.txt | ||
| 33 | +historical mention of v0.6.10 must not change | ||
| 34 | +template: releases/download/v{VERSION}/{FILENAME} | ||
| 35 | +EOF | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +new_case() { | ||
| 39 | + cleanup | ||
| 40 | + case_dir="$(mktemp -d)" | ||
| 41 | + mkdir -p "${case_dir}/scripts" "${case_dir}/docs" | ||
| 42 | + cp "${sync_script}" "${case_dir}/scripts/sync-docs-version.sh" | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +write_all_docs() { | ||
| 46 | + local version="$1" | ||
| 47 | + write_doc "${case_dir}/README.md" "${version}" | ||
| 48 | + write_doc "${case_dir}/docs/PACKAGING.md" "${version}" | ||
| 49 | + write_doc "${case_dir}/docs/AI-GUIDE.md" "${version}" | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +run_sync() { | ||
| 53 | + sync_output="$(bash "${case_dir}/scripts/sync-docs-version.sh" "$@" 2>&1)" && sync_status=0 || sync_status=$? | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +expect_status() { | ||
| 57 | + local want="$1" label="$2" | ||
| 58 | + if [[ ${sync_status} -ne ${want} ]]; then | ||
| 59 | + fail "${label}: exit ${sync_status}, want ${want} (output: ${sync_output})" | ||
| 60 | + fi | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +expect_output_contains() { | ||
| 64 | + local want="$1" label="$2" | ||
| 65 | + if ! grep -qF -- "${want}" <<<"${sync_output}"; then | ||
| 66 | + fail "${label}: output missing '${want}' (got: ${sync_output})" | ||
| 67 | + fi | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +expect_file_contains() { | ||
| 71 | + local path="$1" want="$2" label="$3" | ||
| 72 | + if ! grep -qF -- "${want}" "${path}"; then | ||
| 73 | + fail "${label}: ${path} missing '${want}'" | ||
| 74 | + fi | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +expect_file_not_contains() { | ||
| 78 | + local path="$1" want="$2" label="$3" | ||
| 79 | + if grep -qF -- "${want}" "${path}"; then | ||
| 80 | + fail "${label}: ${path} unexpectedly contains '${want}'" | ||
| 81 | + fi | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +# 1. Normal upgrade: v-prefixed URLs and bare filenames move, the longer | ||
| 85 | +# historical version survives, no prefix-corruption, template untouched. | ||
| 86 | +new_case | ||
| 87 | +write_all_docs "0.6.1" | ||
| 88 | +run_sync v0.7.0 | ||
| 89 | +expect_status 0 "normal upgrade" | ||
| 90 | +for f in README.md docs/PACKAGING.md docs/AI-GUIDE.md; do | ||
| 91 | + expect_file_contains "${case_dir}/${f}" "releases/download/v0.7.0/gc_0.7.0_amd64.deb" "normal upgrade" | ||
| 92 | + expect_file_contains "${case_dir}/${f}" "gc_0.7.0_checksums.txt" "normal upgrade" | ||
| 93 | + expect_file_contains "${case_dir}/${f}" "historical mention of v0.6.10 must not change" "normal upgrade" | ||
| 94 | + expect_file_not_contains "${case_dir}/${f}" "v0.7.00" "normal upgrade" | ||
| 95 | + expect_file_contains "${case_dir}/${f}" "template: releases/download/v{VERSION}/{FILENAME}" "normal upgrade" | ||
| 96 | +done | ||
| 97 | + | ||
| 98 | +# 1b. Left boundary: a longer version that merely ends with the old | ||
| 99 | +# version (v10.6.1 while syncing 0.6.1) must survive both the replace | ||
| 100 | +# and the residual check. | ||
| 101 | +new_case | ||
| 102 | +write_all_docs "0.6.1" | ||
| 103 | +for f in README.md docs/PACKAGING.md docs/AI-GUIDE.md; do | ||
| 104 | + echo "see also v10.6.1 for the older major" >> "${case_dir}/${f}" | ||
| 105 | +done | ||
| 106 | +run_sync v0.7.0 | ||
| 107 | +expect_status 0 "left boundary" | ||
| 108 | +for f in README.md docs/PACKAGING.md docs/AI-GUIDE.md; do | ||
| 109 | + expect_file_contains "${case_dir}/${f}" "see also v10.6.1 for the older major" "left boundary" | ||
| 110 | + expect_file_not_contains "${case_dir}/${f}" "v10.7.0 for the older major" "left boundary" | ||
| 111 | +done | ||
| 112 | + | ||
| 113 | +# 2. Old is a prefix of new (0.6.1 -> 0.6.10): the two-pass placeholders | ||
| 114 | +# must not turn the new version into 0.6.100. | ||
| 115 | +new_case | ||
| 116 | +write_all_docs "0.6.1" | ||
| 117 | +run_sync v0.6.10 | ||
| 118 | +expect_status 0 "prefix collision" | ||
| 119 | +for f in README.md docs/PACKAGING.md docs/AI-GUIDE.md; do | ||
| 120 | + expect_file_contains "${case_dir}/${f}" "releases/download/v0.6.10/gc_0.6.10_amd64.deb" "prefix collision" | ||
| 121 | + expect_file_not_contains "${case_dir}/${f}" "v0.6.100" "prefix collision" | ||
| 122 | +done | ||
| 123 | + | ||
| 124 | +# 3. Partial drift self-heals: two docs already at 0.7.0, one lagging at | ||
| 125 | +# 0.6.1 — a single run brings all three to the target. | ||
| 126 | +new_case | ||
| 127 | +write_doc "${case_dir}/README.md" "0.7.0" | ||
| 128 | +write_doc "${case_dir}/docs/PACKAGING.md" "0.7.0" | ||
| 129 | +write_doc "${case_dir}/docs/AI-GUIDE.md" "0.6.1" | ||
| 130 | +run_sync v0.8.0 | ||
| 131 | +expect_status 0 "drift self-heal" | ||
| 132 | +for f in README.md docs/PACKAGING.md docs/AI-GUIDE.md; do | ||
| 133 | + expect_file_contains "${case_dir}/${f}" "releases/download/v0.8.0/gc_0.8.0_amd64.deb" "drift self-heal" | ||
| 134 | +done | ||
| 135 | + | ||
| 136 | +# 3b. The silent no-op trap: the first file already at the target while | ||
| 137 | +# another lags — the old README-only detection no-op'ed here; per-file | ||
| 138 | +# detection must heal the lagging file in the same run. | ||
| 139 | +new_case | ||
| 140 | +write_doc "${case_dir}/README.md" "0.8.0" | ||
| 141 | +write_doc "${case_dir}/docs/PACKAGING.md" "0.8.0" | ||
| 142 | +write_doc "${case_dir}/docs/AI-GUIDE.md" "0.6.1" | ||
| 143 | +run_sync v0.8.0 | ||
| 144 | +expect_status 0 "self-heal at target" | ||
| 145 | +expect_file_contains "${case_dir}/docs/AI-GUIDE.md" "releases/download/v0.8.0/gc_0.8.0_amd64.deb" "self-heal at target" | ||
| 146 | +expect_file_not_contains "${case_dir}/docs/AI-GUIDE.md" "releases/download/v0.6.1/" "self-heal at target" | ||
| 147 | + | ||
| 148 | +# 4. No-op: everything already at the target version. | ||
| 149 | +new_case | ||
| 150 | +write_all_docs "0.7.0" | ||
| 151 | +before="$(cat "${case_dir}/README.md")" | ||
| 152 | +run_sync v0.7.0 | ||
| 153 | +expect_status 0 "no-op" | ||
| 154 | +expect_output_contains "nothing to do" "no-op" | ||
| 155 | +[[ "${before}" == "$(cat "${case_dir}/README.md")" ]] || fail "no-op: README.md changed" | ||
| 156 | + | ||
| 157 | +# 5. Dry-run writes nothing. | ||
| 158 | +new_case | ||
| 159 | +write_all_docs "0.6.1" | ||
| 160 | +before="$(cat "${case_dir}/docs/PACKAGING.md")" | ||
| 161 | +run_sync v0.7.0 --dry-run | ||
| 162 | +expect_status 0 "dry-run" | ||
| 163 | +[[ "${before}" == "$(cat "${case_dir}/docs/PACKAGING.md")" ]] || fail "dry-run: docs/PACKAGING.md changed" | ||
| 164 | + | ||
| 165 | +# 6. --check passes when every doc references the target. | ||
| 166 | +new_case | ||
| 167 | +write_all_docs "0.7.0" | ||
| 168 | +run_sync v0.7.0 --check | ||
| 169 | +expect_status 0 "check green" | ||
| 170 | + | ||
| 171 | +# 7. --check fails on a lagging doc and names it plus the fix command. | ||
| 172 | +new_case | ||
| 173 | +write_doc "${case_dir}/README.md" "0.7.0" | ||
| 174 | +write_doc "${case_dir}/docs/PACKAGING.md" "0.7.0" | ||
| 175 | +write_doc "${case_dir}/docs/AI-GUIDE.md" "0.6.1" | ||
| 176 | +run_sync v0.7.0 --check | ||
| 177 | +expect_status 1 "check red" | ||
| 178 | +expect_output_contains "docs/AI-GUIDE.md does not reference v0.7.0" "check red" | ||
| 179 | +expect_output_contains "bash scripts/sync-docs-version.sh v0.7.0" "check red" | ||
| 180 | + | ||
| 181 | +# 8. A doc without any releases/download/vX.Y.Z/ URL is a detection error. | ||
| 182 | +new_case | ||
| 183 | +write_all_docs "0.6.1" | ||
| 184 | +printf '# no download URLs here\n' > "${case_dir}/docs/AI-GUIDE.md" | ||
| 185 | +run_sync v0.7.0 | ||
| 186 | +expect_status 1 "detection failure" | ||
| 187 | +expect_output_contains "Could not detect current doc version" "detection failure" | ||
| 188 | + | ||
| 189 | +# 9. Argument validation: invalid version exits 1, usage errors exit 2. | ||
| 190 | +new_case | ||
| 191 | +write_all_docs "0.6.1" | ||
| 192 | +run_sync v1.2 | ||
| 193 | +expect_status 1 "invalid version" | ||
| 194 | +run_sync | ||
| 195 | +expect_status 2 "missing version" | ||
| 196 | +run_sync v0.7.0 --bogus | ||
| 197 | +expect_status 2 "unknown option" | ||
| 198 | +run_sync v0.7.0 --check --dry-run | ||
| 199 | +expect_status 2 "check plus dry-run" | ||
| 200 | + | ||
| 201 | +# 10. A missing doc file is an error. | ||
| 202 | +new_case | ||
| 203 | +write_all_docs "0.6.1" | ||
| 204 | +rm "${case_dir}/docs/AI-GUIDE.md" | ||
| 205 | +run_sync v0.7.0 | ||
| 206 | +expect_status 1 "missing file" | ||
| 207 | +expect_output_contains "Missing file" "missing file" | ||
| 208 | + | ||
| 209 | +if ((failures > 0)); then | ||
| 210 | + echo "sync-docs-version tests: ${failures} failure(s)" >&2 | ||
| 211 | + exit 1 | ||
| 212 | +fi | ||
| 213 | + | ||
| 214 | +echo "sync-docs-version tests passed" | ||