Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
043c3f647c feat(cli): list issue pull requests
Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 09:25:16 +08:00
2 changed files with 165 additions and 0 deletions

View File

@@ -101,6 +101,14 @@ var issueGetCmd = &cobra.Command{
RunE: runIssueGet,
}
var issuePullRequestsCmd = &cobra.Command{
Use: "pull-requests <id>",
Aliases: []string{"prs"},
Short: "List pull requests linked to an issue",
Args: exactArgs(1),
RunE: runIssuePullRequests,
}
var issueCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new issue",
@@ -233,6 +241,7 @@ var validIssueStatuses = []string{
func init() {
issueCmd.AddCommand(issueListCmd)
issueCmd.AddCommand(issueGetCmd)
issueCmd.AddCommand(issuePullRequestsCmd)
issueCmd.AddCommand(issueCreateCmd)
issueCmd.AddCommand(issueUpdateCmd)
issueCmd.AddCommand(issueAssignCmd)
@@ -268,6 +277,9 @@ func init() {
// issue get
issueGetCmd.Flags().String("output", "json", "Output format: table or json")
// issue pull-requests
issuePullRequestsCmd.Flags().String("output", "table", "Output format: table or json")
// issue create
issueCreateCmd.Flags().String("title", "", "Issue title (required)")
issueCreateCmd.Flags().String("description", "", "Issue description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)")
@@ -492,6 +504,68 @@ func runIssueList(cmd *cobra.Command, _ []string) error {
return nil
}
func runIssuePullRequests(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
issueRef, err := resolveIssueRef(ctx, client, args[0])
if err != nil {
return fmt.Errorf("resolve issue: %w", err)
}
var result map[string]any
if err := client.GetJSON(ctx, "/api/issues/"+url.PathEscape(issueRef.ID)+"/pull-requests", &result); err != nil {
return fmt.Errorf("list issue pull requests: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
prs, _ := result["pull_requests"].([]any)
printIssuePullRequestsTable(normalizePullRequestList(prs))
return nil
}
func normalizePullRequestList(raw []any) []map[string]any {
prs := make([]map[string]any, 0, len(raw))
for _, item := range raw {
pr, ok := item.(map[string]any)
if !ok {
continue
}
prs = append(prs, pr)
}
return prs
}
func printIssuePullRequestsTable(prs []map[string]any) {
headers := []string{"NUMBER", "STATE", "TITLE", "URL"}
rows := make([][]string, 0, len(prs))
for _, pr := range prs {
rows = append(rows, []string{
strVal(pr, "number"),
strVal(pr, "state"),
strVal(pr, "title"),
pullRequestURL(pr),
})
}
cli.PrintTable(os.Stdout, headers, rows)
}
func pullRequestURL(pr map[string]any) string {
if url := strVal(pr, "url"); url != "" {
return url
}
return strVal(pr, "html_url")
}
func runIssueGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {

View File

@@ -318,6 +318,97 @@ func TestRunIssueCreateShowsDuplicateMessage(t *testing.T) {
}
}
func newIssuePullRequestsTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "pull-requests"}
cmd.Flags().String("output", "table", "")
return cmd
}
func TestRunIssuePullRequestsListsLinkedPRsAsJSON(t *testing.T) {
var gotPaths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.URL.Path)
switch r.URL.Path {
case "/api/issues/MUL-2818":
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-uuid",
"identifier": "MUL-2818",
"title": "CLI PR lookup",
})
case "/api/issues/issue-uuid/pull-requests":
json.NewEncoder(w).Encode(map[string]any{
"pull_requests": []map[string]any{
{
"url": "https://github.com/multica-ai/multica/pull/42",
"number": float64(42),
"state": "open",
"title": "MUL-2818 add issue PR CLI",
},
},
})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newIssuePullRequestsTestCmd()
_ = cmd.Flags().Set("output", "json")
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := runIssuePullRequests(cmd, []string{"MUL-2818"})
_ = w.Close()
os.Stdout = old
out, _ := io.ReadAll(r)
if err != nil {
t.Fatalf("runIssuePullRequests: %v", err)
}
if want := []string{"/api/issues/MUL-2818", "/api/issues/issue-uuid/pull-requests"}; fmt.Sprint(gotPaths) != fmt.Sprint(want) {
t.Fatalf("paths = %v, want %v", gotPaths, want)
}
var payload map[string]any
if err := json.Unmarshal(out, &payload); err != nil {
t.Fatalf("decode JSON output: %v\n%s", err, string(out))
}
prs, _ := payload["pull_requests"].([]any)
if len(prs) != 1 {
t.Fatalf("pull_requests length = %d, want 1", len(prs))
}
pr, _ := prs[0].(map[string]any)
if pr["url"] != "https://github.com/multica-ai/multica/pull/42" || pr["number"] != float64(42) || pr["state"] != "open" || pr["title"] != "MUL-2818 add issue PR CLI" {
t.Fatalf("unexpected PR payload: %#v", pr)
}
}
func TestRunIssuePullRequestsTableIncludesCoreFields(t *testing.T) {
prs := []map[string]any{{
"url": "https://github.com/multica-ai/multica/pull/42",
"number": float64(42),
"state": "open",
"title": "MUL-2818 add issue PR CLI",
}}
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
printIssuePullRequestsTable(prs)
_ = w.Close()
os.Stdout = old
out, _ := io.ReadAll(r)
text := string(out)
for _, want := range []string{"NUMBER", "STATE", "TITLE", "URL", "42", "open", "MUL-2818 add issue PR CLI", "https://github.com/multica-ai/multica/pull/42"} {
if !strings.Contains(text, want) {
t.Fatalf("table output missing %q:\n%s", want, text)
}
}
}
func TestTruncateID(t *testing.T) {
tests := []struct {
name string