diff --git a/cmd/issues.go b/cmd/issues.go index 05ce2f0..41bd4c7 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -63,6 +63,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..0038170 --- /dev/null +++ b/cmd/issues/dependencies.go @@ -0,0 +1,227 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "bytes" + stdctx "context" + "encoding/json" + "fmt" + "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 := context.InitCommand(cmd) + 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) + + var deps []*gitea.Issue + if _, err := client.Get(ctx, path, &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 := context.InitCommand(cmd) + 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) + } + + if _, err := client.Post(ctx, path, bytes.NewReader(body), nil); err != nil { + return err + } + + 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 := context.InitCommand(cmd) + 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) + } + + if _, err := client.Delete(ctx, path, bytes.NewReader(body)); err != nil { + return err + } + + 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 + if strings.Contains(arg, "/") && strings.Contains(arg, "#") { + 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/client.go b/modules/api/client.go index 31b2a4d..1f2649c 100644 --- a/modules/api/client.go +++ b/modules/api/client.go @@ -97,3 +97,48 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { return body, nil } + +// Post makes an authenticated POST request to the API and decodes the JSON response +func (c *Client) Post(ctx context.Context, path string, body io.Reader, result interface{}) (*http.Response, error) { + return c.doRequest(ctx, "POST", path, body, result) +} + +// Delete makes an authenticated DELETE request to the API +func (c *Client) Delete(ctx context.Context, path string, body io.Reader) (*http.Response, error) { + return c.doRequest(ctx, "DELETE", path, body, nil) +} + +// doRequest performs an HTTP request with the given method +func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, result interface{}) (*http.Response, error) { + url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "token "+c.login.Token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return resp, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return resp, fmt.Errorf("failed to decode response: %w", err) + } + } + + return resp, nil +} diff --git a/modules/api/types.go b/modules/api/types.go index 7ecce2b..a2e03e4 100644 --- a/modules/api/types.go +++ b/modules/api/types.go @@ -57,3 +57,10 @@ 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"` +}