@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.
- package/notes-api/app/config.py +24 -3
- package/notes-api/app/database.py +10 -4
- package/notes-api/app/routers/comments.py +1 -0
- package/notes-api/app/routers/files.py +0 -1
- package/notes-api/app/routers/settings.py +4 -2
- package/notes-api/app/routers/tree.py +10 -2
- package/notes-api/app/services/github_issues.py +1 -1
- package/notes-api/tests/test_config_ssl.py +28 -0
- package/notes-api/tests/test_issues.py +15 -4
- package/notes-api/tests/test_pr.py +0 -1
- package/notes-api/tests/test_pr_chain.py +2 -2
- package/notes-api/tests/test_tree.py +15 -3
- package/notes-app/src/components/IssueList.tsx +36 -16
- package/notes-app/vitest.config.ts +1 -0
- package/package.json +1 -1
package/notes-api/app/config.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Settings router."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from
|
|
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: {
|
|
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 {
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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"},
|
|
@@ -649,7 +649,7 @@ class TestRenameOnWorkingBranch:
|
|
|
649
649
|
|
|
650
650
|
with (
|
|
651
651
|
patch(
|
|
652
|
-
"app.services.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
24
|
+
const requestReload = () => {
|
|
25
|
+
requestSeq.current += 1;
|
|
23
26
|
setLoading(true);
|
|
24
27
|
setError(null);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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={() =>
|
|
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={() =>
|
|
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={
|
|
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={
|
|
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">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ripla/godd-mcp",
|
|
3
|
-
"version": "1.0.2-canary.
|
|
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",
|