已合并
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
已合并
aflyingto创建于 9月30日
共 14 个文件变更+1081-60
@@ -67,6 +67,15 @@ jobs:
67 run: |67 run: |
68 bash scripts/test-release-version-validation.sh68 bash scripts/test-release-version-validation.sh
69 bash scripts/sync-package-version.sh "$(cat VERSION)" --check69 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 dependencies80 - name: Download dependencies
72 run: go mod download81 run: go mod download
M.github/workflows/release.yml+1-0文件内容审核中,请稍后刷新重试
Mdocs/AI-GUIDE.md+6-6文件内容可能有违规信息
@@ -2062,13 +2062,21 @@ gc release create v1.0.0 -R infra-test/gctest1 --title "v1.0.0" --notes "Release
2062# 列出所有 Releases2062# 列出所有 Releases
2063gc release list -R infra-test/gctest12063gc 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# 输出 JSON2071# 输出 JSON
2066gc release list -R infra-test/gctest1 --json2072gc 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 - 查看 Release2082### 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 command617+ // 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 @@
2package delete2package delete
3 3 
4import (4import (
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 release55+ # Preview a deletion (the platform has no delete API)
50- $ gc release delete v1.0.0 -R owner/repo56+ $ gc release delete v1.0.0 -R owner/repo --dry-run
51 57 
52- # Delete without confirmation58+ # Attempt a deletion without confirmation
53 $ gc release delete v1.0.0 -R owner/repo --yes59 $ 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 @@
1package delete1package delete
2 2 
3import (3import (
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 
9func TestNewCmdDelete(t *testing.T) {13func 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 // Flags23 // Flags
24 Repository string24 Repository string
25 Limit int25 Limit int
26+ Page int
26 JSON bool27 JSON bool
27}28}
28 29 
@@ -44,12 +45,15 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
44 # List releases45 # List releases
45 $ gc release list -R owner/repo46 $ 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 results48 # Limit the number of results
51 $ gc release list -R owner/repo --limit 1049 $ 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 JSON57 # Output as JSON
54 $ gc release list -R owner/repo --json58 $ 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 cmd73 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 releases105 // 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 nil167 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 function207// When PublishedAt is nil (e.g., draft releases or API omissions), the function
@@ -1,13 +1,16 @@
1package list1package list
2 2 
3import (3import (
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 
13func TestNewCmdList(t *testing.T) {16func 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 bash1#!/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 and5+# These files are committed, user-facing docs whose download URLs and
5# examples carry a pinned release version. Unlike the build-time version6# examples carry a pinned release version. Unlike the build-time version
6# sync in scripts/package.sh and .github/workflows/release.yml (which cover7# sync in scripts/package.sh and .github/workflows/release.yml (which cover
7# nfpm-*.yaml / pyproject.toml / gc_cli/__init__.py), these docs are not8# 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-detects11# 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 as12+# 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.019# <version> Target release version, e.g. v0.7.0 or 0.7.0
18# --dry-run Print the planned changes without writing files20# --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 replace26+# 1 invalid input / detection failure / residual old version after
27+# replace / --check failed
23# 2 usage error28# 2 usage error
24 29 
25set -euo pipefail30set -euo pipefail
26 31 
27dry_run=032dry_run=0
33+check_mode=0
28target=""34target=""
29-for arg in "${1:-}" "${2:-}"; do35+for arg in "$@"; do
30 case "${arg}" in36 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
36done43done
37 44 
38if [[ -z "${target}" ]]; then45if [[ -z "${target}" ]]; then
39- echo "Usage: $0 <version> [--dry-run]" >&246+ echo "Usage: $0 <version> [--dry-run|--check]" >&2
40 echo "Example: $0 v0.7.0" >&247 echo "Example: $0 v0.7.0" >&2
41 exit 248 exit 2
42fi49fi
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+ 
44if [[ ! "${target}" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then56if [[ ! "${target}" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
45 echo "Invalid version: ${target}" >&257 echo "Invalid version: ${target}" >&2
46 echo "Expected vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH (no prerelease)" >&258 echo "Expected vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH (no prerelease)" >&2
@@ -54,50 +66,89 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
54repo_root="$(cd "${script_dir}/.." && pwd)"66repo_root="$(cd "${script_dir}/.." && pwd)"
55readme="${repo_root}/README.md"67readme="${repo_root}/README.md"
56packaging="${repo_root}/docs/PACKAGING.md"68packaging="${repo_root}/docs/PACKAGING.md"
69+ai_guide="${repo_root}/docs/AI-GUIDE.md"
57 70 
58-for f in "${readme}" "${packaging}"; do71+for f in "${readme}" "${packaging}" "${ai_guide}"; do
59 if [[ ! -f "${f}" ]]; then72 if [[ ! -f "${f}" ]]; then
60 echo "Missing file: ${f}" >&273 echo "Missing file: ${f}" >&2
61 exit 174 exit 1
62 fi75 fi
63done76done
64 77 
65-# Detect the version currently referenced in README via the first78+# 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}" ]]; then81+ failed=0
69- echo "Could not detect current doc version in ${readme}" >&282+ for f in "${readme}" "${packaging}" "${ai_guide}"; do
70- echo "Expected a 'releases/download/vX.Y.Z/' URL" >&283+ if ! grep -qF "releases/download/v${new_num}/" "${f}"; then
71- exit 184+ echo "ERROR: ${f} does not reference ${new_tag}" >&2
72-fi85+ 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}" ]]; then88+ 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 094 exit 0
78fi95fi
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//./\\.}"
83new_re="${new_num//./\\.}"106new_re="${new_num//./\\.}"
84-# Boundary-aware: version followed by non-digit or EOL, so 0.6.1 is not107+# 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 distinct115# Two-pass placeholder replace: move old (v-prefixed + bare) to distinct
89# placeholders, then placeholders to new. Prevents corrupting the new116# 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.10117# 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).
92ptag="__GCDOC_VER_TAG__"127ptag="__GCDOC_VER_TAG__"
93pnum="__GCDOC_VER_NUM__"128pnum="__GCDOC_VER_NUM__"
94-for f in "${readme}" "${packaging}"; do129+ 
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 ]]; then147 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 continue149 continue
99 fi150 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" >&2157 echo "ERROR: residual ${old_tag} in $(basename "${f}") after replace" >&2
107 exit 1158 exit 1
108 fi159 fi
160+ changed=1
109done161done
110 162 
111if [[ ${dry_run} -eq 1 ]]; then163if [[ ${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."
113else167else
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"
115fi169fi
@@ -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+ 
107function main() {130function 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"
Mspec/delivery/release-process.md+2-2文件内容可能有违规信息