@ripla/godd-mcp 1.0.4-canary.21 → 1.0.4-canary.23

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.
@@ -1,16 +1,18 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { render, screen, waitFor } from "@testing-library/react";
2
+ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
3
3
 
4
4
  vi.mock("@/lib/api", () => ({
5
5
  listIssuesApi: vi.fn(),
6
+ listIssueLabelsApi: vi.fn(),
6
7
  getErrorMessage: vi.fn((e: unknown) => String(e)),
7
8
  isAbortError: vi.fn(() => false),
8
9
  }));
9
10
 
10
- import { listIssuesApi } from "@/lib/api";
11
+ import { listIssuesApi, listIssueLabelsApi } from "@/lib/api";
11
12
  import IssueList from "./IssueList";
12
13
 
13
14
  const mockListIssues = vi.mocked(listIssuesApi);
15
+ const mockListLabels = vi.mocked(listIssueLabelsApi);
14
16
 
15
17
  const MOCK_ISSUES = [
16
18
  {
@@ -40,6 +42,9 @@ const MOCK_ISSUES = [
40
42
  describe("IssueList", () => {
41
43
  beforeEach(() => {
42
44
  vi.clearAllMocks();
45
+ mockListLabels.mockResolvedValue({
46
+ labels: [{ name: "bug", color: "d73a4a" }],
47
+ });
43
48
  });
44
49
 
45
50
  it("Issueリストを取得して表示する", async () => {
@@ -90,4 +95,24 @@ describe("IssueList", () => {
90
95
 
91
96
  expect(mockListIssues).toHaveBeenCalledTimes(1);
92
97
  });
98
+
99
+ it("ラベルフィルタ変更時に labels クエリ付きで listIssuesApi を呼ぶ", async () => {
100
+ mockListIssues.mockResolvedValue({ issues: MOCK_ISSUES });
101
+
102
+ render(<IssueList onSelect={vi.fn()} selectedIssue={null} />);
103
+
104
+ await waitFor(() => {
105
+ expect(screen.getByLabelText("ラベルでフィルタ")).toBeTruthy();
106
+ });
107
+
108
+ fireEvent.change(screen.getByLabelText("ラベルでフィルタ"), {
109
+ target: { value: "bug" },
110
+ });
111
+
112
+ await waitFor(() => {
113
+ expect(mockListIssues).toHaveBeenCalledWith(
114
+ expect.objectContaining({ labels: "bug" }),
115
+ );
116
+ });
117
+ });
93
118
  });
@@ -1,11 +1,12 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
2
  import {
3
3
  listIssuesApi,
4
+ listIssueLabelsApi,
4
5
  getErrorMessage,
5
6
  isAbortError,
6
7
  } from "@/lib/api";
7
- import type { IssueSummary } from "@/lib/api";
8
- import { CircleDot, CircleCheck, MessageSquare, RefreshCw } from "lucide-react";
8
+ import type { IssueLabel, IssueSummary } from "@/lib/api";
9
+ import { CircleDot, CircleCheck, MessageSquare, RefreshCw, Tag } from "lucide-react";
9
10
  import { InlineError } from "@/components/Skeleton";
10
11
 
11
12
  type IssueListProps = {
@@ -19,9 +20,25 @@ export default function IssueList({ onSelect, selectedIssue, reloadTrigger }: Is
19
20
  const [loading, setLoading] = useState(true);
20
21
  const [error, setError] = useState<string | null>(null);
21
22
  const [stateFilter, setStateFilter] = useState<"open" | "closed">("open");
23
+ const [labelFilter, setLabelFilter] = useState<string>("");
24
+ const [labels, setLabels] = useState<IssueLabel[]>([]);
22
25
  const [reloadKey, setReloadKey] = useState(0);
23
26
  const requestSeq = useRef(0);
24
27
 
28
+ useEffect(() => {
29
+ const ac = new AbortController();
30
+ listIssueLabelsApi()
31
+ .then((data) => {
32
+ if (ac.signal.aborted) return;
33
+ setLabels([...data.labels].sort((a, b) => a.name.localeCompare(b.name)));
34
+ })
35
+ .catch(() => {
36
+ if (ac.signal.aborted) return;
37
+ setLabels([]);
38
+ });
39
+ return () => ac.abort();
40
+ }, [reloadTrigger]);
41
+
25
42
  const requestReload = () => {
26
43
  requestSeq.current += 1;
27
44
  setLoading(true);
@@ -37,11 +54,24 @@ export default function IssueList({ onSelect, selectedIssue, reloadTrigger }: Is
37
54
  setStateFilter(nextState);
38
55
  };
39
56
 
57
+ const changeLabelFilter = (nextLabel: string) => {
58
+ if (nextLabel === labelFilter) return;
59
+ requestSeq.current += 1;
60
+ setLoading(true);
61
+ setError(null);
62
+ setLabelFilter(nextLabel);
63
+ };
64
+
40
65
  useEffect(() => {
41
66
  const ac = new AbortController();
42
67
  const requestId = requestSeq.current + 1;
43
68
  requestSeq.current = requestId;
44
- listIssuesApi({ state: stateFilter, per_page: 50, signal: ac.signal })
69
+ listIssuesApi({
70
+ state: stateFilter,
71
+ labels: labelFilter || undefined,
72
+ per_page: 50,
73
+ signal: ac.signal,
74
+ })
45
75
  .then((data) => {
46
76
  if (ac.signal.aborted || requestSeq.current !== requestId) return;
47
77
  setIssues([...data.issues].sort((a, b) => b.number - a.number));
@@ -56,7 +86,7 @@ export default function IssueList({ onSelect, selectedIssue, reloadTrigger }: Is
56
86
  }
57
87
  });
58
88
  return () => ac.abort();
59
- }, [stateFilter, reloadKey, reloadTrigger]);
89
+ }, [stateFilter, labelFilter, reloadKey, reloadTrigger]);
60
90
 
61
91
  return (
62
92
  <div className="flex flex-col h-full">
@@ -98,6 +128,28 @@ export default function IssueList({ onSelect, selectedIssue, reloadTrigger }: Is
98
128
  </button>
99
129
  </div>
100
130
 
131
+ {labels.length > 0 && (
132
+ <div className="px-2 pt-2">
133
+ <label className="flex items-center gap-1.5 text-[10px] text-gray-500 mb-1">
134
+ <Tag className="w-3 h-3" />
135
+ ラベル
136
+ </label>
137
+ <select
138
+ value={labelFilter}
139
+ onChange={(e) => changeLabelFilter(e.target.value)}
140
+ className="w-full px-2 py-1 rounded text-xs bg-gray-900 border border-gray-600 text-gray-200 outline-none focus:border-blue-500"
141
+ aria-label="ラベルでフィルタ"
142
+ >
143
+ <option value="">すべて</option>
144
+ {labels.map((label) => (
145
+ <option key={label.name} value={label.name}>
146
+ {label.name}
147
+ </option>
148
+ ))}
149
+ </select>
150
+ </div>
151
+ )}
152
+
101
153
  {/* Issue list */}
102
154
  <div className="flex-1 overflow-y-auto p-2">
103
155
  {loading && issues.length === 0 && (
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildMainPageSearchParams,
4
+ parseIssueSearchParam,
5
+ issueSelectionToSearchParam,
6
+ searchParamsToString,
7
+ } from "./issue-url-sync";
8
+
9
+ describe("parseIssueSearchParam", () => {
10
+ it("parses positive issue numbers", () => {
11
+ expect(parseIssueSearchParam("794")).toBe(794);
12
+ });
13
+
14
+ it("parses new issue sentinel", () => {
15
+ expect(parseIssueSearchParam("new")).toBe("new");
16
+ });
17
+
18
+ it("returns null for invalid values", () => {
19
+ expect(parseIssueSearchParam(null)).toBeNull();
20
+ expect(parseIssueSearchParam("")).toBeNull();
21
+ expect(parseIssueSearchParam("0")).toBeNull();
22
+ expect(parseIssueSearchParam("-1")).toBeNull();
23
+ expect(parseIssueSearchParam("abc")).toBeNull();
24
+ });
25
+ });
26
+
27
+ describe("issueSelectionToSearchParam", () => {
28
+ it("serializes issue selections", () => {
29
+ expect(issueSelectionToSearchParam(123)).toBe("123");
30
+ expect(issueSelectionToSearchParam("new")).toBe("new");
31
+ expect(issueSelectionToSearchParam(null)).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe("buildMainPageSearchParams", () => {
36
+ it("builds issue-only deep link", () => {
37
+ const params = buildMainPageSearchParams({ issue: 794 });
38
+ expect(searchParamsToString(params)).toBe("?issue=794");
39
+ });
40
+
41
+ it("builds new-issue deep link", () => {
42
+ const params = buildMainPageSearchParams({ issue: "new" });
43
+ expect(searchParamsToString(params)).toBe("?issue=new");
44
+ });
45
+
46
+ it("prefers file over issue when both provided", () => {
47
+ const params = buildMainPageSearchParams({
48
+ file: "docs/README.md",
49
+ issue: 794,
50
+ });
51
+ expect(params.get("file")).toBe("docs/README.md");
52
+ expect(params.get("issue")).toBe("794");
53
+ });
54
+
55
+ it("preserves unrelated query params", () => {
56
+ const preserve = new URLSearchParams("x=1&file=old&issue=1");
57
+ const params = buildMainPageSearchParams({
58
+ issue: 42,
59
+ preserve,
60
+ });
61
+ expect(params.get("x")).toBe("1");
62
+ expect(params.get("issue")).toBe("42");
63
+ expect(params.has("file")).toBe(false);
64
+ });
65
+
66
+ it("clears issue and file when neither is set", () => {
67
+ const preserve = new URLSearchParams("file=a&issue=1&x=1");
68
+ const params = buildMainPageSearchParams({ preserve });
69
+ expect(params.get("x")).toBe("1");
70
+ expect(params.has("file")).toBe(false);
71
+ expect(params.has("issue")).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,43 @@
1
+ export type IssueUrlSelection = number | "new" | null;
2
+
3
+ export function parseIssueSearchParam(value: string | null): IssueUrlSelection {
4
+ if (!value) return null;
5
+ if (value === "new") return "new";
6
+ const parsed = Number(value);
7
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
8
+ return null;
9
+ }
10
+
11
+ export function issueSelectionToSearchParam(selection: IssueUrlSelection): string | null {
12
+ if (selection === null) return null;
13
+ if (selection === "new") return "new";
14
+ return String(selection);
15
+ }
16
+
17
+ export function buildMainPageSearchParams(options: {
18
+ file?: string | null;
19
+ issue?: IssueUrlSelection;
20
+ preserve?: URLSearchParams;
21
+ }): URLSearchParams {
22
+ const params = new URLSearchParams();
23
+ if (options.preserve) {
24
+ for (const [key, value] of options.preserve) {
25
+ if (key !== "file" && key !== "issue") {
26
+ params.append(key, value);
27
+ }
28
+ }
29
+ }
30
+ if (options.file) {
31
+ params.set("file", options.file);
32
+ }
33
+ const issueValue = issueSelectionToSearchParam(options.issue ?? null);
34
+ if (issueValue) {
35
+ params.set("issue", issueValue);
36
+ }
37
+ return params;
38
+ }
39
+
40
+ export function searchParamsToString(params: URLSearchParams): string {
41
+ const qs = params.toString();
42
+ return qs ? `?${qs}` : "";
43
+ }
@@ -42,6 +42,11 @@ import { insertChildEntry, moveEntry, removeEntry } from "@/lib/tree-utils";
42
42
  import { createdFileToContent, createdFileToTreeEntry } from "@/lib/created-file";
43
43
  import { DEFAULT_BASE_REF, hasContentDiff, resolveBranchLabels, resolveOriginalContent } from "@/lib/branch-diff";
44
44
  import { clearDraft, loadDraft, saveDraft } from "@/lib/draft-storage";
45
+ import {
46
+ buildMainPageSearchParams,
47
+ parseIssueSearchParam,
48
+ type IssueUrlSelection,
49
+ } from "@/lib/issue-url-sync";
45
50
 
46
51
  const MarkdownEditor = lazy(() => import("@/components/MarkdownEditor"));
47
52
  const SpreadsheetEditor = lazy(() => import("@/components/SpreadsheetEditor"));
@@ -74,6 +79,13 @@ export default function MainPage() {
74
79
  const [selectedFile, setSelectedFile] = useState<string | null>(
75
80
  () => searchParams.get("file"),
76
81
  );
82
+ const [sidebarTab, setSidebarTab] = useState<SidebarTab>(() => {
83
+ if (searchParams.get("file")) return "files";
84
+ return parseIssueSearchParam(searchParams.get("issue")) !== null ? "issues" : "files";
85
+ });
86
+ const [selectedIssue, setSelectedIssue] = useState<number | "new" | null>(() =>
87
+ searchParams.get("file") ? null : parseIssueSearchParam(searchParams.get("issue")),
88
+ );
77
89
  const [fileContent, setFileContent] = useState<FileContent | null>(null);
78
90
  const [baseFileContent, setBaseFileContent] = useState<FileContent | null>(null);
79
91
  const [baseFileError, setBaseFileError] = useState<string | null>(null);
@@ -190,7 +202,17 @@ export default function MainPage() {
190
202
  });
191
203
  }, [showToast]);
192
204
 
193
- const initialFileLoaded = useRef(false);
205
+ const initialDeepLinkLoaded = useRef(false);
206
+
207
+ const syncIssueToUrl = useCallback(
208
+ (issue: IssueUrlSelection) => {
209
+ setSearchParams(
210
+ buildMainPageSearchParams({ issue, preserve: searchParams }),
211
+ { replace: true },
212
+ );
213
+ },
214
+ [searchParams, setSearchParams],
215
+ );
194
216
 
195
217
  useEffect(() => {
196
218
  loadTree(viewingRef ?? undefined);
@@ -297,10 +319,14 @@ export default function MainPage() {
297
319
  autoSave.reset();
298
320
  pendingStylesRef.current = null;
299
321
  setSelectedFile(path);
322
+ setSelectedIssue(null);
300
323
  setBaseFileContent(null);
301
324
  setBaseFileError(null);
302
325
  setActiveDir(path.substring(0, path.lastIndexOf("/")) || "docs");
303
- setSearchParams({ file: path }, { replace: true });
326
+ setSearchParams(
327
+ buildMainPageSearchParams({ file: path, preserve: searchParams }),
328
+ { replace: true },
329
+ );
304
330
  setFileLoading(true);
305
331
  setEditing(false);
306
332
  setShowOriginal(false);
@@ -343,17 +369,45 @@ export default function MainPage() {
343
369
  .finally(() => {
344
370
  if (!controller.signal.aborted) setFileLoading(false);
345
371
  });
346
- }, [autoSave, editSession.session?.branchName, showToast, setSearchParams, viewingRef]);
372
+ }, [autoSave, editSession.session?.branchName, showToast, setSearchParams, searchParams, viewingRef]);
347
373
 
348
374
  useEffect(() => {
349
- if (initialFileLoaded.current) return;
375
+ if (initialDeepLinkLoaded.current) return;
350
376
  if (apiState !== "ready" && apiState !== "mock") return;
377
+
351
378
  const fileParam = searchParams.get("file");
379
+ const issueParam = parseIssueSearchParam(searchParams.get("issue"));
380
+
352
381
  if (fileParam && !fileContent) {
353
- initialFileLoaded.current = true;
382
+ initialDeepLinkLoaded.current = true;
354
383
  handleFileSelect(fileParam);
384
+ return;
355
385
  }
356
- }, [apiState, searchParams, fileContent, handleFileSelect]);
386
+
387
+ if (issueParam !== null && selectedIssue === null && !selectedFile) {
388
+ initialDeepLinkLoaded.current = true;
389
+ setSidebarTab("issues");
390
+ setSelectedIssue(issueParam);
391
+ }
392
+ }, [apiState, searchParams, fileContent, selectedIssue, selectedFile, handleFileSelect]);
393
+
394
+ useEffect(() => {
395
+ if (searchParams.get("file")) return;
396
+
397
+ const issueParam = parseIssueSearchParam(searchParams.get("issue"));
398
+ if (issueParam !== null) {
399
+ setSidebarTab("issues");
400
+ setSelectedIssue((current) => (current === issueParam ? current : issueParam));
401
+ setSelectedFile(null);
402
+ setFileContent(null);
403
+ setEditing(false);
404
+ return;
405
+ }
406
+
407
+ if (!searchParams.get("issue")) {
408
+ setSelectedIssue((current) => (current === null ? current : null));
409
+ }
410
+ }, [searchParams]);
357
411
 
358
412
  const handleSave = useCallback(
359
413
  async (newContent: string, styles?: FileStyles | null) => {
@@ -427,7 +481,10 @@ export default function MainPage() {
427
481
  setBaseFileContent(createdFileToContent(created));
428
482
  setBaseFileError(null);
429
483
  setActiveDir(parentPath);
430
- setSearchParams({ file: created.path }, { replace: true });
484
+ setSearchParams(
485
+ buildMainPageSearchParams({ file: created.path, preserve: searchParams }),
486
+ { replace: true },
487
+ );
431
488
  setFileLoading(false);
432
489
  setEditing(false);
433
490
  setShowOriginal(false);
@@ -441,7 +498,7 @@ export default function MainPage() {
441
498
  showToast(`作成に失敗しました: ${getErrorMessage(e)}`, "error");
442
499
  }
443
500
  },
444
- [dialog, showToast, autoSave, setSearchParams, loadTree],
501
+ [dialog, showToast, autoSave, setSearchParams, searchParams, loadTree],
445
502
  );
446
503
 
447
504
  const handleCreateFolder = useCallback(
@@ -620,8 +677,6 @@ export default function MainPage() {
620
677
  const comments = useComments(selectedFile);
621
678
 
622
679
  const [sidebarOpen, setSidebarOpen] = useState(false);
623
- const [sidebarTab, setSidebarTab] = useState<SidebarTab>("files");
624
- const [selectedIssue, setSelectedIssue] = useState<number | "new" | null>(null);
625
680
  const [issueListReloadTrigger, setIssueListReloadTrigger] = useState(0);
626
681
  const [newIssueKey, setNewIssueKey] = useState(0);
627
682
 
@@ -853,6 +908,7 @@ export default function MainPage() {
853
908
  setNewIssueKey((k) => k + 1);
854
909
  setSelectedFile(null);
855
910
  setFileContent(null);
911
+ syncIssueToUrl("new");
856
912
  setSidebarOpen(false);
857
913
  }}
858
914
  className="w-full px-2 py-1.5 rounded flex items-center justify-center gap-1.5 text-xs text-green-300 bg-green-900/20 border border-green-700/40 hover:bg-green-900/40 transition-colors"
@@ -870,6 +926,7 @@ export default function MainPage() {
870
926
  setSelectedFile(null);
871
927
  setFileContent(null);
872
928
  setEditing(false);
929
+ syncIssueToUrl(num);
873
930
  setSidebarOpen(false);
874
931
  }}
875
932
  />
@@ -1019,9 +1076,13 @@ export default function MainPage() {
1019
1076
  issueNumber={selectedIssue}
1020
1077
  onCreated={(num) => {
1021
1078
  setSelectedIssue(num);
1079
+ syncIssueToUrl(num);
1022
1080
  setIssueListReloadTrigger((k) => k + 1);
1023
1081
  }}
1024
- onClose={() => setSelectedIssue(null)}
1082
+ onClose={() => {
1083
+ setSelectedIssue(null);
1084
+ syncIssueToUrl(null);
1085
+ }}
1025
1086
  />
1026
1087
  </Suspense>
1027
1088
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ripla/godd-mcp",
3
- "version": "1.0.4-canary.21",
3
+ "version": "1.0.4-canary.23",
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",