feat(issues): add dependency management commands
Add commands to manage issue dependencies using the Gitea API: - `tea issues dependencies <index>` - list dependencies - `tea issues dependencies add <index> <dep>` - add a dependency - `tea issues dependencies remove <index> <dep>` - remove a dependency Supports cross-repo dependencies with owner/repo#index syntax. Supports all output formats (table, json, csv, etc.). Closes #4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ var CmdIssues = cli.Command{
|
||||
&issues.CmdIssuesEdit,
|
||||
&issues.CmdIssuesReopen,
|
||||
&issues.CmdIssuesClose,
|
||||
&issues.CmdIssuesDependencies,
|
||||
},
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
|
||||
227
cmd/issues/dependencies.go
Normal file
227
cmd/issues/dependencies.go
Normal file
@@ -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: "<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
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user