@ripla/godd-mcp 1.0.2-canary.1 → 1.0.2-canary.2

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.
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import re
7
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
7
8
 
8
9
  from pydantic import AliasChoices, Field, model_validator
9
10
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -11,6 +12,24 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
11
12
  _logger = logging.getLogger(__name__)
12
13
 
13
14
 
15
+ def _remove_query_param(url: str, key: str) -> str:
16
+ parts = urlsplit(url)
17
+ query = urlencode(
18
+ [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k != key],
19
+ doseq=True,
20
+ )
21
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
22
+
23
+
24
+ def _set_query_param(url: str, key: str, value: str) -> str:
25
+ parts = urlsplit(url)
26
+ query_items = [
27
+ (k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k != key
28
+ ]
29
+ query = urlencode([*query_items, (key, value)], doseq=True)
30
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
31
+
32
+
14
33
  class Settings(BaseSettings):
15
34
  """ripla Notes API settings."""
16
35
 
@@ -72,7 +91,10 @@ class Settings(BaseSettings):
72
91
  def async_database_url(self) -> str:
73
92
  """Async DB URL for SQLAlchemy (asyncpg driver)."""
74
93
  if self.database_url:
75
- return re.sub(r"^postgres(ql)?://", "postgresql+asyncpg://", self.database_url)
94
+ url = re.sub(r"^postgres(ql)?://", "postgresql+asyncpg://", self.database_url)
95
+ if self.database_ssl:
96
+ return _remove_query_param(url, "sslmode")
97
+ return url
76
98
  return (
77
99
  f"postgresql+asyncpg://{self.db_user}:{self.db_password}"
78
100
  f"@{self.db_host}:{self.db_port}/{self.db_name}"
@@ -89,8 +111,7 @@ class Settings(BaseSettings):
89
111
  f"@{self.db_host}:{self.db_port}/{self.db_name}"
90
112
  )
91
113
  if self.database_ssl:
92
- sep = "&" if "?" in url else "?"
93
- url = f"{url}{sep}sslmode=require"
114
+ url = _set_query_param(url, "sslmode", "require")
94
115
  return url
95
116
 
96
117
  @property
@@ -7,10 +7,16 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
7
7
 
8
8
  from app.config import settings
9
9
 
10
- _async_connect_args: dict[str, Any] = {}
11
- if settings.database_ssl:
12
- # asyncpg: required for many managed Postgres providers (e.g. AWS RDS with force_ssl)
13
- _async_connect_args["ssl"] = True
10
+
11
+ def _build_async_connect_args(database_ssl: bool) -> dict[str, Any]:
12
+ if not database_ssl:
13
+ return {}
14
+ # asyncpg: "require" = encrypt connection without certificate verification
15
+ # (equivalent to psycopg2's sslmode=require, works with AWS RDS / GCP Cloud SQL / Azure)
16
+ return {"ssl": "require"}
17
+
18
+
19
+ _async_connect_args = _build_async_connect_args(settings.database_ssl)
14
20
 
15
21
  engine = create_async_engine(
16
22
  settings.async_database_url,
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from datetime import UTC, datetime
6
6
  from typing import Any
7
+
7
8
  from fastapi import APIRouter, Depends, HTTPException, Query, status
8
9
  from pydantic import BaseModel, Field
9
10
  from sqlalchemy import func, select
@@ -43,7 +43,6 @@ STYLES_SUFFIX = ".styles.json"
43
43
 
44
44
  from app.path_validation import validate_docs_path # noqa: E402
45
45
 
46
-
47
46
  _validate_path = validate_docs_path
48
47
 
49
48
 
@@ -1,7 +1,8 @@
1
1
  """Settings router."""
2
2
 
3
3
  import logging
4
- from typing import Any, Callable
4
+ from collections.abc import Callable
5
+ from typing import Any
5
6
 
6
7
  from fastapi import APIRouter, Depends, HTTPException
7
8
  from fastapi.encoders import jsonable_encoder
@@ -72,9 +73,10 @@ async def update_setting(
72
73
  """Update setting (admin only). Only whitelisted keys are accepted."""
73
74
  validator = ALLOWED_SETTINGS.get(key)
74
75
  if validator is None:
76
+ allowed_keys = ", ".join(sorted(ALLOWED_SETTINGS))
75
77
  raise HTTPException(
76
78
  status_code=400,
77
- detail=f"Unknown setting key: {key}. Allowed keys: {', '.join(sorted(ALLOWED_SETTINGS))}",
79
+ detail=f"Unknown setting key: {key}. Allowed keys: {allowed_keys}",
78
80
  )
79
81
  if not validator(body.value):
80
82
  raise HTTPException(
@@ -21,7 +21,12 @@ async def get_tree(
21
21
  ):
22
22
  """Get file tree. Pass *ref* to read a specific branch. Mock mode when GITHUB_TOKEN not set."""
23
23
  if settings.is_mock_mode():
24
- return {"mode": "mock", "source": "mock", "reason": "GITHUB_TOKEN が未設定です", "tree": get_mock_tree()}
24
+ return {
25
+ "mode": "mock",
26
+ "source": "mock",
27
+ "reason": "GITHUB_TOKEN が未設定です",
28
+ "tree": get_mock_tree(),
29
+ }
25
30
 
26
31
  try:
27
32
  config = get_github_config()
@@ -37,7 +42,10 @@ async def get_tree(
37
42
  "Organization の承認が必要な場合があります。"
38
43
  )
39
44
  elif code == 404:
40
- detail = f"リポジトリまたはパスが見つかりません: {settings.github_owner}/{settings.github_repo}"
45
+ detail = (
46
+ "リポジトリまたはパスが見つかりません: "
47
+ f"{settings.github_owner}/{settings.github_repo}"
48
+ )
41
49
  else:
42
50
  detail = f"GitHub API エラー (HTTP {code})"
43
51
  raise HTTPException(
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
 
7
7
  from app.http_client import get_github_client
8
- from app.services.github import parse_link_next, resolve_token, _auth_headers
8
+ from app.services.github import _auth_headers, parse_link_next, resolve_token
9
9
 
10
10
  logger = logging.getLogger(__name__)
11
11
 
@@ -1,6 +1,7 @@
1
1
  """DATABASE_SSL / DB_SSL settings for managed Postgres (e.g. AWS RDS)."""
2
2
 
3
3
  from app.config import Settings
4
+ from app.database import _build_async_connect_args
4
5
 
5
6
 
6
7
  def test_database_ssl_defaults_false(monkeypatch):
@@ -33,3 +34,30 @@ def test_sync_database_url_appends_sslmode_when_ssl_enabled():
33
34
  database_ssl=True,
34
35
  )
35
36
  assert "sslmode=require" in s.sync_database_url
37
+
38
+
39
+ def test_sync_database_url_overrides_existing_sslmode():
40
+ s = Settings(
41
+ database_url="postgresql://u:p@db.example.com:5432/app?sslmode=disable",
42
+ database_ssl=True,
43
+ )
44
+ assert s.sync_database_url.count("sslmode=require") == 1
45
+ assert "sslmode=disable" not in s.sync_database_url
46
+
47
+
48
+ def test_async_database_url_removes_sslmode_when_connect_args_handle_ssl():
49
+ s = Settings(
50
+ database_url="postgresql://u:p@db.example.com:5432/app?sslmode=require&application_name=notes",
51
+ database_ssl=True,
52
+ )
53
+ assert s.async_database_url == (
54
+ "postgresql+asyncpg://u:p@db.example.com:5432/app?application_name=notes"
55
+ )
56
+
57
+
58
+ def test_async_database_connect_args_require_ssl_without_cert_verification():
59
+ assert _build_async_connect_args(database_ssl=True) == {"ssl": "require"}
60
+
61
+
62
+ def test_async_database_connect_args_empty_when_ssl_disabled():
63
+ assert _build_async_connect_args(database_ssl=False) == {}
@@ -3,7 +3,6 @@
3
3
  from unittest.mock import AsyncMock, patch
4
4
 
5
5
  import httpx
6
- import pytest
7
6
  from httpx import AsyncClient
8
7
 
9
8
  from tests.conftest import auth_headers
@@ -46,7 +45,11 @@ class TestListIssues:
46
45
  ):
47
46
  mock_settings.is_mock_mode.return_value = False
48
47
  mock_config.return_value = {"token": "t", "owner": "o", "repo": "r", "branch": "main"}
49
- mock_list.side_effect = httpx.HTTPStatusError("", request=mock_resp.request, response=mock_resp)
48
+ mock_list.side_effect = httpx.HTTPStatusError(
49
+ "",
50
+ request=mock_resp.request,
51
+ response=mock_resp,
52
+ )
50
53
  resp = await client.get("/api/issues", headers=auth_headers(admin_token))
51
54
  assert resp.status_code == 401
52
55
 
@@ -81,7 +84,11 @@ class TestGetIssue:
81
84
  ):
82
85
  mock_settings.is_mock_mode.return_value = False
83
86
  mock_config.return_value = {"token": "t", "owner": "o", "repo": "r", "branch": "main"}
84
- mock_get.side_effect = httpx.HTTPStatusError("", request=mock_resp.request, response=mock_resp)
87
+ mock_get.side_effect = httpx.HTTPStatusError(
88
+ "",
89
+ request=mock_resp.request,
90
+ response=mock_resp,
91
+ )
85
92
  resp = await client.get("/api/issues/999", headers=auth_headers(admin_token))
86
93
  assert resp.status_code == 404
87
94
 
@@ -176,7 +183,11 @@ class TestUpdateIssue:
176
183
  ):
177
184
  mock_settings.is_mock_mode.return_value = False
178
185
  mock_config.return_value = {"token": "t", "owner": "o", "repo": "r", "branch": "main"}
179
- mock_update.side_effect = httpx.HTTPStatusError("", request=mock_resp.request, response=mock_resp)
186
+ mock_update.side_effect = httpx.HTTPStatusError(
187
+ "",
188
+ request=mock_resp.request,
189
+ response=mock_resp,
190
+ )
180
191
  resp = await client.patch(
181
192
  "/api/issues/999",
182
193
  json={"title": "Does not exist"},
@@ -1,6 +1,5 @@
1
1
  """Tests for PR endpoints."""
2
2
 
3
- from unittest.mock import AsyncMock, patch
4
3
 
5
4
  from httpx import AsyncClient
6
5
 
@@ -649,7 +649,7 @@ class TestRenameOnWorkingBranch:
649
649
 
650
650
  with (
651
651
  patch(
652
- "app.services.business_date.get_business_date",
652
+ "app.services.pr_chain.get_business_date",
653
653
  new_callable=AsyncMock, return_value="2026-04-17",
654
654
  ),
655
655
  patch(
@@ -714,7 +714,7 @@ class TestRenameOnWorkingBranch:
714
714
 
715
715
  with (
716
716
  patch(
717
- "app.services.business_date.get_business_date",
717
+ "app.services.pr_chain.get_business_date",
718
718
  new_callable=AsyncMock, return_value="2026-04-17",
719
719
  ),
720
720
  patch(
@@ -68,7 +68,11 @@ class TestTree:
68
68
  mock_config.return_value = {
69
69
  "token": "t", "owner": "o", "repo": "r", "branch": "main", "docs_path": "docs",
70
70
  }
71
- mock_fetch.side_effect = httpx.HTTPStatusError("", request=mock_resp.request, response=mock_resp)
71
+ mock_fetch.side_effect = httpx.HTTPStatusError(
72
+ "",
73
+ request=mock_resp.request,
74
+ response=mock_resp,
75
+ )
72
76
  resp = await client.get("/api/tree", headers=self._headers)
73
77
  assert resp.status_code == 502
74
78
 
@@ -84,7 +88,11 @@ class TestTree:
84
88
  mock_config.return_value = {
85
89
  "token": "t", "owner": "o", "repo": "r", "branch": "main", "docs_path": "docs",
86
90
  }
87
- mock_fetch.side_effect = httpx.HTTPStatusError("", request=mock_resp.request, response=mock_resp)
91
+ mock_fetch.side_effect = httpx.HTTPStatusError(
92
+ "",
93
+ request=mock_resp.request,
94
+ response=mock_resp,
95
+ )
88
96
  resp = await client.get("/api/tree", headers=self._headers)
89
97
  assert resp.status_code == 502
90
98
  assert "権限" in resp.json()["detail"]
@@ -124,7 +132,11 @@ class TestTree:
124
132
  mock_config.return_value = {
125
133
  "token": "t", "owner": "o", "repo": "r", "branch": "main", "docs_path": "docs",
126
134
  }
127
- mock_fetch.side_effect = httpx.HTTPStatusError("", request=mock_resp.request, response=mock_resp)
135
+ mock_fetch.side_effect = httpx.HTTPStatusError(
136
+ "",
137
+ request=mock_resp.request,
138
+ response=mock_resp,
139
+ )
128
140
  resp = await client.get("/api/tree", headers=self._headers)
129
141
  assert resp.status_code == 502
130
142
  assert "見つかりません" in resp.json()["detail"]
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useRef } from "react";
2
2
  import {
3
3
  listIssuesApi,
4
4
  getErrorMessage,
@@ -18,24 +18,44 @@ export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
18
18
  const [loading, setLoading] = useState(true);
19
19
  const [error, setError] = useState<string | null>(null);
20
20
  const [stateFilter, setStateFilter] = useState<"open" | "closed">("open");
21
+ const [reloadKey, setReloadKey] = useState(0);
22
+ const requestSeq = useRef(0);
21
23
 
22
- const loadIssues = useCallback((signal?: AbortSignal) => {
24
+ const requestReload = () => {
25
+ requestSeq.current += 1;
23
26
  setLoading(true);
24
27
  setError(null);
25
- listIssuesApi({ state: stateFilter, per_page: 50, signal })
26
- .then((data) => { setIssues(data.issues); })
27
- .catch((e) => {
28
- if (isAbortError(e)) return;
29
- setError(getErrorMessage(e));
30
- })
31
- .finally(() => { setLoading(false); });
32
- }, [stateFilter]);
28
+ setReloadKey((key) => key + 1);
29
+ };
30
+
31
+ const changeStateFilter = (nextState: "open" | "closed") => {
32
+ if (nextState === stateFilter) return;
33
+ requestSeq.current += 1;
34
+ setLoading(true);
35
+ setError(null);
36
+ setStateFilter(nextState);
37
+ };
33
38
 
34
39
  useEffect(() => {
35
40
  const ac = new AbortController();
36
- loadIssues(ac.signal);
41
+ const requestId = requestSeq.current + 1;
42
+ requestSeq.current = requestId;
43
+ listIssuesApi({ state: stateFilter, per_page: 50, signal: ac.signal })
44
+ .then((data) => {
45
+ if (ac.signal.aborted || requestSeq.current !== requestId) return;
46
+ setIssues(data.issues);
47
+ })
48
+ .catch((e) => {
49
+ if (ac.signal.aborted || requestSeq.current !== requestId || isAbortError(e)) return;
50
+ setError(getErrorMessage(e));
51
+ })
52
+ .finally(() => {
53
+ if (!ac.signal.aborted && requestSeq.current === requestId) {
54
+ setLoading(false);
55
+ }
56
+ });
37
57
  return () => ac.abort();
38
- }, [loadIssues]);
58
+ }, [stateFilter, reloadKey]);
39
59
 
40
60
  return (
41
61
  <div className="flex flex-col h-full">
@@ -44,7 +64,7 @@ export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
44
64
  <div className="flex-1 flex bg-gray-900 rounded border border-gray-600 overflow-hidden text-xs">
45
65
  <button
46
66
  type="button"
47
- onClick={() => setStateFilter("open")}
67
+ onClick={() => changeStateFilter("open")}
48
68
  className={`flex-1 px-2 py-1 transition-colors ${
49
69
  stateFilter === "open"
50
70
  ? "bg-green-700/30 text-green-300"
@@ -56,7 +76,7 @@ export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
56
76
  </button>
57
77
  <button
58
78
  type="button"
59
- onClick={() => setStateFilter("closed")}
79
+ onClick={() => changeStateFilter("closed")}
60
80
  className={`flex-1 px-2 py-1 transition-colors ${
61
81
  stateFilter === "closed"
62
82
  ? "bg-purple-700/30 text-purple-300"
@@ -69,7 +89,7 @@ export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
69
89
  </div>
70
90
  <button
71
91
  type="button"
72
- onClick={() => loadIssues()}
92
+ onClick={requestReload}
73
93
  className="shrink-0 p-1 rounded text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
74
94
  title="更新"
75
95
  >
@@ -85,7 +105,7 @@ export default function IssueList({ onSelect, selectedIssue }: IssueListProps) {
85
105
  </div>
86
106
  )}
87
107
 
88
- {error && <InlineError message={error} onRetry={loadIssues} />}
108
+ {error && <InlineError message={error} onRetry={requestReload} />}
89
109
 
90
110
  {!loading && !error && issues.length === 0 && (
91
111
  <p className="text-xs text-gray-500 text-center py-4">
@@ -7,6 +7,7 @@ export default defineConfig({
7
7
  test: {
8
8
  environment: "jsdom",
9
9
  globals: true,
10
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
10
11
  },
11
12
  resolve: {
12
13
  alias: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ripla/godd-mcp",
3
- "version": "1.0.2-canary.1",
3
+ "version": "1.0.2-canary.2",
4
4
  "type": "module",
5
5
  "description": "GoDD (Governance-orchestrated Driven Development) MCP Server - Encrypted prompt distribution via Model Context Protocol",
6
6
  "main": "dist/index.js",