@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.
Files changed (180) hide show
  1. package/README.md +412 -0
  2. package/dist/godd.cjs +465 -0
  3. package/dist/godd.js +34845 -0
  4. package/dist/index.js +224 -0
  5. package/notes-api/.env.example +29 -0
  6. package/notes-api/README.md +65 -0
  7. package/notes-api/alembic/README +1 -0
  8. package/notes-api/alembic/env.py +62 -0
  9. package/notes-api/alembic/script.py.mako +28 -0
  10. package/notes-api/alembic/versions/001_initial_notes_tables.py +71 -0
  11. package/notes-api/alembic.ini +149 -0
  12. package/notes-api/app/__init__.py +1 -0
  13. package/notes-api/app/config.py +90 -0
  14. package/notes-api/app/database.py +31 -0
  15. package/notes-api/app/main.py +55 -0
  16. package/notes-api/app/models/__init__.py +17 -0
  17. package/notes-api/app/models/audit.py +38 -0
  18. package/notes-api/app/models/base.py +38 -0
  19. package/notes-api/app/models/session.py +32 -0
  20. package/notes-api/app/models/setting.py +16 -0
  21. package/notes-api/app/models/user.py +38 -0
  22. package/notes-api/app/routers/__init__.py +1 -0
  23. package/notes-api/app/routers/auth.py +205 -0
  24. package/notes-api/app/routers/files.py +76 -0
  25. package/notes-api/app/routers/pr.py +92 -0
  26. package/notes-api/app/routers/settings.py +44 -0
  27. package/notes-api/app/routers/tree.py +22 -0
  28. package/notes-api/app/routers/users.py +145 -0
  29. package/notes-api/app/schemas/__init__.py +1 -0
  30. package/notes-api/app/schemas/auth.py +45 -0
  31. package/notes-api/app/schemas/user.py +41 -0
  32. package/notes-api/app/security.py +94 -0
  33. package/notes-api/app/services/__init__.py +1 -0
  34. package/notes-api/app/services/audit.py +25 -0
  35. package/notes-api/app/services/business_date.py +38 -0
  36. package/notes-api/app/services/github.py +140 -0
  37. package/notes-api/app/services/github_pr.py +120 -0
  38. package/notes-api/app/services/pr_chain.py +225 -0
  39. package/notes-api/app/startup.py +66 -0
  40. package/notes-api/docker/Dockerfile +14 -0
  41. package/notes-api/docker/Dockerfile.test +14 -0
  42. package/notes-api/pyproject.toml +53 -0
  43. package/notes-api/tests/conftest.py +21 -0
  44. package/notes-api/tests/test_business_date.py +32 -0
  45. package/notes-api/tests/test_health.py +11 -0
  46. package/notes-api/uv.lock +1012 -0
  47. package/notes-app/README.md +30 -0
  48. package/notes-app/docker/Dockerfile.prod +12 -0
  49. package/notes-app/docker/Dockerfile.test +12 -0
  50. package/notes-app/eslint.config.js +23 -0
  51. package/notes-app/index.html +12 -0
  52. package/notes-app/nginx.conf +23 -0
  53. package/notes-app/package.json +37 -0
  54. package/notes-app/pnpm-lock.yaml +3124 -0
  55. package/notes-app/src/App.tsx +13 -0
  56. package/notes-app/src/main.tsx +13 -0
  57. package/notes-app/src/pages/LoginPage.test.tsx +15 -0
  58. package/notes-app/src/pages/LoginPage.tsx +10 -0
  59. package/notes-app/src/pages/MainPage.tsx +14 -0
  60. package/notes-app/src/styles/globals.css +1 -0
  61. package/notes-app/src/vite-env.d.ts +1 -0
  62. package/notes-app/tsconfig.json +24 -0
  63. package/notes-app/tsconfig.node.json +10 -0
  64. package/notes-app/vite.config.js +21 -0
  65. package/notes-app/vite.config.ts +22 -0
  66. package/notes-app/vitest.config.ts +16 -0
  67. package/package.json +58 -0
  68. package/stacks/django-vue.yaml +33 -0
  69. package/stacks/fastapi-react.yaml +160 -0
  70. package/stacks/flutter-firebase.yaml +58 -0
  71. package/stacks/nextjs-prisma.yaml +33 -0
  72. package/stacks/schema.json +144 -0
  73. package/templates/agents/architect.hbs +183 -0
  74. package/templates/agents/auto-documenter.hbs +36 -0
  75. package/templates/agents/backend-developer.hbs +175 -0
  76. package/templates/agents/code-reviewer.hbs +29 -0
  77. package/templates/agents/database-developer.hbs +52 -0
  78. package/templates/agents/debug-specialist.hbs +39 -0
  79. package/templates/agents/environment-setup.hbs +41 -0
  80. package/templates/agents/frontend-developer.hbs +45 -0
  81. package/templates/agents/integration-qa.hbs +26 -0
  82. package/templates/agents/performance-qa.hbs +24 -0
  83. package/templates/agents/pr-writer.hbs +53 -0
  84. package/templates/agents/quality-lead.hbs +26 -0
  85. package/templates/agents/requirements-analyst.hbs +26 -0
  86. package/templates/agents/security-analyst.hbs +21 -0
  87. package/templates/agents/security-reviewer.hbs +20 -0
  88. package/templates/agents/ssot-updater.hbs +45 -0
  89. package/templates/agents/technical-writer.hbs +28 -0
  90. package/templates/agents/unit-test-engineer.hbs +19 -0
  91. package/templates/agents/user-clone.hbs +43 -0
  92. package/templates/github-actions/aws/deploy-notes.yml.hbs +118 -0
  93. package/templates/github-actions/azure/deploy-notes.yml.hbs +78 -0
  94. package/templates/github-actions/gcp/deploy-notes.yml.hbs +76 -0
  95. package/templates/github-actions/vercel-railway/deploy-notes.yml.hbs +49 -0
  96. package/templates/mindsets/agent-stdio.hbs +51 -0
  97. package/templates/mindsets/dart.hbs +39 -0
  98. package/templates/mindsets/ddd-clean-architecture.hbs +52 -0
  99. package/templates/mindsets/electron.hbs +41 -0
  100. package/templates/mindsets/fastapi.hbs +40 -0
  101. package/templates/mindsets/flutter.hbs +56 -0
  102. package/templates/mindsets/kotlin.hbs +46 -0
  103. package/templates/mindsets/postgresql.hbs +39 -0
  104. package/templates/mindsets/python.hbs +39 -0
  105. package/templates/mindsets/react.hbs +51 -0
  106. package/templates/mindsets/swift.hbs +46 -0
  107. package/templates/mindsets/terraform.hbs +53 -0
  108. package/templates/mindsets/typescript.hbs +34 -0
  109. package/templates/mindsets/vite.hbs +37 -0
  110. package/templates/notes-compose.yml +79 -0
  111. package/templates/partials/cogito-geo.hbs +84 -0
  112. package/templates/partials/cogito-godd.hbs +22 -0
  113. package/templates/partials/cogito-grammar.hbs +105 -0
  114. package/templates/partials/cogito-particle.hbs +76 -0
  115. package/templates/partials/cogito-root-ai.hbs +79 -0
  116. package/templates/partials/cogito-root-arch.hbs +75 -0
  117. package/templates/partials/cogito-root-core.hbs +75 -0
  118. package/templates/partials/cogito-root-daily.hbs +58 -0
  119. package/templates/partials/cogito-root-dev.hbs +71 -0
  120. package/templates/partials/cogito-root-devops.hbs +71 -0
  121. package/templates/partials/cogito-root-framework.hbs +80 -0
  122. package/templates/partials/cogito-root-lang.hbs +74 -0
  123. package/templates/partials/godd-core.hbs +50 -0
  124. package/templates/partials/project-context.hbs +52 -0
  125. package/templates/partials/ssot-header.hbs +40 -0
  126. package/templates/partials/ssot-rules.hbs +30 -0
  127. package/templates/prompts/adr.hbs +39 -0
  128. package/templates/prompts/agent-dev-workflow.hbs +34 -0
  129. package/templates/prompts/analyze-report.hbs +36 -0
  130. package/templates/prompts/auto-documentation.hbs +37 -0
  131. package/templates/prompts/check.hbs +52 -0
  132. package/templates/prompts/commit.hbs +96 -0
  133. package/templates/prompts/dev.hbs +127 -0
  134. package/templates/prompts/docs-init.hbs +95 -0
  135. package/templates/prompts/docs-update.hbs +51 -0
  136. package/templates/prompts/git-workflow.hbs +125 -0
  137. package/templates/prompts/impact-analysis.hbs +43 -0
  138. package/templates/prompts/install.hbs +40 -0
  139. package/templates/prompts/new-branch.hbs +61 -0
  140. package/templates/prompts/notes-deploy.hbs +72 -0
  141. package/templates/prompts/pr-analyze.hbs +44 -0
  142. package/templates/prompts/pr-create.hbs +125 -0
  143. package/templates/prompts/push-execute.hbs +83 -0
  144. package/templates/prompts/push.hbs +24 -0
  145. package/templates/prompts/release-notes.hbs +38 -0
  146. package/templates/prompts/requirements-to-business-flow.hbs +29 -0
  147. package/templates/prompts/requirements-to-tickets.hbs +26 -0
  148. package/templates/prompts/review.hbs +126 -0
  149. package/templates/prompts/setup.hbs +247 -0
  150. package/templates/prompts/spec.hbs +35 -0
  151. package/templates/prompts/specification-to-tickets.hbs +24 -0
  152. package/templates/prompts/submit.hbs +143 -0
  153. package/templates/prompts/sync.hbs +63 -0
  154. package/templates/prompts/test-plan.hbs +56 -0
  155. package/templates/terraform/aws/alb.tf.hbs +58 -0
  156. package/templates/terraform/aws/backend.tf.hbs +10 -0
  157. package/templates/terraform/aws/cloudfront.tf.hbs +96 -0
  158. package/templates/terraform/aws/ecr.tf.hbs +30 -0
  159. package/templates/terraform/aws/ecs.tf.hbs +136 -0
  160. package/templates/terraform/aws/iam.tf.hbs +88 -0
  161. package/templates/terraform/aws/local.tf.hbs +34 -0
  162. package/templates/terraform/aws/output.tf.hbs +49 -0
  163. package/templates/terraform/aws/provider.tf.hbs +26 -0
  164. package/templates/terraform/aws/rds.tf.hbs +38 -0
  165. package/templates/terraform/aws/s3.tf.hbs +34 -0
  166. package/templates/terraform/aws/secrets.tf.hbs +38 -0
  167. package/templates/terraform/aws/security_groups.tf.hbs +72 -0
  168. package/templates/terraform/aws/vpc.tf.hbs +63 -0
  169. package/templates/terraform/azure/backend.tf.hbs +8 -0
  170. package/templates/terraform/azure/iam.tf.hbs +36 -0
  171. package/templates/terraform/azure/main.tf.hbs +199 -0
  172. package/templates/terraform/azure/output.tf.hbs +19 -0
  173. package/templates/terraform/azure/provider.tf.hbs +19 -0
  174. package/templates/terraform/gcp/backend.tf.hbs +6 -0
  175. package/templates/terraform/gcp/iam.tf.hbs +47 -0
  176. package/templates/terraform/gcp/main.tf.hbs +171 -0
  177. package/templates/terraform/gcp/output.tf.hbs +15 -0
  178. package/templates/terraform/gcp/provider.tf.hbs +19 -0
  179. package/templates/terraform/vercel-railway/main.tf.hbs +64 -0
  180. 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()