mulmoclaude 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/bin/mulmoclaude.js +202 -0
- package/bin/prepare-dist.js +93 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
- package/client/assets/index-D8rhwXLq.js +4906 -0
- package/client/assets/index-KNLBjwuh.css +1 -0
- package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
- package/client/assets/material-icons-Dr0goTwe.woff +0 -0
- package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
- package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
- package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
- package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
- package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
- package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
- package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
- package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
- package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
- package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
- package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
- package/client/index.html +28 -0
- package/package.json +66 -0
- package/server/agent/attachmentConverter.ts +270 -0
- package/server/agent/config.ts +414 -0
- package/server/agent/index.ts +260 -0
- package/server/agent/mcp-server.ts +412 -0
- package/server/agent/mcp-tools/index.ts +63 -0
- package/server/agent/mcp-tools/x.ts +188 -0
- package/server/agent/plugin-names.ts +75 -0
- package/server/agent/prompt.ts +349 -0
- package/server/agent/resumeFailover.ts +129 -0
- package/server/agent/sandboxMounts.ts +329 -0
- package/server/agent/stream.ts +194 -0
- package/server/api/auth/bearerAuth.ts +61 -0
- package/server/api/auth/token.ts +98 -0
- package/server/api/csrfGuard.ts +85 -0
- package/server/api/routes/agent.ts +478 -0
- package/server/api/routes/chart.ts +98 -0
- package/server/api/routes/chat-index.ts +46 -0
- package/server/api/routes/config.ts +258 -0
- package/server/api/routes/dispatchResponse.ts +79 -0
- package/server/api/routes/files.ts +812 -0
- package/server/api/routes/html.ts +101 -0
- package/server/api/routes/image.ts +169 -0
- package/server/api/routes/mulmo-script.ts +712 -0
- package/server/api/routes/mulmoScriptValidate.ts +101 -0
- package/server/api/routes/notifications.ts +69 -0
- package/server/api/routes/pdf.ts +163 -0
- package/server/api/routes/plugins.ts +276 -0
- package/server/api/routes/presentHtml.ts +48 -0
- package/server/api/routes/roles.ts +125 -0
- package/server/api/routes/scheduler.ts +153 -0
- package/server/api/routes/schedulerHandlers.ts +151 -0
- package/server/api/routes/schedulerTasks.ts +163 -0
- package/server/api/routes/sessions.ts +294 -0
- package/server/api/routes/sessionsCursor.ts +59 -0
- package/server/api/routes/skills.ts +195 -0
- package/server/api/routes/sources.ts +540 -0
- package/server/api/routes/todos.ts +263 -0
- package/server/api/routes/todosColumnsHandlers.ts +347 -0
- package/server/api/routes/todosHandlers.ts +274 -0
- package/server/api/routes/todosItemsHandlers.ts +386 -0
- package/server/api/routes/wiki/pageIndex.ts +53 -0
- package/server/api/routes/wiki.ts +363 -0
- package/server/api/sandboxStatus.ts +64 -0
- package/server/events/notifications.ts +160 -0
- package/server/events/pub-sub/index.ts +45 -0
- package/server/events/relay-client.ts +288 -0
- package/server/events/scheduler-adapter.ts +302 -0
- package/server/events/session-store/index.ts +492 -0
- package/server/events/task-manager/index.ts +181 -0
- package/server/index.ts +572 -0
- package/server/system/config.ts +243 -0
- package/server/system/credentials.ts +220 -0
- package/server/system/docker.ts +97 -0
- package/server/system/env.ts +109 -0
- package/server/system/logger/config.ts +112 -0
- package/server/system/logger/formatters.ts +40 -0
- package/server/system/logger/index.ts +53 -0
- package/server/system/logger/rotation.ts +37 -0
- package/server/system/logger/sinks.ts +101 -0
- package/server/system/logger/types.ts +29 -0
- package/server/utils/date.ts +57 -0
- package/server/utils/errors.ts +7 -0
- package/server/utils/fetch.ts +27 -0
- package/server/utils/files/atomic.ts +125 -0
- package/server/utils/files/html-io.ts +20 -0
- package/server/utils/files/image-store.ts +66 -0
- package/server/utils/files/index.ts +45 -0
- package/server/utils/files/journal-io.ts +213 -0
- package/server/utils/files/json.ts +69 -0
- package/server/utils/files/markdown-store.ts +33 -0
- package/server/utils/files/naming.ts +50 -0
- package/server/utils/files/reference-dirs-io.ts +45 -0
- package/server/utils/files/roles-io.ts +45 -0
- package/server/utils/files/safe.ts +106 -0
- package/server/utils/files/scheduler-io.ts +20 -0
- package/server/utils/files/scheduler-overrides-io.ts +64 -0
- package/server/utils/files/session-io.ts +136 -0
- package/server/utils/files/spreadsheet-store.ts +63 -0
- package/server/utils/files/todos-io.ts +29 -0
- package/server/utils/files/user-tasks-io.ts +25 -0
- package/server/utils/files/workspace-io.ts +221 -0
- package/server/utils/gemini.ts +59 -0
- package/server/utils/gitignore.ts +69 -0
- package/server/utils/http.ts +15 -0
- package/server/utils/httpError.ts +61 -0
- package/server/utils/id.ts +16 -0
- package/server/utils/json.ts +83 -0
- package/server/utils/logBackgroundError.ts +22 -0
- package/server/utils/markdown.ts +82 -0
- package/server/utils/request.ts +29 -0
- package/server/utils/slug.ts +50 -0
- package/server/utils/spawn.ts +62 -0
- package/server/utils/time.ts +34 -0
- package/server/utils/types.ts +47 -0
- package/server/workspace/chat-index/index.ts +153 -0
- package/server/workspace/chat-index/indexer.ts +209 -0
- package/server/workspace/chat-index/paths.ts +34 -0
- package/server/workspace/chat-index/summarizer.ts +247 -0
- package/server/workspace/chat-index/types.ts +38 -0
- package/server/workspace/custom-dirs.ts +220 -0
- package/server/workspace/helps/business.md +104 -0
- package/server/workspace/helps/github.md +23 -0
- package/server/workspace/helps/index.md +60 -0
- package/server/workspace/helps/mulmoscript.md +249 -0
- package/server/workspace/helps/sandbox.md +90 -0
- package/server/workspace/helps/spreadsheet.md +43 -0
- package/server/workspace/helps/telegram.md +135 -0
- package/server/workspace/helps/wiki.md +131 -0
- package/server/workspace/journal/archivist.ts +386 -0
- package/server/workspace/journal/dailyPass.ts +743 -0
- package/server/workspace/journal/diff.ts +71 -0
- package/server/workspace/journal/index.ts +185 -0
- package/server/workspace/journal/indexFile.ts +136 -0
- package/server/workspace/journal/linkRewrite.ts +4 -0
- package/server/workspace/journal/memoryExtractor.ts +130 -0
- package/server/workspace/journal/optimizationPass.ts +160 -0
- package/server/workspace/journal/paths.ts +76 -0
- package/server/workspace/journal/state.ts +125 -0
- package/server/workspace/paths.ts +158 -0
- package/server/workspace/reference-dirs.ts +252 -0
- package/server/workspace/roles.ts +37 -0
- package/server/workspace/skills/discovery.ts +125 -0
- package/server/workspace/skills/index.ts +10 -0
- package/server/workspace/skills/parser.ts +144 -0
- package/server/workspace/skills/paths.ts +41 -0
- package/server/workspace/skills/scheduler.ts +149 -0
- package/server/workspace/skills/types.ts +30 -0
- package/server/workspace/skills/user-tasks.ts +257 -0
- package/server/workspace/skills/writer.ts +189 -0
- package/server/workspace/sources/arxivDiscovery.ts +182 -0
- package/server/workspace/sources/classifier.ts +268 -0
- package/server/workspace/sources/fetchers/arxiv.ts +170 -0
- package/server/workspace/sources/fetchers/github.ts +106 -0
- package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
- package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
- package/server/workspace/sources/fetchers/index.ts +71 -0
- package/server/workspace/sources/fetchers/registerAll.ts +15 -0
- package/server/workspace/sources/fetchers/rss.ts +141 -0
- package/server/workspace/sources/fetchers/rssParser.ts +295 -0
- package/server/workspace/sources/httpFetcher.ts +230 -0
- package/server/workspace/sources/interests.ts +120 -0
- package/server/workspace/sources/paths.ts +110 -0
- package/server/workspace/sources/pipeline/dedup.ts +60 -0
- package/server/workspace/sources/pipeline/fetch.ts +136 -0
- package/server/workspace/sources/pipeline/index.ts +249 -0
- package/server/workspace/sources/pipeline/notify.ts +72 -0
- package/server/workspace/sources/pipeline/plan.ts +66 -0
- package/server/workspace/sources/pipeline/summarize.ts +189 -0
- package/server/workspace/sources/pipeline/write.ts +185 -0
- package/server/workspace/sources/rateLimiter.ts +148 -0
- package/server/workspace/sources/registry.ts +326 -0
- package/server/workspace/sources/robots.ts +271 -0
- package/server/workspace/sources/sourceState.ts +135 -0
- package/server/workspace/sources/taxonomy.ts +74 -0
- package/server/workspace/sources/types.ts +144 -0
- package/server/workspace/sources/urls.ts +112 -0
- package/server/workspace/tool-trace/classify.ts +114 -0
- package/server/workspace/tool-trace/index.ts +250 -0
- package/server/workspace/tool-trace/writeSearch.ts +98 -0
- package/server/workspace/wiki-backlinks/index.ts +107 -0
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
- package/server/workspace/workspace.ts +66 -0
- package/src/App.vue +720 -0
- package/src/assets/mulmo_bw.png +0 -0
- package/src/components/CanvasViewToggle.vue +27 -0
- package/src/components/ChatAttachmentPreview.vue +45 -0
- package/src/components/ChatImagePreview.vue +17 -0
- package/src/components/ChatInput.vue +208 -0
- package/src/components/FileContentHeader.vue +49 -0
- package/src/components/FileContentRenderer.vue +162 -0
- package/src/components/FileTree.vue +115 -0
- package/src/components/FileTreePane.vue +85 -0
- package/src/components/FilesView.vue +206 -0
- package/src/components/LockStatusPopup.vue +111 -0
- package/src/components/NotificationBell.vue +131 -0
- package/src/components/NotificationToast.vue +72 -0
- package/src/components/PluginLauncher.vue +138 -0
- package/src/components/RightSidebar.vue +113 -0
- package/src/components/RoleSelector.vue +64 -0
- package/src/components/SessionHistoryPanel.vue +176 -0
- package/src/components/SessionTabBar.vue +81 -0
- package/src/components/SettingsMcpTab.vue +350 -0
- package/src/components/SettingsModal.vue +275 -0
- package/src/components/SettingsReferenceDirsTab.vue +173 -0
- package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
- package/src/components/SidebarHeader.vue +69 -0
- package/src/components/StackView.vue +360 -0
- package/src/components/SuggestionsPanel.vue +65 -0
- package/src/components/TodoExplorer.vue +358 -0
- package/src/components/ToolResultsPanel.vue +77 -0
- package/src/components/todo/TodoAddDialog.vue +131 -0
- package/src/components/todo/TodoEditDialog.vue +47 -0
- package/src/components/todo/TodoEditPanel.vue +113 -0
- package/src/components/todo/TodoKanbanView.vue +249 -0
- package/src/components/todo/TodoListView.vue +79 -0
- package/src/components/todo/TodoTableView.vue +177 -0
- package/src/composables/useActiveSession.ts +40 -0
- package/src/composables/useAppApi.ts +45 -0
- package/src/composables/useCanvasViewMode.ts +121 -0
- package/src/composables/useChatScroll.ts +47 -0
- package/src/composables/useClickOutside.ts +26 -0
- package/src/composables/useClipboardCopy.ts +44 -0
- package/src/composables/useContentDisplay.ts +52 -0
- package/src/composables/useDebugBeat.ts +23 -0
- package/src/composables/useDynamicFavicon.ts +115 -0
- package/src/composables/useEventListeners.ts +42 -0
- package/src/composables/useExpandedDirs.ts +64 -0
- package/src/composables/useFaviconState.ts +30 -0
- package/src/composables/useFileSelection.ts +115 -0
- package/src/composables/useFileSortMode.ts +24 -0
- package/src/composables/useFileTree.ts +85 -0
- package/src/composables/useFreshPluginData.ts +89 -0
- package/src/composables/useHealth.ts +38 -0
- package/src/composables/useImeAwareEnter.ts +57 -0
- package/src/composables/useKeyNavigation.ts +60 -0
- package/src/composables/useMarkdownLinkHandler.ts +46 -0
- package/src/composables/useMarkdownMode.ts +17 -0
- package/src/composables/useMcpTools.ts +71 -0
- package/src/composables/useMergedSessions.ts +27 -0
- package/src/composables/useNotifications.ts +90 -0
- package/src/composables/usePdfDownload.ts +60 -0
- package/src/composables/usePendingCalls.ts +77 -0
- package/src/composables/usePubSub.ts +85 -0
- package/src/composables/useRightSidebar.ts +23 -0
- package/src/composables/useRoles.ts +34 -0
- package/src/composables/useSandboxStatus.ts +67 -0
- package/src/composables/useSelectedResult.ts +49 -0
- package/src/composables/useSessionDerived.ts +51 -0
- package/src/composables/useSessionHistory.ts +81 -0
- package/src/composables/useSessionSync.ts +57 -0
- package/src/composables/useViewLayout.ts +55 -0
- package/src/config/apiRoutes.ts +173 -0
- package/src/config/pubsubChannels.ts +45 -0
- package/src/config/roles.ts +335 -0
- package/src/config/schedulerActions.ts +25 -0
- package/src/config/toolNames.ts +71 -0
- package/src/config/workspacePaths.ts +24 -0
- package/src/index.css +107 -0
- package/src/main.ts +25 -0
- package/src/plugins/canvas/Preview.vue +13 -0
- package/src/plugins/canvas/View.vue +333 -0
- package/src/plugins/canvas/definition.ts +38 -0
- package/src/plugins/canvas/index.ts +36 -0
- package/src/plugins/chart/Preview.vue +49 -0
- package/src/plugins/chart/View.vue +143 -0
- package/src/plugins/chart/definition.ts +58 -0
- package/src/plugins/chart/index.ts +52 -0
- package/src/plugins/editImage/Preview.vue +13 -0
- package/src/plugins/editImage/View.vue +13 -0
- package/src/plugins/editImage/definition.ts +27 -0
- package/src/plugins/editImage/index.ts +36 -0
- package/src/plugins/generateImage/Preview.vue +13 -0
- package/src/plugins/generateImage/View.vue +33 -0
- package/src/plugins/generateImage/definition.ts +32 -0
- package/src/plugins/generateImage/index.ts +56 -0
- package/src/plugins/manageRoles/Preview.vue +49 -0
- package/src/plugins/manageRoles/View.vue +525 -0
- package/src/plugins/manageRoles/definition.ts +43 -0
- package/src/plugins/manageRoles/index.ts +47 -0
- package/src/plugins/manageSkills/Preview.vue +21 -0
- package/src/plugins/manageSkills/View.vue +321 -0
- package/src/plugins/manageSkills/definition.ts +49 -0
- package/src/plugins/manageSkills/index.ts +49 -0
- package/src/plugins/manageSource/Preview.vue +33 -0
- package/src/plugins/manageSource/View.vue +697 -0
- package/src/plugins/manageSource/definition.ts +63 -0
- package/src/plugins/manageSource/index.ts +66 -0
- package/src/plugins/markdown/Preview.vue +77 -0
- package/src/plugins/markdown/View.vue +476 -0
- package/src/plugins/markdown/definition.ts +50 -0
- package/src/plugins/markdown/index.ts +36 -0
- package/src/plugins/presentHtml/Preview.vue +25 -0
- package/src/plugins/presentHtml/View.vue +52 -0
- package/src/plugins/presentHtml/definition.ts +27 -0
- package/src/plugins/presentHtml/helpers.ts +72 -0
- package/src/plugins/presentHtml/index.ts +41 -0
- package/src/plugins/presentMulmoScript/Preview.vue +23 -0
- package/src/plugins/presentMulmoScript/View.vue +1166 -0
- package/src/plugins/presentMulmoScript/definition.ts +95 -0
- package/src/plugins/presentMulmoScript/helpers.ts +162 -0
- package/src/plugins/presentMulmoScript/index.ts +40 -0
- package/src/plugins/scheduler/Preview.vue +67 -0
- package/src/plugins/scheduler/TasksTab.vue +205 -0
- package/src/plugins/scheduler/View.vue +565 -0
- package/src/plugins/scheduler/definition.ts +57 -0
- package/src/plugins/scheduler/index.ts +45 -0
- package/src/plugins/scheduler/viewModes.ts +26 -0
- package/src/plugins/spreadsheet/Preview.vue +29 -0
- package/src/plugins/spreadsheet/View.vue +997 -0
- package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
- package/src/plugins/spreadsheet/definition.ts +121 -0
- package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
- package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
- package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
- package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
- package/src/plugins/spreadsheet/engine/engine.ts +176 -0
- package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
- package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
- package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
- package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
- package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
- package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
- package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
- package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
- package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
- package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
- package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
- package/src/plugins/spreadsheet/engine/index.ts +27 -0
- package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
- package/src/plugins/spreadsheet/engine/parser.ts +143 -0
- package/src/plugins/spreadsheet/engine/registry.ts +150 -0
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
- package/src/plugins/spreadsheet/engine/types.ts +64 -0
- package/src/plugins/spreadsheet/index.ts +36 -0
- package/src/plugins/textResponse/Preview.vue +94 -0
- package/src/plugins/textResponse/View.vue +503 -0
- package/src/plugins/textResponse/definition.ts +34 -0
- package/src/plugins/textResponse/index.ts +27 -0
- package/src/plugins/textResponse/plugin.ts +29 -0
- package/src/plugins/textResponse/samples.ts +97 -0
- package/src/plugins/textResponse/types.ts +11 -0
- package/src/plugins/todo/Preview.vue +63 -0
- package/src/plugins/todo/View.vue +364 -0
- package/src/plugins/todo/composables/useTodos.ts +177 -0
- package/src/plugins/todo/definition.ts +45 -0
- package/src/plugins/todo/index.ts +61 -0
- package/src/plugins/todo/labels.ts +163 -0
- package/src/plugins/todo/priority.ts +98 -0
- package/src/plugins/todo/viewModes.ts +19 -0
- package/src/plugins/ui-image/ImagePreview.vue +23 -0
- package/src/plugins/ui-image/ImageView.vue +34 -0
- package/src/plugins/ui-image/index.ts +3 -0
- package/src/plugins/ui-image/types.ts +4 -0
- package/src/plugins/wiki/Preview.vue +65 -0
- package/src/plugins/wiki/View.vue +342 -0
- package/src/plugins/wiki/definition.ts +25 -0
- package/src/plugins/wiki/helpers.ts +59 -0
- package/src/plugins/wiki/index.ts +52 -0
- package/src/router/guards.ts +61 -0
- package/src/router/index.ts +50 -0
- package/src/tools/index.ts +52 -0
- package/src/tools/types.ts +27 -0
- package/src/types/events.ts +16 -0
- package/src/types/fileTree.ts +13 -0
- package/src/types/notification.ts +67 -0
- package/src/types/session.ts +116 -0
- package/src/types/sse.ts +90 -0
- package/src/types/toolCallHistory.ts +13 -0
- package/src/utils/agent/eventDispatch.ts +74 -0
- package/src/utils/agent/request.ts +55 -0
- package/src/utils/agent/toolCalls.ts +62 -0
- package/src/utils/api.ts +218 -0
- package/src/utils/canvas/viewMode.ts +46 -0
- package/src/utils/dom/authTokenMeta.ts +20 -0
- package/src/utils/dom/clickOutside.ts +11 -0
- package/src/utils/dom/externalLink.ts +57 -0
- package/src/utils/dom/scrollable.ts +24 -0
- package/src/utils/errors.ts +11 -0
- package/src/utils/files/expandedDirs.ts +25 -0
- package/src/utils/files/filename.ts +12 -0
- package/src/utils/files/sortChildren.ts +20 -0
- package/src/utils/filesPreview/schedulerPreview.ts +38 -0
- package/src/utils/filesPreview/todoPreview.ts +40 -0
- package/src/utils/format/date.ts +85 -0
- package/src/utils/format/frontmatter.ts +80 -0
- package/src/utils/format/jsonSyntax.ts +109 -0
- package/src/utils/html/previewCsp.ts +65 -0
- package/src/utils/image/resolve.ts +8 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
- package/src/utils/markdown/extractFirstH1.ts +39 -0
- package/src/utils/notification/dispatch.ts +22 -0
- package/src/utils/path/relativeLink.ts +130 -0
- package/src/utils/role/icon.ts +20 -0
- package/src/utils/role/merge.ts +10 -0
- package/src/utils/role/plugins.ts +12 -0
- package/src/utils/session/mergeSessions.ts +103 -0
- package/src/utils/session/seedRoleDefault.ts +35 -0
- package/src/utils/session/sessionEntries.ts +121 -0
- package/src/utils/session/sessionFactory.ts +22 -0
- package/src/utils/session/sessionHelpers.ts +99 -0
- package/src/utils/tools/dedup.ts +17 -0
- package/src/utils/tools/mcp.ts +33 -0
- package/src/utils/tools/pendingCalls.ts +16 -0
- package/src/utils/tools/result.ts +40 -0
- package/src/utils/types.ts +44 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// GitHub Issues + PRs fetcher.
|
|
2
|
+
//
|
|
3
|
+
// Source config shape (PRs included by default — GitHub's REST
|
|
4
|
+
// `/issues` endpoint returns issues AND pulls, and the common
|
|
5
|
+
// use case for UC-5 in the plan is tracking both):
|
|
6
|
+
//
|
|
7
|
+
// fetcher_kind: github-issues
|
|
8
|
+
// github_repo: receptron/mulmoclaude
|
|
9
|
+
// github_issue_state: open # optional: open | closed | all
|
|
10
|
+
// github_include_prs: true # optional: true | false
|
|
11
|
+
//
|
|
12
|
+
// Flow: GET /repos/:owner/:repo/issues?state=...&since=...&sort=updated
|
|
13
|
+
// → JSON array (issues + pulls) → parse each → optionally filter
|
|
14
|
+
// out PRs → filter by cursor (updated_at > cursor) → normalize.
|
|
15
|
+
//
|
|
16
|
+
// Cursor strategy: we pass `since=<lastSeen>` as a server-side
|
|
17
|
+
// pre-filter AND also filter locally, because `since` is
|
|
18
|
+
// "updated at OR later" (inclusive) while we want strictly after.
|
|
19
|
+
// Cursor key: `github_issues_last_updated_at`.
|
|
20
|
+
|
|
21
|
+
import { normalizeUrl, stableItemId } from "../urls.js";
|
|
22
|
+
import type { Source, SourceItem, SourceState } from "../types.js";
|
|
23
|
+
import type { FetcherDeps, FetchResult, SourceFetcher } from "./index.js";
|
|
24
|
+
import { registerFetcher } from "./index.js";
|
|
25
|
+
import { GITHUB_API_BASE, GithubFetcherError, githubFetchJson, isRecord, parseRepoSlug } from "./github.js";
|
|
26
|
+
import { firstParagraph } from "./githubReleases.js";
|
|
27
|
+
|
|
28
|
+
export const ISSUES_CURSOR_KEY = "github_issues_last_updated_at";
|
|
29
|
+
|
|
30
|
+
// Whitelist of values the GitHub API accepts for `state`. A typo
|
|
31
|
+
// here (e.g. `state=Open` uppercase) returns 422 so we validate.
|
|
32
|
+
const ISSUE_STATES = new Set(["open", "closed", "all"]);
|
|
33
|
+
|
|
34
|
+
interface IssuesParams {
|
|
35
|
+
state: "open" | "closed" | "all";
|
|
36
|
+
includePrs: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse + default the optional fetcherParams. Returns the
|
|
40
|
+
// resolved params. Invalid values fall back to defaults rather
|
|
41
|
+
// than erroring — a typo in the source file shouldn't silently
|
|
42
|
+
// break the daily pipeline.
|
|
43
|
+
export function resolveIssuesParams(params: Record<string, string>): IssuesParams {
|
|
44
|
+
const rawState = params["github_issue_state"];
|
|
45
|
+
const state = typeof rawState === "string" && ISSUE_STATES.has(rawState) ? (rawState as "open" | "closed" | "all") : "open";
|
|
46
|
+
const rawInclude = params["github_include_prs"];
|
|
47
|
+
// Any string value other than the literal "false" counts as
|
|
48
|
+
// true. Users don't usually explicitly set it; if they do,
|
|
49
|
+
// they probably want `false`.
|
|
50
|
+
const includePrs = rawInclude !== "false";
|
|
51
|
+
return { state, includePrs };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ParsedIssue {
|
|
55
|
+
id: number | null;
|
|
56
|
+
number: number | null;
|
|
57
|
+
title: string | null;
|
|
58
|
+
htmlUrl: string | null;
|
|
59
|
+
body: string | null;
|
|
60
|
+
updatedAt: string | null;
|
|
61
|
+
createdAt: string | null;
|
|
62
|
+
isPr: boolean;
|
|
63
|
+
state: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Narrow one GitHub issue record into ParsedIssue. Pure —
|
|
67
|
+
// exported for unit tests. `pull_request` field being present
|
|
68
|
+
// (even if empty) is GitHub's canonical "this issue is a PR"
|
|
69
|
+
// signal.
|
|
70
|
+
export function parseGithubIssue(raw: unknown): ParsedIssue | null {
|
|
71
|
+
if (!isRecord(raw)) return null;
|
|
72
|
+
const id = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
|
|
73
|
+
const issueNumber = typeof raw.number === "number" && Number.isFinite(raw.number) ? raw.number : null;
|
|
74
|
+
const title = typeof raw.title === "string" ? raw.title : null;
|
|
75
|
+
const htmlUrl = typeof raw.html_url === "string" ? raw.html_url : null;
|
|
76
|
+
const body = typeof raw.body === "string" ? raw.body : null;
|
|
77
|
+
const updatedAt = typeof raw.updated_at === "string" ? raw.updated_at : null;
|
|
78
|
+
const createdAt = typeof raw.created_at === "string" ? raw.created_at : null;
|
|
79
|
+
const state = typeof raw.state === "string" ? raw.state : null;
|
|
80
|
+
// `pull_request` present in ANY form (object with url, empty
|
|
81
|
+
// object) means this is a PR. Absence means it's an issue.
|
|
82
|
+
const isPr = "pull_request" in raw && raw.pull_request !== undefined && raw.pull_request !== null;
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
number: issueNumber,
|
|
86
|
+
title,
|
|
87
|
+
htmlUrl,
|
|
88
|
+
body,
|
|
89
|
+
updatedAt,
|
|
90
|
+
createdAt,
|
|
91
|
+
isPr,
|
|
92
|
+
state,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build a SourceItem from a parsed issue + the parent Source.
|
|
97
|
+
// Returns null when the item should be skipped (missing URL,
|
|
98
|
+
// cursor-old, PR when PRs excluded).
|
|
99
|
+
export function issueToSourceItem(issue: ParsedIssue, source: Source, params: IssuesParams, lastSeenTs: number | null): SourceItem | null {
|
|
100
|
+
if (issue.isPr && !params.includePrs) return null;
|
|
101
|
+
if (!issue.htmlUrl || !issue.updatedAt) return null;
|
|
102
|
+
|
|
103
|
+
const updatedTs = Date.parse(issue.updatedAt);
|
|
104
|
+
if (Number.isFinite(updatedTs) && lastSeenTs !== null) {
|
|
105
|
+
// `since` is inclusive — re-filter strictly greater locally
|
|
106
|
+
// so an item updated at the exact cursor time doesn't emit
|
|
107
|
+
// again next run.
|
|
108
|
+
if (updatedTs <= lastSeenTs) return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const normalizedUrl = normalizeUrl(issue.htmlUrl);
|
|
112
|
+
if (!normalizedUrl) return null;
|
|
113
|
+
const id = stableItemId(normalizedUrl);
|
|
114
|
+
|
|
115
|
+
// Title annotations: `[PR]` for pulls, `[closed]` for closed
|
|
116
|
+
// state so the daily summary makes state visible at a glance.
|
|
117
|
+
const parts: string[] = [];
|
|
118
|
+
if (issue.isPr) parts.push("[PR]");
|
|
119
|
+
if (issue.state === "closed") parts.push("[closed]");
|
|
120
|
+
const baseTitle = issue.title ?? `#${issue.number ?? "?"}`;
|
|
121
|
+
const title = parts.length > 0 ? `${parts.join(" ")} ${baseTitle}` : baseTitle;
|
|
122
|
+
|
|
123
|
+
const summary = issue.body ? firstParagraph(issue.body) : null;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
title,
|
|
128
|
+
url: normalizedUrl,
|
|
129
|
+
publishedAt: new Date(updatedTs).toISOString(),
|
|
130
|
+
...(summary !== null && { summary }),
|
|
131
|
+
...(issue.body !== null && { content: issue.body }),
|
|
132
|
+
categories: source.categories,
|
|
133
|
+
sourceSlug: source.slug,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function updateIssuesCursor(current: Record<string, string>, issues: readonly ParsedIssue[], params: IssuesParams): Record<string, string> {
|
|
138
|
+
let newest: number | null = null;
|
|
139
|
+
for (const issue of issues) {
|
|
140
|
+
if (issue.isPr && !params.includePrs) continue;
|
|
141
|
+
if (!issue.updatedAt) continue;
|
|
142
|
+
const ts = Date.parse(issue.updatedAt);
|
|
143
|
+
if (!Number.isFinite(ts)) continue;
|
|
144
|
+
if (newest === null || ts > newest) newest = ts;
|
|
145
|
+
}
|
|
146
|
+
if (newest === null) return current;
|
|
147
|
+
const currentTs = current[ISSUES_CURSOR_KEY] ? Date.parse(current[ISSUES_CURSOR_KEY]) : -Infinity;
|
|
148
|
+
if (newest <= currentTs) return current;
|
|
149
|
+
return {
|
|
150
|
+
...current,
|
|
151
|
+
[ISSUES_CURSOR_KEY]: new Date(newest).toISOString(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Pure: run parse + filter + cursor-advance on an already-fetched
|
|
156
|
+
// body, so tests can exercise the normalizer path without HTTP.
|
|
157
|
+
export function processIssuesResponse(rawBody: unknown, source: Source, params: IssuesParams, cursor: Record<string, string>): FetchResult {
|
|
158
|
+
if (!Array.isArray(rawBody)) return { items: [], cursor };
|
|
159
|
+
const parsed: ParsedIssue[] = [];
|
|
160
|
+
for (const raw of rawBody) {
|
|
161
|
+
const issue = parseGithubIssue(raw);
|
|
162
|
+
if (issue) parsed.push(issue);
|
|
163
|
+
}
|
|
164
|
+
const lastSeenTs = cursor[ISSUES_CURSOR_KEY] ? Date.parse(cursor[ISSUES_CURSOR_KEY]) : null;
|
|
165
|
+
const effectiveLastSeen = lastSeenTs !== null && Number.isFinite(lastSeenTs) ? lastSeenTs : null;
|
|
166
|
+
|
|
167
|
+
const items: SourceItem[] = [];
|
|
168
|
+
for (const issue of parsed) {
|
|
169
|
+
if (items.length >= source.maxItemsPerFetch) break;
|
|
170
|
+
const item = issueToSourceItem(issue, source, params, effectiveLastSeen);
|
|
171
|
+
if (item) items.push(item);
|
|
172
|
+
}
|
|
173
|
+
return { items, cursor: updateIssuesCursor(cursor, parsed, params) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Build the GitHub issues URL. `since` and `per_page` are set
|
|
177
|
+
// for freshness + a reasonable upper bound (the API caps at 100).
|
|
178
|
+
// `sort=updated&direction=desc` pairs with the cursor so newest
|
|
179
|
+
// items arrive first.
|
|
180
|
+
export function issuesUrl(owner: string, repo: string, state: string, since: string | null, perPage: number): string {
|
|
181
|
+
const params = new URLSearchParams();
|
|
182
|
+
params.set("state", state);
|
|
183
|
+
params.set("sort", "updated");
|
|
184
|
+
params.set("direction", "desc");
|
|
185
|
+
// GitHub API accepts max 100 per page. Clamp defensively.
|
|
186
|
+
const clamped = Math.max(1, Math.min(100, Math.floor(perPage)));
|
|
187
|
+
params.set("per_page", String(clamped));
|
|
188
|
+
if (since) params.set("since", since);
|
|
189
|
+
return `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params.toString()}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const githubIssuesFetcher: SourceFetcher = {
|
|
193
|
+
kind: "github-issues",
|
|
194
|
+
async fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult> {
|
|
195
|
+
const repoRaw = source.fetcherParams["github_repo"];
|
|
196
|
+
const slug = parseRepoSlug(repoRaw ?? "");
|
|
197
|
+
if (!slug) {
|
|
198
|
+
throw new GithubFetcherError(source.url, 0, `github_repo param is required and must be owner/repo, got ${JSON.stringify(repoRaw)}`);
|
|
199
|
+
}
|
|
200
|
+
const params = resolveIssuesParams(source.fetcherParams);
|
|
201
|
+
const since = state.cursor[ISSUES_CURSOR_KEY] ?? null;
|
|
202
|
+
const url = issuesUrl(slug.owner, slug.repo, params.state, since, source.maxItemsPerFetch);
|
|
203
|
+
const body = await githubFetchJson(url, deps.http);
|
|
204
|
+
return processIssuesResponse(body, source, params, state.cursor);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
registerFetcher(githubIssuesFetcher);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// GitHub Releases fetcher.
|
|
2
|
+
//
|
|
3
|
+
// Source config shape:
|
|
4
|
+
//
|
|
5
|
+
// fetcher_kind: github-releases
|
|
6
|
+
// github_repo: anthropics/claude-code
|
|
7
|
+
//
|
|
8
|
+
// Flow: GET /repos/:owner/:repo/releases → JSON array → parse each
|
|
9
|
+
// release → filter against cursor (published_at) → normalize to
|
|
10
|
+
// SourceItem.
|
|
11
|
+
//
|
|
12
|
+
// Cursor key: `github_releases_last_published_at` — ISO timestamp
|
|
13
|
+
// of the newest release we've emitted. Separate key from the RSS
|
|
14
|
+
// cursor so a source transitioning between fetcher kinds doesn't
|
|
15
|
+
// mishandle state.
|
|
16
|
+
//
|
|
17
|
+
// Unauthenticated only in phase 1. The 60 req/hour/IP rate-limit
|
|
18
|
+
// is plenty for a workspace with a handful of repos.
|
|
19
|
+
|
|
20
|
+
import { normalizeUrl, stableItemId } from "../urls.js";
|
|
21
|
+
import type { Source, SourceItem, SourceState } from "../types.js";
|
|
22
|
+
import type { FetcherDeps, FetchResult, SourceFetcher } from "./index.js";
|
|
23
|
+
import { registerFetcher } from "./index.js";
|
|
24
|
+
import { GITHUB_API_BASE, GithubFetcherError, githubFetchJson, isRecord, parseRepoSlug } from "./github.js";
|
|
25
|
+
|
|
26
|
+
export const RELEASES_CURSOR_KEY = "github_releases_last_published_at";
|
|
27
|
+
|
|
28
|
+
// GitHub Releases endpoint. 30 releases per page by default; we
|
|
29
|
+
// cap at `source.maxItemsPerFetch` downstream. Phase-1 doesn't
|
|
30
|
+
// paginate — one page covers every active project's last ~1-2
|
|
31
|
+
// years of releases, plenty for the cursor to advance on.
|
|
32
|
+
function releasesUrl(owner: string, repo: string): string {
|
|
33
|
+
return `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One parsed release. Mirrors the GitHub API shape we actually
|
|
37
|
+
// read (we drop the other ~20 fields for clarity and to keep the
|
|
38
|
+
// normalizer testable in isolation).
|
|
39
|
+
interface ParsedRelease {
|
|
40
|
+
id: number | null;
|
|
41
|
+
name: string | null;
|
|
42
|
+
tagName: string | null;
|
|
43
|
+
htmlUrl: string | null;
|
|
44
|
+
body: string | null;
|
|
45
|
+
publishedAt: string | null;
|
|
46
|
+
draft: boolean;
|
|
47
|
+
prerelease: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Narrow one GitHub release record into ParsedRelease. Pure —
|
|
51
|
+
// exported so tests can exercise the JSON-shape handling without
|
|
52
|
+
// hitting the network.
|
|
53
|
+
export function parseGithubRelease(raw: unknown): ParsedRelease | null {
|
|
54
|
+
if (!isRecord(raw)) return null;
|
|
55
|
+
const id = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
|
|
56
|
+
const name = typeof raw.name === "string" ? raw.name : null;
|
|
57
|
+
const tagName = typeof raw.tag_name === "string" ? raw.tag_name : null;
|
|
58
|
+
const htmlUrl = typeof raw.html_url === "string" ? raw.html_url : null;
|
|
59
|
+
const body = typeof raw.body === "string" ? raw.body : null;
|
|
60
|
+
const publishedAt = typeof raw.published_at === "string" ? raw.published_at : null;
|
|
61
|
+
const draft = raw.draft === true;
|
|
62
|
+
const prerelease = raw.prerelease === true;
|
|
63
|
+
return { id, name, tagName, htmlUrl, body, publishedAt, draft, prerelease };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build a SourceItem from a parsed release + the parent Source.
|
|
67
|
+
// Returns null when the release doesn't carry the fields we need
|
|
68
|
+
// to make a useful item (missing URL, or cursor says we've seen
|
|
69
|
+
// this release already).
|
|
70
|
+
export function releaseToSourceItem(release: ParsedRelease, source: Source, lastSeenTs: number | null): SourceItem | null {
|
|
71
|
+
// Drafts are private — GitHub only shows them to authed readers.
|
|
72
|
+
// But defensively skip if the API somehow returns one.
|
|
73
|
+
if (release.draft) return null;
|
|
74
|
+
if (!release.htmlUrl || !release.publishedAt) return null;
|
|
75
|
+
|
|
76
|
+
// Cursor filter: drop releases at-or-older than the cursor.
|
|
77
|
+
// Null cursor means first run → pass through.
|
|
78
|
+
const publishedTs = Date.parse(release.publishedAt);
|
|
79
|
+
if (Number.isFinite(publishedTs) && lastSeenTs !== null) {
|
|
80
|
+
if (publishedTs <= lastSeenTs) return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const normalizedUrl = normalizeUrl(release.htmlUrl);
|
|
84
|
+
if (!normalizedUrl) return null;
|
|
85
|
+
const id = stableItemId(normalizedUrl);
|
|
86
|
+
|
|
87
|
+
// Title resolution: prefer <name> (release display name), fall
|
|
88
|
+
// back to <tag_name> (e.g. "v1.2.3"). Annotate pre-releases so
|
|
89
|
+
// the daily summary can visually distinguish them.
|
|
90
|
+
const baseTitle = release.name ?? release.tagName ?? "Release";
|
|
91
|
+
const title = release.prerelease ? `[pre] ${baseTitle}` : baseTitle;
|
|
92
|
+
const summary = release.body ? firstParagraph(release.body) : null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
title,
|
|
97
|
+
url: normalizedUrl,
|
|
98
|
+
publishedAt: new Date(publishedTs).toISOString(),
|
|
99
|
+
...(summary !== null && { summary }),
|
|
100
|
+
...(release.body !== null && { content: release.body }),
|
|
101
|
+
categories: source.categories,
|
|
102
|
+
sourceSlug: source.slug,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Extract the first "paragraph" of a release body for the short
|
|
107
|
+
// summary. GitHub release bodies are Markdown — we take everything
|
|
108
|
+
// up to the first double-newline so multi-line markdown lists or
|
|
109
|
+
// images in paragraph 2+ don't bloat the summary.
|
|
110
|
+
//
|
|
111
|
+
// Pure; exported for tests.
|
|
112
|
+
export function firstParagraph(body: string): string | null {
|
|
113
|
+
const trimmed = body.trim();
|
|
114
|
+
if (!trimmed) return null;
|
|
115
|
+
const doubleNewline = trimmed.indexOf("\n\n");
|
|
116
|
+
const head = doubleNewline === -1 ? trimmed : trimmed.slice(0, doubleNewline);
|
|
117
|
+
return head.length > 0 ? head : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// After filtering, advance the cursor to the newest publishedAt
|
|
121
|
+
// across ALL parsed releases in the response — not just the
|
|
122
|
+
// emitted ones — so a quiet repo doesn't keep replaying its last
|
|
123
|
+
// release on every run.
|
|
124
|
+
//
|
|
125
|
+
// Exported pure for direct unit testing.
|
|
126
|
+
export function updateReleasesCursor(current: Record<string, string>, releases: readonly ParsedRelease[]): Record<string, string> {
|
|
127
|
+
let newest: number | null = null;
|
|
128
|
+
for (const release of releases) {
|
|
129
|
+
if (release.draft) continue;
|
|
130
|
+
if (!release.publishedAt) continue;
|
|
131
|
+
const ts = Date.parse(release.publishedAt);
|
|
132
|
+
if (!Number.isFinite(ts)) continue;
|
|
133
|
+
if (newest === null || ts > newest) newest = ts;
|
|
134
|
+
}
|
|
135
|
+
if (newest === null) return current;
|
|
136
|
+
const currentTs = current[RELEASES_CURSOR_KEY] ? Date.parse(current[RELEASES_CURSOR_KEY]) : -Infinity;
|
|
137
|
+
if (newest <= currentTs) return current;
|
|
138
|
+
return {
|
|
139
|
+
...current,
|
|
140
|
+
[RELEASES_CURSOR_KEY]: new Date(newest).toISOString(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Pure: run the parse + filter + cursor-advance pipeline on an
|
|
145
|
+
// already-fetched JSON body. Exposed separately from the fetch
|
|
146
|
+
// itself so we can test the full normalization path with
|
|
147
|
+
// fabricated API responses and no HTTP stubbing.
|
|
148
|
+
export function processReleasesResponse(rawBody: unknown, source: Source, cursor: Record<string, string>): FetchResult {
|
|
149
|
+
if (!Array.isArray(rawBody)) {
|
|
150
|
+
return { items: [], cursor };
|
|
151
|
+
}
|
|
152
|
+
const parsed: ParsedRelease[] = [];
|
|
153
|
+
for (const raw of rawBody) {
|
|
154
|
+
const release = parseGithubRelease(raw);
|
|
155
|
+
if (release) parsed.push(release);
|
|
156
|
+
}
|
|
157
|
+
const lastSeenTs = cursor[RELEASES_CURSOR_KEY] ? Date.parse(cursor[RELEASES_CURSOR_KEY]) : null;
|
|
158
|
+
const effectiveLastSeen = lastSeenTs !== null && Number.isFinite(lastSeenTs) ? lastSeenTs : null;
|
|
159
|
+
|
|
160
|
+
const items: SourceItem[] = [];
|
|
161
|
+
for (const release of parsed) {
|
|
162
|
+
if (items.length >= source.maxItemsPerFetch) break;
|
|
163
|
+
const item = releaseToSourceItem(release, source, effectiveLastSeen);
|
|
164
|
+
if (item) items.push(item);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
items,
|
|
168
|
+
cursor: updateReleasesCursor(cursor, parsed),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const githubReleasesFetcher: SourceFetcher = {
|
|
173
|
+
kind: "github-releases",
|
|
174
|
+
async fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult> {
|
|
175
|
+
const repoRaw = source.fetcherParams["github_repo"];
|
|
176
|
+
const slug = parseRepoSlug(repoRaw ?? "");
|
|
177
|
+
if (!slug) {
|
|
178
|
+
throw new GithubFetcherError(source.url, 0, `github_repo param is required and must be owner/repo, got ${JSON.stringify(repoRaw)}`);
|
|
179
|
+
}
|
|
180
|
+
const url = releasesUrl(slug.owner, slug.repo);
|
|
181
|
+
const body = await githubFetchJson(url, deps.http);
|
|
182
|
+
return processReleasesResponse(body, source, state.cursor);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
registerFetcher(githubReleasesFetcher);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Fetcher dispatcher.
|
|
2
|
+
//
|
|
3
|
+
// Each source in the registry has a `fetcherKind` that maps to a
|
|
4
|
+
// module under `server/sources/fetchers/<kind>.ts` implementing
|
|
5
|
+
// the `SourceFetcher` interface. The pipeline looks up the right
|
|
6
|
+
// fetcher via `getFetcher(kind)`, then calls `fetcher.fetch(...)`.
|
|
7
|
+
//
|
|
8
|
+
// Adding a new fetcher kind in phase 2 / 3 / later:
|
|
9
|
+
// 1. Create `server/sources/fetchers/<new-kind>.ts` exporting
|
|
10
|
+
// a `SourceFetcher` and calling `registerFetcher(...)` at
|
|
11
|
+
// the bottom so the module self-registers on import.
|
|
12
|
+
// 2. Add the string to `FETCHER_KINDS` in `../types.ts`.
|
|
13
|
+
// 3. **Add a side-effect import for the new module to
|
|
14
|
+
// `./registerAll.ts`.** Production entry points import that
|
|
15
|
+
// barrel; without this step the pipeline still resolves
|
|
16
|
+
// `getFetcher(kind)` to `null` and your fetcher never runs.
|
|
17
|
+
// 4. Add a case to `test/sources/test_fetcherRegistration.ts`
|
|
18
|
+
// so regressions fail a unit test.
|
|
19
|
+
// No other framework change is required.
|
|
20
|
+
|
|
21
|
+
import type { HttpFetcherDeps } from "../httpFetcher.js";
|
|
22
|
+
import type { FetcherKind, Source, SourceItem, SourceState } from "../types.js";
|
|
23
|
+
|
|
24
|
+
// Per-run dependencies threaded into every fetcher so all I/O
|
|
25
|
+
// goes through injectable hooks (tests never touch the network).
|
|
26
|
+
export interface FetcherDeps {
|
|
27
|
+
// Wiring for `fetchPolite` — robots provider, rate limiter,
|
|
28
|
+
// fetch impl, timeout. Shared across fetchers in one pipeline
|
|
29
|
+
// run so their per-host rate limits serialize together.
|
|
30
|
+
http: HttpFetcherDeps;
|
|
31
|
+
// Monotonic wall-clock. Fetchers timestamp new items and update
|
|
32
|
+
// the cursor with it.
|
|
33
|
+
now: () => number;
|
|
34
|
+
// Pipeline-wide abort (e.g. server shutdown). Separate from
|
|
35
|
+
// the per-fetch timeout that lives inside `http`.
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FetchResult {
|
|
40
|
+
// Newly-discovered items (already filtered against the cursor).
|
|
41
|
+
// Empty array is a valid outcome — "no new items since last run".
|
|
42
|
+
items: SourceItem[];
|
|
43
|
+
// Replacement cursor for SourceState. Fetchers return only the
|
|
44
|
+
// keys they own; the caller merges this into the existing state.
|
|
45
|
+
cursor: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SourceFetcher {
|
|
49
|
+
readonly kind: FetcherKind;
|
|
50
|
+
fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Registry of all known fetchers. Populated lazily via
|
|
54
|
+
// `registerFetcher` so each fetcher module is responsible for
|
|
55
|
+
// adding itself at import time — keeps the dispatcher free of
|
|
56
|
+
// hard dependencies on modules that may pull heavy deps
|
|
57
|
+
// (fast-xml-parser etc).
|
|
58
|
+
const FETCHERS = new Map<FetcherKind, SourceFetcher>();
|
|
59
|
+
|
|
60
|
+
export function registerFetcher(fetcher: SourceFetcher): void {
|
|
61
|
+
FETCHERS.set(fetcher.kind, fetcher);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getFetcher(kind: FetcherKind): SourceFetcher | null {
|
|
65
|
+
return FETCHERS.get(kind) ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Test-only: clear the registry between cases.
|
|
69
|
+
export function __resetFetchersForTests(): void {
|
|
70
|
+
FETCHERS.clear();
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Side-effect bootstrap: importing this module registers every
|
|
2
|
+
// known fetcher with the dispatcher in `./index.ts`. Each fetcher
|
|
3
|
+
// module calls `registerFetcher(...)` at import time.
|
|
4
|
+
//
|
|
5
|
+
// The dispatcher itself intentionally does not import the fetcher
|
|
6
|
+
// modules (see the comment in `./index.ts`) so it stays free of
|
|
7
|
+
// heavy parser dependencies and tests can register only the
|
|
8
|
+
// fetchers they need. Production entry points that run the
|
|
9
|
+
// pipeline must import this barrel once so `getFetcher(kind)`
|
|
10
|
+
// returns a non-null result for every FetcherKind.
|
|
11
|
+
|
|
12
|
+
import "./rss.js";
|
|
13
|
+
import "./githubReleases.js";
|
|
14
|
+
import "./githubIssues.js";
|
|
15
|
+
import "./arxiv.js";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// RSS / Atom source fetcher.
|
|
2
|
+
//
|
|
3
|
+
// Flow:
|
|
4
|
+
// 1. fetchPolite(source.url) — respects robots, User-Agent,
|
|
5
|
+
// rate limit, timeout
|
|
6
|
+
// 2. parseFeed(bodyText) — pure XML → ParsedFeed
|
|
7
|
+
// 3. normalizeToSourceItems(...) — ParsedFeedItem[] → SourceItem[]
|
|
8
|
+
// with cursor filtering so we only emit items newer than the
|
|
9
|
+
// last-seen pubDate
|
|
10
|
+
// 4. updateCursor(...) — advance the cursor to the most-recent
|
|
11
|
+
// publishedAt across ALL items in this response (not just
|
|
12
|
+
// the emitted ones) so a quiet feed doesn't keep replaying
|
|
13
|
+
// old items after a one-shot republish
|
|
14
|
+
//
|
|
15
|
+
// The parser / normalizer / cursor logic are pure functions
|
|
16
|
+
// exported for direct unit tests. The `rssFetcher` object is the
|
|
17
|
+
// Promise-aware orchestrator that stitches them together.
|
|
18
|
+
|
|
19
|
+
import { fetchPolite } from "../httpFetcher.js";
|
|
20
|
+
import { normalizeUrl, stableItemId } from "../urls.js";
|
|
21
|
+
import type { Source, SourceItem, SourceState } from "../types.js";
|
|
22
|
+
import type { FetcherDeps, FetchResult, SourceFetcher } from "./index.js";
|
|
23
|
+
import { registerFetcher } from "./index.js";
|
|
24
|
+
import { parseFeed, type ParsedFeed, type ParsedFeedItem } from "./rssParser.js";
|
|
25
|
+
|
|
26
|
+
// Cursor key we store in SourceState.cursor for RSS feeds.
|
|
27
|
+
// ISO timestamp of the most-recent item's publishedAt we've seen.
|
|
28
|
+
// Items whose publishedAt is <= this value are skipped on the
|
|
29
|
+
// next run. Separate key name from other fetchers so adding
|
|
30
|
+
// GitHub / arXiv cursors next to RSS ones on the same source
|
|
31
|
+
// never conflicts.
|
|
32
|
+
export const RSS_CURSOR_KEY = "rss_last_seen_at";
|
|
33
|
+
|
|
34
|
+
export class RssFetcherError extends Error {
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly status: number | null;
|
|
37
|
+
constructor(url: string, status: number | null, message: string) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "RssFetcherError";
|
|
40
|
+
this.url = url;
|
|
41
|
+
this.status = status;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Filter raw parsed items against the cursor and normalize into
|
|
46
|
+
// the pipeline's `SourceItem` shape. Pure — exported so tests
|
|
47
|
+
// can exercise cursor semantics with fabricated ParsedFeed
|
|
48
|
+
// structures without spinning up HTTP.
|
|
49
|
+
export function normalizeToSourceItems(feed: ParsedFeed, source: Source, cursor: Record<string, string>, maxItems: number): SourceItem[] {
|
|
50
|
+
const lastSeen = cursor[RSS_CURSOR_KEY] ?? null;
|
|
51
|
+
const lastSeenTs = lastSeen ? Date.parse(lastSeen) : null;
|
|
52
|
+
const items: SourceItem[] = [];
|
|
53
|
+
|
|
54
|
+
for (const entry of feed.items) {
|
|
55
|
+
if (items.length >= maxItems) break;
|
|
56
|
+
const item = entryToSourceItem(entry, source, lastSeenTs);
|
|
57
|
+
if (item) items.push(item);
|
|
58
|
+
}
|
|
59
|
+
return items;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function entryToSourceItem(entry: ParsedFeedItem, source: Source, lastSeenTs: number | null): SourceItem | null {
|
|
63
|
+
if (!entry.link) return null;
|
|
64
|
+
const normalizedUrl = normalizeUrl(entry.link);
|
|
65
|
+
if (!normalizedUrl) return null;
|
|
66
|
+
// Cursor comparison: drop items at or older than the last-seen
|
|
67
|
+
// timestamp. Null publishedAt → keep (rare but happens with
|
|
68
|
+
// misformatted feeds; we'd rather emit a dup once than lose
|
|
69
|
+
// an item forever).
|
|
70
|
+
if (entry.publishedAt && lastSeenTs !== null) {
|
|
71
|
+
const itemTs = Date.parse(entry.publishedAt);
|
|
72
|
+
if (Number.isFinite(itemTs) && itemTs <= lastSeenTs) return null;
|
|
73
|
+
}
|
|
74
|
+
// Use the feed's own id as a hint, but always derive the
|
|
75
|
+
// SourceItem.id from the normalized URL so cross-source dedup
|
|
76
|
+
// (see #188 Q3) lines up regardless of feed conventions.
|
|
77
|
+
const id = stableItemId(normalizedUrl);
|
|
78
|
+
const publishedAt =
|
|
79
|
+
entry.publishedAt ??
|
|
80
|
+
// Synthesize a fetch-time timestamp when the feed didn't
|
|
81
|
+
// provide one, so downstream sorting has a monotonic key.
|
|
82
|
+
new Date().toISOString();
|
|
83
|
+
|
|
84
|
+
// Build the SourceItem with conditional spreads so we don't
|
|
85
|
+
// carry `undefined` fields that break exactOptionalPropertyTypes
|
|
86
|
+
// on the server tsconfig.
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
title: entry.title,
|
|
90
|
+
url: normalizedUrl,
|
|
91
|
+
publishedAt,
|
|
92
|
+
...(entry.summary !== null && { summary: entry.summary }),
|
|
93
|
+
...(entry.content !== null && { content: entry.content }),
|
|
94
|
+
categories: source.categories,
|
|
95
|
+
sourceSlug: source.slug,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// After filtering, advance the cursor to the newest publishedAt
|
|
100
|
+
// in the full ParsedFeed (not just the emitted items). Doing
|
|
101
|
+
// this on the full set prevents a feed that republishes older
|
|
102
|
+
// items without updating pubDate from causing us to re-emit
|
|
103
|
+
// them forever.
|
|
104
|
+
//
|
|
105
|
+
// Exported pure so tests can pin the advancement policy down.
|
|
106
|
+
export function updateCursor(current: Record<string, string>, feed: ParsedFeed): Record<string, string> {
|
|
107
|
+
let newest: number | null = null;
|
|
108
|
+
for (const entry of feed.items) {
|
|
109
|
+
if (!entry.publishedAt) continue;
|
|
110
|
+
const ts = Date.parse(entry.publishedAt);
|
|
111
|
+
if (!Number.isFinite(ts)) continue;
|
|
112
|
+
if (newest === null || ts > newest) newest = ts;
|
|
113
|
+
}
|
|
114
|
+
if (newest === null) return current;
|
|
115
|
+
// Only advance forwards. A feed whose newest item is older
|
|
116
|
+
// than our cursor should leave the cursor where it was so
|
|
117
|
+
// we don't retroactively re-emit everything on the next run.
|
|
118
|
+
const currentTs = current[RSS_CURSOR_KEY] ? Date.parse(current[RSS_CURSOR_KEY]) : -Infinity;
|
|
119
|
+
if (newest <= currentTs) return current;
|
|
120
|
+
return { ...current, [RSS_CURSOR_KEY]: new Date(newest).toISOString() };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const rssFetcher: SourceFetcher = {
|
|
124
|
+
kind: "rss",
|
|
125
|
+
async fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult> {
|
|
126
|
+
const res = await fetchPolite(source.url, deps.http);
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw new RssFetcherError(source.url, res.status, `RSS fetch ${source.url} failed with HTTP ${res.status}`);
|
|
129
|
+
}
|
|
130
|
+
const body = await res.text();
|
|
131
|
+
const feed = parseFeed(body);
|
|
132
|
+
if (!feed) {
|
|
133
|
+
throw new RssFetcherError(source.url, res.status, `RSS body at ${source.url} did not parse as RSS / Atom / RDF`);
|
|
134
|
+
}
|
|
135
|
+
const items = normalizeToSourceItems(feed, source, state.cursor, source.maxItemsPerFetch);
|
|
136
|
+
const cursor = updateCursor(state.cursor, feed);
|
|
137
|
+
return { items, cursor };
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
registerFetcher(rssFetcher);
|