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.CmdIssuesEdit,
|
||||||
&issues.CmdIssuesReopen,
|
&issues.CmdIssuesReopen,
|
||||||
&issues.CmdIssuesClose,
|
&issues.CmdIssuesClose,
|
||||||
|
&issues.CmdIssuesDependencies,
|
||||||
},
|
},
|
||||||
Flags: append([]cli.Flag{
|
Flags: append([]cli.Flag{
|
||||||
&cli.BoolFlag{
|
&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
|
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"`
|
TotalCount int64 `json:"total_count"`
|
||||||
Jobs []*ActionJob `json:"jobs"`
|
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