@ripla/godd-mcp 0.1.2-beta-20260312091403
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/README.md +412 -0
- package/dist/godd.cjs +465 -0
- package/dist/godd.js +34845 -0
- package/dist/index.js +224 -0
- package/notes-api/.env.example +29 -0
- package/notes-api/README.md +65 -0
- package/notes-api/alembic/README +1 -0
- package/notes-api/alembic/env.py +62 -0
- package/notes-api/alembic/script.py.mako +28 -0
- package/notes-api/alembic/versions/001_initial_notes_tables.py +71 -0
- package/notes-api/alembic.ini +149 -0
- package/notes-api/app/__init__.py +1 -0
- package/notes-api/app/config.py +90 -0
- package/notes-api/app/database.py +31 -0
- package/notes-api/app/main.py +55 -0
- package/notes-api/app/models/__init__.py +17 -0
- package/notes-api/app/models/audit.py +38 -0
- package/notes-api/app/models/base.py +38 -0
- package/notes-api/app/models/session.py +32 -0
- package/notes-api/app/models/setting.py +16 -0
- package/notes-api/app/models/user.py +38 -0
- package/notes-api/app/routers/__init__.py +1 -0
- package/notes-api/app/routers/auth.py +205 -0
- package/notes-api/app/routers/files.py +76 -0
- package/notes-api/app/routers/pr.py +92 -0
- package/notes-api/app/routers/settings.py +44 -0
- package/notes-api/app/routers/tree.py +22 -0
- package/notes-api/app/routers/users.py +145 -0
- package/notes-api/app/schemas/__init__.py +1 -0
- package/notes-api/app/schemas/auth.py +45 -0
- package/notes-api/app/schemas/user.py +41 -0
- package/notes-api/app/security.py +94 -0
- package/notes-api/app/services/__init__.py +1 -0
- package/notes-api/app/services/audit.py +25 -0
- package/notes-api/app/services/business_date.py +38 -0
- package/notes-api/app/services/github.py +140 -0
- package/notes-api/app/services/github_pr.py +120 -0
- package/notes-api/app/services/pr_chain.py +225 -0
- package/notes-api/app/startup.py +66 -0
- package/notes-api/docker/Dockerfile +14 -0
- package/notes-api/docker/Dockerfile.test +14 -0
- package/notes-api/pyproject.toml +53 -0
- package/notes-api/tests/conftest.py +21 -0
- package/notes-api/tests/test_business_date.py +32 -0
- package/notes-api/tests/test_health.py +11 -0
- package/notes-api/uv.lock +1012 -0
- package/notes-app/README.md +30 -0
- package/notes-app/docker/Dockerfile.prod +12 -0
- package/notes-app/docker/Dockerfile.test +12 -0
- package/notes-app/eslint.config.js +23 -0
- package/notes-app/index.html +12 -0
- package/notes-app/nginx.conf +23 -0
- package/notes-app/package.json +37 -0
- package/notes-app/pnpm-lock.yaml +3124 -0
- package/notes-app/src/App.tsx +13 -0
- package/notes-app/src/main.tsx +13 -0
- package/notes-app/src/pages/LoginPage.test.tsx +15 -0
- package/notes-app/src/pages/LoginPage.tsx +10 -0
- package/notes-app/src/pages/MainPage.tsx +14 -0
- package/notes-app/src/styles/globals.css +1 -0
- package/notes-app/src/vite-env.d.ts +1 -0
- package/notes-app/tsconfig.json +24 -0
- package/notes-app/tsconfig.node.json +10 -0
- package/notes-app/vite.config.js +21 -0
- package/notes-app/vite.config.ts +22 -0
- package/notes-app/vitest.config.ts +16 -0
- package/package.json +58 -0
- package/stacks/django-vue.yaml +33 -0
- package/stacks/fastapi-react.yaml +160 -0
- package/stacks/flutter-firebase.yaml +58 -0
- package/stacks/nextjs-prisma.yaml +33 -0
- package/stacks/schema.json +144 -0
- package/templates/agents/architect.hbs +183 -0
- package/templates/agents/auto-documenter.hbs +36 -0
- package/templates/agents/backend-developer.hbs +175 -0
- package/templates/agents/code-reviewer.hbs +29 -0
- package/templates/agents/database-developer.hbs +52 -0
- package/templates/agents/debug-specialist.hbs +39 -0
- package/templates/agents/environment-setup.hbs +41 -0
- package/templates/agents/frontend-developer.hbs +45 -0
- package/templates/agents/integration-qa.hbs +26 -0
- package/templates/agents/performance-qa.hbs +24 -0
- package/templates/agents/pr-writer.hbs +53 -0
- package/templates/agents/quality-lead.hbs +26 -0
- package/templates/agents/requirements-analyst.hbs +26 -0
- package/templates/agents/security-analyst.hbs +21 -0
- package/templates/agents/security-reviewer.hbs +20 -0
- package/templates/agents/ssot-updater.hbs +45 -0
- package/templates/agents/technical-writer.hbs +28 -0
- package/templates/agents/unit-test-engineer.hbs +19 -0
- package/templates/agents/user-clone.hbs +43 -0
- package/templates/github-actions/aws/deploy-notes.yml.hbs +118 -0
- package/templates/github-actions/azure/deploy-notes.yml.hbs +78 -0
- package/templates/github-actions/gcp/deploy-notes.yml.hbs +76 -0
- package/templates/github-actions/vercel-railway/deploy-notes.yml.hbs +49 -0
- package/templates/mindsets/agent-stdio.hbs +51 -0
- package/templates/mindsets/dart.hbs +39 -0
- package/templates/mindsets/ddd-clean-architecture.hbs +52 -0
- package/templates/mindsets/electron.hbs +41 -0
- package/templates/mindsets/fastapi.hbs +40 -0
- package/templates/mindsets/flutter.hbs +56 -0
- package/templates/mindsets/kotlin.hbs +46 -0
- package/templates/mindsets/postgresql.hbs +39 -0
- package/templates/mindsets/python.hbs +39 -0
- package/templates/mindsets/react.hbs +51 -0
- package/templates/mindsets/swift.hbs +46 -0
- package/templates/mindsets/terraform.hbs +53 -0
- package/templates/mindsets/typescript.hbs +34 -0
- package/templates/mindsets/vite.hbs +37 -0
- package/templates/notes-compose.yml +79 -0
- package/templates/partials/cogito-geo.hbs +84 -0
- package/templates/partials/cogito-godd.hbs +22 -0
- package/templates/partials/cogito-grammar.hbs +105 -0
- package/templates/partials/cogito-particle.hbs +76 -0
- package/templates/partials/cogito-root-ai.hbs +79 -0
- package/templates/partials/cogito-root-arch.hbs +75 -0
- package/templates/partials/cogito-root-core.hbs +75 -0
- package/templates/partials/cogito-root-daily.hbs +58 -0
- package/templates/partials/cogito-root-dev.hbs +71 -0
- package/templates/partials/cogito-root-devops.hbs +71 -0
- package/templates/partials/cogito-root-framework.hbs +80 -0
- package/templates/partials/cogito-root-lang.hbs +74 -0
- package/templates/partials/godd-core.hbs +50 -0
- package/templates/partials/project-context.hbs +52 -0
- package/templates/partials/ssot-header.hbs +40 -0
- package/templates/partials/ssot-rules.hbs +30 -0
- package/templates/prompts/adr.hbs +39 -0
- package/templates/prompts/agent-dev-workflow.hbs +34 -0
- package/templates/prompts/analyze-report.hbs +36 -0
- package/templates/prompts/auto-documentation.hbs +37 -0
- package/templates/prompts/check.hbs +52 -0
- package/templates/prompts/commit.hbs +96 -0
- package/templates/prompts/dev.hbs +127 -0
- package/templates/prompts/docs-init.hbs +95 -0
- package/templates/prompts/docs-update.hbs +51 -0
- package/templates/prompts/git-workflow.hbs +125 -0
- package/templates/prompts/impact-analysis.hbs +43 -0
- package/templates/prompts/install.hbs +40 -0
- package/templates/prompts/new-branch.hbs +61 -0
- package/templates/prompts/notes-deploy.hbs +72 -0
- package/templates/prompts/pr-analyze.hbs +44 -0
- package/templates/prompts/pr-create.hbs +125 -0
- package/templates/prompts/push-execute.hbs +83 -0
- package/templates/prompts/push.hbs +24 -0
- package/templates/prompts/release-notes.hbs +38 -0
- package/templates/prompts/requirements-to-business-flow.hbs +29 -0
- package/templates/prompts/requirements-to-tickets.hbs +26 -0
- package/templates/prompts/review.hbs +126 -0
- package/templates/prompts/setup.hbs +247 -0
- package/templates/prompts/spec.hbs +35 -0
- package/templates/prompts/specification-to-tickets.hbs +24 -0
- package/templates/prompts/submit.hbs +143 -0
- package/templates/prompts/sync.hbs +63 -0
- package/templates/prompts/test-plan.hbs +56 -0
- package/templates/terraform/aws/alb.tf.hbs +58 -0
- package/templates/terraform/aws/backend.tf.hbs +10 -0
- package/templates/terraform/aws/cloudfront.tf.hbs +96 -0
- package/templates/terraform/aws/ecr.tf.hbs +30 -0
- package/templates/terraform/aws/ecs.tf.hbs +136 -0
- package/templates/terraform/aws/iam.tf.hbs +88 -0
- package/templates/terraform/aws/local.tf.hbs +34 -0
- package/templates/terraform/aws/output.tf.hbs +49 -0
- package/templates/terraform/aws/provider.tf.hbs +26 -0
- package/templates/terraform/aws/rds.tf.hbs +38 -0
- package/templates/terraform/aws/s3.tf.hbs +34 -0
- package/templates/terraform/aws/secrets.tf.hbs +38 -0
- package/templates/terraform/aws/security_groups.tf.hbs +72 -0
- package/templates/terraform/aws/vpc.tf.hbs +63 -0
- package/templates/terraform/azure/backend.tf.hbs +8 -0
- package/templates/terraform/azure/iam.tf.hbs +36 -0
- package/templates/terraform/azure/main.tf.hbs +199 -0
- package/templates/terraform/azure/output.tf.hbs +19 -0
- package/templates/terraform/azure/provider.tf.hbs +19 -0
- package/templates/terraform/gcp/backend.tf.hbs +6 -0
- package/templates/terraform/gcp/iam.tf.hbs +47 -0
- package/templates/terraform/gcp/main.tf.hbs +171 -0
- package/templates/terraform/gcp/output.tf.hbs +15 -0
- package/templates/terraform/gcp/provider.tf.hbs +19 -0
- package/templates/terraform/vercel-railway/main.tf.hbs +64 -0
- package/templates/terraform/vercel-railway/output.tf.hbs +11 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Auth-related Pydantic schemas."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LoginRequest(BaseModel):
|
|
9
|
+
"""Login request body."""
|
|
10
|
+
|
|
11
|
+
username: str
|
|
12
|
+
password: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenResponse(BaseModel):
|
|
16
|
+
"""Login/refresh response with access token."""
|
|
17
|
+
|
|
18
|
+
access_token: str = Field(..., alias="accessToken")
|
|
19
|
+
user: "UserInfo"
|
|
20
|
+
expires_at: str = Field(..., alias="expiresAt")
|
|
21
|
+
|
|
22
|
+
model_config = {"populate_by_name": True}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UserInfo(BaseModel):
|
|
26
|
+
"""Minimal user info in token response."""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
username: str
|
|
30
|
+
display_name: str = Field(..., alias="displayName")
|
|
31
|
+
role: str
|
|
32
|
+
|
|
33
|
+
model_config = {"populate_by_name": True}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ChangePasswordRequest(BaseModel):
|
|
37
|
+
"""Change password request."""
|
|
38
|
+
|
|
39
|
+
current_password: str = Field(..., alias="currentPassword")
|
|
40
|
+
new_password: str = Field(..., min_length=8, alias="newPassword")
|
|
41
|
+
|
|
42
|
+
model_config = {"populate_by_name": True}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
TokenResponse.model_rebuild()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""User-related Pydantic schemas."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UserCreate(BaseModel):
|
|
9
|
+
"""Create user request."""
|
|
10
|
+
|
|
11
|
+
username: str
|
|
12
|
+
display_name: str = Field(..., alias="displayName")
|
|
13
|
+
password: str
|
|
14
|
+
role: str
|
|
15
|
+
|
|
16
|
+
model_config = {"populate_by_name": True}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UserUpdate(BaseModel):
|
|
20
|
+
"""Update user request (partial)."""
|
|
21
|
+
|
|
22
|
+
display_name: str | None = Field(None, alias="displayName")
|
|
23
|
+
role: str | None = None
|
|
24
|
+
password: str | None = None
|
|
25
|
+
|
|
26
|
+
model_config = {"populate_by_name": True}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UserResponse(BaseModel):
|
|
30
|
+
"""User response (no password)."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
username: str
|
|
34
|
+
display_name: str = Field(..., alias="displayName")
|
|
35
|
+
role: str
|
|
36
|
+
failed_login_attempts: int | None = None
|
|
37
|
+
locked_until: datetime | None = None
|
|
38
|
+
created_at: datetime
|
|
39
|
+
updated_at: datetime
|
|
40
|
+
|
|
41
|
+
model_config = {"populate_by_name": True, "from_attributes": True}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""JWT authentication and password hashing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import bcrypt
|
|
9
|
+
import jwt
|
|
10
|
+
from fastapi import Depends, HTTPException, status
|
|
11
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
12
|
+
|
|
13
|
+
from app.config import settings
|
|
14
|
+
|
|
15
|
+
HTTP_BEARER = HTTPBearer(auto_error=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hash_password(plain: str) -> str:
|
|
19
|
+
"""Hash password with bcrypt."""
|
|
20
|
+
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
24
|
+
"""Verify password against hash."""
|
|
25
|
+
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_access_token(
|
|
29
|
+
sub: str,
|
|
30
|
+
username: str,
|
|
31
|
+
display_name: str,
|
|
32
|
+
role: str,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Create JWT access token."""
|
|
35
|
+
now = datetime.now(timezone.utc)
|
|
36
|
+
exp = now + timedelta(minutes=settings.jwt_access_expires_min)
|
|
37
|
+
payload = {
|
|
38
|
+
"sub": sub,
|
|
39
|
+
"username": username,
|
|
40
|
+
"displayName": display_name,
|
|
41
|
+
"role": role,
|
|
42
|
+
"exp": exp,
|
|
43
|
+
"iat": now,
|
|
44
|
+
}
|
|
45
|
+
return jwt.encode(
|
|
46
|
+
payload,
|
|
47
|
+
settings.jwt_secret,
|
|
48
|
+
algorithm="HS256",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify_access_token(token: str) -> dict[str, Any]:
|
|
53
|
+
"""Verify JWT and return payload."""
|
|
54
|
+
try:
|
|
55
|
+
payload = jwt.decode(
|
|
56
|
+
token,
|
|
57
|
+
settings.jwt_secret,
|
|
58
|
+
algorithms=["HS256"],
|
|
59
|
+
)
|
|
60
|
+
return payload
|
|
61
|
+
except jwt.InvalidTokenError:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
64
|
+
detail="Invalid or expired token",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def auth_dependency(
|
|
69
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(HTTP_BEARER),
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""FastAPI dependency: require valid JWT Bearer token."""
|
|
72
|
+
if not credentials or not credentials.credentials:
|
|
73
|
+
raise HTTPException(
|
|
74
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
75
|
+
detail="Authorization header required",
|
|
76
|
+
)
|
|
77
|
+
return verify_access_token(credentials.credentials)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def require_role(*roles: str):
|
|
81
|
+
"""Return dependency that checks user has one of the given roles."""
|
|
82
|
+
|
|
83
|
+
async def _check(
|
|
84
|
+
payload: dict[str, Any] = Depends(auth_dependency),
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
role = payload.get("role")
|
|
87
|
+
if not role or role not in roles:
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
90
|
+
detail="Insufficient permissions",
|
|
91
|
+
)
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
return _check
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Business logic services."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Audit log service."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from app.models import NotesAuditLog
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def audit_log(
|
|
11
|
+
db: AsyncSession,
|
|
12
|
+
user_id: UUID | None,
|
|
13
|
+
action: str,
|
|
14
|
+
ip_address: str | None = None,
|
|
15
|
+
detail: dict | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Record an audit log entry."""
|
|
18
|
+
entry = NotesAuditLog(
|
|
19
|
+
user_id=user_id,
|
|
20
|
+
action=action,
|
|
21
|
+
ip_address=ip_address,
|
|
22
|
+
detail=detail,
|
|
23
|
+
)
|
|
24
|
+
db.add(entry)
|
|
25
|
+
await db.flush()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Business date calculation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_business_date(now: datetime, start_hour: int, timezone: str) -> str:
|
|
10
|
+
"""Compute business date. Hours before start_hour count as previous day."""
|
|
11
|
+
tz = ZoneInfo(timezone)
|
|
12
|
+
local = now.astimezone(tz)
|
|
13
|
+
if local.hour < start_hour:
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
|
|
16
|
+
prev = local - timedelta(days=1)
|
|
17
|
+
return prev.strftime("%Y-%m-%d")
|
|
18
|
+
return local.strftime("%Y-%m-%d")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def get_business_date(db, now: datetime | None = None) -> str:
|
|
22
|
+
"""Get business date from settings. db is AsyncSession."""
|
|
23
|
+
from sqlalchemy import select
|
|
24
|
+
|
|
25
|
+
from app.models import NotesSetting
|
|
26
|
+
|
|
27
|
+
if now is None:
|
|
28
|
+
now = datetime.now(ZoneInfo("UTC"))
|
|
29
|
+
|
|
30
|
+
result = await db.execute(select(NotesSetting).where(NotesSetting.key == "business_day_start_hour"))
|
|
31
|
+
row = result.scalar_one_or_none()
|
|
32
|
+
start_hour = int(row.value) if row else 6
|
|
33
|
+
|
|
34
|
+
result = await db.execute(select(NotesSetting).where(NotesSetting.key == "timezone"))
|
|
35
|
+
row = result.scalar_one_or_none()
|
|
36
|
+
tz = str(row.value) if row else "Asia/Tokyo"
|
|
37
|
+
|
|
38
|
+
return compute_business_date(now, start_hour, tz)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""GitHub Contents API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from app.config import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_github_config() -> dict:
|
|
10
|
+
"""Get GitHub config from settings."""
|
|
11
|
+
if not settings.github_token:
|
|
12
|
+
raise ValueError("GITHUB_TOKEN is required. Set it in .env or environment variables.")
|
|
13
|
+
return {
|
|
14
|
+
"token": settings.github_token,
|
|
15
|
+
"owner": settings.github_owner,
|
|
16
|
+
"repo": settings.github_repo,
|
|
17
|
+
"branch": settings.github_branch,
|
|
18
|
+
"docs_path": settings.docs_path,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_mock_tree() -> list:
|
|
23
|
+
"""Mock tree for demo mode."""
|
|
24
|
+
return [
|
|
25
|
+
{"name": "README.md", "path": "docs/README.md", "type": "file"},
|
|
26
|
+
{
|
|
27
|
+
"name": "001_project",
|
|
28
|
+
"path": "docs/001_project",
|
|
29
|
+
"type": "dir",
|
|
30
|
+
"children": [
|
|
31
|
+
{"name": "README.md", "path": "docs/001_project/README.md", "type": "file"},
|
|
32
|
+
{"name": "features.csv", "path": "docs/001_project/features.csv", "type": "file"},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "002_business_flow",
|
|
37
|
+
"path": "docs/002_business_flow",
|
|
38
|
+
"type": "dir",
|
|
39
|
+
"children": [
|
|
40
|
+
{"name": "README.md", "path": "docs/002_business_flow/README.md", "type": "file"},
|
|
41
|
+
{"name": "flow-overview.drawio", "path": "docs/002_business_flow/flow-overview.drawio", "type": "file"},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_mock_file_content(file_path: str) -> dict:
|
|
48
|
+
"""Mock file content for demo mode."""
|
|
49
|
+
return {
|
|
50
|
+
"path": file_path,
|
|
51
|
+
"content": f"# {file_path}\n\nこのファイルはモックデータです。\n\nGITHUB_TOKEN を設定すると、実際のリポジトリからドキュメントが読み込まれます。",
|
|
52
|
+
"sha": "mock-sha",
|
|
53
|
+
"size": 100,
|
|
54
|
+
"encoding": "utf-8",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def fetch_directory_tree(dir_path: str, config: dict) -> list:
|
|
59
|
+
"""Fetch directory tree from GitHub Contents API."""
|
|
60
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/contents/{dir_path}?ref={config['branch']}"
|
|
61
|
+
async with httpx.AsyncClient() as client:
|
|
62
|
+
resp = await client.get(
|
|
63
|
+
url,
|
|
64
|
+
headers={
|
|
65
|
+
"Authorization": f"Bearer {config['token']}",
|
|
66
|
+
"Accept": "application/vnd.github.v3+json",
|
|
67
|
+
"User-Agent": "ripla-docs-api",
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
entries = resp.json()
|
|
72
|
+
if not isinstance(entries, list):
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
entries.sort(key=lambda e: (0 if e.get("type") == "dir" else 1, e.get("name", "")))
|
|
76
|
+
tree = []
|
|
77
|
+
for entry in entries:
|
|
78
|
+
if entry.get("type") == "dir":
|
|
79
|
+
children = await fetch_directory_tree(entry["path"], config)
|
|
80
|
+
tree.append({"name": entry["name"], "path": entry["path"], "type": "dir", "children": children})
|
|
81
|
+
elif entry.get("type") == "file":
|
|
82
|
+
tree.append({"name": entry["name"], "path": entry["path"], "type": "file"})
|
|
83
|
+
return tree
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def fetch_file_content(file_path: str, config: dict) -> dict:
|
|
87
|
+
"""Fetch file content from GitHub."""
|
|
88
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/contents/{file_path}?ref={config['branch']}"
|
|
89
|
+
async with httpx.AsyncClient() as client:
|
|
90
|
+
resp = await client.get(
|
|
91
|
+
url,
|
|
92
|
+
headers={
|
|
93
|
+
"Authorization": f"Bearer {config['token']}",
|
|
94
|
+
"Accept": "application/vnd.github.v3+json",
|
|
95
|
+
"User-Agent": "ripla-docs-api",
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
entry = resp.json()
|
|
100
|
+
if entry.get("type") != "file":
|
|
101
|
+
raise ValueError(f"{file_path} is not a file")
|
|
102
|
+
|
|
103
|
+
content = ""
|
|
104
|
+
if entry.get("content") and entry.get("encoding") == "base64":
|
|
105
|
+
import base64
|
|
106
|
+
|
|
107
|
+
content = base64.b64decode(entry["content"]).decode("utf-8")
|
|
108
|
+
return {
|
|
109
|
+
"path": entry["path"],
|
|
110
|
+
"content": content,
|
|
111
|
+
"sha": entry["sha"],
|
|
112
|
+
"size": entry.get("size", 0),
|
|
113
|
+
"encoding": "utf-8",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def fetch_file_commits(file_path: str, config: dict) -> list:
|
|
118
|
+
"""Fetch file commit history from GitHub."""
|
|
119
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/commits?path={file_path}&sha={config['branch']}&per_page=10"
|
|
120
|
+
async with httpx.AsyncClient() as client:
|
|
121
|
+
resp = await client.get(
|
|
122
|
+
url,
|
|
123
|
+
headers={
|
|
124
|
+
"Authorization": f"Bearer {config['token']}",
|
|
125
|
+
"Accept": "application/vnd.github.v3+json",
|
|
126
|
+
"User-Agent": "ripla-docs-api",
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
if resp.status_code != 200:
|
|
130
|
+
return []
|
|
131
|
+
data = resp.json()
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
"sha": c["sha"][:7],
|
|
135
|
+
"message": c["commit"]["message"].split("\n")[0],
|
|
136
|
+
"author": c["commit"]["author"]["name"],
|
|
137
|
+
"date": c["commit"]["author"]["date"],
|
|
138
|
+
}
|
|
139
|
+
for c in data
|
|
140
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""GitHub PR and branch operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from app.config import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _config() -> dict:
|
|
10
|
+
if not settings.github_token:
|
|
11
|
+
raise ValueError("GITHUB_TOKEN required")
|
|
12
|
+
return {
|
|
13
|
+
"token": settings.github_token,
|
|
14
|
+
"owner": settings.github_owner,
|
|
15
|
+
"repo": settings.github_repo,
|
|
16
|
+
"branch": settings.github_branch,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _headers(config: dict) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"Authorization": f"Bearer {config['token']}",
|
|
23
|
+
"Accept": "application/vnd.github.v3+json",
|
|
24
|
+
"User-Agent": "ripla-docs-api",
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def get_branch_sha(branch_name: str, config: dict) -> str:
|
|
30
|
+
"""Get latest commit SHA of a branch."""
|
|
31
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/git/ref/heads/{branch_name}"
|
|
32
|
+
async with httpx.AsyncClient() as client:
|
|
33
|
+
resp = await client.get(url, headers=_headers(config))
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
return resp.json()["object"]["sha"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def create_branch(branch_name: str, base_sha: str, config: dict) -> None:
|
|
39
|
+
"""Create a new branch."""
|
|
40
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/git/refs"
|
|
41
|
+
async with httpx.AsyncClient() as client:
|
|
42
|
+
resp = await client.post(
|
|
43
|
+
url,
|
|
44
|
+
headers=_headers(config),
|
|
45
|
+
json={"ref": f"refs/heads/{branch_name}", "sha": base_sha},
|
|
46
|
+
)
|
|
47
|
+
if resp.status_code not in (200, 201):
|
|
48
|
+
body = resp.text
|
|
49
|
+
if "422" not in str(resp.status_code) and "Reference already exists" not in body:
|
|
50
|
+
resp.raise_for_status()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def delete_branch(branch_name: str, config: dict) -> None:
|
|
54
|
+
"""Delete a branch."""
|
|
55
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/git/refs/heads/{branch_name}"
|
|
56
|
+
async with httpx.AsyncClient() as client:
|
|
57
|
+
resp = await client.delete(url, headers=_headers(config))
|
|
58
|
+
if resp.status_code not in (200, 204, 422):
|
|
59
|
+
resp.raise_for_status()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def commit_file(
|
|
63
|
+
branch_name: str,
|
|
64
|
+
file_path: str,
|
|
65
|
+
content: str,
|
|
66
|
+
message: str,
|
|
67
|
+
existing_sha: str | None,
|
|
68
|
+
config: dict,
|
|
69
|
+
) -> dict:
|
|
70
|
+
"""Create or update file via Contents API."""
|
|
71
|
+
import base64
|
|
72
|
+
|
|
73
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/contents/{file_path}"
|
|
74
|
+
body = {
|
|
75
|
+
"message": message,
|
|
76
|
+
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
77
|
+
"branch": branch_name,
|
|
78
|
+
}
|
|
79
|
+
if existing_sha:
|
|
80
|
+
body["sha"] = existing_sha
|
|
81
|
+
async with httpx.AsyncClient() as client:
|
|
82
|
+
resp = await client.put(url, headers=_headers(config), json=body)
|
|
83
|
+
resp.raise_for_status()
|
|
84
|
+
return {"sha": resp.json()["content"]["sha"]}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def find_open_auto_prs(config: dict) -> list:
|
|
88
|
+
"""Find open PRs with docs-auto label."""
|
|
89
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/pulls?state=open&per_page=100"
|
|
90
|
+
async with httpx.AsyncClient() as client:
|
|
91
|
+
resp = await client.get(url, headers=_headers(config))
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
prs = resp.json()
|
|
94
|
+
return [p for p in prs if any(l.get("name") == "docs-auto" for l in p.get("labels", []))]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def create_pr(title: str, head: str, base: str, body: str, labels: list[str], config: dict) -> dict:
|
|
98
|
+
"""Create a PR."""
|
|
99
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/pulls"
|
|
100
|
+
async with httpx.AsyncClient() as client:
|
|
101
|
+
resp = await client.post(
|
|
102
|
+
url,
|
|
103
|
+
headers=_headers(config),
|
|
104
|
+
json={"title": title, "head": head, "base": base, "body": body, "draft": False},
|
|
105
|
+
)
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
pr = resp.json()
|
|
108
|
+
if labels:
|
|
109
|
+
label_url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/issues/{pr['number']}/labels"
|
|
110
|
+
async with httpx.AsyncClient() as client:
|
|
111
|
+
await client.post(label_url, headers=_headers(config), json={"labels": labels})
|
|
112
|
+
return pr
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def close_pr(pr_number: int, config: dict) -> None:
|
|
116
|
+
"""Close a PR."""
|
|
117
|
+
url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/pulls/{pr_number}"
|
|
118
|
+
async with httpx.AsyncClient() as client:
|
|
119
|
+
resp = await client.patch(url, headers=_headers(config), json={"state": "closed"})
|
|
120
|
+
resp.raise_for_status()
|