gitlink-cli/shortcuts/issue/issue_test.go

1266 lines
39 KiB
Go

package issue
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func newIssueTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func writeText(t *testing.T, w http.ResponseWriter, status int, text string) {
t.Helper()
w.WriteHeader(status)
if _, err := w.Write([]byte(text)); err != nil {
t.Fatalf("write response: %v", err)
}
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
return payload
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if got != want {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func assertNumberSlice(t *testing.T, got interface{}, want []float64) {
t.Helper()
values, ok := got.([]interface{})
if !ok {
t.Fatalf("got %v (%T), want numeric slice", got, got)
}
if len(values) != len(want) {
t.Fatalf("got %v, want %v", values, want)
}
for i, value := range values {
if value != want[i] {
t.Fatalf("got %v, want %v", values, want)
}
}
}
// --- list ---
func TestIssueList(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("category") != "opened" {
t.Fatalf("expected category=opened, got %s", r.URL.Query().Get("category"))
}
writeJSON(t, w, []interface{}{
map[string]interface{}{"id": float64(1), "subject": "bug"},
})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"state": "open", "page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestIssueListWithFilters(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
query := r.URL.Query()
assertEqual(t, query.Get("category"), "closed")
assertEqual(t, query.Get("keyword"), "release")
assertEqual(t, query.Get("participant_category"), "assignedme")
assertEqual(t, query.Get("author_id"), "10")
assertEqual(t, query.Get("assigner_id"), "11")
assertEqual(t, query.Get("milestone_id"), "12")
assertEqual(t, query.Get("status_id"), "5")
assertEqual(t, query.Get("issue_tag_ids"), "1,2")
assertEqual(t, query.Get("sort_by"), "issues.updated_on")
assertEqual(t, query.Get("sort_direction"), "desc")
writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{
"state": "closed",
"keyword": "release",
"participant": "assignedme",
"author-id": "10",
"assignee-id": "11",
"milestone-id": "12",
"status-id": "5",
"tag-ids": "1,2",
"sort-by": "issues.updated_on",
"sort-direction": "desc",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
}
func TestIssueListStateAll(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertEqual(t, r.URL.Query().Get("category"), "all")
writeJSON(t, w, map[string]interface{}{"issues": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"state": "all", "page": "1", "limit": "20"})
if err != nil {
t.Fatalf("list all failed: %v", err)
}
}
// --- create ---
func TestIssueCreate(t *testing.T) {
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(1), "subject": "bug"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"title": "bug: crash",
"body": "description",
"assignee": "alice",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
assertEqual(t, payload["subject"], "bug: crash")
assertEqual(t, payload["status_id"], float64(1))
assertEqual(t, payload["assigned_to_id"], "alice")
}
func TestIssueCreateSupportsMetadataFields(t *testing.T) {
var createPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
createPayload = decodeJSON(t, r)
writeJSON(t, w, createPayload)
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"title": "New issue",
"body": "With metadata",
"priority-id": "3",
"tag-ids": "4,5",
"assigner-ids": "7,8",
"branch": "feature/metadata",
"start-date": "2026-05-01",
"due-date": "2026-05-31",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, createPayload["subject"], "New issue")
assertEqual(t, createPayload["description"], "With metadata")
assertEqual(t, createPayload["priority_id"], float64(3))
assertNumberSlice(t, createPayload["issue_tag_ids"], []float64{4, 5})
assertNumberSlice(t, createPayload["assigner_ids"], []float64{7, 8})
assertEqual(t, createPayload["branch_name"], "feature/metadata")
assertEqual(t, createPayload["start_date"], "2026-05-01")
assertEqual(t, createPayload["due_date"], "2026-05-31")
}
func TestIssueCreateMissingTitle(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{})
if err == nil {
t.Fatal("expected error for missing title")
}
}
// --- view/id alias ---
func TestIssueView(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/v1/owner/repo/issues/42.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"id": float64(42), "subject": "bug"})
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"number": "42"})
if err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestIssueViewMissingNumber(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{})
if err == nil {
t.Fatal("expected error for missing number")
}
}
func TestIssueViewAcceptsIDAlias(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"project_issues_index": 42,
"subject": "Issue from web URL",
})
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
assertEqual(t, requestedPath, "/v1/owner/repo/issues/42.json")
}
func TestIssueNumberTakesPrecedenceOverIDAlias(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"subject": "Existing title"})
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{
"number": "42",
"id": "99",
})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
// --- close ---
func TestIssueClose(t *testing.T) {
var patchPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "close", map[string]string{"number": "42"})
if err != nil {
t.Fatalf("close failed: %v", err)
}
assertEqual(t, patchPayload["subject"], "Existing title")
assertEqual(t, patchPayload["description"], "Existing description")
assertEqual(t, patchPayload["status_id"], float64(5))
}
func TestIssueCloseAcceptsIDAlias(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "close", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("close shortcut failed: %v", err)
}
assertEqual(t, updatePayload["status_id"], float64(5))
}
func TestIssueClosePreservesCurrentMetadata(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Existing title",
"priority": map[string]interface{}{"id": 3},
"tags": []map[string]interface{}{
{"id": 4},
},
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "close", map[string]string{"number": "42"})
if err != nil {
t.Fatalf("close shortcut failed: %v", err)
}
assertEqual(t, updatePayload["subject"], "Existing title")
assertEqual(t, updatePayload["status_id"], float64(5))
assertEqual(t, updatePayload["priority_id"], float64(3))
assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{4})
}
func TestIssueCloseFetchFails(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
writeJSON(t, w, map[string]interface{}{"error": "not found"})
})
defer server.Close()
err := runShortcut(t, server, "close", map[string]string{"number": "999"})
if err == nil {
t.Fatal("expected error when issue not found")
}
}
// --- update ---
func TestIssueUpdateTitle(t *testing.T) {
var patchPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{"number": "42", "title": "New title", "state": "closed"})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, patchPayload["subject"], "New title")
assertEqual(t, patchPayload["description"], "Existing description")
assertEqual(t, patchPayload["status_id"], float64(5))
}
func TestIssueUpdateDescription(t *testing.T) {
var patchPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{"number": "42", "body": "New description"})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, patchPayload["subject"], "Existing title")
assertEqual(t, patchPayload["description"], "New description")
}
func TestIssueUpdateNumericState(t *testing.T) {
var patchPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"subject": "bug",
"description": "desc",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{"number": "42", "state": "3"})
if err != nil {
t.Fatalf("update numeric state failed: %v", err)
}
assertEqual(t, patchPayload["status_id"], float64(3))
}
func TestIssueUpdateAcceptsIDAlias(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{
"id": "42",
"title": "New title",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, updatePayload["subject"], "New title")
assertEqual(t, updatePayload["description"], "Existing description")
}
func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Existing title",
"description": "Existing description",
"status": map[string]interface{}{"id": 1},
"priority": map[string]interface{}{"id": 2},
"tags": []map[string]interface{}{
{"id": 7},
{"id": 8},
},
"assigners": []map[string]interface{}{
{"id": 9},
},
"branch_name": "main",
"start_date": "2026-05-01",
"due_date": "2026-05-31",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{
"number": "42",
"title": "New title",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, updatePayload["subject"], "New title")
assertEqual(t, updatePayload["description"], "Existing description")
assertEqual(t, updatePayload["status_id"], float64(1))
assertEqual(t, updatePayload["priority_id"], float64(2))
assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{7, 8})
assertNumberSlice(t, updatePayload["assigner_ids"], []float64{9})
assertEqual(t, updatePayload["branch_name"], "main")
assertEqual(t, updatePayload["start_date"], "2026-05-01")
assertEqual(t, updatePayload["due_date"], "2026-05-31")
}
func TestIssueUpdateSupportsMetadataFields(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{
"number": "42",
"priority-id": "4",
"tag-ids": "6,7",
"assigner-ids": "8",
"branch": "bugfix/metadata",
"start-date": "2026-06-01",
"due-date": "2026-06-15",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, updatePayload["subject"], "Existing title")
assertEqual(t, updatePayload["description"], "Existing description")
assertEqual(t, updatePayload["priority_id"], float64(4))
assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{6, 7})
assertNumberSlice(t, updatePayload["assigner_ids"], []float64{8})
assertEqual(t, updatePayload["branch_name"], "bugfix/metadata")
assertEqual(t, updatePayload["start_date"], "2026-06-01")
assertEqual(t, updatePayload["due_date"], "2026-06-15")
}
func TestIssueUpdateInvalidState(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"subject": "bug",
"description": "desc",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{"number": "42", "state": "invalid"})
if err == nil {
t.Fatal("expected error for invalid state")
}
}
func TestIssueUpdateNoChanges(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{"number": "42"})
if err == nil {
t.Fatal("expected error when no changes specified")
}
}
func TestIssueRejectsInvalidMetadataIDs(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid metadata should not call API, got %s %s", r.Method, r.URL.Path)
})
defer server.Close()
cases := []struct {
name string
args map[string]string
}{
{name: "bad priority", args: map[string]string{"title": "x", "priority-id": "abc"}},
{name: "empty tag", args: map[string]string{"title": "x", "tag-ids": "1,,2"}},
{name: "label conflicts with tag ids", args: map[string]string{"title": "x", "label": "1", "tag-ids": "2"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := runShortcut(t, server, "create", tc.args); err == nil {
t.Fatal("expected metadata validation error")
}
})
}
}
// --- comment ---
func TestIssueComment(t *testing.T) {
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v1/owner/repo/issues/42/journals.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(1), "message": "ok"})
})
defer server.Close()
err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test comment"})
if err != nil {
t.Fatalf("comment failed: %v", err)
}
assertEqual(t, payload["notes"], "test comment")
}
func TestIssueCommentAcceptsIDAlias(t *testing.T) {
var commentPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
commentPayload = decodeJSON(t, r)
writeJSON(t, w, commentPayload)
})
defer server.Close()
err := runShortcut(t, server, "comment", map[string]string{
"id": "42",
"body": "Fixed",
})
if err != nil {
t.Fatalf("comment shortcut failed: %v", err)
}
assertEqual(t, commentPayload["notes"], "Fixed")
}
func TestIssueCommentMissingBody(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
})
defer server.Close()
err := runShortcut(t, server, "comment", map[string]string{"number": "42"})
if err == nil {
t.Fatal("expected error for missing body")
}
}
func TestIssueNumberOrIDIsRequired(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
cases := []struct {
name string
args map[string]string
}{
{name: "view", args: map[string]string{}},
{name: "close", args: map[string]string{}},
{name: "update", args: map[string]string{"title": "New title"}},
{name: "comment", args: map[string]string{"body": "Fixed"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := runShortcut(t, server, tc.name, tc.args)
if err == nil {
t.Fatal("expected missing issue number error")
}
if !strings.Contains(err.Error(), "--number") || !strings.Contains(err.Error(), "--id") {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
// --- batch-close ---
func TestBatchClosePreservesCurrentDescription(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "batch-close", map[string]string{
"numbers": "42",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-close shortcut failed: %v", err)
}
assertEqual(t, updatePayload["subject"], "Existing title")
assertEqual(t, updatePayload["description"], "Existing description")
assertEqual(t, updatePayload["status_id"], float64(5))
}
func TestBatchCloseDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected in dry-run mode")
})
defer server.Close()
err := runShortcut(t, server, "batch-close", map[string]string{
"numbers": "1, 2, 3",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-close dry-run failed: %v", err)
}
}
func TestBatchCloseNoNumbers(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
})
defer server.Close()
err := runShortcut(t, server, "batch-close", map[string]string{})
if err == nil {
t.Fatal("expected error when no issue numbers provided")
}
}
func TestBatchCloseFetchFails(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeText(t, w, http.StatusNotFound, "not found")
})
defer server.Close()
err := runShortcut(t, server, "batch-close", map[string]string{"numbers": "99"})
if err == nil {
t.Fatal("expected error when fetch fails")
}
}
func TestBatchCloseWithFailedClose(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json":
writeJSON(t, w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1", "status_id": float64(5)})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/2.json":
writeText(t, w, http.StatusInternalServerError, "server error")
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "batch-close", map[string]string{"numbers": "1, 2"})
if err == nil {
t.Fatal("expected error when some issues fail to close")
}
}
// --- issue users ---
func TestIssueAssignersShortcutWithKeyword(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_assigners.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
assertEqual(t, r.URL.Query().Get("keyword"), "alice")
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"assigners": []map[string]interface{}{
{"id": 7, "name": "Alice", "login": "alice"},
},
})
})
defer server.Close()
err := runShortcut(t, server, "assigners", map[string]string{"keyword": "alice"})
if err != nil {
t.Fatalf("assigners shortcut failed: %v", err)
}
}
func TestIssueAuthorsShortcutWithKeyword(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_authors.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
assertEqual(t, r.URL.Query().Get("keyword"), "bob")
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"authors": []map[string]interface{}{
{"id": 8, "name": "Bob", "login": "bob"},
},
})
})
defer server.Close()
err := runShortcut(t, server, "authors", map[string]string{"keyword": "bob"})
if err != nil {
t.Fatalf("authors shortcut failed: %v", err)
}
}
// --- metadata lookup shortcuts ---
func TestIssuePrioritiesShortcut(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_priorities.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
assertEqual(t, r.URL.Query().Get("keyword"), "normal")
writeJSON(t, w, []map[string]interface{}{
{"id": 2, "name": "Normal"},
})
})
defer server.Close()
err := runShortcut(t, server, "priorities", map[string]string{"keyword": "normal"})
if err != nil {
t.Fatalf("priorities shortcut failed: %v", err)
}
}
func TestIssueTagsShortcutWithFilters(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_tags.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
query := r.URL.Query()
assertEqual(t, query.Get("keyword"), "bug")
assertEqual(t, query.Get("only_name"), "true")
assertEqual(t, query.Get("order_by"), "issues_count")
assertEqual(t, query.Get("order_direction"), "desc")
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"issue_tags": []map[string]interface{}{
{"id": 3, "name": "bug"},
},
})
})
defer server.Close()
err := runShortcut(t, server, "tags", map[string]string{
"keyword": "bug",
"only-name": "true",
"order-by": "issues_count",
"order-direction": "desc",
})
if err != nil {
t.Fatalf("tags shortcut failed: %v", err)
}
}
func TestIssueStatusesShortcut(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_statues.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
assertEqual(t, r.URL.Query().Get("page"), "2")
assertEqual(t, r.URL.Query().Get("limit"), "10")
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"statues": []map[string]interface{}{
{"id": 1, "name": "Open"},
},
})
})
defer server.Close()
err := runShortcut(t, server, "statuses", map[string]string{"page": "2", "limit": "10"})
if err != nil {
t.Fatalf("statuses shortcut failed: %v", err)
}
}
// --- HTTP error paths ---
func TestIssueListHTTPError(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestIssueCreateHTTPError(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{"title": "test"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestIssueViewHTTPError(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"number": "42"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestIssueCommentHTTPError(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeText(t, w, http.StatusInternalServerError, "server error")
})
defer server.Close()
err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestIssueUpdateHTTPError(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42), "subject": "bug", "description": "desc",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeText(t, w, http.StatusInternalServerError, "server error")
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{"number": "42", "title": "new"})
if err == nil {
t.Fatal("expected error for PATCH HTTP 500")
}
}
func TestIssueCloseHTTPError(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42), "subject": "bug", "description": "desc",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeText(t, w, http.StatusInternalServerError, "server error")
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "close", map[string]string{"number": "42"})
if err == nil {
t.Fatal("expected error for PATCH HTTP 500")
}
}
func TestFetchExistingIssueBadData(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, "not a map")
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
}
_, err := fetchExistingIssue(ctx, "1")
if err == nil {
t.Fatal("expected error for non-map response")
}
}
func TestFetchExistingIssueNoSubject(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
}
_, err := fetchExistingIssue(ctx, "1")
if err == nil {
t.Fatal("expected error for missing subject")
}
}
// --- normalizeIssueStatus ---
func TestNormalizeIssueStatus(t *testing.T) {
tests := []struct {
input string
want interface{}
wantErr bool
}{
{"open", 1, false},
{"OPEN", 1, false},
{" open ", 1, false},
{"closed", 5, false},
{"CLOSED", 5, false},
{"0", 0, false},
{"10", 10, false},
{"invalid", nil, true},
{"", nil, true},
}
for _, tt := range tests {
got, err := normalizeIssueStatus(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("normalizeIssueStatus(%q) expected error", tt.input)
}
} else {
if err != nil {
t.Errorf("normalizeIssueStatus(%q) error: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("normalizeIssueStatus(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
}
func TestIssueCommentsListsJournalsWithQuery(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
q := r.URL.Query()
assertEqual(t, q.Get("page"), "2")
assertEqual(t, q.Get("limit"), "5")
assertEqual(t, q.Get("category"), "comment")
assertEqual(t, q.Get("keyword"), "hello")
writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "comments", map[string]string{
"number": "42", "page": "2", "limit": "5", "category": "comment", "keyword": "hello",
})
if err != nil {
t.Fatalf("comments failed: %v", err)
}
}
func TestIssueCommentEditPatchesJournal(t *testing.T) {
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/99.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(99)})
})
defer server.Close()
err := runShortcut(t, server, "comment-edit", map[string]string{
"number": "42", "comment-id": "99", "body": "updated",
})
if err != nil {
t.Fatalf("comment-edit failed: %v", err)
}
assertEqual(t, payload["notes"], "updated")
}
func TestIssueCommentDeleteUsesJournalPath(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/99.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
})
defer server.Close()
err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42", "comment-id": "99"})
if err != nil {
t.Fatalf("comment-delete failed: %v", err)
}
}
func TestIssueCommentEditRejectsNonIntegerCommentID(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runShortcut(t, server, "comment-edit", map[string]string{
"number": "42", "comment-id": "abc", "body": "x",
})
if err == nil {
t.Fatal("expected error for non-integer --comment-id")
}
}
func TestIssueCommentDeleteRequiresCommentID(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42"})
if err == nil {
t.Fatal("expected error when --comment-id is missing")
}
}
func TestIssueCommentReplyToSetsParentID(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/7/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(101)})
}))
defer server.Close()
err := runShortcut(t, server, "comment", map[string]string{
"number": "7", "body": "a reply", "reply-to": "99",
})
if err != nil {
t.Fatalf("comment failed: %v", err)
}
assertEqual(t, payload["notes"], "a reply")
assertEqual(t, payload["parent_id"], float64(99))
assertEqual(t, payload["reply_id"], float64(99))
}
func TestIssueCommentRepliesUsesChildrenJournalsPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/7/journals/99/children_journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}})
}))
defer server.Close()
err := runShortcut(t, server, "comment-replies", map[string]string{
"number": "7", "comment-id": "99",
})
if err != nil {
t.Fatalf("comment-replies failed: %v", err)
}
}
func TestIssueCommentRepliesRejectsNonIntegerCommentID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runShortcut(t, server, "comment-replies", map[string]string{
"number": "7", "comment-id": "abc",
})
if err == nil {
t.Fatal("expected error for non-integer --comment-id")
}
}