Files
tea/cmd/issues/dependencies.go
Hugo Nijhuis 5c4620d940
Some checks failed
goreleaser / release-image (push) Failing after 27m28s
goreleaser / goreleaser (push) Failing after 27m29s
fix: validate order of / and # in cross-repo dependency parsing
The previous parsing logic for cross-repo dependencies (owner/repo#123)
only checked if both "/" and "#" were present, but didn't verify that
"/" came before "#". This could cause inputs like "#123/owner/repo" to
incorrectly match the cross-repo pattern.

Now explicitly check that slashIdx < hashIdx before treating as cross-repo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:59:29 +00:00

231 lines
6.5 KiB
Go

// 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: "<issue index>",
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: "<issue index>",
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: "<issue index> <dependency index or owner/repo#index>",
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: "<issue index> <dependency index or owner/repo#index>",
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 <issue index> <dependency index or owner/repo#index>")
}
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 <issue index> <dependency index or owner/repo#index>")
}
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
}