已合并
feat(actions): add repository Actions permission controls (single & org-wide batch) #550
wangwq129创建于 9月17日
feat(actions): add repository Actions permission controls (single & org-wide batch) #550
已合并
共 11 个文件变更+1860-8
| @@ -390,6 +390,12 @@ gitcode precommit check | |||
| 390 | # 查看流水线运行记录 | 390 | # 查看流水线运行记录 |
| 391 | gitcode actions run list -R owner/repo --status FAILED | 391 | gitcode actions run list -R owner/repo --status FAILED |
| 392 | 392 | ||
| 393 | +# 启用单个仓库的 Actions(交互确认) | ||
| 394 | +gitcode actions setting enable -R owner/repo | ||
| 395 | + | ||
| 396 | +# 启用组织下全部仓库的 Actions(非交互) | ||
| 397 | +gitcode actions setting enable --org my-org --yes | ||
| 398 | + | ||
| 393 | # 查看流水线运行详情 | 399 | # 查看流水线运行详情 |
| 394 | gitcode actions run view <run-id> -R owner/repo | 400 | gitcode actions run view <run-id> -R owner/repo |
| 395 | 401 | ||
| @@ -237,7 +237,18 @@ func (c *Client) RawRESTToHost(method, host, endpoint string, body io.Reader, he | |||
| 237 | return nil, fmt.Errorf("failed to read response: %w", err) | 237 | return nil, fmt.Errorf("failed to read response: %w", err) |
| 238 | } | 238 | } |
| 239 | if resp.StatusCode >= 400 { | 239 | if resp.StatusCode >= 400 { |
| 240 | - return nil, decodeAPIError(respBody, resp.StatusCode, resp.Status) | 240 | + err := decodeAPIError(respBody, resp.StatusCode, resp.Status) |
| 241 | + // Endpoints on the web API host authenticate with the browser session | ||
| 242 | + // JWT, so the generic "run gc auth login" advice would send the user | ||
| 243 | + // after a credential that cannot work here. Replace that advice instead | ||
| 244 | + // of appending a second, contradictory instruction. | ||
| 245 | + if resp.StatusCode == http.StatusUnauthorized && host == WebAPIHost { | ||
| 246 | + if apiErr, ok := err.(*APIError); ok { | ||
| 247 | + apiErr.Guidance = fmt.Sprintf("The %s web API rejected the credential. This host authenticates with the gitcode.com web session JWT, not a classic personal access token: refresh gitcode.com, run copy(localStorage.getItem('access_token')) in the browser Console, and pass the value with --with-token.", host) | ||
| 248 | + return nil, apiErr | ||
| 249 | + } | ||
| 250 | + } | ||
| 251 | + return nil, err | ||
| 241 | } | 252 | } |
| 242 | 253 | ||
| 243 | return &RawResponse{ | 254 | return &RawResponse{ |
| @@ -506,6 +517,12 @@ type APIError struct { | |||
| 506 | ErrorMessage string `json:"error_message"` | 517 | ErrorMessage string `json:"error_message"` |
| 507 | ErrorName string `json:"error"` | 518 | ErrorName string `json:"error"` |
| 508 | ErrorCodeName string `json:"error_code_name"` | 519 | ErrorCodeName string `json:"error_code_name"` |
| 520 | + | ||
| 521 | + // Guidance replaces the status-specific guidance that Error appends. A | ||
| 522 | + // caller sets it when the generic advice would be wrong for the endpoint, | ||
| 523 | + // for example the personal-access-token hint on a 401 from a host that | ||
| 524 | + // authenticates with a browser session JWT. | ||
| 525 | + Guidance string `json:"-"` | ||
| 509 | } | 526 | } |
| 510 | 527 | ||
| 511 | func (e *APIError) Error() string { | 528 | func (e *APIError) Error() string { |
| @@ -518,6 +535,12 @@ func (e *APIError) Error() string { | |||
| 518 | msg = "unknown error" | 535 | msg = "unknown error" |
| 519 | } | 536 | } |
| 520 | 537 | ||
| 538 | + // Caller-supplied guidance replaces the generic status guidance so the two | ||
| 539 | + // never contradict each other. | ||
| 540 | + if e.Guidance != "" { | ||
| 541 | + return fmt.Sprintf("HTTP %d: %s\n\n%s", e.StatusCode, msg, e.Guidance) | ||
| 542 | + } | ||
| 543 | + | ||
| 521 | // Add actionable guidance for permission errors | 544 | // Add actionable guidance for permission errors |
| 522 | if e.StatusCode == 403 { | 545 | if e.StatusCode == 403 { |
| 523 | return fmt.Sprintf("HTTP %d: %s\n\nYou don't have permission for this action. Possible reasons:\n- You are not the owner or a project maintainer\n- The project requires specific permissions\n\nFor permission details, check the project settings or contact a maintainer.", e.StatusCode, msg) | 546 | return fmt.Sprintf("HTTP %d: %s\n\nYou don't have permission for this action. Possible reasons:\n- You are not the owner or a project maintainer\n- The project requires specific permissions\n\nFor permission details, check the project settings or contact a maintainer.", e.StatusCode, msg) |
| @@ -2,6 +2,7 @@ package api | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | + "errors" | ||
| 5 | "io" | 6 | "io" |
| 6 | "net/http" | 7 | "net/http" |
| 7 | "net/url" | 8 | "net/url" |
| @@ -354,6 +355,52 @@ func TestRawRESTToHostError(t *testing.T) { | |||
| 354 | } | 355 | } |
| 355 | } | 356 | } |
| 356 | 357 | ||
| 358 | +// TestRawRESTToHostWebAPIUnauthorizedReplacesPATGuidance asserts that a 401 | ||
| 359 | +// from the web API host returns the session-JWT guidance instead of the generic | ||
| 360 | +// personal-access-token advice, so the two cannot contradict each other. | ||
| 361 | +func TestRawRESTToHostWebAPIUnauthorizedReplacesPATGuidance(t *testing.T) { | ||
| 362 | + client := newAuthTestClient(func(req *http.Request) (*http.Response, error) { | ||
| 363 | + return authTestResponse(http.StatusUnauthorized, `{"message":"unauthorized"}`), nil | ||
| 364 | + }) | ||
| 365 | + client.SetToken("test-token", "test") | ||
| 366 | + | ||
| 367 | + _, err := client.RawRESTToHost("GET", WebAPIHost, "/api/v2/test", nil, nil) | ||
| 368 | + if err == nil { | ||
| 369 | + t.Fatal("expected error for 401") | ||
| 370 | + } | ||
| 371 | + var apiErr *APIError | ||
| 372 | + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized { | ||
| 373 | + t.Fatalf("error = %v, want an *APIError carrying status 401", err) | ||
| 374 | + } | ||
| 375 | + message := err.Error() | ||
| 376 | + if !strings.Contains(message, "web API rejected the credential") || !strings.Contains(message, "web session JWT") { | ||
| 377 | + t.Fatalf("message = %q, want host-specific session JWT guidance", message) | ||
| 378 | + } | ||
| 379 | + if strings.Contains(message, "gc auth login") { | ||
| 380 | + t.Fatalf("message = %q, must not keep the generic personal access token advice", message) | ||
| 381 | + } | ||
| 382 | +} | ||
| 383 | + | ||
| 384 | +// TestRawRESTToHostDefaultHostKeepsPATGuidance pins the other side of the | ||
| 385 | +// branch: the default REST host still gets the generic authentication advice. | ||
| 386 | +func TestRawRESTToHostDefaultHostKeepsPATGuidance(t *testing.T) { | ||
| 387 | + client := newAuthTestClient(func(req *http.Request) (*http.Response, error) { | ||
| 388 | + return authTestResponse(http.StatusUnauthorized, `{"message":"unauthorized"}`), nil | ||
| 389 | + }) | ||
| 390 | + client.SetToken("test-token", "test") | ||
| 391 | + | ||
| 392 | + _, err := client.RawRESTToHost("GET", DefaultHost, "/api/v2/test", nil, nil) | ||
| 393 | + if err == nil { | ||
| 394 | + t.Fatal("expected error for 401") | ||
| 395 | + } | ||
| 396 | + if !strings.Contains(err.Error(), "gc auth login") { | ||
| 397 | + t.Fatalf("message = %q, want the generic authentication advice", err.Error()) | ||
| 398 | + } | ||
| 399 | + if strings.Contains(err.Error(), "web session JWT") { | ||
| 400 | + t.Fatalf("message = %q, must not claim the default host needs a web session JWT", err.Error()) | ||
| 401 | + } | ||
| 402 | +} | ||
| 403 | + | ||
| 357 | func TestRawRESTToHostCustomHeaders(t *testing.T) { | 404 | func TestRawRESTToHostCustomHeaders(t *testing.T) { |
| 358 | var gotAccept string | 405 | var gotAccept string |
| 359 | client := newAuthTestClient(func(req *http.Request) (*http.Response, error) { | 406 | client := newAuthTestClient(func(req *http.Request) (*http.Response, error) { |
| @@ -0,0 +1,75 @@ | |||
| 1 | +package api | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "net/url" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// ActionsSetting represents the repository-level Actions permission settings. | ||
| 11 | +// Pointer fields preserve false values and allow fields absent from a response | ||
| 12 | +// to remain absent from an update request. | ||
| 13 | +type ActionsSetting struct { | ||
| 14 | + ActionEnabled *bool `json:"action_enabled,omitempty"` | ||
| 15 | + BlockAllNewPipelines *bool `json:"block_all_new_pipelines,omitempty"` | ||
| 16 | + BlockCrossRepoPRTriggers *bool `json:"block_cross_repo_pr_triggers,omitempty"` | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +// GetActionsSetting gets the repository-level Actions permission settings. | ||
| 20 | +// | ||
| 21 | +// It calls GET /api/v2/projects/{project_id}/actions/setting on the GitCode | ||
| 22 | +// web API host. The endpoint currently returns either the setting object or an | ||
| 23 | +// object envelope containing it in data. | ||
| 24 | +func GetActionsSetting(client *Client, projectID string) (*ActionsSetting, error) { | ||
| 25 | + endpoint := actionsSettingEndpoint(projectID) | ||
| 26 | + resp, err := client.RawRESTToHost("GET", WebAPIHost, endpoint, nil, webAPIHeaders()) | ||
| 27 | + if err != nil { | ||
| 28 | + return nil, err | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + settingBody, err := unwrapActionsSetting(resp.Body) | ||
| 32 | + if err != nil { | ||
| 33 | + return nil, err | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + var setting ActionsSetting | ||
| 37 | + if err := json.Unmarshal(settingBody, &setting); err != nil { | ||
| 38 | + return nil, fmt.Errorf("failed to parse actions setting response: %w", err) | ||
| 39 | + } | ||
| 40 | + return &setting, nil | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +// UpdateActionsSetting updates the repository-level Actions permission | ||
| 44 | +// settings without sending a project_id in the request body. | ||
| 45 | +// | ||
| 46 | +// The PUT body is built only from the modelled fields above. The endpoint | ||
| 47 | +// replaces the whole setting object, so a field the server returns but this | ||
| 48 | +// struct does not model is not echoed back and will fall back to the server | ||
| 49 | +// default. That is a known trade-off: the alternative would be to round-trip | ||
| 50 | +// unknown fields verbatim, which risks sending back stale or read-only values. | ||
| 51 | +func UpdateActionsSetting(client *Client, projectID string, setting *ActionsSetting) error { | ||
| 52 | + body, err := json.Marshal(setting) | ||
| 53 | + if err != nil { | ||
| 54 | + return fmt.Errorf("failed to marshal actions setting: %w", err) | ||
| 55 | + } | ||
| 56 | + _, err = client.RawRESTToHost("PUT", WebAPIHost, actionsSettingEndpoint(projectID), bytes.NewReader(body), webAPIHeaders()) | ||
| 57 | + return err | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +func actionsSettingEndpoint(projectID string) string { | ||
| 61 | + return "/api/v2/projects/" + url.PathEscape(projectID) + "/actions/setting" | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +func unwrapActionsSetting(body []byte) ([]byte, error) { | ||
| 65 | + var envelope struct { | ||
| 66 | + Data json.RawMessage `json:"data"` | ||
| 67 | + } | ||
| 68 | + if err := json.Unmarshal(body, &envelope); err != nil { | ||
| 69 | + return nil, fmt.Errorf("failed to parse actions setting response: %w", err) | ||
| 70 | + } | ||
| 71 | + if len(envelope.Data) > 0 && string(envelope.Data) != "null" { | ||
| 72 | + return envelope.Data, nil | ||
| 73 | + } | ||
| 74 | + return body, nil | ||
| 75 | +} | ||
| @@ -0,0 +1,122 @@ | |||
| 1 | +package api | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "io" | ||
| 6 | + "net/http" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "gitcode.com/gitcode-cli/cli/pkg/testutil" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +func TestGetActionsSettingUnwrapsData(t *testing.T) { | ||
| 13 | + var gotPath string | ||
| 14 | + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 15 | + gotPath = r.URL.EscapedPath() | ||
| 16 | + if got := r.Header.Get("Referer"); got != "https://gitcode.com/" { | ||
| 17 | + t.Errorf("Referer = %q, want %q", got, "https://gitcode.com/") | ||
| 18 | + } | ||
| 19 | + _, _ = w.Write([]byte(`{"data":{"action_enabled":true,"block_all_new_pipelines":false,"block_cross_repo_pr_triggers":true}}`)) | ||
| 20 | + }) | ||
| 21 | + client := NewClientFromHTTP(testutil.NewTestHTTPClient(handler)) | ||
| 22 | + | ||
| 23 | + setting, err := GetActionsSetting(client, "12345") | ||
| 24 | + if err != nil { | ||
| 25 | + t.Fatalf("GetActionsSetting() error = %v", err) | ||
| 26 | + } | ||
| 27 | + if gotPath != "/api/v2/projects/12345/actions/setting" { | ||
| 28 | + t.Fatalf("path = %q", gotPath) | ||
| 29 | + } | ||
| 30 | + if setting.ActionEnabled == nil || !*setting.ActionEnabled { | ||
| 31 | + t.Fatalf("ActionEnabled = %#v, want true", setting.ActionEnabled) | ||
| 32 | + } | ||
| 33 | + if setting.BlockAllNewPipelines == nil || *setting.BlockAllNewPipelines { | ||
| 34 | + t.Fatalf("BlockAllNewPipelines = %#v, want false", setting.BlockAllNewPipelines) | ||
| 35 | + } | ||
| 36 | + if setting.BlockCrossRepoPRTriggers == nil || !*setting.BlockCrossRepoPRTriggers { | ||
| 37 | + t.Fatalf("BlockCrossRepoPRTriggers = %#v, want true", setting.BlockCrossRepoPRTriggers) | ||
| 38 | + } | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +func TestGetActionsSettingAcceptsFlatResponse(t *testing.T) { | ||
| 42 | + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 43 | + _, _ = w.Write([]byte(`{"action_enabled":false}`)) | ||
| 44 | + }) | ||
| 45 | + client := NewClientFromHTTP(testutil.NewTestHTTPClient(handler)) | ||
| 46 | + | ||
| 47 | + setting, err := GetActionsSetting(client, "12345") | ||
| 48 | + if err != nil { | ||
| 49 | + t.Fatalf("GetActionsSetting() error = %v", err) | ||
| 50 | + } | ||
| 51 | + if setting.ActionEnabled == nil || *setting.ActionEnabled { | ||
| 52 | + t.Fatalf("ActionEnabled = %#v, want false", setting.ActionEnabled) | ||
| 53 | + } | ||
| 54 | + if setting.BlockAllNewPipelines != nil || setting.BlockCrossRepoPRTriggers != nil { | ||
| 55 | + t.Fatalf("unexpected absent fields: %#v", setting) | ||
| 56 | + } | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +func TestUpdateActionsSettingPreservesReadFields(t *testing.T) { | ||
| 60 | + var gotPath string | ||
| 61 | + var gotMethod string | ||
| 62 | + var gotBody map[string]any | ||
| 63 | + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 64 | + gotPath = r.URL.EscapedPath() | ||
| 65 | + gotMethod = r.Method | ||
| 66 | + body, err := io.ReadAll(r.Body) | ||
| 67 | + if err != nil { | ||
| 68 | + t.Errorf("read body: %v", err) | ||
| 69 | + return | ||
| 70 | + } | ||
| 71 | + if err := json.Unmarshal(body, &gotBody); err != nil { | ||
| 72 | + t.Errorf("decode body: %v", err) | ||
| 73 | + } | ||
| 74 | + if got := r.Header.Get("Referer"); got != "https://gitcode.com/" { | ||
| 75 | + t.Errorf("Referer = %q, want %q", got, "https://gitcode.com/") | ||
| 76 | + } | ||
| 77 | + w.WriteHeader(http.StatusNoContent) | ||
| 78 | + }) | ||
| 79 | + client := NewClientFromHTTP(testutil.NewTestHTTPClient(handler)) | ||
| 80 | + client.SetToken("test-token", "env") | ||
| 81 | + | ||
| 82 | + setting := &ActionsSetting{ | ||
| 83 | + ActionEnabled: boolPointer(false), | ||
| 84 | + BlockAllNewPipelines: boolPointer(true), | ||
| 85 | + BlockCrossRepoPRTriggers: boolPointer(false), | ||
| 86 | + } | ||
| 87 | + if err := UpdateActionsSetting(client, "12345", setting); err != nil { | ||
| 88 | + t.Fatalf("UpdateActionsSetting() error = %v", err) | ||
| 89 | + } | ||
| 90 | + if gotMethod != http.MethodPut || gotPath != "/api/v2/projects/12345/actions/setting" { | ||
| 91 | + t.Fatalf("request = %s %s", gotMethod, gotPath) | ||
| 92 | + } | ||
| 93 | + if len(gotBody) != 3 { | ||
| 94 | + t.Fatalf("body fields = %#v, want only three setting fields", gotBody) | ||
| 95 | + } | ||
| 96 | + if gotBody["action_enabled"] != false || gotBody["block_all_new_pipelines"] != true || gotBody["block_cross_repo_pr_triggers"] != false { | ||
| 97 | + t.Fatalf("body = %#v", gotBody) | ||
| 98 | + } | ||
| 99 | + if _, ok := gotBody["project_id"]; ok { | ||
| 100 | + t.Fatalf("body unexpectedly contains project_id: %#v", gotBody) | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +func TestActionsSettingEndpointEscapesProjectID(t *testing.T) { | ||
| 105 | + var gotPath string | ||
| 106 | + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 107 | + gotPath = r.URL.EscapedPath() | ||
| 108 | + _, _ = w.Write([]byte(`{"action_enabled":true}`)) | ||
| 109 | + }) | ||
| 110 | + client := NewClientFromHTTP(testutil.NewTestHTTPClient(handler)) | ||
| 111 | + | ||
| 112 | + if _, err := GetActionsSetting(client, "owner/repo"); err != nil { | ||
| 113 | + t.Fatalf("GetActionsSetting() error = %v", err) | ||
| 114 | + } | ||
| 115 | + if gotPath != "/api/v2/projects/owner%2Frepo/actions/setting" { | ||
| 116 | + t.Fatalf("path = %q", gotPath) | ||
| 117 | + } | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +func boolPointer(value bool) *bool { | ||
| 121 | + return &value | ||
| 122 | +} | ||
| @@ -7,6 +7,7 @@ require ( | |||
| 7 | github.com/rogpeppe/go-internal v1.13.1 | 7 | github.com/rogpeppe/go-internal v1.13.1 |
| 8 | github.com/spf13/cobra v1.8.0 | 8 | github.com/spf13/cobra v1.8.0 |
| 9 | github.com/spf13/pflag v1.0.5 | 9 | github.com/spf13/pflag v1.0.5 |
| 10 | + golang.org/x/term v0.25.0 | ||
| 10 | golang.org/x/text v0.21.0 | 11 | golang.org/x/text v0.21.0 |
| 11 | gopkg.in/yaml.v3 v3.0.1 | 12 | gopkg.in/yaml.v3 v3.0.1 |
| 12 | ) | 13 | ) |
| @@ -12,6 +12,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= | |||
| 12 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | 12 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= |
| 13 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= | 13 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= |
| 14 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | 14 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| 15 | +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= | ||
| 16 | +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= | ||
| 15 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= | 17 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= |
| 16 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= | 18 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= |
| 17 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= | 19 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= |
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | runnercmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/runner" | 12 | runnercmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/runner" |
| 13 | runnergroupcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/runner-group" | 13 | runnergroupcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/runner-group" |
| 14 | runnersetcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/runner-set" | 14 | runnersetcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/runner-set" |
| 15 | + settingcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/setting" | ||
| 15 | workflowcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/workflow" | 16 | workflowcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/workflow" |
| 16 | yamlcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/yaml" | 17 | yamlcmd "gitcode.com/gitcode-cli/cli/pkg/cmd/actions/yaml" |
| 17 | cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil" | 18 | cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil" |
| @@ -21,14 +22,13 @@ import ( | |||
| 21 | func NewCmdActions(f *cmdutil.Factory) *cobra.Command { | 22 | func NewCmdActions(f *cmdutil.Factory) *cobra.Command { |
| 22 | cmd := &cobra.Command{ | 23 | cmd := &cobra.Command{ |
| 23 | Use: "actions <command>", | 24 | Use: "actions <command>", |
| 24 | - Short: "Manage GitCode Actions (pipeline runs and workflow jobs)", | 25 | + Short: "Manage GitCode Actions", |
| 25 | Long: heredoc.Doc(` | 26 | Long: heredoc.Doc(` |
| 26 | - Work with GitCode Actions: inspect pipeline runs, workflow jobs and | 27 | + Work with GitCode Actions: inspect pipeline runs and workflow jobs, |
| 27 | - job logs. | 28 | + or enable and disable repository Actions. |
| 28 | 29 | ||
| 29 | - GitCode Actions exposes pipeline run records and workflow jobs through | 30 | + Changing repository Actions permissions is a dangerous operation and |
| 30 | - the Actions v8 API. This command group provides read-only inspection | 31 | + requires confirmation unless --yes is provided. |
| 31 | - of CI run status. | ||
| 32 | `), | 32 | `), |
| 33 | Example: heredoc.Doc(` | 33 | Example: heredoc.Doc(` |
| 34 | # List recent pipeline runs | 34 | # List recent pipeline runs |
| @@ -55,6 +55,7 @@ func NewCmdActions(f *cmdutil.Factory) *cobra.Command { | |||
| 55 | cmd.AddCommand(runnergroupcmd.NewCmdRunnerGroup(f)) | 55 | cmd.AddCommand(runnergroupcmd.NewCmdRunnerGroup(f)) |
| 56 | cmd.AddCommand(runnercmd.NewCmdRunner(f)) | 56 | cmd.AddCommand(runnercmd.NewCmdRunner(f)) |
| 57 | cmd.AddCommand(runnersetcmd.NewCmdRunnerSet(f)) | 57 | cmd.AddCommand(runnersetcmd.NewCmdRunnerSet(f)) |
| 58 | + cmd.AddCommand(settingcmd.NewCmdSetting(f, nil)) | ||
| 58 | cmd.AddCommand(yamlcmd.NewCmdYaml(f)) | 59 | cmd.AddCommand(yamlcmd.NewCmdYaml(f)) |
| 59 | cmd.AddCommand(workflowcmd.NewCmdWorkflow(f)) | 60 | cmd.AddCommand(workflowcmd.NewCmdWorkflow(f)) |
| 60 | 61 | ||
| @@ -0,0 +1,669 @@ | |||
| 1 | +// Package setting implements the actions setting command. | ||
| 2 | +package setting | ||
| 3 | + | ||
| 4 | +import ( | ||
| 5 | + "encoding/base64" | ||
| 6 | + "encoding/json" | ||
| 7 | + "fmt" | ||
| 8 | + "io" | ||
| 9 | + "math" | ||
| 10 | + "net/http" | ||
| 11 | + "os" | ||
| 12 | + "strconv" | ||
| 13 | + "strings" | ||
| 14 | + "time" | ||
| 15 | + | ||
| 16 | + "github.com/MakeNowJust/heredoc/v2" | ||
| 17 | + "github.com/spf13/cobra" | ||
| 18 | + "golang.org/x/term" | ||
| 19 | + | ||
| 20 | + "gitcode.com/gitcode-cli/cli/api" | ||
| 21 | + cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil" | ||
| 22 | + "gitcode.com/gitcode-cli/cli/pkg/iostreams" | ||
| 23 | +) | ||
| 24 | + | ||
| 25 | +const organizationRepoPageSize = 100 | ||
| 26 | + | ||
| 27 | +// organizationRepoMaxPages bounds organization listing so a server that keeps | ||
| 28 | +// returning non-empty pages cannot make the command loop forever. | ||
| 29 | +const organizationRepoMaxPages = 1000 | ||
| 30 | + | ||
| 31 | +// Per-repository result statuses reported in the summary output. | ||
| 32 | +const ( | ||
| 33 | + statusUnchanged = "unchanged" | ||
| 34 | + statusUpdated = "updated" | ||
| 35 | + statusFailed = "failed" | ||
| 36 | +) | ||
| 37 | + | ||
| 38 | +const actionsSettingRiskMessage = "This command calls a GitCode Web API endpoint that is not documented in the official GitCode API reference. Use of this command is at your own risk." | ||
| 39 | + | ||
| 40 | +type terminalPasswordReader func(int) ([]byte, error) | ||
| 41 | + | ||
| 42 | +// webJWTHelpText explains why the command needs the web session JWT and | ||
| 43 | +// where to obtain it. It is shown before the interactive paste prompt. | ||
| 44 | +const webJWTHelpText = `The Actions settings endpoint requires a GitCode web-session JWT; | ||
| 45 | +a classic personal access token cannot be used for this request. | ||
| 46 | +To obtain the JWT: | ||
| 47 | + 1. Open https://gitcode.com and sign in with an account that can manage | ||
| 48 | + the target repository. | ||
| 49 | + 2. Click the account avatar in the top-right corner. | ||
| 50 | + 3. Press F12 and switch to the Console tab. | ||
| 51 | + 4. Run: | ||
| 52 | + copy(localStorage.getItem('access_token')) | ||
| 53 | + 5. Paste the copied value at the next prompt and press Enter. | ||
| 54 | +The JWT is entered without terminal echo and held in memory only for this | ||
| 55 | +command. Do not share it or save it to a file.` | ||
| 56 | + | ||
| 57 | +// SettingOptions configures the actions setting command. | ||
| 58 | +type SettingOptions struct { | ||
| 59 | + IO *iostreams.IOStreams | ||
| 60 | + HttpClient func() (*http.Client, error) | ||
| 61 | + BaseRepo func() (string, error) | ||
| 62 | + | ||
| 63 | + Repository string | ||
| 64 | + Org string | ||
| 65 | + Action string | ||
| 66 | + WithToken bool | ||
| 67 | + Yes bool | ||
| 68 | + JSON bool | ||
| 69 | + | ||
| 70 | + readPassword terminalPasswordReader | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +// SettingResult represents the result for one repository. | ||
| 74 | +type SettingResult struct { | ||
| 75 | + Repository string `json:"repository"` | ||
| 76 | + ProjectID string `json:"project_id"` | ||
| 77 | + PreviousActionEnabled bool `json:"previous_action_enabled"` | ||
| 78 | + ActionEnabled bool `json:"action_enabled"` | ||
| 79 | + Status string `json:"status"` | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +// SettingSummary represents the result of an Actions permission update. | ||
| 83 | +type SettingSummary struct { | ||
| 84 | + Action string `json:"action"` | ||
| 85 | + ActionEnabled bool `json:"action_enabled"` | ||
| 86 | + Organization string `json:"organization,omitempty"` | ||
| 87 | + Repositories []SettingResult `json:"repositories"` | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +type repositoryTarget struct { | ||
| 91 | + Name string | ||
| 92 | + ProjectID string | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +type repositoryState struct { | ||
| 96 | + target repositoryTarget | ||
| 97 | + setting *api.ActionsSetting | ||
| 98 | + previous bool | ||
| 99 | + status string | ||
| 100 | + err error | ||