@ripla/godd-mcp 1.0.4-canary.2 → 1.0.4-canary.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
3
+
4
+ vi.mock("@/lib/api", () => ({
5
+ createIssueApi: vi.fn(),
6
+ updateIssueApi: vi.fn(),
7
+ getIssueApi: vi.fn(),
8
+ getErrorMessage: vi.fn((e: unknown) => String(e)),
9
+ isAbortError: vi.fn(() => false),
10
+ }));
11
+
12
+ vi.mock("@/contexts/AuthContext", () => ({
13
+ useAuth: () => ({ canEdit: true }),
14
+ }));
15
+
16
+ const mockShowToast = vi.hoisted(() => vi.fn());
17
+ vi.mock("@/contexts/ToastContext", () => ({
18
+ useToast: () => ({ showToast: mockShowToast }),
19
+ }));
20
+
21
+ vi.mock("@/components/FileContentView", () => ({
22
+ default: ({ content }: { content: string }) => (
23
+ <div data-testid="file-content-view">{content}</div>
24
+ ),
25
+ }));
26
+
27
+ import { createIssueApi, updateIssueApi, getIssueApi } from "@/lib/api";
28
+ import IssueDetailView from "./IssueDetailView";
29
+
30
+ const mockCreateIssue = vi.mocked(createIssueApi);
31
+ const mockUpdateIssue = vi.mocked(updateIssueApi);
32
+ const mockGetIssue = vi.mocked(getIssueApi);
33
+
34
+ const MOCK_ISSUE = {
35
+ number: 123,
36
+ title: "テスト Issue",
37
+ body: "本文テスト",
38
+ state: "open" as const,
39
+ author: "testuser",
40
+ assignees: [],
41
+ comments: 0,
42
+ createdAt: "2026-05-19T00:00:00Z",
43
+ updatedAt: "2026-05-19T00:00:00Z",
44
+ url: "https://github.com/test/repo/issues/123",
45
+ labels: [],
46
+ };
47
+
48
+ describe("IssueDetailView — 新規 Issue 作成", () => {
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ });
52
+
53
+ it("作成フォームが表示される(isNew=true)", () => {
54
+ render(
55
+ <IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={vi.fn()} />,
56
+ );
57
+ expect(screen.getByText("新規 Issue 作成")).toBeTruthy();
58
+ expect(screen.getByPlaceholderText("Issue のタイトル")).toBeTruthy();
59
+ expect(screen.getByText("作成")).toBeTruthy();
60
+ });
61
+
62
+ it("タイトル未入力時は作成ボタンが disabled", () => {
63
+ render(
64
+ <IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={vi.fn()} />,
65
+ );
66
+ const btn = screen.getByText("作成");
67
+ expect((btn as HTMLButtonElement).disabled).toBe(true);
68
+ });
69
+
70
+ it("作成成功後にプレビュー画面へ遷移し、onCreated が呼ばれる", async () => {
71
+ mockCreateIssue.mockResolvedValue(MOCK_ISSUE);
72
+
73
+ const onCreated = vi.fn();
74
+ render(
75
+ <IssueDetailView issueNumber="new" onCreated={onCreated} onClose={vi.fn()} />,
76
+ );
77
+
78
+ const titleInput = screen.getByPlaceholderText("Issue のタイトル");
79
+ fireEvent.change(titleInput, { target: { value: "テスト Issue" } });
80
+
81
+ await act(async () => {
82
+ fireEvent.click(screen.getByText("作成"));
83
+ });
84
+
85
+ // プレビュー画面に遷移(編集フォームが消える)
86
+ await waitFor(() => {
87
+ expect(screen.queryByText("新規 Issue 作成")).toBeNull();
88
+ });
89
+
90
+ // Issue タイトルがプレビュー表示される
91
+ expect(screen.getByText("テスト Issue")).toBeTruthy();
92
+
93
+ // onCreated コールバックが作成された Issue 番号で呼ばれる
94
+ expect(onCreated).toHaveBeenCalledWith(123);
95
+ });
96
+
97
+ it("キャンセルボタンで onClose が呼ばれる", () => {
98
+ const onClose = vi.fn();
99
+ render(
100
+ <IssueDetailView issueNumber="new" onCreated={vi.fn()} onClose={onClose} />,
101
+ );
102
+ fireEvent.click(screen.getByText("キャンセル"));
103
+ expect(onClose).toHaveBeenCalledOnce();
104
+ });
105
+ });
106
+
107
+ describe("IssueDetailView — 既存 Issue 更新", () => {
108
+ beforeEach(() => {
109
+ vi.clearAllMocks();
110
+ mockGetIssue.mockResolvedValue(MOCK_ISSUE);
111
+ });
112
+
113
+ it("更新成功後にプレビュー画面へ遷移する(既存の更新と同じ挙動)", async () => {
114
+ const updatedIssue = { ...MOCK_ISSUE, title: "更新後タイトル" };
115
+ mockUpdateIssue.mockResolvedValue(updatedIssue);
116
+
117
+ render(
118
+ <IssueDetailView issueNumber={123} onCreated={vi.fn()} onClose={vi.fn()} />,
119
+ );
120
+
121
+ await waitFor(() => {
122
+ expect(screen.getByText("テスト Issue")).toBeTruthy();
123
+ });
124
+
125
+ fireEvent.click(screen.getByText("編集"));
126
+
127
+ const titleInput = screen.getByDisplayValue("テスト Issue");
128
+ fireEvent.change(titleInput, { target: { value: "更新後タイトル" } });
129
+
130
+ await act(async () => {
131
+ fireEvent.click(screen.getByText("保存"));
132
+ });
133
+
134
+ await waitFor(() => {
135
+ expect(screen.queryByText("保存")).toBeNull();
136
+ });
137
+
138
+ expect(screen.getByText("更新後タイトル")).toBeTruthy();
139
+ });
140
+ });
@@ -77,6 +77,8 @@ export default function IssueDetailView({
77
77
  if (isNew) {
78
78
  const created = await createIssueApi({ title: title.trim(), body });
79
79
  showToast(`Issue #${created.number} を作成しました`, "success");
80
+ setIssue(created);
81
+ setEditing(false);
80
82
  onCreated?.(created.number);
81
83
  } else {
82
84
  const updated = await updateIssueApi(issueNumber as number, {
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+
4
+ vi.mock("@/lib/api", () => ({
5
+ listIssuesApi: vi.fn(),
6
+ getErrorMessage: vi.fn((e: unknown) => String(e)),
7
+ isAbortError: vi.fn(() => false),
8
+ }));
9
+
10
+ import { listIssuesApi } from "@/lib/api";
11
+ import IssueList from "./IssueList";
12
+
13
+ const mockListIssues = vi.mocked(listIssuesApi);
14
+
15
+ const MOCK_ISSUES = [
16
+ {
17
+ number: 10,
18
+ title: "Issue 10",
19
+ state: "open" as const,
20
+ author: "user1",
21
+ assignees: [],
22
+ comments: 0,
23
+ labels: [],
24
+ createdAt: "2026-05-19T00:00:00Z",
25
+ updatedAt: "2026-05-19T00:00:00Z",
26
+ },
27
+ {
28
+ number: 9,
29
+ title: "Issue 9",
30
+ state: "open" as const,
31
+ author: "user1",
32
+ assignees: [],
33
+ comments: 0,
34
+ labels: [],
35
+ createdAt: "2026-05-18T00:00:00Z",
36
+ updatedAt: "2026-05-18T00:00:00Z",
37
+ },
38
+ ];
39
+
40
+ describe("IssueList", () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ });
44
+
45
+ it("Issueリストを取得して表示する", async () => {
46
+ mockListIssues.mockResolvedValue({ issues: MOCK_ISSUES });
47
+
48
+ render(<IssueList onSelect={vi.fn()} selectedIssue={null} />);
49
+
50
+ await waitFor(() => {
51
+ expect(screen.getByText("Issue 10")).toBeTruthy();
52
+ expect(screen.getByText("Issue 9")).toBeTruthy();
53
+ });
54
+ });
55
+
56
+ it("reloadTrigger が変わると listIssuesApi を再呼び出しする", async () => {
57
+ mockListIssues.mockResolvedValue({ issues: MOCK_ISSUES });
58
+
59
+ const { rerender } = render(
60
+ <IssueList onSelect={vi.fn()} selectedIssue={null} reloadTrigger={0} />,
61
+ );
62
+
63
+ await waitFor(() => {
64
+ expect(mockListIssues).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ rerender(
68
+ <IssueList onSelect={vi.fn()} selectedIssue={null} reloadTrigger={1} />,
69
+ );
70
+
71
+ await waitFor(() => {
72
+ expect(mockListIssues).toHaveBeenCalledTimes(2);
73
+ });
74
+ });
75
+
76
+ it("reloadTrigger が同じ値のままなら再呼び出しされない", async () => {
77
+ mockListIssues.mockResolvedValue({ issues: MOCK_ISSUES });
78
+
79
+ const { rerender } = render(
80
+ <IssueList onSelect={vi.fn()} selectedIssue={null} reloadTrigger={0} />,
81
+ );
82
+
83
+ await waitFor(() => {
84
+ expect(mockListIssues).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ rerender(
88
+ <IssueList onSelect={vi.fn()} selectedIssue={null} reloadTrigger={0} />,
89
+ );
90
+
91
+ expect(mockListIssues).toHaveBeenCalledTimes(1);
92
+ });
93
+ });
@@ -11,9 +11,10 @@ import { InlineError } from "@/components/Skeleton";
11
11
  type IssueListProps = {
12
12
  onSelect: (number: number) => void;
13
13
  selectedIssue: number | null;
14
+ reloadTrigger?: number;
14
15
  };
15
16
 
16
- export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
17
+ export default function IssueList({ onSelect, selectedIssue, reloadTrigger }: IssueListProps) {
17
18
  const [issues, setIssues] = useState<IssueSummary[]>([]);
18
19
  const [loading, setLoading] = useState(true);
19
20
  const [error, setError] = useState<string | null>(null);
@@ -43,7 +44,7 @@ export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
43
44
  listIssuesApi({ state: stateFilter, per_page: 50, signal: ac.signal })
44
45
  .then((data) => {
45
46
  if (ac.signal.aborted || requestSeq.current !== requestId) return;
46
- setIssues(data.issues);
47
+ setIssues([...data.issues].sort((a, b) => b.number - a.number));
47
48
  })
48
49
  .catch((e) => {
49
50
  if (ac.signal.aborted || requestSeq.current !== requestId || isAbortError(e)) return;
@@ -55,7 +56,7 @@ export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
55
56
  }
56
57
  });
57
58
  return () => ac.abort();
58
- }, [stateFilter, reloadKey]);
59
+ }, [stateFilter, reloadKey, reloadTrigger]);
59
60
 
60
61
  return (
61
62
  <div className="flex flex-col h-full">
@@ -614,6 +614,7 @@ export default function MainPage() {
614
614
  const [sidebarOpen, setSidebarOpen] = useState(false);
615
615
  const [sidebarTab, setSidebarTab] = useState<SidebarTab>("files");
616
616
  const [selectedIssue, setSelectedIssue] = useState<number | "new" | null>(null);
617
+ const [issueListReloadTrigger, setIssueListReloadTrigger] = useState(0);
617
618
 
618
619
  const handleFileSelectResponsive = useCallback(
619
620
  (path: string) => {
@@ -853,6 +854,7 @@ export default function MainPage() {
853
854
  )}
854
855
  <IssueList
855
856
  selectedIssue={typeof selectedIssue === "number" ? selectedIssue : null}
857
+ reloadTrigger={issueListReloadTrigger}
856
858
  onSelect={(num) => {
857
859
  setSelectedIssue(num);
858
860
  setSelectedFile(null);
@@ -945,9 +947,11 @@ export default function MainPage() {
945
947
  {selectedIssue != null && !selectedFile && (
946
948
  <Suspense fallback={<ContentSkeleton />}>
947
949
  <IssueDetailView
950
+ key={String(selectedIssue)}
948
951
  issueNumber={selectedIssue}
949
952
  onCreated={(num) => {
950
953
  setSelectedIssue(num);
954
+ setIssueListReloadTrigger((k) => k + 1);
951
955
  }}
952
956
  onClose={() => setSelectedIssue(null)}
953
957
  />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ripla/godd-mcp",
3
- "version": "1.0.4-canary.2",
3
+ "version": "1.0.4-canary.4",
4
4
  "type": "module",
5
5
  "description": "GoDD MCP Server - AI-powered development workflow tools via Model Context Protocol (slash commands support)",
6
6
  "main": "dist/index.js",