diff --git a/README.md b/README.md index fb05b84..f3924b2 100644 --- a/README.md +++ b/README.md @@ -473,6 +473,30 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i ``` +### Gitea Actions + +```bash +# List workflow files (.gitea/workflows) +gitlink-cli action +list --owner Gitlink --repo forgeplus + +# List runs of a workflow +gitlink-cli action +runs --owner Gitlink --repo forgeplus -w ci.yml + +# Trigger a workflow run on a branch +gitlink-cli action +run --owner Gitlink --repo forgeplus -w ci.yml -r master + +# Rerun a whole run, or a single job +gitlink-cli action +rerun --owner Gitlink --repo forgeplus -i 6 +gitlink-cli action +job-rerun --owner Gitlink --repo forgeplus -i 6 -j build + +# Raw logs of a workflow job +gitlink-cli action +logs --owner Gitlink --repo forgeplus -i 6 -j 0 + +# Enable or disable a workflow +gitlink-cli action +disable --owner Gitlink --repo forgeplus -w ci.yml +gitlink-cli action +enable --owner Gitlink --repo forgeplus -w ci.yml +``` + ### Pipeline Operations ```bash diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 825ed11..067d948 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,4 +1,13 @@ { + "cmd.action.disable.short": "Disable Actions for the repository", + "cmd.action.enable.short": "Enable Actions for the repository", + "cmd.action.job_rerun.short": "Rerun a single job of a run", + "cmd.action.list.short": "List workflow files (.gitea/workflows)", + "cmd.action.logs.short": "Fetch the raw logs of a workflow job", + "cmd.action.rerun.short": "Rerun all jobs of a run", + "cmd.action.run.short": "Trigger a workflow run", + "cmd.action.runs.short": "List runs of a workflow", + "cmd.action.short": "Gitea Actions (CI workflow) operations", "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", "cmd.api.short": "Make raw API requests to GitLink", "cmd.auth.login.short": "Login to GitLink", @@ -116,6 +125,10 @@ "error.missing_required_flag": "required flag --{name} is missing", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.unsupported_language": "unsupported language: {lang}", + "flag.action.job": "Job name within the run", + "flag.action.ref": "Branch or tag to run on", + "flag.action.run_id": "Run ID from action +runs", + "flag.action.workflow": "Workflow file name (e.g. ci.yml)", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", "flag.api.batch_file": "Read an API batch plan from a JSON file", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 4921aa9..94fcfda 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,4 +1,13 @@ { + "cmd.action.disable.short": "为仓库停用 Actions", + "cmd.action.enable.short": "为仓库启用 Actions", + "cmd.action.job_rerun.short": "重跑运行中的单个任务", + "cmd.action.list.short": "列出工作流文件(.gitea/workflows)", + "cmd.action.logs.short": "获取流水线任务的原始日志", + "cmd.action.rerun.short": "重跑一次运行的全部任务", + "cmd.action.run.short": "触发一次工作流运行", + "cmd.action.runs.short": "列出工作流的运行记录", + "cmd.action.short": "Gitea Actions(CI 工作流)操作", "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", "cmd.api.short": "向 GitLink 发起原始 API 请求", "cmd.auth.login.short": "登录 GitLink", @@ -116,6 +125,10 @@ "error.missing_required_flag": "缺少必需参数 --{name}", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", + "flag.action.job": "运行中的任务名", + "flag.action.ref": "运行所在的分支或标签", + "flag.action.run_id": "运行 ID(来自 action +runs)", + "flag.action.workflow": "工作流文件名(如 ci.yml)", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", diff --git a/shortcuts/action/action.go b/shortcuts/action/action.go new file mode 100644 index 0000000..9b8b26a --- /dev/null +++ b/shortcuts/action/action.go @@ -0,0 +1,233 @@ +package action + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func actionsPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/actions", ctx.Owner, ctx.Repo) +} + +func requireIntArg(ctx *common.RuntimeContext, name string) (string, error) { + value, err := ctx.RequireArg(name) + if err != nil { + return "", err + } + if _, err := strconv.Atoi(value); err != nil { + return "", fmt.Errorf("--%s must be an integer, got %q", name, value) + } + return value, nil +} + +// Shortcuts returns Gitea Actions (CI workflow) shortcuts. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) + return []*common.Shortcut{ + { + Name: "list", + Description: tr.T("cmd.action.list.short"), + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", actionsPath(ctx), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "runs", + Description: tr.T("cmd.action.runs.short"), + Flags: []common.Flag{ + {Name: "workflow", Short: "w", Usage: tr.T("flag.action.workflow"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + workflow, err := ctx.RequireArg("workflow") + if err != nil { + return err + } + q := url.Values{} + q.Set("workflow", workflow) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", actionsPath(ctx)+"/runs", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "run", + Description: tr.T("cmd.action.run.short"), + Flags: []common.Flag{ + {Name: "workflow", Short: "w", Usage: tr.T("flag.action.workflow"), Required: true}, + {Name: "ref", Short: "r", Usage: tr.T("flag.action.ref"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + workflow, err := ctx.RequireArg("workflow") + if err != nil { + return err + } + ref, err := ctx.RequireArg("ref") + if err != nil { + return err + } + q := url.Values{} + q.Set("workflow", workflow) + q.Set("ref", ref) + env, err := ctx.CallAPIWithQuery("POST", actionsPath(ctx)+"/runs", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "rerun", + Description: tr.T("cmd.action.rerun.short"), + Flags: []common.Flag{ + {Name: "run-id", Short: "i", Usage: tr.T("flag.action.run_id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + runID, err := requireIntArg(ctx, "run-id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/runs/%s/rerun", actionsPath(ctx), runID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "job-rerun", + Description: tr.T("cmd.action.job_rerun.short"), + Flags: []common.Flag{ + {Name: "run-id", Short: "i", Usage: tr.T("flag.action.run_id"), Required: true}, + {Name: "job", Short: "j", Usage: tr.T("flag.action.job"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + runID, err := requireIntArg(ctx, "run-id") + if err != nil { + return err + } + job, err := ctx.RequireArg("job") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/runs/%s/jobs/%s/rerun", actionsPath(ctx), runID, url.PathEscape(job)), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "logs", + Description: tr.T("cmd.action.logs.short"), + Flags: []common.Flag{ + {Name: "run-id", Short: "i", Usage: tr.T("flag.action.run_id"), Required: true}, + {Name: "job", Short: "j", Usage: tr.T("flag.action.job"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + runID, err := requireIntArg(ctx, "run-id") + if err != nil { + return err + } + job, err := ctx.RequireArg("job") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/runs/%s/jobs/%s/logs", actionsPath(ctx), runID, url.PathEscape(job)), nil) + if err != nil { + return err + } + if text, ok := env.Data.(string); ok { + fmt.Print(text) + return nil + } + return ctx.Output(env) + }, + }, + { + Name: "enable", + Description: tr.T("cmd.action.enable.short"), + Flags: []common.Flag{ + {Name: "workflow", Short: "w", Usage: tr.T("flag.action.workflow"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + workflow, err := ctx.RequireArg("workflow") + if err != nil { + return err + } + q := url.Values{} + q.Set("workflow", workflow) + env, err := ctx.CallAPIWithQuery("POST", actionsPath(ctx)+"/enable", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "disable", + Description: tr.T("cmd.action.disable.short"), + Flags: []common.Flag{ + {Name: "workflow", Short: "w", Usage: tr.T("flag.action.workflow"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + workflow, err := ctx.RequireArg("workflow") + if err != nil { + return err + } + q := url.Values{} + q.Set("workflow", workflow) + env, err := ctx.CallAPIWithQuery("POST", actionsPath(ctx)+"/disable", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/action/action_test.go b/shortcuts/action/action_test.go new file mode 100644 index 0000000..a17032b --- /dev/null +++ b/shortcuts/action/action_test.go @@ -0,0 +1,179 @@ +package action + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("write response: %v", err) + } +} + +func TestActionListUsesActionsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/actions.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"total_count": 1, "files": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{}); err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestActionRunsRequiresWorkflowAndForwardsQuery(t *testing.T) { + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/actions/runs.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + query = r.URL.RawQuery + writeJSON(t, w, map[string]interface{}{"total_data": 0, "runs": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "runs", map[string]string{"workflow": "ci.yml", "page": "1", "limit": "20"}); err != nil { + t.Fatalf("runs failed: %v", err) + } + if !strings.Contains(query, "workflow=ci.yml") { + t.Fatalf("expected workflow in query, got %q", query) + } + + if err := runShortcut(t, server, "runs", map[string]string{}); err == nil { + t.Fatal("expected error when --workflow missing") + } +} + +func TestActionRunPostsWorkflowAndRef(t *testing.T) { + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/actions/runs.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + query = r.URL.RawQuery + writeJSON(t, w, map[string]interface{}{"status": float64(0)}) + })) + defer server.Close() + + if err := runShortcut(t, server, "run", map[string]string{"workflow": "ci.yml", "ref": "master"}); err != nil { + t.Fatalf("run failed: %v", err) + } + if !strings.Contains(query, "workflow=ci.yml") || !strings.Contains(query, "ref=master") { + t.Fatalf("unexpected query: %q", query) + } +} + +func TestActionRerunValidatesRunID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/actions/runs/7/rerun.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"status": float64(0)}) + })) + defer server.Close() + + if err := runShortcut(t, server, "rerun", map[string]string{"run-id": "7"}); err != nil { + t.Fatalf("rerun failed: %v", err) + } + if err := runShortcut(t, server, "rerun", map[string]string{"run-id": "abc"}); err == nil { + t.Fatal("expected error for non-integer --run-id") + } +} + +func TestActionJobRerunBuildsNestedPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/actions/runs/7/jobs/build/rerun.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"status": float64(0)}) + })) + defer server.Close() + + if err := runShortcut(t, server, "job-rerun", map[string]string{"run-id": "7", "job": "build"}); err != nil { + t.Fatalf("job-rerun failed: %v", err) + } +} + +func TestActionEnableDisableEndpoints(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + paths = append(paths, r.URL.Path) + writeJSON(t, w, map[string]interface{}{"status": float64(0)}) + })) + defer server.Close() + + if err := runShortcut(t, server, "enable", map[string]string{"workflow": "ci.yml"}); err != nil { + t.Fatalf("enable failed: %v", err) + } + if err := runShortcut(t, server, "disable", map[string]string{"workflow": "ci.yml"}); err != nil { + t.Fatalf("disable failed: %v", err) + } + if err := runShortcut(t, server, "disable", map[string]string{}); err == nil { + t.Fatal("expected error when --workflow missing") + } + want := []string{"/v1/owner/repo/actions/enable.json", "/v1/owner/repo/actions/disable.json"} + for i, p := range want { + if paths[i] != p { + t.Fatalf("expected %s, got %s", p, paths[i]) + } + } +} + +func TestActionLogsBuildsNestedLogsPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/actions/runs/6/jobs/0/logs.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "log line 1\nlog line 2\n") + })) + defer server.Close() + + if err := runShortcut(t, server, "logs", map[string]string{"run-id": "6", "job": "0"}); err != nil { + t.Fatalf("logs failed: %v", err) + } + + if err := runShortcut(t, server, "logs", map[string]string{"run-id": "abc", "job": "0"}); err == nil { + t.Fatal("expected error for non-integer --run-id") + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 7a5e411..c16356a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/action" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/commit" @@ -35,6 +36,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr = translators[0] } groups := map[string][]*common.Shortcut{ + "action": action.Shortcuts(tr), "repo": repo.Shortcuts(tr), "issue": issue.Shortcuts(tr), "label": label.Shortcuts(), @@ -59,6 +61,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { } descriptions := map[string]string{ + "action": tr.T("cmd.action.short"), "repo": tr.T("cmd.repo.short"), "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 165c496..8e6f24e 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "wiki", "commit", + "dataset", "health", "ignore", "wiki", "action", } groupSet := map[string]bool{}