// 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 // 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 }