@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,32 @@
1
+ """NotesEditingSession model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from sqlalchemy import ForeignKey, Integer, String
8
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
9
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
10
+
11
+ from app.models.base import Base, TimestampMixin, UUIDMixin
12
+
13
+
14
+ class NotesEditingSession(Base, UUIDMixin, TimestampMixin):
15
+ """Editing session (user + business date branch)."""
16
+
17
+ __tablename__ = "notes_editing_sessions"
18
+
19
+ user_id: Mapped[uuid.UUID] = mapped_column(
20
+ UUID(as_uuid=True),
21
+ ForeignKey("notes_users.id", ondelete="CASCADE"),
22
+ nullable=False,
23
+ )
24
+ branch_name: Mapped[str] = mapped_column(String(300), nullable=False)
25
+ pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
26
+ pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
27
+ commit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
28
+ business_date: Mapped[str] = mapped_column(String(10), nullable=False)
29
+ status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
30
+ changed_files: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
31
+
32
+ user: Mapped["NotesUser"] = relationship("NotesUser", back_populates="sessions")
@@ -0,0 +1,16 @@
1
+ """NotesSetting model."""
2
+
3
+ from sqlalchemy import String
4
+ from sqlalchemy.dialects.postgresql import JSONB
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+ from app.models.base import Base
8
+
9
+
10
+ class NotesSetting(Base):
11
+ """Key-value settings (KVS)."""
12
+
13
+ __tablename__ = "notes_settings"
14
+
15
+ key: Mapped[str] = mapped_column(String(100), primary_key=True)
16
+ value: Mapped[dict] = mapped_column(JSONB, nullable=False)
@@ -0,0 +1,38 @@
1
+ """NotesUser model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from sqlalchemy import DateTime, Integer, String
8
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
9
+
10
+ from app.models.base import Base, TimestampMixin, UUIDMixin
11
+
12
+
13
+ class NotesUser(Base, UUIDMixin, TimestampMixin):
14
+ """User for ripla Notes (admin/editor/viewer)."""
15
+
16
+ __tablename__ = "notes_users"
17
+
18
+ username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
19
+ display_name: Mapped[str] = mapped_column(String(200), nullable=False)
20
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
21
+ role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
22
+ refresh_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
23
+ failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
24
+ locked_until: Mapped[datetime | None] = mapped_column(
25
+ DateTime(timezone=True),
26
+ nullable=True,
27
+ )
28
+
29
+ sessions: Mapped[list["NotesEditingSession"]] = relationship(
30
+ "NotesEditingSession",
31
+ back_populates="user",
32
+ cascade="all, delete-orphan",
33
+ )
34
+ audit_logs: Mapped[list["NotesAuditLog"]] = relationship(
35
+ "NotesAuditLog",
36
+ back_populates="user",
37
+ foreign_keys="NotesAuditLog.user_id",
38
+ )
@@ -0,0 +1 @@
1
+ """API routers."""
@@ -0,0 +1,205 @@
1
+ """Auth router: login, refresh, logout, me, change-password."""
2
+
3
+ from datetime import datetime, timezone
4
+ from uuid import UUID
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
7
+ from sqlalchemy import select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from app.config import settings
11
+ from app.database import get_db
12
+ from app.models import NotesSetting, NotesUser
13
+ from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserInfo
14
+ from app.security import auth_dependency, create_access_token, hash_password, verify_password
15
+ from app.services.audit import audit_log
16
+
17
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
18
+
19
+
20
+ async def _get_setting(db: AsyncSession, key: str, fallback: int | str) -> int | str:
21
+ """Get setting value by key."""
22
+ result = await db.execute(select(NotesSetting).where(NotesSetting.key == key))
23
+ row = result.scalar_one_or_none()
24
+ if row is None:
25
+ return fallback
26
+ return row.value
27
+
28
+
29
+ @router.post("/login", response_model=TokenResponse)
30
+ async def login(
31
+ request: Request,
32
+ body: LoginRequest,
33
+ db: AsyncSession = Depends(get_db),
34
+ ):
35
+ """Login with username/password. Returns JWT + sets refresh cookie."""
36
+ ip = request.client.host if request.client else None
37
+ if request.headers.get("x-forwarded-for"):
38
+ ip = request.headers.get("x-forwarded-for").split(",")[0].strip()
39
+
40
+ if not body.username or not body.password:
41
+ raise HTTPException(status_code=400, detail="username and password are required")
42
+
43
+ result = await db.execute(select(NotesUser).where(NotesUser.username == body.username))
44
+ user = result.scalar_one_or_none()
45
+ if user is None:
46
+ await audit_log(db, None, "login_failed", ip, {"username": body.username, "reason": "user_not_found"})
47
+ raise HTTPException(status_code=401, detail="Invalid credentials")
48
+
49
+ if user.locked_until and user.locked_until > datetime.now(timezone.utc):
50
+ remaining = int((user.locked_until - datetime.now(timezone.utc)).total_seconds() / 60)
51
+ await audit_log(db, user.id, "login_failed", ip, {"reason": "account_locked"})
52
+ raise HTTPException(status_code=423, detail=f"Account locked. Try again in {remaining} minutes.")
53
+
54
+ if not verify_password(body.password, user.password_hash):
55
+ max_attempts = await _get_setting(db, "max_login_attempts", 5)
56
+ lockout_min = await _get_setting(db, "lockout_duration_min", 15)
57
+ user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
58
+ if user.failed_login_attempts >= max_attempts:
59
+ from datetime import timedelta
60
+
61
+ user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
62
+ await db.flush()
63
+ await audit_log(db, user.id, "login_failed", ip, {"reason": "wrong_password", "attempts": user.failed_login_attempts})
64
+ raise HTTPException(status_code=401, detail="Invalid credentials")
65
+
66
+ import uuid
67
+
68
+ access_token = create_access_token(
69
+ str(user.id),
70
+ user.username,
71
+ user.display_name,
72
+ user.role,
73
+ )
74
+ refresh_token = str(uuid.uuid4())
75
+ user.refresh_token = refresh_token
76
+ user.failed_login_attempts = 0
77
+ user.locked_until = None
78
+ await db.flush()
79
+
80
+ await audit_log(db, user.id, "login", ip)
81
+
82
+ expires_at = datetime.fromtimestamp(
83
+ datetime.now(timezone.utc).timestamp() + settings.jwt_access_expires_min * 60,
84
+ tz=timezone.utc,
85
+ ).isoformat()
86
+ data = TokenResponse(
87
+ access_token=access_token,
88
+ user=UserInfo(id=str(user.id), username=user.username, display_name=user.display_name, role=user.role),
89
+ expires_at=expires_at,
90
+ )
91
+ from fastapi.responses import JSONResponse
92
+
93
+ response = JSONResponse(content=data.model_dump(by_alias=True))
94
+ response.set_cookie(
95
+ key="refresh_token",
96
+ value=refresh_token,
97
+ httponly=True,
98
+ samesite="strict",
99
+ path="/api/auth",
100
+ max_age=settings.jwt_refresh_expires_days * 86400,
101
+ )
102
+ return response
103
+
104
+
105
+ @router.post("/refresh", response_model=TokenResponse)
106
+ async def refresh(
107
+ request: Request,
108
+ db: AsyncSession = Depends(get_db),
109
+ ):
110
+ """Refresh access token using refresh_token cookie."""
111
+ cookie = request.headers.get("cookie", "")
112
+ refresh_token = None
113
+ for part in cookie.split(";"):
114
+ part = part.strip()
115
+ if part.startswith("refresh_token="):
116
+ refresh_token = part.split("=", 1)[1].strip()
117
+ break
118
+ if not refresh_token:
119
+ raise HTTPException(status_code=401, detail="Refresh token required")
120
+
121
+ result = await db.execute(select(NotesUser).where(NotesUser.refresh_token == refresh_token))
122
+ user = result.scalar_one_or_none()
123
+ if user is None:
124
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
125
+
126
+ access_token = create_access_token(str(user.id), user.username, user.display_name, user.role)
127
+ expires_at = datetime.fromtimestamp(
128
+ datetime.now(timezone.utc).timestamp() + settings.jwt_access_expires_min * 60,
129
+ tz=timezone.utc,
130
+ ).isoformat()
131
+ data = TokenResponse(
132
+ access_token=access_token,
133
+ user=UserInfo(id=str(user.id), username=user.username, display_name=user.display_name, role=user.role),
134
+ expires_at=expires_at,
135
+ )
136
+ from fastapi.responses import JSONResponse
137
+
138
+ return JSONResponse(content=data.model_dump(by_alias=True))
139
+
140
+
141
+ @router.post("/logout")
142
+ async def logout(request: Request, db: AsyncSession = Depends(get_db)):
143
+ """Logout: clear refresh token and cookie."""
144
+ cookie = request.headers.get("cookie", "")
145
+ for part in cookie.split(";"):
146
+ part = part.strip()
147
+ if part.startswith("refresh_token="):
148
+ token = part.split("=", 1)[1].strip()
149
+ result = await db.execute(select(NotesUser).where(NotesUser.refresh_token == token))
150
+ user = result.scalar_one_or_none()
151
+ if user:
152
+ user.refresh_token = None
153
+ await db.flush()
154
+ break
155
+
156
+ from fastapi.responses import JSONResponse
157
+
158
+ response = JSONResponse(content={"success": True})
159
+ response.delete_cookie(key="refresh_token", path="/api/auth")
160
+ return response
161
+
162
+
163
+ @router.get("/me")
164
+ async def me(
165
+ payload: dict = Depends(auth_dependency),
166
+ db: AsyncSession = Depends(get_db),
167
+ ):
168
+ """Get current user info."""
169
+ result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(payload["sub"])))
170
+ user = result.scalar_one_or_none()
171
+ if user is None:
172
+ raise HTTPException(status_code=404, detail="User not found")
173
+ return {
174
+ "id": str(user.id),
175
+ "username": user.username,
176
+ "displayName": user.display_name,
177
+ "role": user.role,
178
+ "createdAt": user.created_at.isoformat() if user.created_at else None,
179
+ }
180
+
181
+
182
+ @router.post("/change-password")
183
+ async def change_password(
184
+ request: Request,
185
+ body: ChangePasswordRequest,
186
+ payload: dict = Depends(auth_dependency),
187
+ db: AsyncSession = Depends(get_db),
188
+ ):
189
+ """Change password for current user."""
190
+ if not body.current_password or not body.new_password:
191
+ raise HTTPException(status_code=400, detail="currentPassword and newPassword are required")
192
+
193
+ result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(payload["sub"])))
194
+ user = result.scalar_one_or_none()
195
+ if user is None:
196
+ raise HTTPException(status_code=404, detail="User not found")
197
+ if not verify_password(body.current_password, user.password_hash):
198
+ raise HTTPException(status_code=401, detail="Current password is incorrect")
199
+
200
+ user.password_hash = hash_password(body.new_password)
201
+ await db.flush()
202
+
203
+ ip = request.client.host if request.client else None
204
+ await audit_log(db, user.id, "password_changed", ip)
205
+ return {"success": True}
@@ -0,0 +1,76 @@
1
+ """Files router: get/put file content, history."""
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+ from pydantic import BaseModel
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from app.config import settings
8
+ from app.database import get_db
9
+ from app.security import auth_dependency, require_role
10
+ from app.services.github import fetch_file_commits, fetch_file_content, get_github_config, get_mock_file_content
11
+ from app.services.pr_chain import save_to_working_branch
12
+
13
+ router = APIRouter(prefix="/api", tags=["files"])
14
+
15
+
16
+ class FileUpdateBody(BaseModel):
17
+ """File update request body."""
18
+
19
+ content: str
20
+ sha: str | None = None
21
+ message: str | None = None
22
+
23
+
24
+ @router.get("/files/{file_path:path}")
25
+ async def get_file(file_path: str):
26
+ """Get file content. Mock mode when GITHUB_TOKEN not set."""
27
+ if not file_path:
28
+ raise HTTPException(status_code=400, detail="File path is required")
29
+ decoded = file_path # Already decoded by FastAPI
30
+ try:
31
+ if settings.is_mock_mode():
32
+ return get_mock_file_content(decoded)
33
+ config = get_github_config()
34
+ return await fetch_file_content(decoded, config)
35
+ except Exception as e:
36
+ msg = str(e)
37
+ if "404" in msg:
38
+ raise HTTPException(status_code=404, detail="File not found")
39
+ raise HTTPException(status_code=500, detail=msg)
40
+
41
+
42
+ @router.get("/history/{file_path:path}")
43
+ async def get_history(file_path: str):
44
+ """Get file commit history."""
45
+ if not file_path:
46
+ raise HTTPException(status_code=400, detail="File path is required")
47
+ if settings.is_mock_mode():
48
+ return []
49
+ try:
50
+ config = get_github_config()
51
+ return await fetch_file_commits(file_path, config)
52
+ except Exception as e:
53
+ raise HTTPException(status_code=500, detail=str(e))
54
+
55
+
56
+ @router.put("/files/{file_path:path}")
57
+ async def put_file(
58
+ file_path: str,
59
+ body: FileUpdateBody,
60
+ db: AsyncSession = Depends(get_db),
61
+ payload: dict = Depends(auth_dependency),
62
+ _: dict = Depends(require_role("admin", "editor")),
63
+ ):
64
+ """Save file (PR chain). Mock mode disabled."""
65
+ if settings.is_mock_mode():
66
+ raise HTTPException(status_code=400, detail="Mock mode: save disabled")
67
+ try:
68
+ config = get_github_config()
69
+ user_info = {"id": str(payload["sub"]), "username": payload["username"], "displayName": payload.get("displayName", payload["username"])}
70
+ message = body.message or f"docs: update {file_path.split('/')[-1]}"
71
+ result = await save_to_working_branch(
72
+ db, file_path, body.content, message, body.sha, user_info, config
73
+ )
74
+ return result
75
+ except Exception as e:
76
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,92 @@
1
+ """PR router: sessions, create, discard."""
2
+
3
+ from uuid import UUID
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.config import settings
9
+ from app.database import get_db
10
+ from app.security import auth_dependency, require_role
11
+ from app.services.github import get_github_config
12
+ from app.services.github_pr import find_open_auto_prs
13
+ from app.services.pr_chain import create_ready_pr, discard_changes, get_all_active_sessions, get_session_for_user
14
+
15
+ router = APIRouter(prefix="/api/pr", tags=["pr"], dependencies=[Depends(auth_dependency)])
16
+
17
+
18
+ @router.get("/sessions")
19
+ async def list_sessions(db: AsyncSession = Depends(get_db)):
20
+ """Get all active sessions."""
21
+ if settings.is_mock_mode():
22
+ return {"sessions": [], "mock": True}
23
+ sessions = await get_all_active_sessions(db)
24
+ return {"sessions": sessions}
25
+
26
+
27
+ @router.get("/my-session")
28
+ async def my_session(
29
+ payload: dict = Depends(auth_dependency),
30
+ db: AsyncSession = Depends(get_db),
31
+ ):
32
+ """Get current user's session."""
33
+ if settings.is_mock_mode():
34
+ return {"session": None, "mock": True}
35
+ session = await get_session_for_user(db, UUID(payload["sub"]))
36
+ return {"session": session}
37
+
38
+
39
+ @router.get("/latest")
40
+ async def latest_pr(
41
+ payload: dict = Depends(auth_dependency),
42
+ ):
43
+ """Get latest open auto PR."""
44
+ if settings.is_mock_mode():
45
+ return {"pr": None, "mock": True}
46
+ try:
47
+ config = get_github_config()
48
+ prs = await find_open_auto_prs(config)
49
+ if not prs:
50
+ return {"pr": None}
51
+ prs.sort(key=lambda p: p.get("created_at", ""), reverse=True)
52
+ return {"pr": prs[0]}
53
+ except Exception as e:
54
+ raise HTTPException(status_code=500, detail=str(e))
55
+
56
+
57
+ @router.post("/create")
58
+ async def create_pr_route(
59
+ payload: dict = Depends(auth_dependency),
60
+ db: AsyncSession = Depends(get_db),
61
+ _: dict = Depends(require_role("admin", "editor")),
62
+ ):
63
+ """Create PR from current user's branch."""
64
+ if settings.is_mock_mode():
65
+ raise HTTPException(status_code=400, detail="Mock mode: PR creation disabled")
66
+ try:
67
+ config = get_github_config()
68
+ pr = await create_ready_pr(db, UUID(payload["sub"]), config)
69
+ return {"pr": pr}
70
+ except ValueError as e:
71
+ raise HTTPException(status_code=400, detail=str(e))
72
+ except Exception as e:
73
+ raise HTTPException(status_code=500, detail=str(e))
74
+
75
+
76
+ @router.post("/discard")
77
+ async def discard_pr(
78
+ payload: dict = Depends(auth_dependency),
79
+ db: AsyncSession = Depends(get_db),
80
+ _: dict = Depends(require_role("admin", "editor")),
81
+ ):
82
+ """Discard changes."""
83
+ if settings.is_mock_mode():
84
+ raise HTTPException(status_code=400, detail="Mock mode: discard disabled")
85
+ try:
86
+ config = get_github_config()
87
+ await discard_changes(db, UUID(payload["sub"]), config)
88
+ return {"success": True}
89
+ except ValueError as e:
90
+ raise HTTPException(status_code=400, detail=str(e))
91
+ except Exception as e:
92
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,44 @@
1
+ """Settings router."""
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+ from pydantic import BaseModel
5
+ from sqlalchemy import select
6
+ from sqlalchemy.dialects.postgresql import insert
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.database import get_db
10
+ from app.models import NotesSetting
11
+ from app.security import auth_dependency, require_role
12
+
13
+ router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(auth_dependency)])
14
+
15
+
16
+ @router.get("")
17
+ async def get_settings(db: AsyncSession = Depends(get_db)):
18
+ """Get all settings as key-value object."""
19
+ result = await db.execute(select(NotesSetting))
20
+ rows = result.all()
21
+ return {row.key: row.value for row in rows}
22
+
23
+
24
+ class SettingUpdate(BaseModel):
25
+ """Setting update body."""
26
+
27
+ value: int | str | list | dict
28
+
29
+
30
+ @router.put("/{key}")
31
+ async def update_setting(
32
+ key: str,
33
+ body: SettingUpdate,
34
+ db: AsyncSession = Depends(get_db),
35
+ _: dict = Depends(require_role("admin")),
36
+ ):
37
+ """Update setting (admin only)."""
38
+ stmt = insert(NotesSetting).values(key=key, value=body.value).on_conflict_do_update(
39
+ index_elements=["key"],
40
+ set_={"value": body.value},
41
+ )
42
+ await db.execute(stmt)
43
+ await db.flush()
44
+ return {"key": key, "value": body.value}
@@ -0,0 +1,22 @@
1
+ """Tree router: file tree from GitHub."""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from app.config import settings
6
+ from app.services.github import fetch_directory_tree, get_github_config, get_mock_tree
7
+
8
+ router = APIRouter(prefix="/api", tags=["tree"])
9
+
10
+
11
+ @router.get("/tree")
12
+ async def get_tree(path: str | None = None):
13
+ """Get file tree. Mock mode when GITHUB_TOKEN not set."""
14
+ try:
15
+ if settings.is_mock_mode():
16
+ return get_mock_tree()
17
+ config = get_github_config()
18
+ query_path = path or config["docs_path"]
19
+ tree = await fetch_directory_tree(query_path, config)
20
+ return tree
21
+ except Exception as e:
22
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,145 @@
1
+ """Users router (admin only)."""
2
+
3
+ from uuid import UUID
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.database import get_db
10
+ from app.models import NotesAuditLog, NotesUser
11
+ from app.schemas.user import UserCreate, UserUpdate
12
+ from app.security import auth_dependency, hash_password, require_role
13
+ from app.services.audit import audit_log
14
+
15
+ router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(auth_dependency), Depends(require_role("admin"))])
16
+
17
+
18
+ @router.get("")
19
+ async def list_users(db: AsyncSession = Depends(get_db)):
20
+ """List all users (admin only)."""
21
+ result = await db.execute(
22
+ select(
23
+ NotesUser.id,
24
+ NotesUser.username,
25
+ NotesUser.display_name,
26
+ NotesUser.role,
27
+ NotesUser.failed_login_attempts,
28
+ NotesUser.locked_until,
29
+ NotesUser.created_at,
30
+ NotesUser.updated_at,
31
+ ).order_by(NotesUser.created_at)
32
+ )
33
+ rows = result.all()
34
+ return [
35
+ {
36
+ "id": str(r.id),
37
+ "username": r.username,
38
+ "displayName": r.display_name,
39
+ "role": r.role,
40
+ "failedLoginAttempts": r.failed_login_attempts,
41
+ "lockedUntil": r.locked_until.isoformat() if r.locked_until else None,
42
+ "createdAt": r.created_at.isoformat() if r.created_at else None,
43
+ "updatedAt": r.updated_at.isoformat() if r.updated_at else None,
44
+ }
45
+ for r in rows
46
+ ]
47
+
48
+
49
+ @router.post("", status_code=201)
50
+ async def create_user(
51
+ body: UserCreate,
52
+ payload: dict = Depends(auth_dependency),
53
+ db: AsyncSession = Depends(get_db),
54
+ ):
55
+ """Create user (admin only)."""
56
+ if body.role not in ("admin", "editor", "viewer"):
57
+ raise HTTPException(status_code=400, detail="role must be admin, editor, or viewer")
58
+ if len(body.password) < 8:
59
+ raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
60
+
61
+ result = await db.execute(select(NotesUser).where(NotesUser.username == body.username))
62
+ if result.scalar_one_or_none():
63
+ raise HTTPException(status_code=409, detail="Username already exists")
64
+
65
+ user = NotesUser(
66
+ username=body.username,
67
+ display_name=body.display_name,
68
+ password_hash=hash_password(body.password),
69
+ role=body.role,
70
+ )
71
+ db.add(user)
72
+ await db.flush()
73
+ await db.refresh(user)
74
+
75
+ await audit_log(db, UUID(payload["sub"]), "user_create", detail={"targetUser": body.username, "role": body.role})
76
+
77
+ return {
78
+ "id": str(user.id),
79
+ "username": user.username,
80
+ "displayName": user.display_name,
81
+ "role": user.role,
82
+ "createdAt": user.created_at.isoformat() if user.created_at else None,
83
+ }
84
+
85
+
86
+ @router.put("/{user_id}")
87
+ async def update_user(
88
+ user_id: str,
89
+ body: UserUpdate,
90
+ payload: dict = Depends(auth_dependency),
91
+ db: AsyncSession = Depends(get_db),
92
+ ):
93
+ """Update user (admin only)."""
94
+ result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(user_id)))
95
+ user = result.scalar_one_or_none()
96
+ if user is None:
97
+ raise HTTPException(status_code=404, detail="User not found")
98
+
99
+ if body.display_name is not None:
100
+ user.display_name = body.display_name
101
+ if body.role is not None:
102
+ if body.role not in ("admin", "editor", "viewer"):
103
+ raise HTTPException(status_code=400, detail="role must be admin, editor, or viewer")
104
+ user.role = body.role
105
+ if body.password is not None:
106
+ if len(body.password) < 8:
107
+ raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
108
+ user.password_hash = hash_password(body.password)
109
+
110
+ await db.flush()
111
+ await db.refresh(user)
112
+
113
+ await audit_log(db, UUID(payload["sub"]), "user_update", detail={"targetUserId": user_id, "fields": list(body.model_dump(exclude_none=True).keys())})
114
+
115
+ return {
116
+ "id": str(user.id),
117
+ "username": user.username,
118
+ "displayName": user.display_name,
119
+ "role": user.role,
120
+ "createdAt": user.created_at.isoformat() if user.created_at else None,
121
+ "updatedAt": user.updated_at.isoformat() if user.updated_at else None,
122
+ }
123
+
124
+
125
+ @router.delete("/{user_id}")
126
+ async def delete_user(
127
+ user_id: str,
128
+ payload: dict = Depends(auth_dependency),
129
+ db: AsyncSession = Depends(get_db),
130
+ ):
131
+ """Delete user (admin only)."""
132
+ if user_id == str(payload["sub"]):
133
+ raise HTTPException(status_code=400, detail="Cannot delete your own account")
134
+
135
+ result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(user_id)))
136
+ user = result.scalar_one_or_none()
137
+ if user is None:
138
+ raise HTTPException(status_code=404, detail="User not found")
139
+
140
+ username = user.username
141
+ await db.delete(user)
142
+ await db.flush()
143
+
144
+ await audit_log(db, UUID(payload["sub"]), "user_delete", detail={"targetUser": username})
145
+ return {"success": True}
@@ -0,0 +1 @@
1
+ """Pydantic schemas."""