feat(issues): add dependency management commands
This commit is contained in:
@@ -62,6 +62,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{
|
||||||
|
|||||||
266
cmd/issues/dependencies.go
Normal file
266
cmd/issues/dependencies.go
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package issues
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
stdctx "context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"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, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
resp, err := client.Do("GET", path, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var deps []*gitea.Issue
|
||||||
|
decoder := json.NewDecoder(resp.Body)
|
||||||
|
if err := decoder.Decode(&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, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do("POST", path, bytes.NewReader(body), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
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, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do("DELETE", path, bytes.NewReader(body), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
66
modules/api/types.go
Normal file
66
modules/api/types.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ActionRun represents a workflow run
|
||||||
|
type ActionRun struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"display_title"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
HeadBranch string `json:"head_branch"`
|
||||||
|
HeadSHA string `json:"head_sha"`
|
||||||
|
RunNumber int64 `json:"run_number"`
|
||||||
|
RunAttempt int64 `json:"run_attempt"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionRunList is a list of workflow runs
|
||||||
|
type ActionRunList struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
WorkflowRuns []*ActionRun `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionJob represents a job within a workflow run
|
||||||
|
type ActionJob struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RunID int64 `json:"run_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
Steps []*ActionJobStep `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionJobStep represents a step within a job
|
||||||
|
type ActionJobStep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion"`
|
||||||
|
StartedAt *time.Time `json:"started_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionJobList is a list of jobs
|
||||||
|
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