@ripla/godd-mcp 1.0.4-canary.9 → 1.0.4

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 (348) hide show
  1. package/README.md +89 -94
  2. package/dist/godd.cjs +349 -233
  3. package/dist/godd.js +2193 -838
  4. package/dist/index.js +152 -122
  5. package/{notes-api → notes/api}/.env.example +6 -1
  6. package/{notes-api → notes/api}/README.md +6 -5
  7. package/{notes-api → notes/api}/app/main.py +29 -2
  8. package/{notes-api → notes/api}/app/routers/auth.py +6 -1
  9. package/{notes-api → notes/api}/app/routers/issues.py +157 -1
  10. package/{notes-api → notes/api}/app/routers/pr.py +2 -2
  11. package/{notes-api → notes/api}/app/services/github_app_token.py +32 -1
  12. package/{notes-api → notes/api}/app/services/github_issues.py +101 -11
  13. package/{notes-api → notes/api}/app/services/github_pr.py +6 -1
  14. package/{notes-api → notes/api}/tests/test_github_app_token.py +49 -1
  15. package/{notes-api → notes/api}/tests/test_github_service.py +42 -0
  16. package/notes/api/tests/test_health.py +52 -0
  17. package/{notes-api → notes/api}/tests/test_issues.py +216 -0
  18. package/{notes-api → notes/api}/tests/test_pr.py +24 -0
  19. package/{notes-api → notes/api}/tests/test_pr_chain.py +19 -0
  20. package/{notes-api → notes/api}/uv.lock +178 -2
  21. package/notes/app/DESIGN.md +273 -0
  22. package/{notes-app → notes/app}/README.md +4 -1
  23. package/notes/app/docker/Dockerfile.e2e +19 -0
  24. package/notes/app/docker/Dockerfile.prod +15 -0
  25. package/notes/app/docker/Dockerfile.test +17 -0
  26. package/notes/app/e2e/file-tree.spec.ts +26 -0
  27. package/notes/app/e2e/file-view.spec.ts +25 -0
  28. package/notes/app/e2e/fixtures.ts +28 -0
  29. package/{notes-app → notes/app}/e2e/global-setup.ts +5 -7
  30. package/notes/app/e2e/login.spec.ts +25 -0
  31. package/notes/app/e2e/pages/login-page.ts +29 -0
  32. package/notes/app/e2e/pages/main-page.ts +29 -0
  33. package/notes/app/e2e/panel-resize-handle.spec.ts +213 -0
  34. package/{notes-app → notes/app}/eslint.config.js +9 -0
  35. package/{notes-app → notes/app}/package.json +0 -3
  36. package/{notes-app → notes/app}/pnpm-workspace.yaml +1 -0
  37. package/notes/app/src/components/CommentPanel.test.tsx +194 -0
  38. package/{notes-app → notes/app}/src/components/CommentPanel.tsx +119 -10
  39. package/notes/app/src/components/CommentSelectionPopover.tsx +26 -0
  40. package/notes/app/src/components/EditToolbar.test.tsx +102 -0
  41. package/{notes-app → notes/app}/src/components/EditToolbar.tsx +5 -54
  42. package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +250 -0
  43. package/notes/app/src/components/FileInfoPanel.history-tab.test.tsx +146 -0
  44. package/notes/app/src/components/FileInfoPanel.test.tsx +101 -0
  45. package/{notes-app → notes/app}/src/components/FileInfoPanel.tsx +135 -20
  46. package/notes/app/src/components/IssueCommentThread.test.tsx +93 -0
  47. package/notes/app/src/components/IssueCommentThread.tsx +156 -0
  48. package/notes/app/src/components/IssueDetailView.test.tsx +1118 -0
  49. package/notes/app/src/components/IssueDetailView.tsx +476 -0
  50. package/notes/app/src/components/IssueList.test.tsx +545 -0
  51. package/notes/app/src/components/IssueList.tsx +374 -0
  52. package/{notes-app → notes/app}/src/components/LabelSelector.test.tsx +94 -8
  53. package/notes/app/src/components/LabelSelector.tsx +452 -0
  54. package/{notes-app → notes/app}/src/components/MarkdownEditor.tsx +140 -2
  55. package/notes/app/src/components/PanelResizeHandle.test.tsx +481 -0
  56. package/notes/app/src/components/PanelResizeHandle.tsx +96 -0
  57. package/{notes-app → notes/app}/src/hooks/useEditSession.test.ts +171 -23
  58. package/{notes-app → notes/app}/src/hooks/useEditSession.ts +49 -62
  59. package/notes/app/src/hooks/useMdUpLayout.test.ts +10 -0
  60. package/notes/app/src/hooks/useMdUpLayout.ts +27 -0
  61. package/notes/app/src/hooks/usePeriodicSync.test.ts +244 -0
  62. package/notes/app/src/hooks/usePeriodicSync.ts +83 -0
  63. package/notes/app/src/hooks/useResizablePanelWidth.test.ts +251 -0
  64. package/notes/app/src/hooks/useResizablePanelWidth.ts +167 -0
  65. package/{notes-app → notes/app}/src/lib/api.test.ts +81 -1
  66. package/{notes-app → notes/app}/src/lib/api.ts +98 -4
  67. package/notes/app/src/lib/commentAnchor.test.ts +22 -0
  68. package/notes/app/src/lib/commentAnchor.ts +28 -0
  69. package/notes/app/src/lib/commentHighlight.test.ts +110 -0
  70. package/notes/app/src/lib/commentHighlight.ts +44 -0
  71. package/notes/app/src/lib/github-sync.test.ts +126 -0
  72. package/notes/app/src/lib/github-sync.ts +89 -0
  73. package/notes/app/src/lib/issue-list-utils.test.ts +275 -0
  74. package/notes/app/src/lib/issue-list-utils.ts +135 -0
  75. package/notes/app/src/lib/issue-mobile-layout.test.ts +17 -0
  76. package/notes/app/src/lib/issue-mobile-layout.ts +20 -0
  77. package/notes/app/src/lib/issue-url-sync.test.ts +73 -0
  78. package/notes/app/src/lib/issue-url-sync.ts +43 -0
  79. package/notes/app/src/lib/main-page-default-view.test.ts +37 -0
  80. package/notes/app/src/lib/main-page-default-view.ts +15 -0
  81. package/notes/app/src/lib/resizable-panel.test.ts +199 -0
  82. package/notes/app/src/lib/resizable-panel.ts +136 -0
  83. package/notes/app/src/pages/LoginPage.test.tsx +17 -0
  84. package/notes/app/src/pages/MainPage.branch-modal.test.tsx +226 -0
  85. package/notes/app/src/pages/MainPage.issue-sync.test.tsx +145 -0
  86. package/notes/app/src/pages/MainPage.sidebar.test.tsx +361 -0
  87. package/{notes-app → notes/app}/src/pages/MainPage.tsx +406 -61
  88. package/{notes-app → notes/app}/src/pages/SettingsPage.test.tsx +32 -23
  89. package/{notes-app → notes/app}/src/pages/UserManagementPage.test.tsx +8 -18
  90. package/notes/app/src/test/mocks.ts +156 -0
  91. package/notes/app/src/test/render.tsx +27 -0
  92. package/package.json +3 -5
  93. package/notes-api/tests/test_health.py +0 -11
  94. package/notes-app/docker/Dockerfile.e2e +0 -14
  95. package/notes-app/docker/Dockerfile.prod +0 -12
  96. package/notes-app/docker/Dockerfile.test +0 -12
  97. package/notes-app/e2e/file-tree.spec.ts +0 -29
  98. package/notes-app/e2e/file-view.spec.ts +0 -24
  99. package/notes-app/e2e/login.spec.ts +0 -32
  100. package/notes-app/src/components/IssueDetailView.test.tsx +0 -201
  101. package/notes-app/src/components/IssueDetailView.tsx +0 -318
  102. package/notes-app/src/components/IssueList.test.tsx +0 -93
  103. package/notes-app/src/components/IssueList.tsx +0 -169
  104. package/notes-app/src/components/LabelSelector.tsx +0 -276
  105. package/notes-app/src/pages/LoginPage.test.tsx +0 -29
  106. package/stacks/django-vue.yaml +0 -33
  107. package/stacks/fastapi-react.yaml +0 -160
  108. package/stacks/flutter-firebase.yaml +0 -58
  109. package/stacks/nextjs-prisma.yaml +0 -33
  110. package/stacks/schema.json +0 -144
  111. package/templates/agents/architect.hbs +0 -183
  112. package/templates/agents/auto-documenter.hbs +0 -36
  113. package/templates/agents/backend-developer.hbs +0 -175
  114. package/templates/agents/code-reviewer.hbs +0 -29
  115. package/templates/agents/database-developer.hbs +0 -52
  116. package/templates/agents/debug-specialist.hbs +0 -39
  117. package/templates/agents/environment-setup.hbs +0 -41
  118. package/templates/agents/frontend-developer.hbs +0 -45
  119. package/templates/agents/integration-qa.hbs +0 -26
  120. package/templates/agents/performance-qa.hbs +0 -24
  121. package/templates/agents/pr-writer.hbs +0 -53
  122. package/templates/agents/quality-lead.hbs +0 -26
  123. package/templates/agents/requirements-analyst.hbs +0 -26
  124. package/templates/agents/security-analyst.hbs +0 -21
  125. package/templates/agents/security-reviewer.hbs +0 -20
  126. package/templates/agents/ssot-updater.hbs +0 -45
  127. package/templates/agents/technical-writer.hbs +0 -28
  128. package/templates/agents/unit-test-engineer.hbs +0 -19
  129. package/templates/agents/user-clone.hbs +0 -49
  130. package/templates/github-actions/aws/deploy-notes.yml.hbs +0 -117
  131. package/templates/github-actions/azure/deploy-notes.yml.hbs +0 -78
  132. package/templates/github-actions/gcp/deploy-notes.yml.hbs +0 -76
  133. package/templates/github-actions/vercel-railway/deploy-notes.yml.hbs +0 -49
  134. package/templates/mindsets/agent-stdio.hbs +0 -51
  135. package/templates/mindsets/dart.hbs +0 -39
  136. package/templates/mindsets/ddd-clean-architecture.hbs +0 -52
  137. package/templates/mindsets/electron.hbs +0 -41
  138. package/templates/mindsets/fastapi.hbs +0 -40
  139. package/templates/mindsets/flutter.hbs +0 -56
  140. package/templates/mindsets/kotlin.hbs +0 -46
  141. package/templates/mindsets/postgresql.hbs +0 -39
  142. package/templates/mindsets/python.hbs +0 -39
  143. package/templates/mindsets/react.hbs +0 -51
  144. package/templates/mindsets/swift.hbs +0 -46
  145. package/templates/mindsets/terraform.hbs +0 -53
  146. package/templates/mindsets/typescript.hbs +0 -34
  147. package/templates/mindsets/vite.hbs +0 -37
  148. package/templates/notes-compose.yml +0 -82
  149. package/templates/partials/cogito-geo.hbs +0 -84
  150. package/templates/partials/cogito-godd.hbs +0 -22
  151. package/templates/partials/cogito-grammar.hbs +0 -105
  152. package/templates/partials/cogito-particle.hbs +0 -76
  153. package/templates/partials/cogito-root-ai.hbs +0 -79
  154. package/templates/partials/cogito-root-arch.hbs +0 -75
  155. package/templates/partials/cogito-root-core.hbs +0 -75
  156. package/templates/partials/cogito-root-daily.hbs +0 -58
  157. package/templates/partials/cogito-root-dev.hbs +0 -71
  158. package/templates/partials/cogito-root-devops.hbs +0 -71
  159. package/templates/partials/cogito-root-framework.hbs +0 -80
  160. package/templates/partials/cogito-root-lang.hbs +0 -74
  161. package/templates/partials/godd-core.hbs +0 -50
  162. package/templates/partials/project-context.hbs +0 -52
  163. package/templates/partials/ssot-header.hbs +0 -40
  164. package/templates/partials/ssot-rules.hbs +0 -30
  165. package/templates/prompts/adr.hbs +0 -39
  166. package/templates/prompts/agent-dev-workflow.hbs +0 -34
  167. package/templates/prompts/analyze-report.hbs +0 -36
  168. package/templates/prompts/auto-documentation.hbs +0 -37
  169. package/templates/prompts/check.hbs +0 -52
  170. package/templates/prompts/commit.hbs +0 -96
  171. package/templates/prompts/dev.hbs +0 -127
  172. package/templates/prompts/docs-init.hbs +0 -101
  173. package/templates/prompts/docs-update.hbs +0 -51
  174. package/templates/prompts/git-workflow.hbs +0 -125
  175. package/templates/prompts/impact-analysis.hbs +0 -43
  176. package/templates/prompts/install.hbs +0 -40
  177. package/templates/prompts/new-branch.hbs +0 -61
  178. package/templates/prompts/notes-deploy.hbs +0 -138
  179. package/templates/prompts/pr-analyze.hbs +0 -44
  180. package/templates/prompts/pr-create.hbs +0 -125
  181. package/templates/prompts/push-execute.hbs +0 -83
  182. package/templates/prompts/push.hbs +0 -24
  183. package/templates/prompts/release-notes.hbs +0 -38
  184. package/templates/prompts/requirements-to-business-flow.hbs +0 -29
  185. package/templates/prompts/requirements-to-tickets.hbs +0 -26
  186. package/templates/prompts/review.hbs +0 -126
  187. package/templates/prompts/setup.hbs +0 -247
  188. package/templates/prompts/spec.hbs +0 -35
  189. package/templates/prompts/specification-to-tickets.hbs +0 -24
  190. package/templates/prompts/submit.hbs +0 -143
  191. package/templates/prompts/sync.hbs +0 -63
  192. package/templates/prompts/test-plan.hbs +0 -56
  193. package/templates/scripts/aws/terraform-backend-setup.sh.hbs +0 -149
  194. package/templates/terraform/aws/alb.tf.hbs +0 -58
  195. package/templates/terraform/aws/backend.tf.hbs +0 -10
  196. package/templates/terraform/aws/cloudfront.tf.hbs +0 -96
  197. package/templates/terraform/aws/ecr.tf.hbs +0 -30
  198. package/templates/terraform/aws/ecs.tf.hbs +0 -142
  199. package/templates/terraform/aws/iam.tf.hbs +0 -88
  200. package/templates/terraform/aws/local.tf.hbs +0 -34
  201. package/templates/terraform/aws/output.tf.hbs +0 -49
  202. package/templates/terraform/aws/provider.tf.hbs +0 -26
  203. package/templates/terraform/aws/rds.tf.hbs +0 -38
  204. package/templates/terraform/aws/s3.tf.hbs +0 -34
  205. package/templates/terraform/aws/secrets.tf.hbs +0 -53
  206. package/templates/terraform/aws/security_groups.tf.hbs +0 -81
  207. package/templates/terraform/aws/vpc.tf.hbs +0 -63
  208. package/templates/terraform/azure/backend.tf.hbs +0 -8
  209. package/templates/terraform/azure/iam.tf.hbs +0 -36
  210. package/templates/terraform/azure/main.tf.hbs +0 -203
  211. package/templates/terraform/azure/output.tf.hbs +0 -19
  212. package/templates/terraform/azure/provider.tf.hbs +0 -19
  213. package/templates/terraform/gcp/backend.tf.hbs +0 -6
  214. package/templates/terraform/gcp/iam.tf.hbs +0 -47
  215. package/templates/terraform/gcp/main.tf.hbs +0 -175
  216. package/templates/terraform/gcp/output.tf.hbs +0 -15
  217. package/templates/terraform/gcp/provider.tf.hbs +0 -19
  218. package/templates/terraform/vercel-railway/main.tf.hbs +0 -64
  219. package/templates/terraform/vercel-railway/output.tf.hbs +0 -11
  220. /package/{notes-api → notes/api}/.dockerignore +0 -0
  221. /package/{notes-api → notes/api}/alembic/README +0 -0
  222. /package/{notes-api → notes/api}/alembic/env.py +0 -0
  223. /package/{notes-api → notes/api}/alembic/script.py.mako +0 -0
  224. /package/{notes-api → notes/api}/alembic/versions/001_initial_notes_tables.py +0 -0
  225. /package/{notes-api → notes/api}/alembic/versions/002_add_performance_indexes.py +0 -0
  226. /package/{notes-api → notes/api}/alembic/versions/003_add_yjs_documents.py +0 -0
  227. /package/{notes-api → notes/api}/alembic/versions/004_add_comments.py +0 -0
  228. /package/{notes-api → notes/api}/alembic/versions/005_add_github_username.py +0 -0
  229. /package/{notes-api → notes/api}/alembic/versions/006_add_check_constraints.py +0 -0
  230. /package/{notes-api → notes/api}/alembic.ini +0 -0
  231. /package/{notes-api → notes/api}/app/__init__.py +0 -0
  232. /package/{notes-api → notes/api}/app/config.py +0 -0
  233. /package/{notes-api → notes/api}/app/database.py +0 -0
  234. /package/{notes-api → notes/api}/app/http_client.py +0 -0
  235. /package/{notes-api → notes/api}/app/models/__init__.py +0 -0
  236. /package/{notes-api → notes/api}/app/models/audit.py +0 -0
  237. /package/{notes-api → notes/api}/app/models/base.py +0 -0
  238. /package/{notes-api → notes/api}/app/models/collab.py +0 -0
  239. /package/{notes-api → notes/api}/app/models/comment.py +0 -0
  240. /package/{notes-api → notes/api}/app/models/session.py +0 -0
  241. /package/{notes-api → notes/api}/app/models/setting.py +0 -0
  242. /package/{notes-api → notes/api}/app/models/user.py +0 -0
  243. /package/{notes-api → notes/api}/app/path_validation.py +0 -0
  244. /package/{notes-api → notes/api}/app/routers/__init__.py +0 -0
  245. /package/{notes-api → notes/api}/app/routers/collab.py +0 -0
  246. /package/{notes-api → notes/api}/app/routers/comments.py +0 -0
  247. /package/{notes-api → notes/api}/app/routers/files.py +0 -0
  248. /package/{notes-api → notes/api}/app/routers/settings.py +0 -0
  249. /package/{notes-api → notes/api}/app/routers/tree.py +0 -0
  250. /package/{notes-api → notes/api}/app/routers/upload.py +0 -0
  251. /package/{notes-api → notes/api}/app/routers/users.py +0 -0
  252. /package/{notes-api → notes/api}/app/schemas/__init__.py +0 -0
  253. /package/{notes-api → notes/api}/app/schemas/auth.py +0 -0
  254. /package/{notes-api → notes/api}/app/schemas/user.py +0 -0
  255. /package/{notes-api → notes/api}/app/security.py +0 -0
  256. /package/{notes-api → notes/api}/app/services/__init__.py +0 -0
  257. /package/{notes-api → notes/api}/app/services/audit.py +0 -0
  258. /package/{notes-api → notes/api}/app/services/business_date.py +0 -0
  259. /package/{notes-api → notes/api}/app/services/collab.py +0 -0
  260. /package/{notes-api → notes/api}/app/services/github.py +0 -0
  261. /package/{notes-api → notes/api}/app/services/pr_chain.py +0 -0
  262. /package/{notes-api → notes/api}/app/startup.py +0 -0
  263. /package/{notes-api → notes/api}/docker/Dockerfile +0 -0
  264. /package/{notes-api → notes/api}/docker/Dockerfile.test +0 -0
  265. /package/{notes-api → notes/api}/pyproject.toml +0 -0
  266. /package/{notes-api → notes/api}/tests/conftest.py +0 -0
  267. /package/{notes-api → notes/api}/tests/test_audit.py +0 -0
  268. /package/{notes-api → notes/api}/tests/test_auth.py +0 -0
  269. /package/{notes-api → notes/api}/tests/test_business_date.py +0 -0
  270. /package/{notes-api → notes/api}/tests/test_collab.py +0 -0
  271. /package/{notes-api → notes/api}/tests/test_comments.py +0 -0
  272. /package/{notes-api → notes/api}/tests/test_config_ssl.py +0 -0
  273. /package/{notes-api → notes/api}/tests/test_config_warnings.py +0 -0
  274. /package/{notes-api → notes/api}/tests/test_files.py +0 -0
  275. /package/{notes-api → notes/api}/tests/test_path_validation.py +0 -0
  276. /package/{notes-api → notes/api}/tests/test_security.py +0 -0
  277. /package/{notes-api → notes/api}/tests/test_settings.py +0 -0
  278. /package/{notes-api → notes/api}/tests/test_tree.py +0 -0
  279. /package/{notes-api → notes/api}/tests/test_upload.py +0 -0
  280. /package/{notes-api → notes/api}/tests/test_users.py +0 -0
  281. /package/{notes-app → notes/app}/.dockerignore +0 -0
  282. /package/{notes-app → notes/app}/index.html +0 -0
  283. /package/{notes-app → notes/app}/nginx.conf +0 -0
  284. /package/{notes-app → notes/app}/package-lock.json +0 -0
  285. /package/{notes-app → notes/app}/playwright.config.ts +0 -0
  286. /package/{notes-app → notes/app}/pnpm-lock.yaml +0 -0
  287. /package/{notes-app → notes/app}/src/App.tsx +0 -0
  288. /package/{notes-app → notes/app}/src/components/BranchSelectorModal.tsx +0 -0
  289. /package/{notes-app → notes/app}/src/components/CodeBlockView.test.tsx +0 -0
  290. /package/{notes-app → notes/app}/src/components/CodeBlockView.tsx +0 -0
  291. /package/{notes-app → notes/app}/src/components/CollabStatus.tsx +0 -0
  292. /package/{notes-app → notes/app}/src/components/DiffPreview.tsx +0 -0
  293. /package/{notes-app → notes/app}/src/components/DrawioEditor.tsx +0 -0
  294. /package/{notes-app → notes/app}/src/components/DrawioViewer.tsx +0 -0
  295. /package/{notes-app → notes/app}/src/components/ErrorBoundary.test.tsx +0 -0
  296. /package/{notes-app → notes/app}/src/components/ErrorBoundary.tsx +0 -0
  297. /package/{notes-app → notes/app}/src/components/FileContentView.tsx +0 -0
  298. /package/{notes-app → notes/app}/src/components/FileContextMenu.test.tsx +0 -0
  299. /package/{notes-app → notes/app}/src/components/FileContextMenu.tsx +0 -0
  300. /package/{notes-app → notes/app}/src/components/FileDialogs.test.tsx +0 -0
  301. /package/{notes-app → notes/app}/src/components/FileDialogs.tsx +0 -0
  302. /package/{notes-app → notes/app}/src/components/SaveStatusIndicator.test.tsx +0 -0
  303. /package/{notes-app → notes/app}/src/components/SaveStatusIndicator.tsx +0 -0
  304. /package/{notes-app → notes/app}/src/components/Skeleton.tsx +0 -0
  305. /package/{notes-app → notes/app}/src/components/SpreadsheetEditor.test.tsx +0 -0
  306. /package/{notes-app → notes/app}/src/components/SpreadsheetEditor.tsx +0 -0
  307. /package/{notes-app → notes/app}/src/components/TextEditor.tsx +0 -0
  308. /package/{notes-app → notes/app}/src/components/ToastContainer.tsx +0 -0
  309. /package/{notes-app → notes/app}/src/components/TreeNode.tsx +0 -0
  310. /package/{notes-app → notes/app}/src/contexts/AuthContext.test.tsx +0 -0
  311. /package/{notes-app → notes/app}/src/contexts/AuthContext.tsx +0 -0
  312. /package/{notes-app → notes/app}/src/contexts/ToastContext.test.ts +0 -0
  313. /package/{notes-app → notes/app}/src/contexts/ToastContext.tsx +0 -0
  314. /package/{notes-app → notes/app}/src/hooks/useAutoSave.test.ts +0 -0
  315. /package/{notes-app → notes/app}/src/hooks/useAutoSave.ts +0 -0
  316. /package/{notes-app → notes/app}/src/hooks/useCollaboration.ts +0 -0
  317. /package/{notes-app → notes/app}/src/hooks/useComments.ts +0 -0
  318. /package/{notes-app → notes/app}/src/hooks/useYText.ts +0 -0
  319. /package/{notes-app → notes/app}/src/lib/branch-diff.test.ts +0 -0
  320. /package/{notes-app → notes/app}/src/lib/branch-diff.ts +0 -0
  321. /package/{notes-app → notes/app}/src/lib/created-file.test.ts +0 -0
  322. /package/{notes-app → notes/app}/src/lib/created-file.ts +0 -0
  323. /package/{notes-app → notes/app}/src/lib/csv-utils.test.ts +0 -0
  324. /package/{notes-app → notes/app}/src/lib/csv-utils.ts +0 -0
  325. /package/{notes-app → notes/app}/src/lib/deepLinkPathToSearch.test.ts +0 -0
  326. /package/{notes-app → notes/app}/src/lib/deepLinkPathToSearch.ts +0 -0
  327. /package/{notes-app → notes/app}/src/lib/draft-storage.test.ts +0 -0
  328. /package/{notes-app → notes/app}/src/lib/draft-storage.ts +0 -0
  329. /package/{notes-app → notes/app}/src/lib/mermaid-render.ts +0 -0
  330. /package/{notes-app → notes/app}/src/lib/postLoginRedirect.test.ts +0 -0
  331. /package/{notes-app → notes/app}/src/lib/postLoginRedirect.ts +0 -0
  332. /package/{notes-app → notes/app}/src/lib/tree-utils.ts +0 -0
  333. /package/{notes-app → notes/app}/src/lib/user-colors.ts +0 -0
  334. /package/{notes-app → notes/app}/src/lib/xlsx-utils.test.ts +0 -0
  335. /package/{notes-app → notes/app}/src/lib/xlsx-utils.ts +0 -0
  336. /package/{notes-app → notes/app}/src/main.tsx +0 -0
  337. /package/{notes-app → notes/app}/src/pages/LoginPage.tsx +0 -0
  338. /package/{notes-app → notes/app}/src/pages/MainPage.insertChildEntry.test.ts +0 -0
  339. /package/{notes-app → notes/app}/src/pages/SettingsPage.tsx +0 -0
  340. /package/{notes-app → notes/app}/src/pages/UserManagementPage.tsx +0 -0
  341. /package/{notes-app → notes/app}/src/styles/globals.css +0 -0
  342. /package/{notes-app → notes/app}/src/types.ts +0 -0
  343. /package/{notes-app → notes/app}/src/vite-env.d.ts +0 -0
  344. /package/{notes-app → notes/app}/tsconfig.json +0 -0
  345. /package/{notes-app → notes/app}/tsconfig.node.json +0 -0
  346. /package/{notes-app → notes/app}/vite.config.js +0 -0
  347. /package/{notes-app → notes/app}/vite.config.ts +0 -0
  348. /package/{notes-app → notes/app}/vitest.config.ts +0 -0
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ GITHUB_SYNC_POLL_INTERVAL_MS,
4
+ hasIssueSnapshotChanged,
5
+ isIssueSnapshotStale,
6
+ shouldDispatchIssueListBackgroundSync,
7
+ shouldSyncGithub,
8
+ } from "./github-sync";
9
+
10
+ describe("github-sync", () => {
11
+ it("ポーリング間隔は 30 秒", () => {
12
+ expect(GITHUB_SYNC_POLL_INTERVAL_MS).toBe(30_000);
13
+ });
14
+
15
+ it("shouldSyncGithub は ready のみ true", () => {
16
+ expect(shouldSyncGithub("ready")).toBe(true);
17
+ expect(shouldSyncGithub("mock")).toBe(false);
18
+ expect(shouldSyncGithub("loading")).toBe(false);
19
+ expect(shouldSyncGithub("error")).toBe(false);
20
+ });
21
+
22
+ it("hasIssueSnapshotChanged は state / updatedAt / title / labels の差分を検知する", () => {
23
+ const base = {
24
+ state: "open" as const,
25
+ updatedAt: "2026-06-01T00:00:00Z",
26
+ title: "A",
27
+ labels: [{ name: "bug", color: "d73a4a" }],
28
+ };
29
+
30
+ expect(hasIssueSnapshotChanged(base, base)).toBe(false);
31
+ expect(hasIssueSnapshotChanged(base, { ...base, state: "closed" })).toBe(true);
32
+ expect(hasIssueSnapshotChanged(base, { ...base, updatedAt: "2026-06-02T00:00:00Z" })).toBe(true);
33
+ expect(hasIssueSnapshotChanged(base, { ...base, title: "B" })).toBe(true);
34
+ expect(
35
+ hasIssueSnapshotChanged(base, {
36
+ ...base,
37
+ labels: [{ name: "enhancement", color: "a2eeef" }],
38
+ }),
39
+ ).toBe(true);
40
+ expect(
41
+ hasIssueSnapshotChanged(base, {
42
+ ...base,
43
+ labels: [{ name: "bug", color: "ff0000" }],
44
+ }),
45
+ ).toBe(true);
46
+ expect(hasIssueSnapshotChanged(null, base)).toBe(false);
47
+ });
48
+
49
+ describe("isIssueSnapshotStale (#971)", () => {
50
+ const base = {
51
+ state: "open" as const,
52
+ updatedAt: "2026-06-10T00:00:00Z",
53
+ title: "A",
54
+ labels: [{ name: "bug", color: "d73a4a" }],
55
+ };
56
+
57
+ it("next の updatedAt が previous より古ければ stale(保存後の eventual consistency 読取)", () => {
58
+ expect(
59
+ isIssueSnapshotStale(base, { ...base, updatedAt: "2026-05-19T00:00:00Z" }),
60
+ ).toBe(true);
61
+ });
62
+
63
+ it("next の updatedAt が previous より新しければ stale ではない", () => {
64
+ expect(
65
+ isIssueSnapshotStale(base, { ...base, updatedAt: "2026-06-11T00:00:00Z" }),
66
+ ).toBe(false);
67
+ });
68
+
69
+ it("同一 updatedAt で snapshot 内容が後退する応答は stale(EC 同時刻 stale 読取・#979)", () => {
70
+ // GitHub の updated_at は編集ごとに前進するため、同一 updatedAt で内容が異なる応答は
71
+ // 正当な並行更新ではなく EC の古い replica 読取とみなす(巻き戻り防止)
72
+ expect(isIssueSnapshotStale(base, { ...base, title: "B" })).toBe(true);
73
+ expect(isIssueSnapshotStale(base, { ...base, state: "closed" })).toBe(true);
74
+ expect(
75
+ isIssueSnapshotStale(base, {
76
+ ...base,
77
+ labels: [{ name: "enhancement", color: "a2eeef" }],
78
+ }),
79
+ ).toBe(true);
80
+ });
81
+
82
+ it("同一 updatedAt でも snapshot 内容が同一なら stale ではない(適用は no-op)", () => {
83
+ expect(isIssueSnapshotStale(base, { ...base })).toBe(false);
84
+ });
85
+
86
+ it("同一インスタントを指す別フォーマットの updatedAt は内容判定で扱う(文字列差では stale にしない)", () => {
87
+ // 秒解像度で同一時刻だが文字列表現が異なる(Z / +00:00 / .000Z)応答。
88
+ // updatedAt 文字列の差ではなく snapshot 内容(state / title / labels)の差のみで stale を判定する
89
+ const equalInstant = [
90
+ "2026-06-10T00:00:00.000Z",
91
+ "2026-06-10T00:00:00+00:00",
92
+ ];
93
+ for (const updatedAt of equalInstant) {
94
+ // 内容同一: 文字列フォーマットが違っても stale 扱いしない(no-op)
95
+ expect(isIssueSnapshotStale(base, { ...base, updatedAt })).toBe(false);
96
+ // 内容後退: 同一インスタントなら別フォーマットでも stale
97
+ expect(isIssueSnapshotStale(base, { ...base, updatedAt, title: "B" })).toBe(true);
98
+ }
99
+ });
100
+
101
+ it("previous が無ければ stale ではない", () => {
102
+ expect(isIssueSnapshotStale(null, base)).toBe(false);
103
+ });
104
+
105
+ it("解析不能な日時は誤スキップを避けるため stale ではない", () => {
106
+ expect(isIssueSnapshotStale(base, { ...base, updatedAt: "invalid" })).toBe(false);
107
+ expect(
108
+ isIssueSnapshotStale({ ...base, updatedAt: "invalid" }, base),
109
+ ).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe("shouldDispatchIssueListBackgroundSync (#932)", () => {
114
+ it("IssueList が periodic sync 中なら背景再取得を抑制する(重複回避)", () => {
115
+ expect(
116
+ shouldDispatchIssueListBackgroundSync({ issueListSyncing: true }),
117
+ ).toBe(false);
118
+ });
119
+
120
+ it("IssueList が periodic sync していなければ背景再取得を dispatch する(一覧を最新化)", () => {
121
+ expect(
122
+ shouldDispatchIssueListBackgroundSync({ issueListSyncing: false }),
123
+ ).toBe(true);
124
+ });
125
+ });
126
+ });
@@ -0,0 +1,89 @@
1
+ import type { IssueLabel, IssueSummary } from "@/lib/api";
2
+
3
+ /** GitHub 上の変更を Notes UI に反映するポーリング間隔(ms) */
4
+ export const GITHUB_SYNC_POLL_INTERVAL_MS = 30_000;
5
+
6
+ export type IssueSnapshot = Pick<IssueSummary, "state" | "updatedAt" | "title" | "labels">;
7
+
8
+ function issueLabelSignature(labels: IssueLabel[]): string {
9
+ return [...labels]
10
+ .map((label) => `${label.name}:${label.color}`)
11
+ .sort()
12
+ .join(",");
13
+ }
14
+
15
+ /**
16
+ * snapshot の内容(state / title / labels)が後退・変化したかを判定する(`updatedAt` は比較しない)。
17
+ * 同一インスタントの応答どうしを内容のみで比較したい同一秒 stale 判定(#979)で用いる。
18
+ */
19
+ function hasIssueSnapshotContentChanged(
20
+ previous: IssueSnapshot,
21
+ next: IssueSnapshot,
22
+ ): boolean {
23
+ return (
24
+ previous.state !== next.state
25
+ || previous.title !== next.title
26
+ || issueLabelSignature(previous.labels) !== issueLabelSignature(next.labels)
27
+ );
28
+ }
29
+
30
+ export function hasIssueSnapshotChanged(
31
+ previous: IssueSnapshot | null | undefined,
32
+ next: IssueSnapshot,
33
+ ): boolean {
34
+ if (!previous) return false;
35
+ return previous.updatedAt !== next.updatedAt || hasIssueSnapshotContentChanged(previous, next);
36
+ }
37
+
38
+ /**
39
+ * `next`(poll / foreground load 応答)が現在表示中 `previous` に対して stale(巻き戻り)かを判定する
40
+ * (古い `updatedAt`: #971 / 同一秒の内容後退: #979)。
41
+ *
42
+ * GitHub の `updatedAt` は編集ごとに前進(単調増加)するため、次の応答は stale とみなせる:
43
+ * 1. `next.updatedAt` が `previous` より**古い**: 書込(save / close-reopen / label 変更)直後の
44
+ * eventual consistency による更新前読取。
45
+ * 2. `next.updatedAt` が**同一秒**で snapshot 内容(state / title / labels)が異なる: 同一秒の古い
46
+ * replica 読取(#979)。GitHub で同一 `updatedAt` が異なる正規スナップショットを指すことはないため、
47
+ * 内容が後退する同一秒応答は正当な並行更新ではなく EC の stale replica とみなす。
48
+ *
49
+ * いずれも適用・通知すると保存内容が一時的に巻き戻るため、呼び出し側はスキップする。現在表示を維持し、
50
+ * 厳密に新しい `updatedAt` の応答が届いた時点で通常どおり反映される(EC 解消後に自己回復)。
51
+ *
52
+ * 取得開始順の freshness ゲート(#949 / `appliedGen`)とは別軸(データの新しさ)で、save より後に
53
+ * 開始した取得はゲートを通過してしまうため本判定が必要。stale 応答では `appliedGen` を進めない
54
+ * (採用した取得のみ世代を消費する)。同一秒で内容が同一なら stale ではない(適用は no-op)。
55
+ * 本文(`body`)は snapshot 対象外のため、同一秒の body のみの差異は本判定の対象外(次 poll で反映)。
56
+ * いずれかが解析不能な日時なら誤スキップを避けるため `false` を返す。
57
+ */
58
+ export function isIssueSnapshotStale(
59
+ previous: IssueSnapshot | null | undefined,
60
+ next: IssueSnapshot,
61
+ ): boolean {
62
+ if (!previous) return false;
63
+ const previousTime = Date.parse(previous.updatedAt);
64
+ const nextTime = Date.parse(next.updatedAt);
65
+ if (Number.isNaN(previousTime) || Number.isNaN(nextTime)) return false;
66
+ if (nextTime < previousTime) return true;
67
+ // 同一インスタント(updatedAt が同時刻)で snapshot 内容が後退する応答は EC の古い replica 読取(#979)。
68
+ // updatedAt は既に同時刻のため、文字列フォーマット差(Z / +00:00 等)に影響されない内容のみで判定する。
69
+ if (nextTime === previousTime) return hasIssueSnapshotContentChanged(previous, next);
70
+ return false;
71
+ }
72
+
73
+ export function shouldSyncGithub(apiState: "loading" | "ready" | "mock" | "error"): boolean {
74
+ return apiState === "ready";
75
+ }
76
+
77
+ /**
78
+ * 詳細 poll 起因の一覧背景再取得(IssueList の `backgroundSyncTrigger`)を dispatch すべきか判定する(#932)。
79
+ *
80
+ * IssueList が自前の periodic sync 中(Issues タブ表示 + live)は、毎周期で一覧全体(選択中 Issue 含む)を
81
+ * 背景再取得している。そこへ詳細 poll の変更検知でさらに再取得を重ねると同一周期内に
82
+ * `GET /api/issues` が二重 dispatch されるため抑制する。
83
+ * タブ非表示などで IssueList が同期していない場合は、(隠れた)一覧を最新に保つため dispatch する。
84
+ */
85
+ export function shouldDispatchIssueListBackgroundSync(params: {
86
+ issueListSyncing: boolean;
87
+ }): boolean {
88
+ return !params.issueListSyncing;
89
+ }
@@ -0,0 +1,275 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ issueMatchesListFilter,
4
+ mergeOptimisticIssue,
5
+ isValidAssigneeFilter,
6
+ issueDetailToSummary,
7
+ isOptimisticIssueSettled,
8
+ shouldClearOptimisticForSelection,
9
+ } from "./issue-list-utils";
10
+ import type { IssueDetail, IssueSummary } from "@/lib/api";
11
+
12
+ const baseIssue = (overrides: Partial<IssueSummary> = {}): IssueSummary => ({
13
+ number: 1,
14
+ title: "Test",
15
+ state: "open",
16
+ labels: [],
17
+ author: "user",
18
+ assignees: [],
19
+ comments: 0,
20
+ createdAt: "2026-05-25T00:00:00Z",
21
+ updatedAt: "2026-05-25T00:00:00Z",
22
+ ...overrides,
23
+ });
24
+
25
+ const baseDetail = (overrides: Partial<IssueDetail> = {}): IssueDetail => ({
26
+ ...baseIssue(),
27
+ body: "本文",
28
+ url: "https://github.com/ripla-inc/GoDD/issues/1",
29
+ ...overrides,
30
+ });
31
+
32
+ describe("issueMatchesListFilter", () => {
33
+ it("open フィルタで open Issue のみ一致する", () => {
34
+ expect(issueMatchesListFilter(baseIssue({ state: "open" }), "open", "", "")).toBe(true);
35
+ expect(issueMatchesListFilter(baseIssue({ state: "closed" }), "open", "", "")).toBe(false);
36
+ });
37
+
38
+ it("ラベルフィルタが設定されている場合は該当ラベルを持つ Issue のみ一致する", () => {
39
+ const labeled = baseIssue({ labels: [{ name: "bug", color: "d73a4a" }] });
40
+ expect(issueMatchesListFilter(labeled, "open", "bug", "")).toBe(true);
41
+ expect(issueMatchesListFilter(labeled, "open", "enhancement", "")).toBe(false);
42
+ });
43
+
44
+ it("担当者 none フィルタでは未割当 Issue のみ一致する", () => {
45
+ expect(issueMatchesListFilter(baseIssue({ assignees: [] }), "open", "", "none")).toBe(true);
46
+ expect(
47
+ issueMatchesListFilter(baseIssue({ assignees: ["octocat"] }), "open", "", "none"),
48
+ ).toBe(false);
49
+ });
50
+
51
+ it("担当者フィルタでは指定ユーザーが assignees に含まれる Issue のみ一致する", () => {
52
+ const assigned = baseIssue({ assignees: ["octocat"] });
53
+ expect(issueMatchesListFilter(assigned, "open", "", "octocat")).toBe(true);
54
+ expect(issueMatchesListFilter(assigned, "open", "", "hubot")).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe("isValidAssigneeFilter", () => {
59
+ it("空文字・none・有効な GitHub login を許可する", () => {
60
+ expect(isValidAssigneeFilter("")).toBe(true);
61
+ expect(isValidAssigneeFilter("none")).toBe(true);
62
+ expect(isValidAssigneeFilter("octocat")).toBe(true);
63
+ });
64
+
65
+ it("不正な入力を拒否する", () => {
66
+ expect(isValidAssigneeFilter("-invalid")).toBe(false);
67
+ expect(isValidAssigneeFilter("user-")).toBe(false);
68
+ expect(isValidAssigneeFilter("has space")).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("mergeOptimisticIssue", () => {
73
+ const existing = [
74
+ baseIssue({ number: 10, title: "Issue 10" }),
75
+ baseIssue({ number: 9, title: "Issue 9" }),
76
+ ];
77
+
78
+ it("optimistic Issue を先頭付近にマージし、重複を除去する", () => {
79
+ const optimistic = baseIssue({ number: 11, title: "New Issue" });
80
+ const merged = mergeOptimisticIssue(existing, optimistic, "open", "", "");
81
+
82
+ expect(merged.map((issue) => issue.number)).toEqual([11, 10, 9]);
83
+ });
84
+
85
+ it("フィルタ条件に合わない optimistic Issue はマージしない", () => {
86
+ const optimistic = baseIssue({ number: 11, state: "closed" });
87
+ const merged = mergeOptimisticIssue(existing, optimistic, "open", "", "");
88
+
89
+ expect(merged).toEqual(existing);
90
+ });
91
+
92
+ it("API 取得結果に同じ番号が含まれる場合は optimistic を置き換える", () => {
93
+ const optimistic = baseIssue({ number: 10, title: "Optimistic title" });
94
+ const merged = mergeOptimisticIssue(existing, optimistic, "open", "", "");
95
+
96
+ expect(merged.filter((issue) => issue.number === 10)).toHaveLength(1);
97
+ expect(merged[0]?.title).toBe("Optimistic title");
98
+ });
99
+
100
+ it("一覧内の Issue がフィルタ非一致になった optimistic は即時に除外する(#945)", () => {
101
+ // open フィルタ中に選択 Issue #10 が closed 化したケース
102
+ const optimistic = baseIssue({ number: 10, state: "closed" });
103
+ const merged = mergeOptimisticIssue(existing, optimistic, "open", "", "");
104
+
105
+ expect(merged.map((issue) => issue.number)).toEqual([9]);
106
+ });
107
+
108
+ it("除外対象が無いフィルタ非一致では元配列の参照を維持する(#945)", () => {
109
+ // #11 closed は一覧に存在しないため、何も除外せず参照を保つ
110
+ const optimistic = baseIssue({ number: 11, state: "closed" });
111
+ const merged = mergeOptimisticIssue(existing, optimistic, "open", "", "");
112
+
113
+ expect(merged).toBe(existing);
114
+ });
115
+ });
116
+
117
+ describe("issueDetailToSummary", () => {
118
+ it("IssueDetail から body / url を落として IssueSummary を射影する(#945)", () => {
119
+ const detail = baseDetail({
120
+ number: 42,
121
+ title: "Detail title",
122
+ state: "closed",
123
+ labels: [{ name: "bug", color: "d73a4a" }],
124
+ assignees: ["octocat"],
125
+ comments: 3,
126
+ updatedAt: "2026-06-10T00:00:00Z",
127
+ });
128
+
129
+ const summary = issueDetailToSummary(detail);
130
+
131
+ expect(summary).toEqual({
132
+ number: 42,
133
+ title: "Detail title",
134
+ state: "closed",
135
+ labels: [{ name: "bug", color: "d73a4a" }],
136
+ author: "user",
137
+ assignees: ["octocat"],
138
+ comments: 3,
139
+ createdAt: "2026-05-25T00:00:00Z",
140
+ updatedAt: "2026-06-10T00:00:00Z",
141
+ });
142
+ expect("body" in summary).toBe(false);
143
+ expect("url" in summary).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe("isOptimisticIssueSettled", () => {
148
+ const listIssues = [
149
+ baseIssue({ number: 10, updatedAt: "2026-05-19T00:00:00Z" }),
150
+ baseIssue({ number: 9, updatedAt: "2026-05-18T00:00:00Z" }),
151
+ ];
152
+
153
+ describe("presence モード(新規作成)", () => {
154
+ it("同番号が一覧に出現したら解除する(実体が optimistic より古くても可)", () => {
155
+ const optimistic = baseIssue({ number: 10, updatedAt: "2026-05-25T00:00:00Z" });
156
+ expect(
157
+ isOptimisticIssueSettled(optimistic, listIssues, "presence", "open", "", ""),
158
+ ).toBe(true);
159
+ });
160
+
161
+ it("同番号が一覧に無ければ解除しない", () => {
162
+ const optimistic = baseIssue({ number: 11 });
163
+ expect(
164
+ isOptimisticIssueSettled(optimistic, listIssues, "presence", "open", "", ""),
165
+ ).toBe(false);
166
+ });
167
+ });
168
+
169
+ describe("snapshot モード(外部変更の即時反映)", () => {
170
+ it("一覧側がまだ古い間は解除しない(先に解除すると表示が巻き戻る)", () => {
171
+ const optimistic = baseIssue({ number: 10, updatedAt: "2026-06-10T00:00:00Z" });
172
+ expect(
173
+ isOptimisticIssueSettled(optimistic, listIssues, "snapshot", "open", "", ""),
174
+ ).toBe(false);
175
+ });
176
+
177
+ it("一覧側の実体が optimistic に追いついたら解除する", () => {
178
+ const caughtUp = [
179
+ baseIssue({ number: 10, updatedAt: "2026-06-10T00:00:00Z" }),
180
+ baseIssue({ number: 9 }),
181
+ ];
182
+ const optimistic = baseIssue({ number: 10, updatedAt: "2026-06-10T00:00:00Z" });
183
+ expect(
184
+ isOptimisticIssueSettled(optimistic, caughtUp, "snapshot", "open", "", ""),
185
+ ).toBe(true);
186
+ });
187
+
188
+ it("一覧が確実に新しい(後続変更で追い越し)なら内容差分があっても解除する", () => {
189
+ // optimistic より後の別変更で一覧が updatedAt を追い越したケース(一覧を信頼)
190
+ const overtaken = [
191
+ baseIssue({ number: 10, title: "後続変更", updatedAt: "2026-06-11T00:00:00Z" }),
192
+ ];
193
+ const optimistic = baseIssue({ number: 10, title: "古い optimistic", updatedAt: "2026-06-10T00:00:00Z" });
194
+ expect(
195
+ isOptimisticIssueSettled(optimistic, overtaken, "snapshot", "open", "", ""),
196
+ ).toBe(true);
197
+ });
198
+
199
+ it("同時刻でも一覧行が stale 内容なら解除しない(早期解除での巻き戻り防止)", () => {
200
+ // updatedAt は秒解像度。タイムスタンプ一致でも内容(title)が未追従なら待機する
201
+ const sameTimeStale = [
202
+ baseIssue({ number: 10, title: "stale", updatedAt: "2026-06-10T00:00:00Z" }),
203
+ ];
204
+ const optimistic = baseIssue({ number: 10, title: "更新済み", updatedAt: "2026-06-10T00:00:00Z" });
205
+ expect(
206
+ isOptimisticIssueSettled(optimistic, sameTimeStale, "snapshot", "open", "", ""),
207
+ ).toBe(false);
208
+ });
209
+
210
+ it("フィルタ非一致で一覧から除外された場合は解除する(open フィルタ中の closed 化)", () => {
211
+ // 再取得で #10 が open 一覧から消えた状態
212
+ const withoutClosed = [baseIssue({ number: 9 })];
213
+ const optimistic = baseIssue({ number: 10, state: "closed", updatedAt: "2026-06-10T00:00:00Z" });
214
+ expect(
215
+ isOptimisticIssueSettled(optimistic, withoutClosed, "snapshot", "open", "", ""),
216
+ ).toBe(true);
217
+ });
218
+
219
+ it("フィルタには一致するのに未取得(ページ範囲外など)なら解除しない", () => {
220
+ const withoutTen = [baseIssue({ number: 9 })];
221
+ const optimistic = baseIssue({ number: 10, updatedAt: "2026-06-10T00:00:00Z" });
222
+ expect(
223
+ isOptimisticIssueSettled(optimistic, withoutTen, "snapshot", "open", "", ""),
224
+ ).toBe(false);
225
+ });
226
+
227
+ it("updatedAt が空文字・不正で比較不能なら実体を信頼して解除する(#945)", () => {
228
+ // バックエンドが updated_at 未取得時に "" を返すケース(NaN 比較で固着させない)
229
+ const listWithEmpty = [baseIssue({ number: 10, updatedAt: "" })];
230
+ const optimistic = baseIssue({ number: 10, updatedAt: "2026-06-10T00:00:00Z" });
231
+ expect(
232
+ isOptimisticIssueSettled(optimistic, listWithEmpty, "snapshot", "open", "", ""),
233
+ ).toBe(true);
234
+
235
+ const optimisticEmpty = baseIssue({ number: 10, updatedAt: "" });
236
+ expect(
237
+ isOptimisticIssueSettled(
238
+ optimisticEmpty,
239
+ [baseIssue({ number: 10, updatedAt: "2026-06-10T00:00:00Z" })],
240
+ "snapshot",
241
+ "open",
242
+ "",
243
+ "",
244
+ ),
245
+ ).toBe(true);
246
+ });
247
+ });
248
+ });
249
+
250
+ describe("shouldClearOptimisticForSelection", () => {
251
+ it("snapshot optimistic は選択中 Issue から離れたら破棄する(#945)", () => {
252
+ const optimistic = baseIssue({ number: 10 });
253
+ // 別 Issue へ移動
254
+ expect(shouldClearOptimisticForSelection(optimistic, "snapshot", 11)).toBe(true);
255
+ // "new" へ移動
256
+ expect(shouldClearOptimisticForSelection(optimistic, "snapshot", "new")).toBe(true);
257
+ // 未選択へ
258
+ expect(shouldClearOptimisticForSelection(optimistic, "snapshot", null)).toBe(true);
259
+ });
260
+
261
+ it("選択中 Issue が optimistic と一致する間は破棄しない", () => {
262
+ const optimistic = baseIssue({ number: 10 });
263
+ expect(shouldClearOptimisticForSelection(optimistic, "snapshot", 10)).toBe(false);
264
+ });
265
+
266
+ it("presence(新規作成)は選択変化で破棄しない(番号出現で解除する)", () => {
267
+ const optimistic = baseIssue({ number: 10 });
268
+ expect(shouldClearOptimisticForSelection(optimistic, "presence", 11)).toBe(false);
269
+ expect(shouldClearOptimisticForSelection(optimistic, "presence", null)).toBe(false);
270
+ });
271
+
272
+ it("optimistic が無ければ破棄不要", () => {
273
+ expect(shouldClearOptimisticForSelection(null, "snapshot", 11)).toBe(false);
274
+ });
275
+ });
@@ -0,0 +1,135 @@
1
+ import type { IssueDetail, IssueSummary } from "@/lib/api";
2
+ import { hasIssueSnapshotChanged } from "@/lib/github-sync";
3
+
4
+ const GITHUB_LOGIN_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/;
5
+
6
+ /**
7
+ * optimistic スロットの解除(`onOptimisticSettled`)判定方式(#945)。
8
+ *
9
+ * - `presence`: 一覧に同番号 Issue が現れた時点で解除(新規作成の eventual consistency 対策。#932)。
10
+ * 実体取得が optimistic より古くても「番号が出現したら確定」とみなす。
11
+ * - `snapshot`: 一覧側の実体が optimistic と同等以上に新しくなった時点で解除(選択中 Issue の外部変更を
12
+ * 即時反映するケース。詳細 poll が取得済みの新スナップショットを渡すため、古い一覧行で先に解除すると
13
+ * 表示が巻き戻る)。フィルタ非一致で一覧から除外された場合も「反映済み」とみなして解除する。
14
+ */
15
+ export type OptimisticSettleMode = "presence" | "snapshot";
16
+
17
+ export function isValidAssigneeFilter(value: string): boolean {
18
+ if (!value) return true;
19
+ if (value === "none") return true;
20
+ return GITHUB_LOGIN_RE.test(value);
21
+ }
22
+
23
+ export function issueMatchesListFilter(
24
+ issue: IssueSummary,
25
+ stateFilter: "open" | "closed",
26
+ labelFilter: string,
27
+ assigneeFilter: string,
28
+ ): boolean {
29
+ if (issue.state !== stateFilter) return false;
30
+ if (labelFilter && !issue.labels.some((label) => label.name === labelFilter)) {
31
+ return false;
32
+ }
33
+ if (assigneeFilter === "none") {
34
+ if (issue.assignees.length > 0) return false;
35
+ } else if (assigneeFilter && !issue.assignees.includes(assigneeFilter)) {
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+
41
+ /**
42
+ * 詳細ビューの `IssueDetail` を一覧行用の `IssueSummary` へ射影する(#945)。
43
+ * `body` / `url` を落とし、optimistic スロットに余分なフィールドを持ち込まない。
44
+ */
45
+ export function issueDetailToSummary(detail: IssueDetail): IssueSummary {
46
+ return {
47
+ number: detail.number,
48
+ title: detail.title,
49
+ state: detail.state,
50
+ labels: detail.labels,
51
+ author: detail.author,
52
+ assignees: detail.assignees,
53
+ comments: detail.comments,
54
+ createdAt: detail.createdAt,
55
+ updatedAt: detail.updatedAt,
56
+ };
57
+ }
58
+
59
+ export function mergeOptimisticIssue(
60
+ issues: IssueSummary[],
61
+ optimisticIssue: IssueSummary | null | undefined,
62
+ stateFilter: "open" | "closed",
63
+ labelFilter: string,
64
+ assigneeFilter: string,
65
+ ): IssueSummary[] {
66
+ if (!optimisticIssue) return issues;
67
+
68
+ const withoutDuplicate = issues.filter((issue) => issue.number !== optimisticIssue.number);
69
+
70
+ if (!issueMatchesListFilter(optimisticIssue, stateFilter, labelFilter, assigneeFilter)) {
71
+ // 最新スナップショットが現在のフィルタに非一致(例: open フィルタ中に選択 Issue が closed 化、
72
+ // ラベルが外れる)なら、一覧から即時に除外する。除外対象が無ければ参照を維持して再描画を避ける。
73
+ return withoutDuplicate.length === issues.length ? issues : withoutDuplicate;
74
+ }
75
+
76
+ return [optimisticIssue, ...withoutDuplicate].sort((a, b) => b.number - a.number);
77
+ }
78
+
79
+ /**
80
+ * optimistic スロットを解除してよいか(実体が反映されたか)を判定する(#945)。
81
+ *
82
+ * @param mode 解除方式。`presence` は同番号の出現で解除、`snapshot` は実体が optimistic と
83
+ * 同等以上に新しくなった(または現在のフィルタから除外された)時点で解除する。
84
+ */
85
+ export function isOptimisticIssueSettled(
86
+ optimisticIssue: IssueSummary,
87
+ issues: IssueSummary[],
88
+ mode: OptimisticSettleMode,
89
+ stateFilter: "open" | "closed",
90
+ labelFilter: string,
91
+ assigneeFilter: string,
92
+ ): boolean {
93
+ const match = issues.find((issue) => issue.number === optimisticIssue.number);
94
+
95
+ if (mode === "presence") {
96
+ return match !== undefined;
97
+ }
98
+
99
+ // snapshot: 一覧側の実体が optimistic に追いついたら解除する
100
+ if (match) {
101
+ const listTime = Date.parse(match.updatedAt);
102
+ const optimisticTime = Date.parse(optimisticIssue.updatedAt);
103
+ // updatedAt が空文字・不正値(バックエンドは未取得時に "" を返しうる)で比較不能なら、
104
+ // 一覧(実体)を信頼して解除する。比較不能を理由に optimistic を固定し続けない。
105
+ if (Number.isNaN(listTime) || Number.isNaN(optimisticTime)) return true;
106
+ // 一覧が確実に新しい(別の後続変更で追い越した場合を含む)なら解除する。
107
+ if (listTime > optimisticTime) return true;
108
+ // 同時刻(updatedAt は秒解像度)の場合は、内容が optimistic に追いついた時のみ解除する。
109
+ // タイムスタンプ一致でも一覧行がまだ stale 内容なら、早期解除で表示が巻き戻るのを防ぐ。
110
+ return listTime === optimisticTime && !hasIssueSnapshotChanged(optimisticIssue, match);
111
+ }
112
+
113
+ // 一覧に存在しない場合、フィルタ非一致で正当に除外されていれば「反映済み」として解除する。
114
+ // フィルタには一致するのに未取得(ページ範囲外など)なら、表示継続のため解除しない。
115
+ return !issueMatchesListFilter(optimisticIssue, stateFilter, labelFilter, assigneeFilter);
116
+ }
117
+
118
+ /**
119
+ * 選択中 Issue の変化に伴って snapshot optimistic を破棄すべきか判定する(#945)。
120
+ *
121
+ * snapshot optimistic は「選択中 Issue の詳細 poll が検知した外部変更」を一覧行へ橋渡しするための
122
+ * 一時状態。別 Issue(または `"new"` / 未選択)へ移動した時点で対象外となるため破棄し、古い行は
123
+ * `IssueList` 自前の periodic sync に委ねる。`presence`(新規作成)は対象外で、従来どおり番号出現で解除する。
124
+ */
125
+ export function shouldClearOptimisticForSelection(
126
+ optimisticIssue: IssueSummary | null,
127
+ mode: OptimisticSettleMode,
128
+ selectedIssue: number | "new" | null,
129
+ ): boolean {
130
+ return (
131
+ optimisticIssue !== null
132
+ && mode === "snapshot"
133
+ && optimisticIssue.number !== selectedIssue
134
+ );
135
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ ISSUE_MOBILE_ACTION_BUTTON,
4
+ ISSUE_MOBILE_DETAIL_ROOT,
5
+ ISSUE_MOBILE_LIST_ITEM,
6
+ ISSUE_MOBILE_TAP_TARGET,
7
+ } from "./issue-mobile-layout";
8
+
9
+ describe("issue-mobile-layout", () => {
10
+ it("モバイル向けタップ領域クラスを含む", () => {
11
+ expect(ISSUE_MOBILE_TAP_TARGET).toContain("min-h-11");
12
+ expect(ISSUE_MOBILE_TAP_TARGET).toContain("touch-manipulation");
13
+ expect(ISSUE_MOBILE_LIST_ITEM).toContain("min-h-11");
14
+ expect(ISSUE_MOBILE_ACTION_BUTTON).toContain("w-full sm:w-auto");
15
+ expect(ISSUE_MOBILE_DETAIL_ROOT).toContain("overflow-y-auto");
16
+ });
17
+ });
@@ -0,0 +1,20 @@
1
+ /** Shared Tailwind classes for Issue panel mobile UX (< md / 768px). */
2
+ export const ISSUE_MOBILE_TAP_TARGET =
3
+ "min-h-11 touch-manipulation sm:min-h-0 sm:touch-auto";
4
+
5
+ export const ISSUE_MOBILE_FILTER_CONTROL =
6
+ "min-h-10 touch-manipulation sm:min-h-0 sm:touch-auto";
7
+
8
+ export const ISSUE_MOBILE_LIST_ITEM =
9
+ "min-h-11 py-2.5 touch-manipulation sm:min-h-0 sm:py-1.5";
10
+
11
+ export const ISSUE_MOBILE_ACTION_ROW =
12
+ "flex flex-col sm:flex-row items-stretch sm:items-center gap-2";
13
+
14
+ export const ISSUE_MOBILE_ACTION_BUTTON = [
15
+ ISSUE_MOBILE_TAP_TARGET,
16
+ "w-full sm:w-auto px-4 py-2.5 sm:px-3 sm:py-1 rounded text-sm",
17
+ ].join(" ");
18
+
19
+ export const ISSUE_MOBILE_DETAIL_ROOT =
20
+ "flex flex-col min-h-0 h-full overflow-y-auto overscroll-y-contain p-3 sm:p-6 max-w-3xl mx-auto w-full";