diff --git a/cmd/issues.go b/cmd/issues.go index 16da65a..31d0a4b 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -62,6 +62,7 @@ var CmdIssues = cli.Command{ &issues.CmdIssuesEdit, &issues.CmdIssuesReopen, &issues.CmdIssuesClose, + &issues.CmdIssuesDependencies, }, Flags: append([]cli.Flag{ &cli.BoolFlag{ diff --git a/cmd/issues/dependencies.go b/cmd/issues/dependencies.go new file mode 100644 index 0000000..e55d3fd --- /dev/null +++ b/cmd/issues/dependencies.go @@ -0,0 +1,266 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "bytes" + stdctx "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/api" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + "code.gitea.io/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdIssuesDependencies represents the subcommand for issue dependencies +var CmdIssuesDependencies = cli.Command{ + Name: "dependencies", + Aliases: []string{"deps", "dep"}, + Usage: "Manage issue dependencies", + Description: "List, add, or remove dependencies for an issue", + ArgsUsage: "", + Action: runDependenciesList, + Commands: []*cli.Command{ + &cmdDependenciesList, + &cmdDependenciesAdd, + &cmdDependenciesRemove, + }, + Flags: flags.AllDefaultFlags, +} + +var cmdDependenciesList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List dependencies for an issue", + Description: "List issues that the specified issue depends on (blockers)", + ArgsUsage: "", + Action: runDependenciesList, + Flags: flags.AllDefaultFlags, +} + +var cmdDependenciesAdd = cli.Command{ + Name: "add", + Usage: "Add a dependency to an issue", + Description: "Make an issue depend on another issue (blocker)", + ArgsUsage: " ", + Action: runDependenciesAdd, + Flags: flags.AllDefaultFlags, +} + +var cmdDependenciesRemove = cli.Command{ + Name: "remove", + Aliases: []string{"rm"}, + Usage: "Remove a dependency from an issue", + Description: "Remove a dependency from an issue", + ArgsUsage: " ", + Action: runDependenciesRemove, + Flags: flags.AllDefaultFlags, +} + +// runDependenciesList lists dependencies for an issue +func runDependenciesList(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 1 { + return fmt.Errorf("issue index required") + } + + index, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := api.NewClient(c.Login) + path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index) + + resp, err := client.Do("GET", path, nil, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var deps []*gitea.Issue + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&deps); err != nil { + return err + } + + if len(deps) == 0 { + fmt.Printf("Issue #%d has no dependencies\n", index) + return nil + } + + print.IssuesPullsList(deps, c.Output, []string{"index", "state", "title", "owner", "repo"}) + return nil +} + +// runDependenciesAdd adds a dependency to an issue +func runDependenciesAdd(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 2 { + return fmt.Errorf("usage: tea issues dependencies add ") + } + + index, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return fmt.Errorf("invalid issue index: %w", err) + } + + depOwner, depRepo, depIndex, err := parseDependencyArg(cmd.Args().Get(1), c.Owner, c.Repo) + if err != nil { + return err + } + + client := api.NewClient(c.Login) + path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index) + + reqBody := api.IssueDependencyRequest{ + Owner: depOwner, + Repo: depRepo, + Index: depIndex, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := client.Do("POST", path, bytes.NewReader(body), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + if depOwner == c.Owner && depRepo == c.Repo { + fmt.Printf("Added dependency: issue #%d now depends on #%d\n", index, depIndex) + } else { + fmt.Printf("Added dependency: issue #%d now depends on %s/%s#%d\n", index, depOwner, depRepo, depIndex) + } + return nil +} + +// runDependenciesRemove removes a dependency from an issue +func runDependenciesRemove(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 2 { + return fmt.Errorf("usage: tea issues dependencies remove ") + } + + index, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return fmt.Errorf("invalid issue index: %w", err) + } + + depOwner, depRepo, depIndex, err := parseDependencyArg(cmd.Args().Get(1), c.Owner, c.Repo) + if err != nil { + return err + } + + client := api.NewClient(c.Login) + path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index) + + reqBody := api.IssueDependencyRequest{ + Owner: depOwner, + Repo: depRepo, + Index: depIndex, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := client.Do("DELETE", path, bytes.NewReader(body), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + if depOwner == c.Owner && depRepo == c.Repo { + fmt.Printf("Removed dependency: issue #%d no longer depends on #%d\n", index, depIndex) + } else { + fmt.Printf("Removed dependency: issue #%d no longer depends on %s/%s#%d\n", index, depOwner, depRepo, depIndex) + } + return nil +} + +// parseDependencyArg parses a dependency argument which can be: +// - "123" or "#123" (same repo) +// - "owner/repo#123" (cross-repo) +func parseDependencyArg(arg, defaultOwner, defaultRepo string) (owner, repo string, index int64, err error) { + // Check for cross-repo format: owner/repo#123 + // Ensure "/" comes before "#" to distinguish from same-repo "#123" + slashIdx := strings.Index(arg, "/") + hashIdx := strings.Index(arg, "#") + if slashIdx != -1 && hashIdx != -1 && slashIdx < hashIdx { + parts := strings.SplitN(arg, "#", 2) + if len(parts) != 2 { + return "", "", 0, fmt.Errorf("invalid dependency format: %s (expected owner/repo#index)", arg) + } + + repoPath := parts[0] + indexStr := parts[1] + + repoParts := strings.SplitN(repoPath, "/", 2) + if len(repoParts) != 2 { + return "", "", 0, fmt.Errorf("invalid dependency format: %s (expected owner/repo#index)", arg) + } + + owner = repoParts[0] + repo = repoParts[1] + + index, err = strconv.ParseInt(indexStr, 10, 64) + if err != nil { + return "", "", 0, fmt.Errorf("invalid issue index: %s", indexStr) + } + + return owner, repo, index, nil + } + + // Same repo format: 123 or #123 + index, err = utils.ArgToIndex(arg) + if err != nil { + return "", "", 0, fmt.Errorf("invalid issue index: %w", err) + } + + return defaultOwner, defaultRepo, index, nil +} diff --git a/modules/api/types.go b/modules/api/types.go new file mode 100644 index 0000000..a2e03e4 --- /dev/null +++ b/modules/api/types.go @@ -0,0 +1,66 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import "time" + +// ActionRun represents a workflow run +type ActionRun struct { + ID int64 `json:"id"` + Title string `json:"display_title"` + Path string `json:"path"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + RunNumber int64 `json:"run_number"` + RunAttempt int64 `json:"run_attempt"` + HTMLURL string `json:"html_url"` + URL string `json:"url"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +// ActionRunList is a list of workflow runs +type ActionRunList struct { + TotalCount int64 `json:"total_count"` + WorkflowRuns []*ActionRun `json:"workflow_runs"` +} + +// ActionJob represents a job within a workflow run +type ActionJob struct { + ID int64 `json:"id"` + RunID int64 `json:"run_id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + Steps []*ActionJobStep `json:"steps"` +} + +// ActionJobStep represents a step within a job +type ActionJobStep struct { + Name string `json:"name"` + Number int64 `json:"number"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +// ActionJobList is a list of jobs +type ActionJobList struct { + TotalCount int64 `json:"total_count"` + Jobs []*ActionJob `json:"jobs"` +} + +// IssueDependencyRequest is the request body for adding/removing issue dependencies +type IssueDependencyRequest struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Index int64 `json:"index"` +}