@maudecode/kanvil 0.2.0 → 0.3.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/apps/server/.env.example +19 -0
- package/apps/server/dist/auth-JcS53IcO.mjs +12424 -0
- package/apps/server/dist/{config-CbNqtJEj.mjs → config-D3cAABi7.mjs} +27 -2
- package/apps/server/dist/index.mjs +2504 -295
- package/apps/server/dist/migrate.mjs +1 -1
- package/apps/server/dist/recover.mjs +4 -4
- package/apps/server/drizzle/0014_inbox_dismissal.sql +1 -0
- package/apps/server/drizzle/0015_notification_categories.sql +14 -0
- package/apps/server/drizzle/0016_ticket_resources.sql +19 -0
- package/apps/server/drizzle/0017_project_backgrounds.sql +3 -0
- package/apps/server/drizzle/0018_delivery_outbox.sql +65 -0
- package/apps/server/drizzle/0019_retire_push_deliveries.sql +1 -0
- package/apps/server/drizzle/0020_calendar_feeds.sql +16 -0
- package/apps/server/drizzle/0021_staged_uploads.sql +14 -0
- package/apps/server/drizzle/0022_manual_order.sql +6 -0
- package/apps/server/drizzle/0023_workspace_invitations.sql +22 -0
- package/apps/server/drizzle/0024_workflow_states.sql +39 -0
- package/apps/server/drizzle/0025_workspace_labels.sql +10 -0
- package/apps/server/drizzle/0026_public_read.sql +1 -0
- package/apps/server/drizzle/0027_custom_fields.sql +16 -0
- package/apps/server/drizzle/0028_project_lifecycle.sql +11 -0
- package/apps/server/drizzle/0029_ticket_relocation.sql +16 -0
- package/apps/server/drizzle/0030_repository_connections.sql +39 -0
- package/apps/server/drizzle/0031_repository_providers.sql +3 -0
- package/apps/server/drizzle/0032_workspace_ownership.sql +11 -0
- package/apps/server/drizzle/0033_import_runs.sql +38 -0
- package/apps/server/drizzle/0034_issue_sync.sql +38 -0
- package/apps/server/drizzle/0035_account_deletion.sql +9 -0
- package/apps/server/drizzle/0036_workspace_roles.sql +16 -0
- package/apps/server/drizzle/0037_email_code_owner.sql +14 -0
- package/apps/server/drizzle/meta/0014_snapshot.json +4246 -0
- package/apps/server/drizzle/meta/0015_snapshot.json +4297 -0
- package/apps/server/drizzle/meta/0016_snapshot.json +4443 -0
- package/apps/server/drizzle/meta/0017_snapshot.json +4468 -0
- package/apps/server/drizzle/meta/0018_snapshot.json +4948 -0
- package/apps/server/drizzle/meta/0019_snapshot.json +4826 -0
- package/apps/server/drizzle/meta/0020_snapshot.json +4947 -0
- package/apps/server/drizzle/meta/0021_snapshot.json +5059 -0
- package/apps/server/drizzle/meta/0022_snapshot.json +5094 -0
- package/apps/server/drizzle/meta/0023_snapshot.json +5267 -0
- package/apps/server/drizzle/meta/0024_snapshot.json +5405 -0
- package/apps/server/drizzle/meta/0025_snapshot.json +5474 -0
- package/apps/server/drizzle/meta/0026_snapshot.json +5481 -0
- package/apps/server/drizzle/meta/0027_snapshot.json +5605 -0
- package/apps/server/drizzle/meta/0028_snapshot.json +5699 -0
- package/apps/server/drizzle/meta/0029_snapshot.json +5809 -0
- package/apps/server/drizzle/meta/0030_snapshot.json +6066 -0
- package/apps/server/drizzle/meta/0031_snapshot.json +6074 -0
- package/apps/server/drizzle/meta/0032_snapshot.json +6100 -0
- package/apps/server/drizzle/meta/0033_snapshot.json +6406 -0
- package/apps/server/drizzle/meta/0034_snapshot.json +6698 -0
- package/apps/server/drizzle/meta/0035_snapshot.json +6704 -0
- package/apps/server/drizzle/meta/0036_snapshot.json +6825 -0
- package/apps/server/drizzle/meta/0037_snapshot.json +6825 -0
- package/apps/server/drizzle/meta/_journal.json +168 -0
- package/apps/web/dist/assets/CompositeItem-BbuEp99X.js +1 -0
- package/apps/web/dist/assets/Trans-DJGH63s1.js +1 -0
- package/apps/web/dist/assets/abnfDiagram-WEYOIA3C-DTOx8N4B.js +1 -0
- package/apps/web/dist/assets/admin-settings-CJG9PMKR.js +1 -0
- package/apps/web/dist/assets/arc-Y1ARJVZ4.js +1 -0
- package/apps/web/dist/assets/architecture-WOLXFQ4H-DlC3p5z3.js +1 -0
- package/apps/web/dist/assets/architectureDiagram-ZHVYKT53-DooN3Z58.js +36 -0
- package/apps/web/dist/assets/archive-restore-B6_SsOIP.js +1 -0
- package/apps/web/dist/assets/array-BifhSqXX.js +1 -0
- package/apps/web/dist/assets/az-AZ-BtrlbgjS.js +1 -0
- package/apps/web/dist/assets/az-D-kj4lmf.js +1 -0
- package/apps/web/dist/assets/az-DqY__MpS.js +1 -0
- package/apps/web/dist/assets/blockDiagram-RJAE7QA3-CD5AgY4U.js +140 -0
- package/apps/web/dist/assets/c4Diagram-XDPPPE54-CTDOcLuN.js +38 -0
- package/apps/web/dist/assets/channel-BhHvU4I-.js +1 -0
- package/apps/web/dist/assets/chunk-2E7WHVMD-D9qnBqTV.js +88 -0
- package/apps/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/apps/web/dist/assets/chunk-4EMNKR2T-Kz5JiJ85.js +1 -0
- package/apps/web/dist/assets/chunk-55XCU76K-DnKiFpYM.js +1 -0
- package/apps/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/apps/web/dist/assets/chunk-7S6XSTFO-CpdNd6Ke.js +2 -0
- package/apps/web/dist/assets/chunk-AQDTSKZB-CpyuSWt8.js +72 -0
- package/apps/web/dist/assets/chunk-DSNLSHJZ-DTC3y9rb.js +129 -0
- package/apps/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/apps/web/dist/assets/chunk-HAGFOTED-Bv8PrMYK.js +62 -0
- package/apps/web/dist/assets/chunk-HNE4Q36P-CDDJ_yRa.js +1 -0
- package/apps/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/apps/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/apps/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/apps/web/dist/assets/chunk-NQOKRVIF-lNtnsWHS.js +213 -0
- package/apps/web/dist/assets/chunk-UHW7PFBP-D79ptlTN.js +1 -0
- package/apps/web/dist/assets/chunk-VQ5W62QI-B8hWFCiM.js +1 -0
- package/apps/web/dist/assets/chunk-WQQL4CDK-DAGuWzS3.js +10 -0
- package/apps/web/dist/assets/chunk-XXDRQBXY-CnbRkih_.js +1 -0
- package/apps/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/apps/web/dist/assets/chunk-YZO67HFG-C0ptzlKO.js +1 -0
- package/apps/web/dist/assets/chunk-ZAUX2LTX-ah8U5xoJ.js +1 -0
- package/apps/web/dist/assets/chunk-ZCHMQSOR-DYqATOIZ.js +2 -0
- package/apps/web/dist/assets/classDiagram-v2-4MYWR3UA-DYytA-ua.js +217 -0
- package/apps/web/dist/assets/core-jXc03Sty.js +1 -0
- package/apps/web/dist/assets/corner-left-up-DA7MG5uX.js +1 -0
- package/apps/web/dist/assets/cose-bilkent-JH36ORCC-lWVj4msc.js +1 -0
- package/apps/web/dist/assets/cynefin-EF2NZ3EQ-Xy6e6m1E.js +1 -0
- package/apps/web/dist/assets/cynefinDiagram-WF24UDRP-BYarZJSm.js +62 -0
- package/apps/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/apps/web/dist/assets/dagre-NK4GIZVX-WLvvUY1o.js +4 -0
- package/apps/web/dist/assets/de-DAv77-wP.js +1 -0
- package/apps/web/dist/assets/de-DE-Bu_Ko2QA.js +1 -0
- package/apps/web/dist/assets/de-UHreLwyV.js +1 -0
- package/apps/web/dist/assets/default-D5TdIZQS.js +56 -0
- package/apps/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/apps/web/dist/assets/delivery-settings-BE5MW8U0.js +1 -0
- package/apps/web/dist/assets/diagram-CBK3MWTC-D6PtxkLg.js +24 -0
- package/apps/web/dist/assets/diagram-I5XIIV2D-DTOKQwIY.js +24 -0
- package/apps/web/dist/assets/diagram-ODIL2UCW-BmERt0Xu.js +30 -0
- package/apps/web/dist/assets/diagram-PFBEJUXE-WDUe08eD.js +3 -0
- package/apps/web/dist/assets/diagram-RJA3I4VV-B2K-NLGG.js +41 -0
- package/apps/web/dist/assets/diagram-TYUNFSNR-BLf7PpV7.js +200 -0
- package/apps/web/dist/assets/dist-CumnGVmJ.js +1 -0
- package/apps/web/dist/assets/ebnfDiagram-V4P3TTPT-DwBUQs9p.js +1 -0
- package/apps/web/dist/assets/el-D2ypddWn.js +1 -0
- package/apps/web/dist/assets/el-EJnrsyVr.js +1 -0
- package/apps/web/dist/assets/el-GR-CpKEps47.js +1 -0
- package/apps/web/dist/assets/elk-QONTBCTQ-AuT8PinL.js +27 -0
- package/apps/web/dist/assets/ellipsis-D6154JKs.js +1 -0
- package/apps/web/dist/assets/en-EnSRisMz.js +1 -0
- package/apps/web/dist/assets/en-US-DKYSujv3.js +1 -0
- package/apps/web/dist/assets/erDiagram-KYQIGQAA-h--u4Eo6.js +99 -0
- package/apps/web/dist/assets/es-B_49BMhR.js +1 -0
- package/apps/web/dist/assets/es-D2ScuV-5.js +1 -0
- package/apps/web/dist/assets/es-ES-DVkfi7H-.js +1 -0
- package/apps/web/dist/assets/eventmodeling-K75KTNOO-CW4hDt1j.js +1 -0
- package/apps/web/dist/assets/flowDiagram-IUGLLWBU-BGUscnNP.js +1 -0
- package/apps/web/dist/assets/forms-BcD6Wwyv.js +1 -0
- package/apps/web/dist/assets/fr-B-WmPGZD.js +1 -0
- package/apps/web/dist/assets/fr-C_VpS8u6.js +1 -0
- package/apps/web/dist/assets/fr-FR-DkHaEHqi.js +1 -0
- package/apps/web/dist/assets/ganttDiagram-R7GPNTJY-CB3lsQvj.js +292 -0
- package/apps/web/dist/assets/gitGraph-VSP46ZUC-Hu-kvdCR.js +1 -0
- package/apps/web/dist/assets/gitGraphDiagram-PDYL7XHY-DCuY3C1Q.js +106 -0
- package/apps/web/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/apps/web/dist/assets/hi-CW4rdp_m.js +1 -0
- package/apps/web/dist/assets/hi-DvoDcUpL.js +1 -0
- package/apps/web/dist/assets/hi-IN-CbUDNSTK.js +1 -0
- package/apps/web/dist/assets/highlight-CMydIf9M.js +5 -0
- package/apps/web/dist/assets/id-CPASBZ2r.js +1 -0
- package/apps/web/dist/assets/id-CvL3yBVr.js +1 -0
- package/apps/web/dist/assets/id-ID-DLTkxt2j.js +1 -0
- package/apps/web/dist/assets/index-DoqlyejP.js +17 -0
- package/apps/web/dist/assets/index-DyQNFS69.css +2 -0
- package/apps/web/dist/assets/info-OHQRW6UA-Cc8VwTsk.js +1 -0
- package/apps/web/dist/assets/infoDiagram-MVDRKAX5-KIMOx55J.js +2 -0
- package/apps/web/dist/assets/init-C-OQMol4.js +1 -0
- package/apps/web/dist/assets/invitations-BmpelTJu.js +1 -0
- package/apps/web/dist/assets/isSameWeek-2ftYGvBT.js +1 -0
- package/apps/web/dist/assets/ishikawaDiagram-HPEHV2GE-CAou726L.js +70 -0
- package/apps/web/dist/assets/it-Cd7u97Jd.js +1 -0
- package/apps/web/dist/assets/it-DOLotXOj.js +1 -0
- package/apps/web/dist/assets/it-IT-CIzq0YwC.js +1 -0
- package/apps/web/dist/assets/item-Cq_ZbivE.js +1 -0
- package/apps/web/dist/assets/ja-B1iJt9qB.js +1 -0
- package/apps/web/dist/assets/ja-JP-CIE4-D-v.js +1 -0
- package/apps/web/dist/assets/ja-LkOrjegp.js +1 -0
- package/apps/web/dist/assets/journeyDiagram-BOSHW7O7-YbJuPYDH.js +139 -0
- package/apps/web/dist/assets/kanban-definition-FNSA4HC5-nuRnm9y8.js +89 -0
- package/apps/web/dist/assets/katex-B_JcIwqj.js +257 -0
- package/apps/web/dist/assets/ko-DPpXw683.js +1 -0
- package/apps/web/dist/assets/ko-KR-DYP5Zae6.js +1 -0
- package/apps/web/dist/assets/ko-fmUq1-8W.js +1 -0
- package/apps/web/dist/assets/label-settings-B3LsJxhk.js +1 -0
- package/apps/web/dist/assets/line-WsxZYwZr.js +1 -0
- package/apps/web/dist/assets/linear-C9iwaMOi.js +1 -0
- package/apps/web/dist/assets/lucide-DIlszs-T.js +1 -0
- package/apps/web/dist/assets/markdown-blocks-Bw26pRKB.js +2 -0
- package/apps/web/dist/assets/member-directory-CHKUw0u4.js +1 -0
- package/apps/web/dist/assets/mermaid-parser.core-B5c-1CnP.js +7 -0
- package/apps/web/dist/assets/mermaid.core-vGVkgzA3.js +44 -0
- package/apps/web/dist/assets/mindmap-definition-6OY57UG3-uooJyUak.js +98 -0
- package/apps/web/dist/assets/mk-CkaQRpqe.js +1 -0
- package/apps/web/dist/assets/mk-LWVrR-T-.js +1 -0
- package/apps/web/dist/assets/mk-MK-CpOo5Bd8.js +1 -0
- package/apps/web/dist/assets/nl-DiEjqOA8.js +1 -0
- package/apps/web/dist/assets/nl-NL-Mu8G8RON.js +1 -0
- package/apps/web/dist/assets/nl-ro6hbqo5.js +1 -0
- package/apps/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/apps/web/dist/assets/packet-JDAUHWVQ-WJpqlH1d.js +1 -0
- package/apps/web/dist/assets/path-fybaL0A-.js +1 -0
- package/apps/web/dist/assets/pegDiagram-ECIUJNBX-CUvajD93.js +1 -0
- package/apps/web/dist/assets/pie-XZMESJXO-DvandPby.js +1 -0
- package/apps/web/dist/assets/pieDiagram-GNMPY74T-CqxeyIaP.js +39 -0
- package/apps/web/dist/assets/pl-Bx_fYLKY.js +1 -0
- package/apps/web/dist/assets/pl-CauoMfkf.js +1 -0
- package/apps/web/dist/assets/pl-PL-DGuxe2Aq.js +1 -0
- package/apps/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/apps/web/dist/assets/project-lifecycle-Ba7iRF3u.js +1 -0
- package/apps/web/dist/assets/project-settings-CtOWgEam.js +1 -0
- package/apps/web/dist/assets/pt-BR-BpOhwoDW.js +1 -0
- package/apps/web/dist/assets/pt-BR-D-a7F5zJ.js +1 -0
- package/apps/web/dist/assets/pt-BR-DwkMPgq1.js +1 -0
- package/apps/web/dist/assets/public-project-CHRjhFIn.js +1 -0
- package/apps/web/dist/assets/quadrantDiagram-D57K4MC3-DFjNmaZc.js +7 -0
- package/apps/web/dist/assets/radar-ABXABTNO-CJhZF9j2.js +1 -0
- package/apps/web/dist/assets/railroad-I3PHUGI6-DZC10oBD.js +1 -0
- package/apps/web/dist/assets/railroad-abnf-LNEFI6M7-BUJF2n6u.js +1 -0
- package/apps/web/dist/assets/railroad-ebnf-DWYD2UWJ-CG7oB02K.js +1 -0
- package/apps/web/dist/assets/railroad-peg-JR7BVNR7-DBCZuZJY.js +1 -0
- package/apps/web/dist/assets/railroadDiagram-Y72NRBL7-CvN3poBq.js +1 -0
- package/apps/web/dist/assets/requirementDiagram-EQYSW5RA-BEItNirc.js +84 -0
- package/apps/web/dist/assets/rolldown-runtime-COiEfhsA.js +1 -0
- package/apps/web/dist/assets/rotate-ccw-Bm0ESNH_.js +1 -0
- package/apps/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/apps/web/dist/assets/ru-D-WIMI5_.js +1 -0
- package/apps/web/dist/assets/ru-DOaRp00B.js +1 -0
- package/apps/web/dist/assets/ru-RU-DrXnqZbV.js +1 -0
- package/apps/web/dist/assets/sankeyDiagram-SY24JHO5-DluLIjax.js +40 -0
- package/apps/web/dist/assets/search-D94SZwrC.js +85 -0
- package/apps/web/dist/assets/send-D9TETK3s.js +1 -0
- package/apps/web/dist/assets/sequenceDiagram-BLEHNHQU-ZcwnsNxK.js +169 -0
- package/apps/web/dist/assets/settings-Dd9EVxua.js +1 -0
- package/apps/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/apps/web/dist/assets/sliders-horizontal-CiZZFL1o.js +1 -0
- package/apps/web/dist/assets/src-BWF7jMDv.js +1 -0
- package/apps/web/dist/assets/stateDiagram-v2-A6IESKWZ-DD6vH3rM.js +291 -0
- package/apps/web/dist/assets/swimlanes-B3ENRKFQ-pX_qyO4X.js +1 -0
- package/apps/web/dist/assets/swimlanesDiagram-AYQRV4AK-CeVsLUsg.js +8 -0
- package/apps/web/dist/assets/switch-B_u8n1o6.js +1 -0
- package/apps/web/dist/assets/table-DcCLC7fi.js +1 -0
- package/apps/web/dist/assets/ticket-detail-C9egaVSf.js +2 -0
- package/apps/web/dist/assets/ticket-editor-UlSZgpWF.js +2 -0
- package/apps/web/dist/assets/ticket-list-controls-BidzkMOD.js +1 -0
- package/apps/web/dist/assets/ticket-list-vNBO8mkk.js +5 -0
- package/apps/web/dist/assets/timeline-definition-JXYKGLGB-DLBjAxKq.js +120 -0
- package/apps/web/dist/assets/toDate-B3HYxwtT.js +1 -0
- package/apps/web/dist/assets/tr-BZEJyY-F.js +1 -0
- package/apps/web/dist/assets/tr-DeYojjLx.js +1 -0
- package/apps/web/dist/assets/tr-TR-B92YeFW5.js +1 -0
- package/apps/web/dist/assets/treeView-D4JQ5SDB-DsJ44q_q.js +1 -0
- package/apps/web/dist/assets/treemap-SAJKECNS-BmXoBpVL.js +1 -0
- package/apps/web/dist/assets/uk-DACrfgCP.js +1 -0
- package/apps/web/dist/assets/uk-UA-DmcDs-MU.js +1 -0
- package/apps/web/dist/assets/uk-f8X5q5C_.js +1 -0
- package/apps/web/dist/assets/upload-CGpna3R2.js +1 -0
- package/apps/web/dist/assets/useQueries-BihXYrAP.js +1 -0
- package/apps/web/dist/assets/usecaseDiagram-WPOLBNNX-eU2tr2si.js +328 -0
- package/apps/web/dist/assets/vennDiagram-Q3Y3IDCW-CoVTWGNg.js +34 -0
- package/apps/web/dist/assets/vi-CSGaJFlR.js +1 -0
- package/apps/web/dist/assets/vi-CkqUo4w_.js +1 -0
- package/apps/web/dist/assets/vi-VN-DM5JYNuP.js +1 -0
- package/apps/web/dist/assets/visual-editor-B6HiiLMg.js +208 -0
- package/apps/web/dist/assets/wardley-7MLQ67FV-C6mVVk98.js +1 -0
- package/apps/web/dist/assets/wardleyDiagram-IVAIDKWA-m3CeJc7a.js +78 -0
- package/apps/web/dist/assets/workspace-settings-CSvVqaL6.js +1 -0
- package/apps/web/dist/assets/xychartDiagram-4URNHDU4-Dw6fLaip.js +7 -0
- package/apps/web/dist/assets/zh-CN-BdRcimyZ.js +1 -0
- package/apps/web/dist/assets/zh-CN-CC7ghLLr.js +1 -0
- package/apps/web/dist/assets/zh-CN-CiUOkQXe.js +1 -0
- package/apps/web/dist/assets/zh-TW-BH1L9tOt.js +1 -0
- package/apps/web/dist/assets/zh-TW-BlIRMi1C.js +1 -0
- package/apps/web/dist/assets/zh-TW-Co4UBSfi.js +1 -0
- package/apps/web/dist/index.html +14 -8
- package/compose.yml +12 -0
- package/docs/api.md +285 -10
- package/docs/infrastructure.md +2 -2
- package/docs/localization.md +25 -0
- package/docs/plan.md +28 -10
- package/docs/releasing.md +1 -1
- package/docs/self-hosting.md +44 -5
- package/docs/themes.md +1 -1
- package/docs/verification.md +1 -1
- package/docs/workspaces.md +18 -2
- package/package.json +12 -8
- package/apps/server/dist/auth-I1XEr0d4.mjs +0 -2931
- package/apps/web/dist/assets/CompositeItem-BWPJ5Mol.js +0 -1
- package/apps/web/dist/assets/admin-settings-DZqkC6ju.js +0 -1
- package/apps/web/dist/assets/date-picker-6H_iwP6t.js +0 -1
- package/apps/web/dist/assets/ellipsis-BT172Uvu.js +0 -1
- package/apps/web/dist/assets/forms-B4Vg3CPz.js +0 -92
- package/apps/web/dist/assets/image-edit-trigger-DGmVtWLC.js +0 -1
- package/apps/web/dist/assets/index-C_a21taR.js +0 -50
- package/apps/web/dist/assets/index-DM-D_3Sv.css +0 -2
- package/apps/web/dist/assets/kbd-tTQGZxaK.js +0 -1
- package/apps/web/dist/assets/link-DoIP9KJ6.js +0 -2
- package/apps/web/dist/assets/list-order-Bzsrihyr.js +0 -1
- package/apps/web/dist/assets/lucide-U995NW8E.js +0 -1
- package/apps/web/dist/assets/member-directory-BiCE8DRq.js +0 -1
- package/apps/web/dist/assets/popover-BhzZ0U1d.js +0 -1
- package/apps/web/dist/assets/project-label-provider-9L7Uts3_.js +0 -1
- package/apps/web/dist/assets/project-settings-Q7Q9Reec.js +0 -1
- package/apps/web/dist/assets/rotate-ccw-sjyLwLLy.js +0 -1
- package/apps/web/dist/assets/settings-B6qD6q1J.js +0 -1
- package/apps/web/dist/assets/settings-layout-B-AwUIMu.js +0 -1
- package/apps/web/dist/assets/switch-D0nFHj6l.js +0 -1
- package/apps/web/dist/assets/table-CYZiwo9g.js +0 -1
- package/apps/web/dist/assets/ticket-detail-B-3vbHSB.js +0 -2
- package/apps/web/dist/assets/ticket-editor-C3d8TZIT.js +0 -1
- package/apps/web/dist/assets/ticket-list-LtGGOnwN.js +0 -5
- package/apps/web/dist/assets/ticket-tags-2rdmkcJV.js +0 -1
- package/apps/web/dist/assets/upload-DXPRzAaa.js +0 -1
- package/apps/web/dist/assets/useNavigate-DFohfUD3.js +0 -1
- package/apps/web/dist/assets/workspace-settings-D3tCm1vH.js +0 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { a as instanceUpdateSchema, c as s3SettingsSchema, d as createDatabase, f as account,
|
|
1
|
+
import { a as instanceUpdateSchema, b as user$1, c as s3SettingsSchema, d as createDatabase, f as account, i as brokerSettingsSchema, m as auth_schema_exports, n as adminInstanceSchema, o as photoInputSchema, p as apikey, s as photoResultSchema, t as readConfig, u as searchSettingsSchema, y as session } from "./config-D3cAABi7.mjs";
|
|
2
2
|
import { t as migrateDatabase } from "./migrate-C9f4ozB4.mjs";
|
|
3
|
-
import { A as
|
|
3
|
+
import { $ as subscriptions, A as createTicketSearch, B as blobObjects, C as DeliveryError, Ct as roles, D as requireInstanceAdmin, Dt as matchesOIDCBinding, E as createWriteLimiter, Et as ticketExportFormat, F as stagedUploadCutoff, G as members, H as eventClock, I as createObjectStore, J as profilePhotos, K as notificationPreferences, L as createPollingJob, M as createBlobStorage, N as decodeImage, O as requireSession, P as imageResponse, Q as storageBackends, R as attachmentContent, S as deliverBatch, St as pushDeviceInput, T as createAccess, Tt as stateLabels, U as events, V as deliveryCheckpoints, X as pushDevices, Y as projects, Z as stagedUploads, _ as recipientColumns, _t as notificationPreferencesSchema, a as deliverySecrets, at as credentialInput, b as receiveRepositoryWebhook, bt as operations, c as ownerBlocker, ct as hasAdminRole, d as createDomain, dt as isOperation, et as tickets, f as fail, ft as isPresetColor, g as reachable, gt as notificationPreferencesInput, h as one, ht as notificationIdsInput, i as createInstance, it as closedStates, j as createTicketIndex, k as readCalendarFeed, l as appendEvents, lt as importLimits, m as notify, mt as memberSchema, n as emailCodesAvailable, nt as workspaces, o as otherActiveAdministrator, ot as defaultWorkflow, p as json, pt as isPublicOperation, q as notifications, r as signInProviders, rt as ApiError, s as ownedWorkspaces, st as errorSchema, t as createAuth, tt as workspaceMembers, u as assertVersion, ut as importProviderDefinitions, v as requireRow, vt as notificationReadInput, w as createHttpSender, wt as scopes, x as defaultSenders, xt as publicOperations, y as processImports, yt as okSchema, z as attachments } from "./auth-JcS53IcO.mjs";
|
|
4
4
|
import { serve } from "@hono/node-server";
|
|
5
5
|
import pg from "pg";
|
|
6
|
-
import {
|
|
6
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
7
|
+
import { and, asc, count, desc, eq, gt, ilike, inArray, isNotNull, isNull, notInArray, or, sql } from "drizzle-orm";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
10
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import { Expo } from "expo-server-sdk";
|
|
10
12
|
import { isDeepStrictEqual } from "node:util";
|
|
11
13
|
import { EventEmitter } from "node:events";
|
|
12
14
|
import { connect } from "amqplib";
|
|
@@ -18,7 +20,7 @@ import { streamSSE } from "hono/streaming";
|
|
|
18
20
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
19
21
|
import { APIError } from "better-auth/api";
|
|
20
22
|
import { requireMcpAuth } from "@better-auth/mcp";
|
|
21
|
-
import
|
|
23
|
+
import TurndownService from "turndown";
|
|
22
24
|
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
|
|
23
25
|
//#region ../../packages/contracts/src/users.ts
|
|
24
26
|
const accountSchema = memberSchema.pick({
|
|
@@ -65,22 +67,46 @@ const accountDetailsSchema = accountSchema.extend({
|
|
|
65
67
|
sessionCount: z.number().int().nonnegative()
|
|
66
68
|
});
|
|
67
69
|
const localPasswordSchema = z.object({ password: z.string().min(12).max(128) }).strict();
|
|
70
|
+
const adminWorkspaceQuerySchema = accountQuerySchema.pick({
|
|
71
|
+
q: true,
|
|
72
|
+
limit: true,
|
|
73
|
+
offset: true
|
|
74
|
+
});
|
|
75
|
+
const adminWorkspacesSchema = z.object({
|
|
76
|
+
workspaces: z.array(z.object({
|
|
77
|
+
id: z.uuid(),
|
|
78
|
+
name: z.string(),
|
|
79
|
+
slug: z.string(),
|
|
80
|
+
ownerId: z.string().nullable().default(null),
|
|
81
|
+
createdAt: z.iso.datetime(),
|
|
82
|
+
memberCount: z.number().int().nonnegative(),
|
|
83
|
+
projectCount: z.number().int().nonnegative(),
|
|
84
|
+
administrators: z.array(accountSchema.pick({
|
|
85
|
+
id: true,
|
|
86
|
+
name: true,
|
|
87
|
+
email: true,
|
|
88
|
+
image: true
|
|
89
|
+
}))
|
|
90
|
+
})),
|
|
91
|
+
total: z.number().int().nonnegative()
|
|
92
|
+
});
|
|
68
93
|
//#endregion
|
|
69
94
|
//#region src/account-administration.ts
|
|
70
95
|
const person = {
|
|
71
|
-
id: user.id,
|
|
72
|
-
name: user.name,
|
|
73
|
-
email: user.email,
|
|
74
|
-
image: user.image,
|
|
75
|
-
role: user.role,
|
|
76
|
-
banned: sql`coalesce(${user.banned}, false)`,
|
|
77
|
-
createdAt: user.createdAt
|
|
96
|
+
id: user$1.id,
|
|
97
|
+
name: user$1.name,
|
|
98
|
+
email: user$1.email,
|
|
99
|
+
image: user$1.image,
|
|
100
|
+
role: user$1.role,
|
|
101
|
+
banned: sql`coalesce(${user$1.banned}, false)`,
|
|
102
|
+
createdAt: user$1.createdAt
|
|
78
103
|
};
|
|
79
104
|
const mutationBody = z.object({
|
|
80
105
|
userId: z.string().optional(),
|
|
81
106
|
role: z.union([z.string(), z.array(z.string())]).optional(),
|
|
82
107
|
data: z.record(z.string(), z.unknown()).optional()
|
|
83
108
|
}).passthrough();
|
|
109
|
+
const pattern = (q) => `%${q.replace(/[\\%_]/g, "\\$&")}%`;
|
|
84
110
|
function authPath(request) {
|
|
85
111
|
try {
|
|
86
112
|
return decodeURIComponent(new URL(request.url).pathname).replace(/\/+/g, "/").replace(/\/$/, "");
|
|
@@ -99,7 +125,7 @@ function createAccountAdministration(database, auth, config) {
|
|
|
99
125
|
fail(503, "session_revocation_failed", "The password changed, but existing sessions could not be revoked. Revoke this user’s sessions before continuing.");
|
|
100
126
|
}
|
|
101
127
|
}
|
|
102
|
-
async function mutate(request, run, administratorOnly = true) {
|
|
128
|
+
async function mutate(request, run, administratorOnly = true, subjectId) {
|
|
103
129
|
const login = await auth.api.getSession({ headers: request.headers });
|
|
104
130
|
if (!login) fail(401, "unauthorized", "Sign in to continue");
|
|
105
131
|
if (administratorOnly && !hasAdminRole(login.user.role) || login.user.banned) fail(403, "forbidden", "Instance administrator access is required");
|
|
@@ -108,6 +134,7 @@ function createAccountAdministration(database, auth, config) {
|
|
|
108
134
|
try {
|
|
109
135
|
await lock.query("SELECT pg_advisory_lock(7361083)");
|
|
110
136
|
locked = true;
|
|
137
|
+
await lock.query("SELECT pg_advisory_lock(7361080)");
|
|
111
138
|
const path = authPath(request);
|
|
112
139
|
if (path === "/api/auth/unlink-account") {
|
|
113
140
|
if (!one((await lock.query("SELECT email_password_enabled AS enabled FROM instance_settings WHERE id=1")).rows).enabled) {
|
|
@@ -116,25 +143,29 @@ function createAccountAdministration(database, auth, config) {
|
|
|
116
143
|
if (!(await lock.query(`SELECT account_id AS "accountId", id_token AS "idToken" FROM account WHERE user_id=$1 AND provider_id='oidc' AND id<>$2`, [login.user.id, accountId])).rows.some((binding) => matchesOIDCBinding(binding, issuer, config.OIDC_CLIENT_ID))) fail(409, "oidc_binding_required", "Enable password sign-in before unlinking your last account for the configured OIDC provider");
|
|
117
144
|
}
|
|
118
145
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
if (path.endsWith("/admin/remove-user")) fail(403, "forbidden", "Delete accounts with delete_account so their history stays intact");
|
|
147
|
+
const body = path.startsWith("/api/auth/admin/") ? mutationBody.parse(await request.clone().json()) : void 0;
|
|
148
|
+
const userId = subjectId ?? body?.userId;
|
|
149
|
+
const passwordUserId = path.endsWith("/admin/set-user-password") ? userId : void 0;
|
|
150
|
+
if (administratorOnly && userId) {
|
|
151
|
+
const current = one((await lock.query(`SELECT id, role, coalesce(banned,false) AS banned FROM "user" WHERE id = $1 AND deleted_at IS NULL`, [userId])).rows);
|
|
152
|
+
if (body && [
|
|
153
|
+
"set-role",
|
|
154
|
+
"update-user",
|
|
155
|
+
"ban-user"
|
|
156
|
+
].some((name) => path.endsWith(`/admin/${name}`))) {
|
|
130
157
|
const role = body.role ?? body.data?.role ?? current.role;
|
|
131
158
|
const candidate = z.union([z.string(), z.array(z.string())]).nullable().safeParse(role);
|
|
132
159
|
const nextRole = candidate.success ? Array.isArray(candidate.data) ? candidate.data.join(",") : candidate.data : current.role;
|
|
133
160
|
if (body.data && Object.hasOwn(body.data, "banned")) z.boolean().parse(body.data.banned);
|
|
134
161
|
const banned = path.endsWith("/ban-user") || body.data?.banned === true || body.data?.banned === void 0 && current.banned;
|
|
135
|
-
|
|
136
|
-
|
|
162
|
+
const guard = drizzle(lock, { schema: auth_schema_exports });
|
|
163
|
+
if (banned && !current.banned) {
|
|
164
|
+
const owned = await ownedWorkspaces(guard, current.id);
|
|
165
|
+
const [blocker] = ownerBlocker(owned);
|
|
166
|
+
if (blocker) fail(409, blocker.code, blocker.message, { workspaces: owned });
|
|
137
167
|
}
|
|
168
|
+
if (hasAdminRole(current.role) && !current.banned && (!hasAdminRole(nextRole) || banned) && !await otherActiveAdministrator(guard, current.id)) fail(409, "last_administrator", "Keep at least one active instance administrator");
|
|
138
169
|
}
|
|
139
170
|
}
|
|
140
171
|
const response = await run();
|
|
@@ -142,7 +173,7 @@ function createAccountAdministration(database, auth, config) {
|
|
|
142
173
|
return response;
|
|
143
174
|
} finally {
|
|
144
175
|
try {
|
|
145
|
-
if (locked) await lock.query("SELECT
|
|
176
|
+
if (locked) await lock.query("SELECT pg_advisory_unlock_all()");
|
|
146
177
|
} finally {
|
|
147
178
|
lock.release();
|
|
148
179
|
}
|
|
@@ -157,7 +188,8 @@ function createAccountAdministration(database, auth, config) {
|
|
|
157
188
|
"ban-user",
|
|
158
189
|
"unban-user",
|
|
159
190
|
"remove-user",
|
|
160
|
-
"set-user-password"
|
|
191
|
+
"set-user-password",
|
|
192
|
+
"impersonate-user"
|
|
161
193
|
].some((name) => path === `/api/auth/admin/${name}`);
|
|
162
194
|
const unlink = path === "/api/auth/unlink-account";
|
|
163
195
|
return request.method === "POST" && (guarded || unlink) ? mutate(request, () => auth.handler(request), !unlink) : auth.handler(request);
|
|
@@ -183,6 +215,16 @@ function createAccountAdministration(database, auth, config) {
|
|
|
183
215
|
content: { "application/json": { schema: accountDetailsSchema } }
|
|
184
216
|
} }
|
|
185
217
|
});
|
|
218
|
+
app.openAPIRegistry.registerPath({
|
|
219
|
+
method: "get",
|
|
220
|
+
path: "/api/v1/admin/workspaces",
|
|
221
|
+
security: [{ sessionCookie: [] }],
|
|
222
|
+
request: { query: adminWorkspaceQuerySchema },
|
|
223
|
+
responses: { 200: {
|
|
224
|
+
description: "Every workspace with member and project counts and its administrators",
|
|
225
|
+
content: { "application/json": { schema: adminWorkspacesSchema } }
|
|
226
|
+
} }
|
|
227
|
+
});
|
|
186
228
|
app.openAPIRegistry.registerPath({
|
|
187
229
|
method: "post",
|
|
188
230
|
path: "/api/v1/admin/users/{id}/password",
|
|
@@ -200,17 +242,17 @@ function createAccountAdministration(database, auth, config) {
|
|
|
200
242
|
} }
|
|
201
243
|
});
|
|
202
244
|
app.get("/api/v1/admin/users", async (c) => {
|
|
203
|
-
const input = accountQuerySchema.parse(c.req.query()), needle =
|
|
204
|
-
const administrator = sql`'admin' = ANY(string_to_array(${user.role}, ','))`;
|
|
205
|
-
const filter = and(input.q ? or(ilike(user.name, needle), ilike(user.email, needle)) : void 0, input.role === "admin" ? administrator : input.role === "user" ? sql`NOT coalesce(${administrator},false)` : void 0, input.status === "all" ? void 0 : input.status === "disabled" ? eq(user.banned, true) : sql`NOT coalesce(${user.banned},false)`);
|
|
206
|
-
const [users, total] = await Promise.all([database.db.select(person).from(user).where(filter).orderBy(asc(user.name), asc(user.id)).limit(input.limit).offset(input.offset), database.db.select({ value: count() }).from(user).where(filter)]);
|
|
245
|
+
const input = accountQuerySchema.parse(c.req.query()), needle = pattern(input.q);
|
|
246
|
+
const administrator = sql`'admin' = ANY(string_to_array(${user$1.role}, ','))`;
|
|
247
|
+
const filter = and(isNull(user$1.deletedAt), input.q ? or(ilike(user$1.name, needle), ilike(user$1.email, needle)) : void 0, input.role === "admin" ? administrator : input.role === "user" ? sql`NOT coalesce(${administrator},false)` : void 0, input.status === "all" ? void 0 : input.status === "disabled" ? eq(user$1.banned, true) : sql`NOT coalesce(${user$1.banned},false)`);
|
|
248
|
+
const [users, total] = await Promise.all([database.db.select(person).from(user$1).where(filter).orderBy(asc(user$1.name), asc(user$1.id)).limit(input.limit).offset(input.offset), database.db.select({ value: count() }).from(user$1).where(filter)]);
|
|
207
249
|
return Response.json(json({
|
|
208
250
|
users,
|
|
209
251
|
total: total[0]?.value ?? 0
|
|
210
252
|
}));
|
|
211
253
|
});
|
|
212
254
|
app.get("/api/v1/admin/users/:id", async (c) => {
|
|
213
|
-
const id = c.req.param("id"), row = one(await database.db.select(person).from(user).where(eq(user.id, id)));
|
|
255
|
+
const id = c.req.param("id"), row = one(await database.db.select(person).from(user$1).where(and(eq(user$1.id, id), isNull(user$1.deletedAt))));
|
|
214
256
|
const [spaces, direct, providers, sessions] = await Promise.all([
|
|
215
257
|
database.db.select({
|
|
216
258
|
id: workspaces.id,
|
|
@@ -233,6 +275,38 @@ function createAccountAdministration(database, auth, config) {
|
|
|
233
275
|
sessionCount: sessions[0]?.value ?? 0
|
|
234
276
|
}));
|
|
235
277
|
});
|
|
278
|
+
app.get("/api/v1/admin/workspaces", async (c) => {
|
|
279
|
+
const input = adminWorkspaceQuerySchema.parse(c.req.query()), needle = pattern(input.q);
|
|
280
|
+
const filter = and(isNull(workspaces.deletedAt), input.q ? or(ilike(workspaces.name, needle), ilike(workspaces.slug, needle)) : void 0);
|
|
281
|
+
const [rows, total] = await Promise.all([database.db.select({
|
|
282
|
+
id: workspaces.id,
|
|
283
|
+
name: workspaces.name,
|
|
284
|
+
slug: workspaces.slug,
|
|
285
|
+
ownerId: workspaces.ownerId,
|
|
286
|
+
createdAt: workspaces.createdAt,
|
|
287
|
+
memberCount: sql`(SELECT count(*)::int FROM workspace_members m WHERE m.workspace_id = workspaces.id)`,
|
|
288
|
+
projectCount: sql`(SELECT count(*)::int FROM projects p WHERE p.workspace_id = workspaces.id AND p.deleted_at IS NULL)`
|
|
289
|
+
}).from(workspaces).where(filter).orderBy(asc(workspaces.name), asc(workspaces.id)).limit(input.limit).offset(input.offset), database.db.select({ value: count() }).from(workspaces).where(filter)]);
|
|
290
|
+
const administrators = rows.length ? await database.db.select({
|
|
291
|
+
workspaceId: workspaceMembers.workspaceId,
|
|
292
|
+
id: user$1.id,
|
|
293
|
+
name: user$1.name,
|
|
294
|
+
email: user$1.email,
|
|
295
|
+
image: user$1.image
|
|
296
|
+
}).from(workspaceMembers).innerJoin(user$1, eq(user$1.id, workspaceMembers.userId)).where(and(eq(workspaceMembers.role, "admin"), inArray(workspaceMembers.workspaceId, rows.map((row) => row.id)))).orderBy(asc(user$1.name), asc(user$1.id)) : [];
|
|
297
|
+
return Response.json(json({
|
|
298
|
+
workspaces: rows.map((row) => ({
|
|
299
|
+
...row,
|
|
300
|
+
administrators: administrators.filter((admin) => admin.workspaceId === row.id).map(({ id, name, email, image }) => ({
|
|
301
|
+
id,
|
|
302
|
+
name,
|
|
303
|
+
email,
|
|
304
|
+
image
|
|
305
|
+
}))
|
|
306
|
+
})),
|
|
307
|
+
total: total[0]?.value ?? 0
|
|
308
|
+
}));
|
|
309
|
+
});
|
|
236
310
|
app.post("/api/v1/admin/users/:id/password", async (c) => mutate(c.req.raw, async () => {
|
|
237
311
|
const input = localPasswordSchema.parse(await c.req.json()), userId = c.req.param("id");
|
|
238
312
|
await auth.api.setUserPassword({
|
|
@@ -244,91 +318,13 @@ function createAccountAdministration(database, auth, config) {
|
|
|
244
318
|
});
|
|
245
319
|
await revokeAfterPasswordChange(c.req.raw.headers, userId);
|
|
246
320
|
return Response.json({ ok: true });
|
|
247
|
-
}));
|
|
321
|
+
}, true, c.req.param("id")));
|
|
248
322
|
}
|
|
249
323
|
};
|
|
250
324
|
}
|
|
251
325
|
//#endregion
|
|
252
|
-
//#region src/
|
|
253
|
-
const
|
|
254
|
-
const credentialMetadata = z.object({ projectIds: z.array(z.uuid()).max(1e3) });
|
|
255
|
-
function createWriteLimiter(max, windowMs = 6e4) {
|
|
256
|
-
const recent = /* @__PURE__ */ new Map();
|
|
257
|
-
return { consume(key, now = Date.now()) {
|
|
258
|
-
const hits = (recent.get(key) ?? []).filter((at) => at > now - windowMs);
|
|
259
|
-
if (hits.length >= max) {
|
|
260
|
-
const retryAfterSeconds = Math.max(1, Math.ceil(((hits[0] ?? now) + windowMs - now) / 1e3));
|
|
261
|
-
fail(429, "rate_limited", "Too many changes in a short time; wait and retry", { retryAfterSeconds });
|
|
262
|
-
}
|
|
263
|
-
hits.push(now);
|
|
264
|
-
recent.set(key, hits);
|
|
265
|
-
if (recent.size > 1e4) {
|
|
266
|
-
for (const [id, stamps] of recent) if (!stamps.some((at) => at > now - windowMs)) recent.delete(id);
|
|
267
|
-
}
|
|
268
|
-
} };
|
|
269
|
-
}
|
|
270
|
-
function requireSession(actor) {
|
|
271
|
-
if (actor.machine) fail(403, "session_required", "Use a signed-in account for this action");
|
|
272
|
-
return actor;
|
|
273
|
-
}
|
|
274
|
-
function requireInstanceAdmin(actor) {
|
|
275
|
-
requireSession(actor);
|
|
276
|
-
if (!actor.admin) fail(403, "forbidden", "Instance administrator access is required");
|
|
277
|
-
return actor;
|
|
278
|
-
}
|
|
279
|
-
function storedJson(value) {
|
|
280
|
-
if (typeof value !== "string") return value;
|
|
281
|
-
try {
|
|
282
|
-
return JSON.parse(value);
|
|
283
|
-
} catch {
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
function createAccess(auth, domain, config, database) {
|
|
288
|
-
async function credentialActor(key) {
|
|
289
|
-
const permissions = credentialPermissions.safeParse(storedJson(key.permissions));
|
|
290
|
-
const metadata = credentialMetadata.safeParse(storedJson(key.metadata));
|
|
291
|
-
if (!permissions.success || !metadata.success) fail(403, "invalid_credential_scope", "This credential has no valid project permissions");
|
|
292
|
-
return domain.actor(key.referenceId, {
|
|
293
|
-
ownerId: key.id,
|
|
294
|
-
scopes: permissions.data.kanvil,
|
|
295
|
-
projectIds: metadata.data.projectIds
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
async function authenticate(request) {
|
|
299
|
-
const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
|
300
|
-
if (token) {
|
|
301
|
-
if (!token.startsWith("kv_")) fail(401, "unauthorized", "Use a Kanvil API credential");
|
|
302
|
-
const result = await auth.api.verifyApiKey({ body: { key: token } });
|
|
303
|
-
if (!result.valid || !result.key) fail(401, "unauthorized", "The API credential is invalid or expired");
|
|
304
|
-
return credentialActor(result.key);
|
|
305
|
-
}
|
|
306
|
-
const session = await auth.api.getSession({ headers: request.headers });
|
|
307
|
-
if (!session) fail(401, "unauthorized", "Sign in to continue");
|
|
308
|
-
return domain.actor(session.user.id);
|
|
309
|
-
}
|
|
310
|
-
async function revalidate(request, identity) {
|
|
311
|
-
if (!identity.machine) return authenticate(request);
|
|
312
|
-
const key = (await database.db.select().from(apikey).where(eq(apikey.id, identity.ownerId)))[0];
|
|
313
|
-
if (!key?.enabled || key.referenceId !== identity.id || key.expiresAt && key.expiresAt <= /* @__PURE__ */ new Date()) fail(401, "unauthorized", "The API credential is invalid or expired");
|
|
314
|
-
return credentialActor(key);
|
|
315
|
-
}
|
|
316
|
-
function checkOrigin(request) {
|
|
317
|
-
if ([
|
|
318
|
-
"GET",
|
|
319
|
-
"HEAD",
|
|
320
|
-
"OPTIONS"
|
|
321
|
-
].includes(request.method)) return;
|
|
322
|
-
const origin = request.headers.get("origin");
|
|
323
|
-
if (origin && !config.trustedOrigins.includes(origin)) fail(403, "invalid_origin", "This origin is not allowed");
|
|
324
|
-
if (request.headers.get("sec-fetch-site") === "cross-site" && !origin) fail(403, "invalid_origin", "Cross-site writes are not allowed");
|
|
325
|
-
}
|
|
326
|
-
return {
|
|
327
|
-
authenticate,
|
|
328
|
-
revalidate,
|
|
329
|
-
checkOrigin
|
|
330
|
-
};
|
|
331
|
-
}
|
|
326
|
+
//#region src/version.ts
|
|
327
|
+
const applicationVersion = "0.3.0";
|
|
332
328
|
//#endregion
|
|
333
329
|
//#region src/realtime.ts
|
|
334
330
|
async function createRealtime(config) {
|
|
@@ -791,7 +787,7 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
|
|
|
791
787
|
},
|
|
792
788
|
deployment: {
|
|
793
789
|
url: instance.config.KANVIL_URL,
|
|
794
|
-
version:
|
|
790
|
+
version: applicationVersion,
|
|
795
791
|
oidc: instance.config.OIDC_DISCOVERY_URL ? {
|
|
796
792
|
name: instance.config.OIDC_NAME,
|
|
797
793
|
discoveryUrl: instance.config.OIDC_DISCOVERY_URL,
|
|
@@ -868,10 +864,10 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
|
|
|
868
864
|
target: profilePhotos.userId,
|
|
869
865
|
set: { objectId: id }
|
|
870
866
|
});
|
|
871
|
-
await tx.update(user).set({
|
|
867
|
+
await tx.update(user$1).set({
|
|
872
868
|
image,
|
|
873
869
|
updatedAt: /* @__PURE__ */ new Date()
|
|
874
|
-
}).where(eq(user.id, actor.id));
|
|
870
|
+
}).where(eq(user$1.id, actor.id));
|
|
875
871
|
});
|
|
876
872
|
blobs.sync().catch(() => {});
|
|
877
873
|
return Response.json({ image });
|
|
@@ -880,10 +876,10 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
|
|
|
880
876
|
const actor = requireSession(c.get("actor"));
|
|
881
877
|
await database.db.transaction(async (tx) => {
|
|
882
878
|
await tx.delete(profilePhotos).where(eq(profilePhotos.userId, actor.id));
|
|
883
|
-
await tx.update(user).set({
|
|
879
|
+
await tx.update(user$1).set({
|
|
884
880
|
image: null,
|
|
885
881
|
updatedAt: /* @__PURE__ */ new Date()
|
|
886
|
-
}).where(eq(user.id, actor.id));
|
|
882
|
+
}).where(eq(user$1.id, actor.id));
|
|
887
883
|
});
|
|
888
884
|
return Response.json({ image: null });
|
|
889
885
|
});
|
|
@@ -895,117 +891,2175 @@ function registerInstanceRoutes(app, database, instance, blobs, search, delivery
|
|
|
895
891
|
});
|
|
896
892
|
}
|
|
897
893
|
//#endregion
|
|
898
|
-
//#region src/
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
894
|
+
//#region src/adapters/github.ts
|
|
895
|
+
const sameSecret = (received, expected) => timingSafeEqual(createHash("sha256").update(received).digest(), createHash("sha256").update(expected).digest());
|
|
896
|
+
const hmacHex = (secret, body) => createHmac("sha256", secret).update(body).digest("hex");
|
|
897
|
+
const repository = z.object({
|
|
898
|
+
full_name: z.string(),
|
|
899
|
+
html_url: z.url({ protocol: /^https?$/ })
|
|
900
|
+
});
|
|
901
|
+
const reference = z.object({
|
|
902
|
+
ref: z.string().min(1),
|
|
903
|
+
ref_type: z.string(),
|
|
904
|
+
repository
|
|
905
|
+
});
|
|
906
|
+
const item$2 = {
|
|
907
|
+
number: z.number().int().positive(),
|
|
908
|
+
html_url: z.url({ protocol: /^https?$/ }),
|
|
909
|
+
title: z.string(),
|
|
910
|
+
state: z.enum(["open", "closed"]),
|
|
911
|
+
updated_at: z.iso.datetime({ offset: true })
|
|
912
|
+
};
|
|
913
|
+
const pullRequest = z.object({
|
|
914
|
+
action: z.string(),
|
|
915
|
+
pull_request: z.object({
|
|
916
|
+
...item$2,
|
|
917
|
+
draft: z.boolean().default(false),
|
|
918
|
+
merged: z.boolean().nullish(),
|
|
919
|
+
merged_at: z.string().nullish(),
|
|
920
|
+
head: z.object({ ref: z.string() })
|
|
921
|
+
}),
|
|
922
|
+
changes: z.object({ title: z.object({ from: z.string() }).optional() }).optional(),
|
|
923
|
+
repository
|
|
924
|
+
});
|
|
925
|
+
const issueFields$1 = z.object({
|
|
926
|
+
...item$2,
|
|
927
|
+
body: z.string().nullish(),
|
|
928
|
+
labels: z.array(z.object({ name: z.string() })).nullish(),
|
|
929
|
+
pull_request: z.unknown().optional()
|
|
930
|
+
});
|
|
931
|
+
const issue$1 = z.object({
|
|
932
|
+
action: z.string(),
|
|
933
|
+
issue: issueFields$1,
|
|
934
|
+
repository
|
|
935
|
+
});
|
|
936
|
+
const issueComment = z.object({
|
|
937
|
+
action: z.string(),
|
|
938
|
+
issue: issueFields$1,
|
|
939
|
+
comment: z.object({
|
|
940
|
+
id: z.union([z.number(), z.string()]),
|
|
941
|
+
body: z.string().nullish(),
|
|
942
|
+
user: z.object({ login: z.string() }).nullish(),
|
|
943
|
+
created_at: z.iso.datetime({ offset: true }).nullish()
|
|
944
|
+
}),
|
|
945
|
+
is_pull: z.boolean().optional(),
|
|
946
|
+
repository
|
|
947
|
+
});
|
|
948
|
+
const githubIssue = (found) => ({
|
|
949
|
+
number: found.number,
|
|
950
|
+
url: found.html_url,
|
|
951
|
+
title: found.title,
|
|
952
|
+
description: found.body ?? "",
|
|
953
|
+
open: found.state === "open",
|
|
954
|
+
labels: (found.labels ?? []).map((label) => label.name),
|
|
955
|
+
updatedAt: new Date(found.updated_at)
|
|
956
|
+
});
|
|
957
|
+
const issueActions = [
|
|
958
|
+
"opened",
|
|
959
|
+
"edited",
|
|
960
|
+
"closed",
|
|
961
|
+
"reopened",
|
|
962
|
+
"labeled",
|
|
963
|
+
"unlabeled",
|
|
964
|
+
"label_updated",
|
|
965
|
+
"label_cleared"
|
|
966
|
+
];
|
|
967
|
+
function githubIssueEvent(event, payload) {
|
|
968
|
+
if (event !== "issues") return null;
|
|
969
|
+
const parsed = issue$1.parse(payload);
|
|
970
|
+
return parsed.issue.pull_request || !issueActions.includes(parsed.action) ? null : githubIssue(parsed.issue);
|
|
971
|
+
}
|
|
972
|
+
function githubComment(event, payload) {
|
|
973
|
+
if (event !== "issue_comment") return null;
|
|
974
|
+
const parsed = issueComment.parse(payload);
|
|
975
|
+
if (parsed.action !== "created" || parsed.issue.pull_request || parsed.is_pull) return null;
|
|
976
|
+
return {
|
|
977
|
+
id: String(parsed.comment.id),
|
|
978
|
+
issue: parsed.issue.number,
|
|
979
|
+
body: parsed.comment.body ?? "",
|
|
980
|
+
author: parsed.comment.user?.login ?? "",
|
|
981
|
+
createdAt: parsed.comment.created_at ? new Date(parsed.comment.created_at) : null
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const readGitHubIssue = (body) => githubIssue(issueFields$1.parse(JSON.parse(body)));
|
|
985
|
+
const githubIssueBody = (changes) => JSON.stringify({
|
|
986
|
+
...changes.title !== void 0 && { title: changes.title },
|
|
987
|
+
...changes.description !== void 0 && { body: changes.description },
|
|
988
|
+
...changes.open !== void 0 && { state: changes.open ? "open" : "closed" }
|
|
989
|
+
});
|
|
990
|
+
const pullRequestTriggers = {
|
|
991
|
+
opened: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
|
|
992
|
+
reopened: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
|
|
993
|
+
ready_for_review: () => "pull_request_opened",
|
|
994
|
+
converted_to_draft: () => "pull_request_draft"
|
|
995
|
+
};
|
|
996
|
+
const workInProgress = (title) => /^\s*(?:wip:|\[wip\])/i.test(title);
|
|
997
|
+
const githubNamed = (payload) => z.object({ repository: repository.optional() }).parse(payload).repository?.full_name ?? null;
|
|
998
|
+
function githubChange(event, payload, { branchPath = "tree", titleDrafts = false } = {}) {
|
|
999
|
+
if (event === "create" || event === "delete") {
|
|
1000
|
+
const parsed = reference.parse(payload);
|
|
1001
|
+
if (parsed.ref_type !== "branch") return null;
|
|
1002
|
+
const name = parsed.ref.replace(/^refs\/heads\//, "");
|
|
1003
|
+
const path = name.split("/").map(encodeURIComponent).join("/");
|
|
1004
|
+
return {
|
|
1005
|
+
kind: "branch",
|
|
1006
|
+
url: `${parsed.repository.html_url}/${branchPath}/${path}`,
|
|
1007
|
+
title: name,
|
|
1008
|
+
number: null,
|
|
1009
|
+
state: event === "create" ? "active" : "deleted",
|
|
1010
|
+
trigger: event === "create" ? "branch_created" : null,
|
|
1011
|
+
references: [name],
|
|
1012
|
+
occurredAt: null,
|
|
1013
|
+
creates: event === "create"
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
if (event === "pull_request") {
|
|
1017
|
+
const { action, pull_request: pr, changes } = pullRequest.parse(payload);
|
|
1018
|
+
const merged = !!(pr.merged ?? pr.merged_at), draft = pr.draft || titleDrafts && workInProgress(pr.title);
|
|
1019
|
+
if (action !== "closed" && action !== "edited" && !pullRequestTriggers[action]) return null;
|
|
1020
|
+
const retitled = titleDrafts && action === "edited" && changes?.title && workInProgress(changes.title.from) !== draft;
|
|
1021
|
+
return {
|
|
1022
|
+
kind: "pull_request",
|
|
1023
|
+
url: pr.html_url,
|
|
1024
|
+
title: pr.title,
|
|
1025
|
+
number: pr.number,
|
|
1026
|
+
state: merged ? "merged" : pr.state === "closed" ? "closed" : draft ? "draft" : "open",
|
|
1027
|
+
trigger: action === "closed" ? merged ? "pull_request_merged" : "pull_request_closed" : retitled ? draft ? "pull_request_draft" : "pull_request_opened" : pullRequestTriggers[action]?.(draft) ?? null,
|
|
1028
|
+
references: [pr.head.ref, pr.title],
|
|
1029
|
+
occurredAt: new Date(pr.updated_at),
|
|
1030
|
+
creates: true
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
if (event === "issues") {
|
|
1034
|
+
const { action, issue: found } = issue$1.parse(payload);
|
|
1035
|
+
if (!issueActions.includes(action)) return null;
|
|
1036
|
+
return {
|
|
1037
|
+
kind: "issue",
|
|
1038
|
+
url: found.html_url,
|
|
1039
|
+
title: found.title,
|
|
1040
|
+
number: found.number,
|
|
1041
|
+
state: found.state,
|
|
1042
|
+
trigger: null,
|
|
1043
|
+
references: [found.title],
|
|
1044
|
+
occurredAt: new Date(found.updated_at),
|
|
1045
|
+
creates: true
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
const headers$2 = (token) => ({
|
|
1051
|
+
Accept: "application/vnd.github+json",
|
|
1052
|
+
Authorization: `Bearer ${token}`,
|
|
1053
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
1054
|
+
});
|
|
1055
|
+
function createGitHubDriver(apiUrl) {
|
|
1056
|
+
const api = (path) => new URL(path, apiUrl.endsWith("/") ? apiUrl : `${apiUrl}/`).href;
|
|
1057
|
+
return {
|
|
1058
|
+
signed: (secret, received, body) => sameSecret(received.get("x-hub-signature-256") ?? "", `sha256=${hmacHex(secret, body)}`),
|
|
1059
|
+
read(received, body) {
|
|
1060
|
+
const id = received.get("x-github-delivery"), event = received.get("x-github-event");
|
|
1061
|
+
if (!id || !event) throw new Error("Missing GitHub delivery headers");
|
|
1062
|
+
const payload = JSON.parse(body);
|
|
1063
|
+
return {
|
|
1064
|
+
id,
|
|
1065
|
+
event,
|
|
1066
|
+
repository: githubNamed(payload),
|
|
1067
|
+
change: githubChange(event, payload),
|
|
1068
|
+
issue: githubIssueEvent(event, payload),
|
|
1069
|
+
comment: githubComment(event, payload)
|
|
1070
|
+
};
|
|
1071
|
+
},
|
|
1072
|
+
access: ({ repository: name }, token) => ({
|
|
1073
|
+
method: "GET",
|
|
1074
|
+
url: api(`repos/${name}`),
|
|
1075
|
+
headers: headers$2(token),
|
|
1076
|
+
body: ""
|
|
1077
|
+
}),
|
|
1078
|
+
comment: ({ repository: name }, token, { number }, text) => ({
|
|
1079
|
+
url: api(`repos/${name}/issues/${number}/comments`),
|
|
1080
|
+
headers: headers$2(token),
|
|
1081
|
+
body: JSON.stringify({ body: text }),
|
|
1082
|
+
read: true
|
|
1083
|
+
}),
|
|
1084
|
+
createIssue: ({ repository: name }, token, content) => ({
|
|
1085
|
+
url: api(`repos/${name}/issues`),
|
|
1086
|
+
headers: headers$2(token),
|
|
1087
|
+
body: githubIssueBody(content),
|
|
1088
|
+
read: true
|
|
1089
|
+
}),
|
|
1090
|
+
updateIssue: ({ repository: name }, token, number, changes) => ({
|
|
1091
|
+
method: "PATCH",
|
|
1092
|
+
url: api(`repos/${name}/issues/${number}`),
|
|
1093
|
+
headers: headers$2(token),
|
|
1094
|
+
body: githubIssueBody(changes),
|
|
1095
|
+
read: true
|
|
1096
|
+
}),
|
|
1097
|
+
issue: readGitHubIssue
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
1101
|
+
//#region src/adapters/gitea.ts
|
|
1102
|
+
const instanceApi = ({ baseUrl }, path) => {
|
|
1103
|
+
if (!baseUrl) throw new DeliveryError("invalid_endpoint", false);
|
|
1104
|
+
return `${baseUrl}/${path}`;
|
|
1105
|
+
};
|
|
1106
|
+
const headers$1 = (token) => ({
|
|
1107
|
+
Accept: "application/json",
|
|
1108
|
+
Authorization: `token ${token}`
|
|
1109
|
+
});
|
|
1110
|
+
function createGiteaDriver() {
|
|
1111
|
+
return {
|
|
1112
|
+
signed: (secret, received, body) => sameSecret(received.get("x-gitea-signature") ?? "", hmacHex(secret, body)),
|
|
1113
|
+
read(received, body) {
|
|
1114
|
+
const id = received.get("x-gitea-delivery"), sent = received.get("x-gitea-event");
|
|
1115
|
+
if (!id || !sent) throw new Error("Missing Gitea delivery headers");
|
|
1116
|
+
const event = sent === "issue_label" ? "issues" : sent;
|
|
1117
|
+
const payload = JSON.parse(body);
|
|
1118
|
+
return {
|
|
1119
|
+
id,
|
|
1120
|
+
event,
|
|
1121
|
+
repository: githubNamed(payload),
|
|
1122
|
+
change: githubChange(event, payload, {
|
|
1123
|
+
branchPath: "src/branch",
|
|
1124
|
+
titleDrafts: true
|
|
1125
|
+
}),
|
|
1126
|
+
issue: githubIssueEvent(event, payload),
|
|
1127
|
+
comment: githubComment(event, payload)
|
|
1128
|
+
};
|
|
1129
|
+
},
|
|
1130
|
+
access: (target, token) => ({
|
|
1131
|
+
method: "GET",
|
|
1132
|
+
url: instanceApi(target, `api/v1/repos/${target.repository}`),
|
|
1133
|
+
headers: headers$1(token),
|
|
1134
|
+
body: ""
|
|
1135
|
+
}),
|
|
1136
|
+
comment: (target, token, { number }, text) => ({
|
|
1137
|
+
url: instanceApi(target, `api/v1/repos/${target.repository}/issues/${number}/comments`),
|
|
1138
|
+
headers: headers$1(token),
|
|
1139
|
+
body: JSON.stringify({ body: text }),
|
|
1140
|
+
read: true
|
|
1141
|
+
}),
|
|
1142
|
+
createIssue: (target, token, content) => ({
|
|
1143
|
+
url: instanceApi(target, `api/v1/repos/${target.repository}/issues`),
|
|
1144
|
+
headers: headers$1(token),
|
|
1145
|
+
body: githubIssueBody(content),
|
|
1146
|
+
read: true
|
|
1147
|
+
}),
|
|
1148
|
+
updateIssue: (target, token, number, changes) => ({
|
|
1149
|
+
method: "PATCH",
|
|
1150
|
+
url: instanceApi(target, `api/v1/repos/${target.repository}/issues/${number}`),
|
|
1151
|
+
headers: headers$1(token),
|
|
1152
|
+
body: githubIssueBody(changes),
|
|
1153
|
+
read: true
|
|
1154
|
+
}),
|
|
1155
|
+
issue: readGitHubIssue
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/adapters/gitlab.ts
|
|
1160
|
+
const project$1 = z.object({
|
|
1161
|
+
path_with_namespace: z.string(),
|
|
1162
|
+
web_url: z.url({ protocol: /^https?$/ })
|
|
1163
|
+
});
|
|
1164
|
+
const push = z.object({
|
|
1165
|
+
ref: z.string(),
|
|
1166
|
+
before: z.string(),
|
|
1167
|
+
after: z.string(),
|
|
1168
|
+
project: project$1
|
|
1169
|
+
});
|
|
1170
|
+
const item$1 = {
|
|
1171
|
+
iid: z.number().int().positive(),
|
|
1172
|
+
url: z.url({ protocol: /^https?$/ }),
|
|
1173
|
+
title: z.string(),
|
|
1174
|
+
state: z.string(),
|
|
1175
|
+
action: z.string().default("update"),
|
|
1176
|
+
updated_at: z.string()
|
|
1177
|
+
};
|
|
1178
|
+
const mergeRequest = z.object({
|
|
1179
|
+
object_attributes: z.object({
|
|
1180
|
+
...item$1,
|
|
1181
|
+
draft: z.boolean().optional(),
|
|
1182
|
+
work_in_progress: z.boolean().optional(),
|
|
1183
|
+
source_branch: z.string()
|
|
1184
|
+
}),
|
|
1185
|
+
changes: z.object({ draft: z.object({ current: z.boolean() }).optional() }).optional(),
|
|
1186
|
+
project: project$1
|
|
1187
|
+
});
|
|
1188
|
+
const issue = z.object({
|
|
1189
|
+
object_attributes: z.object({
|
|
1190
|
+
...item$1,
|
|
1191
|
+
description: z.string().nullish()
|
|
1192
|
+
}),
|
|
1193
|
+
labels: z.array(z.object({ title: z.string() })).nullish(),
|
|
1194
|
+
project: project$1
|
|
1195
|
+
});
|
|
1196
|
+
const note = z.object({
|
|
1197
|
+
object_attributes: z.object({
|
|
1198
|
+
id: z.union([z.number(), z.string()]),
|
|
1199
|
+
note: z.string(),
|
|
1200
|
+
noteable_type: z.string(),
|
|
1201
|
+
created_at: z.string().nullish(),
|
|
1202
|
+
system: z.boolean().nullish()
|
|
1203
|
+
}),
|
|
1204
|
+
issue: z.object({ iid: z.number().int().positive() }).optional(),
|
|
1205
|
+
user: z.object({ username: z.string() }).nullish(),
|
|
1206
|
+
project: project$1
|
|
1207
|
+
});
|
|
1208
|
+
const apiIssue = z.object({
|
|
1209
|
+
iid: z.number().int().positive(),
|
|
1210
|
+
web_url: z.url({ protocol: /^https?$/ }),
|
|
1211
|
+
title: z.string(),
|
|
1212
|
+
description: z.string().nullish(),
|
|
1213
|
+
state: z.string(),
|
|
1214
|
+
labels: z.array(z.string()).nullish(),
|
|
1215
|
+
updated_at: z.string()
|
|
1216
|
+
});
|
|
1217
|
+
const absent = /^0+$/;
|
|
1218
|
+
function hostTime(value) {
|
|
1219
|
+
const time = new Date(value.replace(/^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) UTC$/, "$1T$2Z"));
|
|
1220
|
+
return Number.isNaN(time.getTime()) ? null : time;
|
|
1221
|
+
}
|
|
1222
|
+
const mergeRequestTriggers = {
|
|
1223
|
+
open: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
|
|
1224
|
+
reopen: (draft) => draft ? "pull_request_draft" : "pull_request_opened",
|
|
1225
|
+
merge: () => "pull_request_merged",
|
|
1226
|
+
close: () => "pull_request_closed"
|
|
1227
|
+
};
|
|
1228
|
+
function issueOf(payload) {
|
|
1229
|
+
if (z.object({ object_kind: z.string() }).parse(payload).object_kind !== "issue") return null;
|
|
1230
|
+
const { object_attributes: found, labels } = issue.parse(payload);
|
|
1231
|
+
if (![
|
|
1232
|
+
"open",
|
|
1233
|
+
"update",
|
|
1234
|
+
"close",
|
|
1235
|
+
"reopen"
|
|
1236
|
+
].includes(found.action)) return null;
|
|
1237
|
+
return {
|
|
1238
|
+
number: found.iid,
|
|
1239
|
+
url: found.url,
|
|
1240
|
+
title: found.title,
|
|
1241
|
+
description: found.description ?? "",
|
|
1242
|
+
open: found.state === "opened",
|
|
1243
|
+
labels: (labels ?? []).map((label) => label.title),
|
|
1244
|
+
updatedAt: hostTime(found.updated_at)
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
function commentOf(payload) {
|
|
1248
|
+
if (z.object({ object_kind: z.string() }).parse(payload).object_kind !== "note") return null;
|
|
1249
|
+
const parsed = note.parse(payload), found = parsed.object_attributes;
|
|
1250
|
+
if (found.noteable_type !== "Issue" || found.system || !parsed.issue) return null;
|
|
1251
|
+
return {
|
|
1252
|
+
id: String(found.id),
|
|
1253
|
+
issue: parsed.issue.iid,
|
|
1254
|
+
body: found.note,
|
|
1255
|
+
author: parsed.user?.username ?? "",
|
|
1256
|
+
createdAt: found.created_at ? hostTime(found.created_at) : null
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
function change(payload) {
|
|
1260
|
+
const kind = z.object({ object_kind: z.string() }).parse(payload).object_kind;
|
|
1261
|
+
if (kind === "push") {
|
|
1262
|
+
const parsed = push.parse(payload);
|
|
1263
|
+
const created = absent.test(parsed.before), deleted = absent.test(parsed.after);
|
|
1264
|
+
if (!parsed.ref.startsWith("refs/heads/") || created === deleted) return null;
|
|
1265
|
+
const name = parsed.ref.slice(11);
|
|
1266
|
+
return {
|
|
1267
|
+
kind: "branch",
|
|
1268
|
+
url: `${parsed.project.web_url}/-/tree/${name.split("/").map(encodeURIComponent).join("/")}`,
|
|
1269
|
+
title: name,
|
|
1270
|
+
number: null,
|
|
1271
|
+
state: created ? "active" : "deleted",
|
|
1272
|
+
trigger: created ? "branch_created" : null,
|
|
1273
|
+
references: [name],
|
|
1274
|
+
occurredAt: null,
|
|
1275
|
+
creates: created
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
if (kind === "merge_request") {
|
|
1279
|
+
const { object_attributes: mr, changes } = mergeRequest.parse(payload);
|
|
1280
|
+
const draft = mr.draft ?? mr.work_in_progress ?? false;
|
|
1281
|
+
if (mr.action !== "update" && !mergeRequestTriggers[mr.action]) return null;
|
|
1282
|
+
return {
|
|
1283
|
+
kind: "pull_request",
|
|
1284
|
+
url: mr.url,
|
|
1285
|
+
title: mr.title,
|
|
1286
|
+
number: mr.iid,
|
|
1287
|
+
state: mr.state === "merged" || mr.state === "closed" ? mr.state : draft ? "draft" : "open",
|
|
1288
|
+
trigger: mr.action === "update" ? changes?.draft ? changes.draft.current ? "pull_request_draft" : "pull_request_opened" : null : mergeRequestTriggers[mr.action]?.(draft) ?? null,
|
|
1289
|
+
references: [mr.source_branch, mr.title],
|
|
1290
|
+
occurredAt: hostTime(mr.updated_at),
|
|
1291
|
+
creates: true
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
if (kind === "issue") {
|
|
1295
|
+
const { object_attributes: found } = issue.parse(payload);
|
|
1296
|
+
if (![
|
|
1297
|
+
"open",
|
|
1298
|
+
"update",
|
|
1299
|
+
"close",
|
|
1300
|
+
"reopen"
|
|
1301
|
+
].includes(found.action)) return null;
|
|
1302
|
+
return {
|
|
1303
|
+
kind: "issue",
|
|
1304
|
+
url: found.url,
|
|
1305
|
+
title: found.title,
|
|
1306
|
+
number: found.iid,
|
|
1307
|
+
state: found.state === "opened" ? "open" : found.state,
|
|
1308
|
+
trigger: null,
|
|
1309
|
+
references: [found.title],
|
|
1310
|
+
occurredAt: hostTime(found.updated_at),
|
|
1311
|
+
creates: true
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
const headers = (token) => ({
|
|
1317
|
+
Accept: "application/json",
|
|
1318
|
+
"PRIVATE-TOKEN": token
|
|
1319
|
+
});
|
|
1320
|
+
const projectApi = (target, path = "") => instanceApi(target, `api/v4/projects/${encodeURIComponent(target.repository)}${path}`);
|
|
1321
|
+
function createGitLabDriver() {
|
|
1322
|
+
return {
|
|
1323
|
+
signed: (secret, received) => sameSecret(received.get("x-gitlab-token") ?? "", secret),
|
|
1324
|
+
read(received, body) {
|
|
1325
|
+
const id = received.get("idempotency-key") ?? received.get("x-gitlab-event-uuid"), event = received.get("x-gitlab-event");
|
|
1326
|
+
if (!id || !event) throw new Error("Missing GitLab delivery headers");
|
|
1327
|
+
const payload = JSON.parse(body);
|
|
1328
|
+
return {
|
|
1329
|
+
id,
|
|
1330
|
+
event,
|
|
1331
|
+
repository: z.object({ project: project$1.partial().optional() }).parse(payload).project?.path_with_namespace ?? null,
|
|
1332
|
+
change: change(payload),
|
|
1333
|
+
issue: issueOf(payload),
|
|
1334
|
+
comment: commentOf(payload)
|
|
1335
|
+
};
|
|
1336
|
+
},
|
|
1337
|
+
access: (target, token) => ({
|
|
1338
|
+
method: "GET",
|
|
1339
|
+
url: projectApi(target),
|
|
1340
|
+
headers: headers(token),
|
|
1341
|
+
body: ""
|
|
1342
|
+
}),
|
|
1343
|
+
comment: (target, token, { kind, number }, text) => ({
|
|
1344
|
+
url: projectApi(target, `/${kind === "pull_request" ? "merge_requests" : "issues"}/${number}/notes`),
|
|
1345
|
+
headers: headers(token),
|
|
1346
|
+
body: JSON.stringify({ body: text }),
|
|
1347
|
+
read: true
|
|
1348
|
+
}),
|
|
1349
|
+
createIssue: (target, token, content) => ({
|
|
1350
|
+
url: projectApi(target, "/issues"),
|
|
1351
|
+
headers: headers(token),
|
|
1352
|
+
body: JSON.stringify({
|
|
1353
|
+
title: content.title,
|
|
1354
|
+
description: content.description
|
|
1355
|
+
}),
|
|
1356
|
+
read: true
|
|
1357
|
+
}),
|
|
1358
|
+
updateIssue: (target, token, number, changes) => ({
|
|
1359
|
+
method: "PUT",
|
|
1360
|
+
url: projectApi(target, `/issues/${number}`),
|
|
1361
|
+
headers: headers(token),
|
|
1362
|
+
body: JSON.stringify({
|
|
1363
|
+
...changes.title !== void 0 && { title: changes.title },
|
|
1364
|
+
...changes.description !== void 0 && { description: changes.description },
|
|
1365
|
+
...changes.open !== void 0 && { state_event: changes.open ? "reopen" : "close" }
|
|
1366
|
+
}),
|
|
1367
|
+
read: true
|
|
1368
|
+
}),
|
|
1369
|
+
issue(body) {
|
|
1370
|
+
const found = apiIssue.parse(JSON.parse(body));
|
|
1371
|
+
return {
|
|
1372
|
+
number: found.iid,
|
|
1373
|
+
url: found.web_url,
|
|
1374
|
+
title: found.title,
|
|
1375
|
+
description: found.description ?? "",
|
|
1376
|
+
open: found.state === "opened",
|
|
1377
|
+
labels: found.labels ?? [],
|
|
1378
|
+
updatedAt: hostTime(found.updated_at)
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/adapters/source-bundle.ts
|
|
1385
|
+
const byStatus = (status) => status >= 200 && status < 300 ? "ok" : status === 401 || status === 403 ? "rejected" : status === 404 ? "not_found" : status === 429 ? "limited" : status >= 500 ? "failing" : "invalid";
|
|
1386
|
+
const attempts = 4;
|
|
1387
|
+
const limitWindows = {
|
|
1388
|
+
trello: 1e4,
|
|
1389
|
+
plane: 6e4,
|
|
1390
|
+
kaneo: 6e4
|
|
1391
|
+
};
|
|
1392
|
+
function delay(provider, response, attempt, outcome) {
|
|
1393
|
+
const header = response?.headers?.["retry-after"], value = Array.isArray(header) ? header[0] : header;
|
|
1394
|
+
const seconds = value === void 0 ? NaN : /^\d+$/.test(value) ? Number(value) : (Date.parse(value) - Date.now()) / 1e3;
|
|
1395
|
+
if (Number.isFinite(seconds)) return Math.min(Math.max(seconds, 0), 30) * 1e3;
|
|
1396
|
+
const window = outcome === "limited" ? limitWindows[provider] : void 0;
|
|
1397
|
+
return window ? window / 3 : 2 ** (attempt - 1) * 1e3;
|
|
1398
|
+
}
|
|
1399
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1400
|
+
const unreadable = (provider) => new ApiError(502, "source_unavailable", `${importProviderDefinitions[provider].label} sent a response Kanvil cannot read`);
|
|
1401
|
+
function readable(provider, schema, data) {
|
|
1402
|
+
const parsed = schema.safeParse(data);
|
|
1403
|
+
if (!parsed.success) throw unreadable(provider);
|
|
1404
|
+
return parsed.data;
|
|
1405
|
+
}
|
|
1406
|
+
async function readSource(http, provider, request, judge = byStatus, { retry = true } = {}) {
|
|
1407
|
+
const { label, project } = importProviderDefinitions[provider];
|
|
1408
|
+
for (let attempt = 1;; attempt++) {
|
|
1409
|
+
let response;
|
|
1410
|
+
try {
|
|
1411
|
+
response = await http({
|
|
1412
|
+
...request,
|
|
1413
|
+
read: true
|
|
1414
|
+
});
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
if (!(error instanceof DeliveryError)) throw error;
|
|
1417
|
+
if (retry && error.code === "network_error" && attempt < attempts) {
|
|
1418
|
+
await wait(delay(provider, void 0, attempt));
|
|
928
1419
|
continue;
|
|
929
1420
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1421
|
+
throw new ApiError(502, "source_unavailable", `${label} could not be reached (${error.code})`);
|
|
1422
|
+
}
|
|
1423
|
+
let data;
|
|
1424
|
+
try {
|
|
1425
|
+
data = JSON.parse(response.body ?? "");
|
|
1426
|
+
} catch {
|
|
1427
|
+
data = void 0;
|
|
1428
|
+
}
|
|
1429
|
+
const outcome = judge(response.status, data);
|
|
1430
|
+
if (outcome === "ok") {
|
|
1431
|
+
if (data === void 0) throw unreadable(provider);
|
|
1432
|
+
return data;
|
|
1433
|
+
}
|
|
1434
|
+
if (retry && (outcome === "limited" || outcome === "failing") && attempt < attempts) {
|
|
1435
|
+
await wait(delay(provider, response, attempt, outcome));
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
if (outcome === "rejected") throw new ApiError(422, "source_rejected", `${label} refused these credentials, or they cannot read this ${project.toLowerCase()}`);
|
|
1439
|
+
if (outcome === "not_found") throw new ApiError(404, "not_found", `${label} has no such ${project.toLowerCase()}`);
|
|
1440
|
+
if (outcome === "limited") throw new ApiError(429, "source_rate_limited", `${label} is limiting requests; try again later`);
|
|
1441
|
+
throw new ApiError(502, "source_unavailable", `${label} answered with an error (${response.status})`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
const category = (name, fallback) => defaultWorkflow.find((state) => state.name.toLowerCase() === name.trim().toLowerCase())?.category ?? fallback;
|
|
1445
|
+
const dueAt = (date) => date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? `${date}T23:59:59.999Z` : null;
|
|
1446
|
+
const dueTime = (value) => value ? dueAt(value) ?? isoTime(value) : null;
|
|
1447
|
+
const startTime = (value) => !value ? null : /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : isoTime(value);
|
|
1448
|
+
const isoTime = (value) => new Date(value.replace(/([+-]\d{2})(\d{2})$/, "$1:$2")).toISOString();
|
|
1449
|
+
const kindOf = (names) => names.some((name) => name.trim().toLowerCase() === "bug") ? "bug" : "feature";
|
|
1450
|
+
function toneOf(color) {
|
|
1451
|
+
if (!color) return "gray";
|
|
1452
|
+
if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
|
|
1453
|
+
return color.toLowerCase().split(/[^a-z]+/).find(isPresetColor) ?? "gray";
|
|
1454
|
+
}
|
|
1455
|
+
function withChecklists(description, checklists) {
|
|
1456
|
+
const lists = checklists.filter((list) => list.items.length).map((list) => `### ${list.name.trim() || "Checklist"}\n\n${list.items.map((item) => `- [${item.done ? "x" : " "}] ${item.name.trim()}`).join("\n")}`);
|
|
1457
|
+
return [description.trim(), ...lists].filter(Boolean).join("\n\n");
|
|
1458
|
+
}
|
|
1459
|
+
const formerMember = (ref) => ({
|
|
1460
|
+
ref,
|
|
1461
|
+
name: "Former member",
|
|
1462
|
+
email: ""
|
|
1463
|
+
});
|
|
1464
|
+
function bundleBuilder(provider) {
|
|
1465
|
+
const { label, project } = importProviderDefinitions[provider];
|
|
1466
|
+
const people = /* @__PURE__ */ new Map(), labels = /* @__PURE__ */ new Map(), notes = /* @__PURE__ */ new Map();
|
|
1467
|
+
const tickets = [], relations = [], comments = [];
|
|
1468
|
+
const within = (size, limit, what) => {
|
|
1469
|
+
if (size >= limit) throw new ApiError(400, "validation_error", `This ${project.toLowerCase()} has more ${what} than an import holds (${limit})`);
|
|
1470
|
+
};
|
|
1471
|
+
const note = (content, reason, count = 1) => {
|
|
1472
|
+
const entry = notes.get(content) ?? {
|
|
1473
|
+
content,
|
|
1474
|
+
count: count === null ? null : 0,
|
|
1475
|
+
reason
|
|
1476
|
+
};
|
|
1477
|
+
if (entry.count !== null && count !== null) entry.count += count;
|
|
1478
|
+
notes.set(content, entry);
|
|
1479
|
+
};
|
|
1480
|
+
const clip = (value, max) => {
|
|
1481
|
+
if (value.length <= max) return value;
|
|
1482
|
+
note("shortened text", "Titles, descriptions, comments, or label names longer than Kanvil holds were cut at its limit.");
|
|
1483
|
+
return value.slice(0, max);
|
|
1484
|
+
};
|
|
1485
|
+
const person = (who) => {
|
|
1486
|
+
if (!who) return null;
|
|
1487
|
+
if (!people.has(who.ref)) {
|
|
1488
|
+
within(people.size, importLimits.members, "people");
|
|
1489
|
+
people.set(who.ref, who);
|
|
1490
|
+
}
|
|
1491
|
+
return who.ref;
|
|
1492
|
+
};
|
|
1493
|
+
return {
|
|
1494
|
+
note,
|
|
1495
|
+
clip,
|
|
1496
|
+
person,
|
|
1497
|
+
assignee(who) {
|
|
1498
|
+
if (who.length > 1) note("additional assignees", "A ticket has one assignee; items with several keep the first.", who.length - 1);
|
|
1499
|
+
return person(who[0]);
|
|
1500
|
+
},
|
|
1501
|
+
labels(items) {
|
|
1502
|
+
const names = [];
|
|
1503
|
+
for (const item of items) {
|
|
1504
|
+
const name = clip(item.name.trim(), 40);
|
|
1505
|
+
if (!name || names.includes(name)) continue;
|
|
1506
|
+
if (!labels.has(name)) {
|
|
1507
|
+
within(labels.size, importLimits.labels, "labels");
|
|
1508
|
+
labels.set(name, {
|
|
1509
|
+
name,
|
|
1510
|
+
color: toneOf(item.color)
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
names.push(name);
|
|
933
1514
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1515
|
+
if (names.length > 30) note("labels past 30", "A ticket holds 30 labels; issues with more keep their first 30.");
|
|
1516
|
+
return names.slice(0, 30);
|
|
1517
|
+
},
|
|
1518
|
+
ticket(ticket) {
|
|
1519
|
+
within(tickets.length, importLimits.tickets, "issues");
|
|
1520
|
+
tickets.push(ticket);
|
|
1521
|
+
},
|
|
1522
|
+
relation(relation) {
|
|
1523
|
+
within(relations.length, importLimits.relations, "relationships");
|
|
1524
|
+
relations.push(relation);
|
|
1525
|
+
},
|
|
1526
|
+
comment(comment) {
|
|
1527
|
+
within(comments.length, importLimits.comments, "comments");
|
|
1528
|
+
comments.push(comment);
|
|
1529
|
+
},
|
|
1530
|
+
build(source, workflow) {
|
|
1531
|
+
if (!workflow.length) workflow.push({
|
|
1532
|
+
ref: "kanvil:todo",
|
|
1533
|
+
name: stateLabels.todo,
|
|
1534
|
+
category: "todo",
|
|
1535
|
+
color: "default"
|
|
1536
|
+
});
|
|
1537
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1538
|
+
for (const state of workflow) {
|
|
1539
|
+
const base = state.name || "Untitled";
|
|
1540
|
+
let name = base;
|
|
1541
|
+
for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base.slice(0, 40 - ` ${n}`.length)} ${n}`;
|
|
1542
|
+
taken.add(name.toLowerCase());
|
|
1543
|
+
state.name = name;
|
|
1544
|
+
}
|
|
1545
|
+
return {
|
|
1546
|
+
format: ticketExportFormat,
|
|
1547
|
+
version: 1,
|
|
1548
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1549
|
+
project: {
|
|
1550
|
+
ref: source.ref,
|
|
1551
|
+
key: source.key.slice(0, 20),
|
|
1552
|
+
name: clip(source.name.trim(), 120) || label,
|
|
1553
|
+
description: clip(source.description, 5e3)
|
|
1554
|
+
},
|
|
1555
|
+
completeness: { omitted: [...notes.values()] },
|
|
1556
|
+
members: [...people.values()],
|
|
1557
|
+
workflow,
|
|
1558
|
+
customFields: [],
|
|
1559
|
+
labels: [...labels.values()],
|
|
1560
|
+
tickets,
|
|
1561
|
+
relations,
|
|
1562
|
+
comments,
|
|
1563
|
+
evidence: [],
|
|
1564
|
+
resources: []
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
const sourceTicket = (ticket) => ({
|
|
1570
|
+
acceptanceCriteria: "",
|
|
1571
|
+
difficulty: null,
|
|
1572
|
+
scopes: [],
|
|
1573
|
+
automationReady: false,
|
|
1574
|
+
holdReason: null,
|
|
1575
|
+
startAt: null,
|
|
1576
|
+
customFields: {},
|
|
1577
|
+
...ticket
|
|
1578
|
+
});
|
|
1579
|
+
//#endregion
|
|
1580
|
+
//#region src/adapters/jira.ts
|
|
1581
|
+
const jiraProject = z.object({
|
|
1582
|
+
id: z.string(),
|
|
1583
|
+
key: z.string(),
|
|
1584
|
+
name: z.string(),
|
|
1585
|
+
description: z.string().nullish()
|
|
1586
|
+
});
|
|
1587
|
+
const jiraPerson = z.object({
|
|
1588
|
+
accountId: z.string().optional(),
|
|
1589
|
+
key: z.string().optional(),
|
|
1590
|
+
name: z.string().optional(),
|
|
1591
|
+
displayName: z.string().default(""),
|
|
1592
|
+
emailAddress: z.string().default("")
|
|
1593
|
+
}).nullish();
|
|
1594
|
+
const jiraComment = z.object({
|
|
1595
|
+
id: z.string(),
|
|
1596
|
+
body: z.string().nullish(),
|
|
1597
|
+
author: jiraPerson,
|
|
1598
|
+
created: z.string()
|
|
1599
|
+
});
|
|
1600
|
+
const commentPage = z.object({
|
|
1601
|
+
comments: z.array(jiraComment),
|
|
1602
|
+
total: z.number(),
|
|
1603
|
+
startAt: z.number().default(0)
|
|
1604
|
+
});
|
|
1605
|
+
const jiraIssue = z.object({
|
|
1606
|
+
id: z.string(),
|
|
1607
|
+
key: z.string(),
|
|
1608
|
+
fields: z.object({
|
|
1609
|
+
summary: z.string().default(""),
|
|
1610
|
+
description: z.string().nullish(),
|
|
1611
|
+
priority: z.object({ name: z.string() }).nullish(),
|
|
1612
|
+
labels: z.array(z.string()).default([]),
|
|
1613
|
+
assignee: jiraPerson,
|
|
1614
|
+
reporter: jiraPerson,
|
|
1615
|
+
parent: z.object({ id: z.string() }).nullish(),
|
|
1616
|
+
issuelinks: z.array(z.object({
|
|
1617
|
+
type: z.object({ name: z.string() }),
|
|
1618
|
+
outwardIssue: z.object({ id: z.string() }).optional()
|
|
1619
|
+
})).default([]),
|
|
1620
|
+
status: z.object({ id: z.string() }),
|
|
1621
|
+
issuetype: z.object({ name: z.string() }).nullish(),
|
|
1622
|
+
duedate: z.string().nullish(),
|
|
1623
|
+
created: z.string(),
|
|
1624
|
+
timeoriginalestimate: z.number().nullish(),
|
|
1625
|
+
fixVersions: z.array(z.unknown()).default([]),
|
|
1626
|
+
attachment: z.array(z.unknown()).default([]),
|
|
1627
|
+
comment: commentPage.optional()
|
|
1628
|
+
})
|
|
1629
|
+
});
|
|
1630
|
+
const statuses = z.array(z.object({ statuses: z.array(z.object({
|
|
1631
|
+
id: z.string(),
|
|
1632
|
+
name: z.string(),
|
|
1633
|
+
statusCategory: z.object({ key: z.string() }).optional()
|
|
1634
|
+
})) }));
|
|
1635
|
+
const jiraCategory = {
|
|
1636
|
+
new: "todo",
|
|
1637
|
+
indeterminate: "in_progress",
|
|
1638
|
+
done: "done"
|
|
1639
|
+
};
|
|
1640
|
+
const jiraPriority = {
|
|
1641
|
+
highest: "urgent",
|
|
1642
|
+
blocker: "urgent",
|
|
1643
|
+
critical: "urgent",
|
|
1644
|
+
high: "high",
|
|
1645
|
+
major: "high",
|
|
1646
|
+
medium: "medium",
|
|
1647
|
+
low: "low",
|
|
1648
|
+
minor: "low",
|
|
1649
|
+
lowest: "low",
|
|
1650
|
+
trivial: "low"
|
|
1651
|
+
};
|
|
1652
|
+
const jiraRelation = {
|
|
1653
|
+
blocks: "blocks",
|
|
1654
|
+
duplicate: "duplicate",
|
|
1655
|
+
cloners: "clones",
|
|
1656
|
+
"problem/incident": "causes"
|
|
1657
|
+
};
|
|
1658
|
+
const pageSize$2 = 50;
|
|
1659
|
+
const issueFields = [
|
|
1660
|
+
"summary",
|
|
1661
|
+
"description",
|
|
1662
|
+
"priority",
|
|
1663
|
+
"labels",
|
|
1664
|
+
"assignee",
|
|
1665
|
+
"reporter",
|
|
1666
|
+
"parent",
|
|
1667
|
+
"issuelinks",
|
|
1668
|
+
"status",
|
|
1669
|
+
"issuetype",
|
|
1670
|
+
"duedate",
|
|
1671
|
+
"created",
|
|
1672
|
+
"timeoriginalestimate",
|
|
1673
|
+
"fixVersions",
|
|
1674
|
+
"attachment"
|
|
1675
|
+
];
|
|
1676
|
+
function createJiraReader(http) {
|
|
1677
|
+
const get = async (source, schema, path, query = {}) => {
|
|
1678
|
+
const url = new URL(`${source.baseUrl}/rest/api/2/${path}`);
|
|
1679
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
|
|
1680
|
+
return readable("jira", schema, await readSource(http, "jira", {
|
|
1681
|
+
method: "GET",
|
|
1682
|
+
url: url.toString(),
|
|
1683
|
+
headers: {
|
|
1684
|
+
Accept: "application/json",
|
|
1685
|
+
Authorization: source.edition === "cloud" ? `Basic ${Buffer.from(`${source.email}:${source.token}`).toString("base64")}` : `Bearer ${source.token}`
|
|
1686
|
+
},
|
|
1687
|
+
body: ""
|
|
1688
|
+
}));
|
|
1689
|
+
};
|
|
1690
|
+
async function* issuePages(source, project, fields) {
|
|
1691
|
+
const common = {
|
|
1692
|
+
jql: `project = ${JSON.stringify(project)} ORDER BY created ASC`,
|
|
1693
|
+
fields: fields.join(","),
|
|
1694
|
+
maxResults: pageSize$2
|
|
1695
|
+
};
|
|
1696
|
+
if (source.edition === "cloud") for (let token;;) {
|
|
1697
|
+
const page = await get(source, z.object({
|
|
1698
|
+
issues: z.array(jiraIssue),
|
|
1699
|
+
nextPageToken: z.string().nullish(),
|
|
1700
|
+
isLast: z.boolean().optional()
|
|
1701
|
+
}), "search/jql", {
|
|
1702
|
+
...common,
|
|
1703
|
+
...token ? { nextPageToken: token } : {}
|
|
1704
|
+
});
|
|
1705
|
+
yield page.issues;
|
|
1706
|
+
if (page.isLast === true || !page.nextPageToken) return;
|
|
1707
|
+
token = page.nextPageToken;
|
|
1708
|
+
}
|
|
1709
|
+
for (let startAt = 0;;) {
|
|
1710
|
+
const page = await get(source, z.object({
|
|
1711
|
+
issues: z.array(jiraIssue),
|
|
1712
|
+
total: z.number()
|
|
1713
|
+
}), "search", {
|
|
1714
|
+
...common,
|
|
1715
|
+
startAt
|
|
1716
|
+
});
|
|
1717
|
+
yield page.issues;
|
|
1718
|
+
startAt += page.issues.length;
|
|
1719
|
+
if (!page.issues.length || startAt >= page.total) return;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return {
|
|
1723
|
+
async projects(source) {
|
|
1724
|
+
let found = [];
|
|
1725
|
+
if (source.edition === "data_center") found = await get(source, z.array(jiraProject), "project");
|
|
1726
|
+
else for (let startAt = 0;;) {
|
|
1727
|
+
const page = await get(source, z.object({
|
|
1728
|
+
values: z.array(jiraProject),
|
|
1729
|
+
isLast: z.boolean().default(true)
|
|
1730
|
+
}), "project/search", {
|
|
1731
|
+
startAt,
|
|
1732
|
+
maxResults: pageSize$2
|
|
1733
|
+
});
|
|
1734
|
+
found.push(...page.values);
|
|
1735
|
+
startAt += page.values.length;
|
|
1736
|
+
if (page.isLast || !page.values.length) break;
|
|
1737
|
+
}
|
|
1738
|
+
return found.map((item) => ({
|
|
1739
|
+
ref: item.id,
|
|
1740
|
+
key: item.key,
|
|
1741
|
+
name: item.name,
|
|
1742
|
+
group: ""
|
|
1743
|
+
}));
|
|
1744
|
+
},
|
|
1745
|
+
async bundle(source, projectRef, includeComments) {
|
|
1746
|
+
const project = await get(source, jiraProject, `project/${encodeURIComponent(projectRef)}`);
|
|
1747
|
+
const out = bundleBuilder("jira");
|
|
1748
|
+
const workflow = /* @__PURE__ */ new Map();
|
|
1749
|
+
for (const type of await get(source, statuses, `project/${project.id}/statuses`)) for (const status of type.statuses) workflow.set(status.id, {
|
|
1750
|
+
ref: status.id,
|
|
1751
|
+
name: out.clip(status.name.trim(), 40),
|
|
1752
|
+
category: category(status.name, jiraCategory[status.statusCategory?.key ?? ""] ?? "todo"),
|
|
1753
|
+
color: "default"
|
|
1754
|
+
});
|
|
1755
|
+
const person = (who) => {
|
|
1756
|
+
const ref = who && (who.accountId ?? who.key ?? who.name);
|
|
1757
|
+
return out.person(who && ref ? {
|
|
1758
|
+
ref,
|
|
1759
|
+
name: who.displayName || ref,
|
|
1760
|
+
email: who.emailAddress
|
|
1761
|
+
} : null);
|
|
1762
|
+
};
|
|
1763
|
+
const fields = includeComments ? [...issueFields, "comment"] : issueFields;
|
|
1764
|
+
for await (const page of issuePages(source, project.key, fields)) for (const { id, key, fields: issue } of page) {
|
|
1765
|
+
const labels = out.labels(issue.labels.map((name) => ({ name })));
|
|
1766
|
+
out.ticket(sourceTicket({
|
|
1767
|
+
ref: id,
|
|
1768
|
+
key,
|
|
1769
|
+
title: out.clip(issue.summary.trim(), 500) || key,
|
|
1770
|
+
description: out.clip(issue.description ?? "", 1e5),
|
|
1771
|
+
priority: jiraPriority[issue.priority?.name.toLowerCase() ?? ""] ?? "none",
|
|
1772
|
+
kind: kindOf([issue.issuetype?.name ?? "", ...labels]),
|
|
1773
|
+
labels,
|
|
1774
|
+
stateRef: issue.status.id,
|
|
1775
|
+
assigneeRef: person(issue.assignee),
|
|
1776
|
+
parentRef: issue.parent?.id ?? null,
|
|
1777
|
+
createdByRef: person(issue.reporter),
|
|
1778
|
+
createdAt: isoTime(issue.created),
|
|
1779
|
+
dueAt: dueAt(issue.duedate),
|
|
1780
|
+
archived: false
|
|
1781
|
+
}));
|
|
1782
|
+
for (const link of issue.issuelinks) if (link.outwardIssue) out.relation({
|
|
1783
|
+
sourceRef: id,
|
|
1784
|
+
targetRef: link.outwardIssue.id,
|
|
1785
|
+
type: jiraRelation[link.type.name.toLowerCase()] ?? "related"
|
|
1786
|
+
});
|
|
1787
|
+
if (issue.timeoriginalestimate != null) out.note("estimates", "Kanvil has no estimates; difficulty is left unset.");
|
|
1788
|
+
if (issue.fixVersions.length) out.note("fix versions", "Kanvil has no releases or milestones; the issues import without them.");
|
|
1789
|
+
if (issue.attachment.length) out.note("attachments", "Files stay in Jira; text that links to them keeps its addresses.", issue.attachment.length);
|
|
1790
|
+
if (!issue.comment) continue;
|
|
1791
|
+
const notes = [...issue.comment.comments];
|
|
1792
|
+
while (notes.length < issue.comment.total) {
|
|
1793
|
+
const more = await get(source, commentPage, `issue/${id}/comment`, {
|
|
1794
|
+
startAt: notes.length,
|
|
1795
|
+
maxResults: 100
|
|
1796
|
+
});
|
|
1797
|
+
if (!more.comments.length) break;
|
|
1798
|
+
notes.push(...more.comments);
|
|
1799
|
+
}
|
|
1800
|
+
for (const note of notes) if (note.body?.trim()) out.comment({
|
|
1801
|
+
ref: note.id,
|
|
1802
|
+
ticketRef: id,
|
|
1803
|
+
authorRef: person(note.author),
|
|
1804
|
+
body: out.clip(note.body, 5e4),
|
|
1805
|
+
createdAt: isoTime(note.created)
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
|
|
1809
|
+
out.note("sprints and story points", "Jira keeps them in custom fields an import does not read.", null);
|
|
1810
|
+
out.note("Jira formatting", "Descriptions and comments keep Jira's wiki markup as plain text.", null);
|
|
1811
|
+
if (source.edition === "data_center") out.note("epic links", "Data Center keeps epic links in a custom field an import does not read; sub-tasks keep their parent.", null);
|
|
1812
|
+
return out.build({
|
|
1813
|
+
ref: project.id,
|
|
1814
|
+
key: project.key,
|
|
1815
|
+
name: project.name,
|
|
1816
|
+
description: project.description ?? ""
|
|
1817
|
+
}, [...workflow.values()]);
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
//#endregion
|
|
1822
|
+
//#region src/adapters/kaneo.ts
|
|
1823
|
+
const task = z.object({
|
|
1824
|
+
id: z.string(),
|
|
1825
|
+
number: z.number(),
|
|
1826
|
+
title: z.string(),
|
|
1827
|
+
description: z.string().nullish(),
|
|
1828
|
+
descriptionDeferred: z.boolean().optional(),
|
|
1829
|
+
status: z.string(),
|
|
1830
|
+
priority: z.string().nullish(),
|
|
1831
|
+
startDate: z.string().nullish(),
|
|
1832
|
+
dueDate: z.string().nullish(),
|
|
1833
|
+
createdAt: z.string(),
|
|
1834
|
+
userId: z.string().nullish(),
|
|
1835
|
+
assigneeName: z.string().nullish(),
|
|
1836
|
+
labels: z.array(z.object({
|
|
1837
|
+
name: z.string(),
|
|
1838
|
+
color: z.string().nullish()
|
|
1839
|
+
})).default([]),
|
|
1840
|
+
externalLinks: z.array(z.unknown()).default([])
|
|
1841
|
+
});
|
|
1842
|
+
const column = z.object({
|
|
1843
|
+
id: z.string(),
|
|
1844
|
+
name: z.string(),
|
|
1845
|
+
isFinal: z.boolean().default(false),
|
|
1846
|
+
position: z.number().default(0),
|
|
1847
|
+
tasks: z.array(task).default([])
|
|
1848
|
+
});
|
|
1849
|
+
const board$2 = z.object({
|
|
1850
|
+
data: z.object({
|
|
1851
|
+
id: z.string(),
|
|
1852
|
+
name: z.string(),
|
|
1853
|
+
slug: z.string(),
|
|
1854
|
+
description: z.string().nullish(),
|
|
1855
|
+
descriptionDeferred: z.boolean().optional(),
|
|
1856
|
+
workspaceId: z.string(),
|
|
1857
|
+
columns: z.array(column).default([]),
|
|
1858
|
+
archivedTasks: z.array(task).default([]),
|
|
1859
|
+
plannedTasks: z.array(task).default([])
|
|
1860
|
+
}),
|
|
1861
|
+
pagination: z.object({
|
|
1862
|
+
totalPages: z.number(),
|
|
1863
|
+
relatedTotalPages: z.number().default(1)
|
|
1864
|
+
})
|
|
1865
|
+
});
|
|
1866
|
+
const relation = z.object({
|
|
1867
|
+
id: z.string(),
|
|
1868
|
+
sourceTaskId: z.string(),
|
|
1869
|
+
targetTaskId: z.string(),
|
|
1870
|
+
relationType: z.string()
|
|
1871
|
+
});
|
|
1872
|
+
const comment$2 = z.object({
|
|
1873
|
+
id: z.string(),
|
|
1874
|
+
userId: z.string().nullish(),
|
|
1875
|
+
content: z.string().nullish(),
|
|
1876
|
+
createdAt: z.string(),
|
|
1877
|
+
user: z.object({ name: z.string().nullish() }).nullish()
|
|
1878
|
+
});
|
|
1879
|
+
const priorities$1 = [
|
|
1880
|
+
"urgent",
|
|
1881
|
+
"high",
|
|
1882
|
+
"medium",
|
|
1883
|
+
"low"
|
|
1884
|
+
];
|
|
1885
|
+
const priorityOf$1 = (value) => priorities$1.find((priority) => priority === value) ?? "none";
|
|
1886
|
+
const plannedRef = "kaneo:planned";
|
|
1887
|
+
const pageSize$1 = 100;
|
|
1888
|
+
function createKaneoReader(http) {
|
|
1889
|
+
const get = async (source, schema, path, query = {}) => {
|
|
1890
|
+
const base = source.baseUrl.endsWith("/api") ? source.baseUrl : `${source.baseUrl}/api`;
|
|
1891
|
+
const url = new URL(`${base}/${path}`);
|
|
1892
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
|
|
1893
|
+
return readable("kaneo", schema, await readSource(http, "kaneo", {
|
|
1894
|
+
method: "GET",
|
|
1895
|
+
url: url.toString(),
|
|
1896
|
+
headers: {
|
|
1897
|
+
Accept: "application/json",
|
|
1898
|
+
Authorization: `Bearer ${source.token}`
|
|
1899
|
+
},
|
|
1900
|
+
body: ""
|
|
1901
|
+
}));
|
|
1902
|
+
};
|
|
1903
|
+
return {
|
|
1904
|
+
async projects(source) {
|
|
1905
|
+
const workspaces = await get(source, z.array(z.object({
|
|
1906
|
+
id: z.string(),
|
|
1907
|
+
name: z.string()
|
|
1908
|
+
})), "workspace");
|
|
1909
|
+
const found = [];
|
|
1910
|
+
for (const workspace of workspaces) for (const project of await get(source, z.array(z.object({
|
|
1911
|
+
id: z.string(),
|
|
1912
|
+
name: z.string(),
|
|
1913
|
+
slug: z.string()
|
|
1914
|
+
})), "project", {
|
|
1915
|
+
workspaceId: workspace.id,
|
|
1916
|
+
includeArchived: "true"
|
|
1917
|
+
})) found.push({
|
|
1918
|
+
ref: project.id,
|
|
1919
|
+
key: project.slug.toUpperCase(),
|
|
1920
|
+
name: project.name,
|
|
1921
|
+
group: workspace.name
|
|
1922
|
+
});
|
|
1923
|
+
return found;
|
|
1924
|
+
},
|
|
1925
|
+
async bundle(source, projectId, includeComments) {
|
|
1926
|
+
const out = bundleBuilder("kaneo");
|
|
1927
|
+
const tasks = /* @__PURE__ */ new Map(), columns = /* @__PURE__ */ new Map();
|
|
1928
|
+
let project;
|
|
1929
|
+
for (let page = 1, pages = 1; page <= pages; page++) for (let related = 1, relatedPages = 1; related <= relatedPages; related++) {
|
|
1930
|
+
const found = await get(source, board$2, `task/tasks/${encodeURIComponent(projectId)}`, {
|
|
1931
|
+
page,
|
|
1932
|
+
limit: pageSize$1,
|
|
1933
|
+
relatedPage: related,
|
|
1934
|
+
sortBy: "number",
|
|
1935
|
+
sortOrder: "asc"
|
|
1936
|
+
});
|
|
1937
|
+
project ??= found.data;
|
|
1938
|
+
pages = found.pagination.totalPages;
|
|
1939
|
+
relatedPages = found.pagination.relatedTotalPages;
|
|
1940
|
+
for (const item of found.data.columns) if (!columns.has(item.id)) columns.set(item.id, item);
|
|
1941
|
+
for (const item of [
|
|
1942
|
+
...found.data.columns.flatMap((list) => list.tasks),
|
|
1943
|
+
...found.data.archivedTasks,
|
|
1944
|
+
...found.data.plannedTasks
|
|
1945
|
+
]) {
|
|
1946
|
+
const known = tasks.get(item.id);
|
|
1947
|
+
if (!known) tasks.set(item.id, item);
|
|
1948
|
+
else {
|
|
1949
|
+
const names = new Set(known.labels.map((label) => label.name));
|
|
1950
|
+
known.labels.push(...item.labels.filter((label) => !names.has(label.name)));
|
|
1951
|
+
known.externalLinks.push(...item.externalLinks);
|
|
949
1952
|
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
if (!project) throw new ApiError(404, "not_found", "Kaneo has no such project");
|
|
1956
|
+
const lists = [...columns.values()].toSorted((a, b) => a.position - b.position);
|
|
1957
|
+
const workflow = lists.map((list) => ({
|
|
1958
|
+
ref: list.id,
|
|
1959
|
+
name: out.clip(list.name.trim(), 40) || list.id,
|
|
1960
|
+
category: category(list.name, list.isFinal ? "done" : "todo"),
|
|
1961
|
+
color: "default"
|
|
1962
|
+
}));
|
|
1963
|
+
if ([...tasks.values()].some((item) => item.status === "planned")) workflow.unshift({
|
|
1964
|
+
ref: plannedRef,
|
|
1965
|
+
name: "Planned",
|
|
1966
|
+
category: "backlog",
|
|
1967
|
+
color: "default"
|
|
1968
|
+
});
|
|
1969
|
+
const archivedRef = lists.findLast((list) => list.isFinal)?.id ?? lists.at(-1)?.id ?? plannedRef;
|
|
1970
|
+
const people = new Map((await get(source, z.array(z.object({
|
|
1971
|
+
id: z.string(),
|
|
1972
|
+
name: z.string().nullish(),
|
|
1973
|
+
email: z.string().nullish()
|
|
1974
|
+
})), `workspace/${encodeURIComponent(project.workspaceId)}/members`)).map((member) => [member.id, {
|
|
1975
|
+
ref: member.id,
|
|
1976
|
+
name: member.name || member.id,
|
|
1977
|
+
email: member.email ?? ""
|
|
1978
|
+
}]));
|
|
1979
|
+
const person = (id, name) => id ? people.get(id) ?? {
|
|
1980
|
+
...formerMember(id),
|
|
1981
|
+
...name ? { name } : {}
|
|
1982
|
+
} : null;
|
|
1983
|
+
const linked = /* @__PURE__ */ new Set(), parents = /* @__PURE__ */ new Map(), pending = [];
|
|
1984
|
+
for (const item of tasks.values()) {
|
|
1985
|
+
const key = `${project.slug.toUpperCase()}-${item.number}`;
|
|
1986
|
+
const description = item.descriptionDeferred ? (await get(source, z.object({ description: z.string().nullish() }), `task/${encodeURIComponent(item.id)}`)).description ?? "" : item.description ?? "";
|
|
1987
|
+
const labels = out.labels(item.labels);
|
|
1988
|
+
pending.push(sourceTicket({
|
|
1989
|
+
ref: item.id,
|
|
1990
|
+
key,
|
|
1991
|
+
title: out.clip(item.title.trim(), 500) || key,
|
|
1992
|
+
description: out.clip(description, 1e5),
|
|
1993
|
+
priority: priorityOf$1(item.priority),
|
|
1994
|
+
kind: kindOf(labels),
|
|
1995
|
+
labels,
|
|
1996
|
+
stateRef: item.status === "planned" ? plannedRef : item.status === "archived" || !columns.has(item.status) ? archivedRef : item.status,
|
|
1997
|
+
assigneeRef: out.person(person(item.userId, item.assigneeName)),
|
|
1998
|
+
parentRef: null,
|
|
1999
|
+
createdByRef: null,
|
|
2000
|
+
createdAt: isoTime(item.createdAt),
|
|
2001
|
+
startAt: startTime(item.startDate),
|
|
2002
|
+
dueAt: dueTime(item.dueDate),
|
|
2003
|
+
archived: item.status === "archived"
|
|
2004
|
+
}));
|
|
2005
|
+
if (item.externalLinks.length) out.note("external links", "Links to pull requests and issues stay in Kaneo.", item.externalLinks.length);
|
|
2006
|
+
for (const link of await get(source, z.array(relation), `task-relation/${encodeURIComponent(item.id)}`)) {
|
|
2007
|
+
if (linked.has(link.id)) continue;
|
|
2008
|
+
linked.add(link.id);
|
|
2009
|
+
if (link.relationType === "subtask") {
|
|
2010
|
+
if (parents.has(link.targetTaskId)) out.note("other parents", "A ticket has one parent; subtasks keep the first.");
|
|
2011
|
+
else parents.set(link.targetTaskId, link.sourceTaskId);
|
|
2012
|
+
} else out.relation({
|
|
2013
|
+
sourceRef: link.sourceTaskId,
|
|
2014
|
+
targetRef: link.targetTaskId,
|
|
2015
|
+
type: link.relationType === "blocks" ? "blocks" : "related"
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
if (includeComments) {
|
|
2019
|
+
for (const note of await get(source, z.array(comment$2), `comment/${encodeURIComponent(item.id)}`)) if (note.content?.trim()) out.comment({
|
|
2020
|
+
ref: note.id,
|
|
2021
|
+
ticketRef: item.id,
|
|
2022
|
+
authorRef: out.person(person(note.userId, note.user?.name)),
|
|
2023
|
+
body: out.clip(note.content, 5e4),
|
|
2024
|
+
createdAt: isoTime(note.createdAt)
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
965
2027
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
2028
|
+
for (const ticket of pending) out.ticket({
|
|
2029
|
+
...ticket,
|
|
2030
|
+
parentRef: parents.get(ticket.ref) ?? null
|
|
2031
|
+
});
|
|
2032
|
+
if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
|
|
2033
|
+
try {
|
|
2034
|
+
const fields = await get(source, z.array(z.unknown()), `custom-field/project/${encodeURIComponent(project.id)}`);
|
|
2035
|
+
if (fields.length) out.note("custom fields", "Custom fields and their values stay in Kaneo.", fields.length);
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
if (!(error instanceof ApiError && error.code === "not_found")) throw error;
|
|
973
2038
|
}
|
|
2039
|
+
out.note("time entries", "Kanvil has no time tracking; logged time stays in Kaneo.", null);
|
|
2040
|
+
return out.build({
|
|
2041
|
+
ref: project.id,
|
|
2042
|
+
key: project.slug.toUpperCase(),
|
|
2043
|
+
name: project.name,
|
|
2044
|
+
description: project.descriptionDeferred ? (await get(source, z.object({ description: z.string().nullish() }), `project/${encodeURIComponent(project.id)}`)).description ?? "" : project.description ?? ""
|
|
2045
|
+
}, workflow);
|
|
974
2046
|
}
|
|
975
|
-
}
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
//#endregion
|
|
2050
|
+
//#region src/adapters/linear.ts
|
|
2051
|
+
const pageInfo = z.object({
|
|
2052
|
+
hasNextPage: z.boolean(),
|
|
2053
|
+
endCursor: z.string().nullable()
|
|
2054
|
+
});
|
|
2055
|
+
const linearPerson = z.object({
|
|
2056
|
+
id: z.string(),
|
|
2057
|
+
name: z.string(),
|
|
2058
|
+
email: z.string().nullish()
|
|
2059
|
+
}).transform(({ id, name, email }) => ({
|
|
2060
|
+
ref: id,
|
|
2061
|
+
name,
|
|
2062
|
+
email: email ?? ""
|
|
2063
|
+
}));
|
|
2064
|
+
const ref = z.object({ id: z.string() }).nullable();
|
|
2065
|
+
const teams = z.object({ teams: z.object({
|
|
2066
|
+
nodes: z.array(z.object({
|
|
2067
|
+
id: z.string(),
|
|
2068
|
+
key: z.string(),
|
|
2069
|
+
name: z.string()
|
|
2070
|
+
})),
|
|
2071
|
+
pageInfo
|
|
2072
|
+
}) });
|
|
2073
|
+
const team = z.object({ team: z.object({
|
|
2074
|
+
id: z.string(),
|
|
2075
|
+
key: z.string(),
|
|
2076
|
+
name: z.string(),
|
|
2077
|
+
description: z.string().nullable(),
|
|
2078
|
+
states: z.object({ nodes: z.array(z.object({
|
|
2079
|
+
id: z.string(),
|
|
2080
|
+
name: z.string(),
|
|
2081
|
+
type: z.string(),
|
|
2082
|
+
color: z.string()
|
|
2083
|
+
})) })
|
|
2084
|
+
}) });
|
|
2085
|
+
const issues = z.object({ team: z.object({ issues: z.object({
|
|
2086
|
+
nodes: z.array(z.object({
|
|
2087
|
+
id: z.string(),
|
|
2088
|
+
identifier: z.string(),
|
|
2089
|
+
title: z.string(),
|
|
2090
|
+
description: z.string().nullable(),
|
|
2091
|
+
priority: z.number(),
|
|
2092
|
+
estimate: z.number().nullable(),
|
|
2093
|
+
dueDate: z.string().nullable(),
|
|
2094
|
+
createdAt: z.string(),
|
|
2095
|
+
archivedAt: z.string().nullable(),
|
|
2096
|
+
state: z.object({ id: z.string() }),
|
|
2097
|
+
parent: ref,
|
|
2098
|
+
cycle: ref,
|
|
2099
|
+
projectMilestone: ref,
|
|
2100
|
+
project: ref,
|
|
2101
|
+
assignee: linearPerson.nullable(),
|
|
2102
|
+
creator: linearPerson.nullable(),
|
|
2103
|
+
labels: z.object({
|
|
2104
|
+
nodes: z.array(z.object({
|
|
2105
|
+
name: z.string(),
|
|
2106
|
+
color: z.string()
|
|
2107
|
+
})),
|
|
2108
|
+
pageInfo: pageInfo.pick({ hasNextPage: true })
|
|
2109
|
+
}),
|
|
2110
|
+
relations: z.object({
|
|
2111
|
+
nodes: z.array(z.object({
|
|
2112
|
+
type: z.string(),
|
|
2113
|
+
relatedIssue: z.object({ id: z.string() })
|
|
2114
|
+
})),
|
|
2115
|
+
pageInfo: pageInfo.pick({ hasNextPage: true })
|
|
2116
|
+
})
|
|
2117
|
+
})),
|
|
2118
|
+
pageInfo
|
|
2119
|
+
}) }) });
|
|
2120
|
+
const comments = z.object({ comments: z.object({
|
|
2121
|
+
nodes: z.array(z.object({
|
|
2122
|
+
id: z.string(),
|
|
2123
|
+
body: z.string(),
|
|
2124
|
+
createdAt: z.string(),
|
|
2125
|
+
user: linearPerson.nullable(),
|
|
2126
|
+
issue: ref
|
|
2127
|
+
})),
|
|
2128
|
+
pageInfo
|
|
2129
|
+
}) });
|
|
2130
|
+
const teamsQuery = `query Teams($after: String) {
|
|
2131
|
+
teams(first: 100, after: $after) { nodes { id key name } pageInfo { hasNextPage endCursor } }
|
|
2132
|
+
}`;
|
|
2133
|
+
const teamQuery = `query Team($team: String!) {
|
|
2134
|
+
team(id: $team) { id key name description states(first: 100) { nodes { id name type color } } }
|
|
2135
|
+
}`;
|
|
2136
|
+
const issuesQuery = `query Issues($team: String!, $after: String) {
|
|
2137
|
+
team(id: $team) {
|
|
2138
|
+
issues(first: 50, after: $after, includeArchived: true, orderBy: createdAt) {
|
|
2139
|
+
nodes {
|
|
2140
|
+
id identifier title description priority estimate dueDate createdAt archivedAt
|
|
2141
|
+
state { id } parent { id } cycle { id } projectMilestone { id } project { id }
|
|
2142
|
+
assignee { id name email } creator { id name email }
|
|
2143
|
+
labels(first: 50) { nodes { name color } pageInfo { hasNextPage } }
|
|
2144
|
+
relations(first: 50) { nodes { type relatedIssue { id } } pageInfo { hasNextPage } }
|
|
2145
|
+
}
|
|
2146
|
+
pageInfo { hasNextPage endCursor }
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}`;
|
|
2150
|
+
const commentsQuery = `query Comments($team: ID!, $after: String) {
|
|
2151
|
+
comments(first: 100, after: $after, includeArchived: true, filter: { issue: { team: { id: { eq: $team } } } }) {
|
|
2152
|
+
nodes { id body createdAt user { id name email } issue { id } }
|
|
2153
|
+
pageInfo { hasNextPage endCursor }
|
|
2154
|
+
}
|
|
2155
|
+
}`;
|
|
2156
|
+
const graphErrors = z.object({ errors: z.array(z.object({
|
|
2157
|
+
message: z.string().default(""),
|
|
2158
|
+
extensions: z.object({ code: z.string().optional() }).optional()
|
|
2159
|
+
})).optional() }).catch({});
|
|
2160
|
+
function judge$1(status, data) {
|
|
2161
|
+
const errors = graphErrors.parse(data).errors ?? [];
|
|
2162
|
+
const codes = errors.map((error) => error.extensions?.code);
|
|
2163
|
+
if (codes.includes("RATELIMITED")) return "limited";
|
|
2164
|
+
if (codes.includes("AUTHENTICATION_ERROR") || codes.includes("FORBIDDEN")) return "rejected";
|
|
2165
|
+
if (errors.some((error) => /not found/i.test(error.message))) return "not_found";
|
|
2166
|
+
if (errors.length) return status >= 200 && status < 300 ? "invalid" : byStatus(status);
|
|
2167
|
+
return byStatus(status);
|
|
2168
|
+
}
|
|
2169
|
+
const linearCategory = {
|
|
2170
|
+
triage: "backlog",
|
|
2171
|
+
backlog: "backlog",
|
|
2172
|
+
unstarted: "todo",
|
|
2173
|
+
started: "in_progress",
|
|
2174
|
+
completed: "done",
|
|
2175
|
+
canceled: "cancelled"
|
|
2176
|
+
};
|
|
2177
|
+
const linearPriority = [
|
|
2178
|
+
"none",
|
|
2179
|
+
"urgent",
|
|
2180
|
+
"high",
|
|
2181
|
+
"medium",
|
|
2182
|
+
"low"
|
|
2183
|
+
];
|
|
2184
|
+
const linearRelation = {
|
|
2185
|
+
blocks: "blocks",
|
|
2186
|
+
duplicate: "duplicate",
|
|
2187
|
+
related: "related",
|
|
2188
|
+
similar: "related"
|
|
2189
|
+
};
|
|
2190
|
+
const pages$1 = async (read) => {
|
|
2191
|
+
const all = [];
|
|
2192
|
+
for (let after = null;;) {
|
|
2193
|
+
const page = await read(after);
|
|
2194
|
+
all.push(...page.nodes);
|
|
2195
|
+
if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) return all;
|
|
2196
|
+
after = page.pageInfo.endCursor;
|
|
2197
|
+
}
|
|
2198
|
+
};
|
|
2199
|
+
function createLinearReader(http, url) {
|
|
2200
|
+
const query = async (token, schema, text, variables) => {
|
|
2201
|
+
const data = await readSource(http, "linear", {
|
|
2202
|
+
method: "POST",
|
|
2203
|
+
url,
|
|
2204
|
+
headers: { Authorization: token },
|
|
2205
|
+
body: JSON.stringify({
|
|
2206
|
+
query: text,
|
|
2207
|
+
variables
|
|
2208
|
+
})
|
|
2209
|
+
}, judge$1);
|
|
2210
|
+
return readable("linear", schema, readable("linear", z.object({ data: z.unknown() }), data).data);
|
|
2211
|
+
};
|
|
2212
|
+
return {
|
|
2213
|
+
async projects(token) {
|
|
2214
|
+
return (await pages$1(async (after) => (await query(token, teams, teamsQuery, { after })).teams)).map((item) => ({
|
|
2215
|
+
ref: item.id,
|
|
2216
|
+
key: item.key,
|
|
2217
|
+
name: item.name,
|
|
2218
|
+
group: ""
|
|
2219
|
+
}));
|
|
2220
|
+
},
|
|
2221
|
+
async bundle(token, teamId, includeComments) {
|
|
2222
|
+
const source = (await query(token, team, teamQuery, { team: teamId })).team;
|
|
2223
|
+
const out = bundleBuilder("linear");
|
|
2224
|
+
const workflow = source.states.nodes.map((state) => ({
|
|
2225
|
+
ref: state.id,
|
|
2226
|
+
name: out.clip(state.name.trim(), 40),
|
|
2227
|
+
category: category(state.name, linearCategory[state.type] ?? "todo"),
|
|
2228
|
+
color: toneOf(state.color)
|
|
2229
|
+
}));
|
|
2230
|
+
const read = await pages$1(async (after) => (await query(token, issues, issuesQuery, {
|
|
2231
|
+
team: source.id,
|
|
2232
|
+
after
|
|
2233
|
+
})).team.issues);
|
|
2234
|
+
for (const issue of read) {
|
|
2235
|
+
const labels = out.labels(issue.labels.nodes);
|
|
2236
|
+
out.ticket(sourceTicket({
|
|
2237
|
+
ref: issue.id,
|
|
2238
|
+
key: issue.identifier,
|
|
2239
|
+
title: out.clip(issue.title.trim(), 500) || issue.identifier,
|
|
2240
|
+
description: out.clip(issue.description ?? "", 1e5),
|
|
2241
|
+
priority: linearPriority[issue.priority] ?? "none",
|
|
2242
|
+
kind: kindOf(labels),
|
|
2243
|
+
labels,
|
|
2244
|
+
stateRef: issue.state.id,
|
|
2245
|
+
assigneeRef: out.person(issue.assignee),
|
|
2246
|
+
parentRef: issue.parent?.id ?? null,
|
|
2247
|
+
createdByRef: out.person(issue.creator),
|
|
2248
|
+
createdAt: isoTime(issue.createdAt),
|
|
2249
|
+
dueAt: dueAt(issue.dueDate),
|
|
2250
|
+
archived: issue.archivedAt !== null
|
|
2251
|
+
}));
|
|
2252
|
+
for (const relation of issue.relations.nodes) out.relation({
|
|
2253
|
+
sourceRef: issue.id,
|
|
2254
|
+
targetRef: relation.relatedIssue.id,
|
|
2255
|
+
type: linearRelation[relation.type] ?? "related"
|
|
2256
|
+
});
|
|
2257
|
+
if (issue.labels.pageInfo.hasNextPage || issue.relations.pageInfo.hasNextPage) out.note("labels or relationships past 50", "Kanvil reads the first 50 labels and relationships of each Linear issue.");
|
|
2258
|
+
if (issue.cycle) out.note("cycles", "Kanvil has no cycles; issues keep their state and dates.");
|
|
2259
|
+
if (issue.estimate !== null) out.note("estimates", "Kanvil has no estimates; difficulty is left unset.");
|
|
2260
|
+
if (issue.projectMilestone) out.note("milestones", "Kanvil has no milestones; the issues import without them.");
|
|
2261
|
+
if (issue.project) out.note("Linear projects", "Project membership is not carried over; issues import into one project.");
|
|
2262
|
+
}
|
|
2263
|
+
if (includeComments) {
|
|
2264
|
+
const notes = await pages$1(async (after) => (await query(token, comments, commentsQuery, {
|
|
2265
|
+
team: source.id,
|
|
2266
|
+
after
|
|
2267
|
+
})).comments);
|
|
2268
|
+
for (const note of notes) if (note.issue && note.body.trim()) out.comment({
|
|
2269
|
+
ref: note.id,
|
|
2270
|
+
ticketRef: note.issue.id,
|
|
2271
|
+
authorRef: out.person(note.user),
|
|
2272
|
+
body: out.clip(note.body, 5e4),
|
|
2273
|
+
createdAt: isoTime(note.createdAt)
|
|
2274
|
+
});
|
|
2275
|
+
} else out.note("comments", "Left out when the import was set up.", null);
|
|
2276
|
+
out.note("attachments", "Files and linked resources stay in Linear.", null);
|
|
2277
|
+
return out.build({
|
|
2278
|
+
ref: source.id,
|
|
2279
|
+
key: source.key,
|
|
2280
|
+
name: source.name,
|
|
2281
|
+
description: source.description ?? ""
|
|
2282
|
+
}, workflow);
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
//#endregion
|
|
2287
|
+
//#region src/adapters/plane.ts
|
|
2288
|
+
const perPage = 100;
|
|
2289
|
+
const listOf = (item) => z.union([z.array(item).transform((results) => ({
|
|
2290
|
+
results,
|
|
2291
|
+
next_cursor: null,
|
|
2292
|
+
more: false
|
|
2293
|
+
})), z.object({
|
|
2294
|
+
results: z.array(item),
|
|
2295
|
+
next_cursor: z.string().nullish(),
|
|
2296
|
+
next_page_results: z.boolean().default(false)
|
|
2297
|
+
}).transform(({ results, next_cursor, next_page_results }) => ({
|
|
2298
|
+
results,
|
|
2299
|
+
next_cursor,
|
|
2300
|
+
more: next_page_results
|
|
2301
|
+
}))]);
|
|
2302
|
+
const project = z.object({
|
|
2303
|
+
id: z.string(),
|
|
2304
|
+
identifier: z.string(),
|
|
2305
|
+
name: z.string(),
|
|
2306
|
+
description: z.string().nullish()
|
|
2307
|
+
});
|
|
2308
|
+
const state = z.object({
|
|
2309
|
+
id: z.string(),
|
|
2310
|
+
name: z.string(),
|
|
2311
|
+
color: z.string().nullish(),
|
|
2312
|
+
group: z.string()
|
|
2313
|
+
});
|
|
2314
|
+
const label$1 = z.object({
|
|
2315
|
+
id: z.string(),
|
|
2316
|
+
name: z.string(),
|
|
2317
|
+
color: z.string().nullish()
|
|
2318
|
+
});
|
|
2319
|
+
const member$1 = z.object({
|
|
2320
|
+
id: z.string(),
|
|
2321
|
+
display_name: z.string().nullish(),
|
|
2322
|
+
first_name: z.string().nullish(),
|
|
2323
|
+
last_name: z.string().nullish(),
|
|
2324
|
+
email: z.string().nullish()
|
|
2325
|
+
});
|
|
2326
|
+
const named = z.union([z.string(), z.object({ id: z.string() }).transform(({ id }) => id)]);
|
|
2327
|
+
const item = z.object({
|
|
2328
|
+
id: z.string(),
|
|
2329
|
+
sequence_id: z.number(),
|
|
2330
|
+
name: z.string(),
|
|
2331
|
+
description_html: z.string().nullish(),
|
|
2332
|
+
priority: z.string().nullish(),
|
|
2333
|
+
state: named.nullish(),
|
|
2334
|
+
parent: named.nullish(),
|
|
2335
|
+
assignees: z.array(named).default([]),
|
|
2336
|
+
labels: z.array(named).default([]),
|
|
2337
|
+
start_date: z.string().nullish(),
|
|
2338
|
+
target_date: z.string().nullish(),
|
|
2339
|
+
created_at: z.string(),
|
|
2340
|
+
created_by: named.nullish(),
|
|
2341
|
+
estimate_point: named.nullish(),
|
|
2342
|
+
cycle_id: z.string().nullish(),
|
|
2343
|
+
attachment_count: z.number().default(0),
|
|
2344
|
+
link_count: z.number().default(0)
|
|
2345
|
+
});
|
|
2346
|
+
const comment$1 = z.object({
|
|
2347
|
+
id: z.string(),
|
|
2348
|
+
comment_html: z.string().nullish(),
|
|
2349
|
+
actor: named.nullish(),
|
|
2350
|
+
created_by: named.nullish(),
|
|
2351
|
+
created_at: z.string()
|
|
2352
|
+
});
|
|
2353
|
+
const linked = z.array(z.object({ issue_id: z.string() })).default([]);
|
|
2354
|
+
const relations$1 = z.object({
|
|
2355
|
+
blocking: linked,
|
|
2356
|
+
duplicate: linked,
|
|
2357
|
+
relates_to: linked,
|
|
2358
|
+
start_before: linked,
|
|
2359
|
+
start_after: linked,
|
|
2360
|
+
finish_before: linked,
|
|
2361
|
+
finish_after: linked
|
|
2362
|
+
});
|
|
2363
|
+
const planeCategory = {
|
|
2364
|
+
triage: "backlog",
|
|
2365
|
+
backlog: "backlog",
|
|
2366
|
+
unstarted: "todo",
|
|
2367
|
+
started: "in_progress",
|
|
2368
|
+
completed: "done",
|
|
2369
|
+
cancelled: "cancelled"
|
|
2370
|
+
};
|
|
2371
|
+
const priorities = [
|
|
2372
|
+
"urgent",
|
|
2373
|
+
"high",
|
|
2374
|
+
"medium",
|
|
2375
|
+
"low"
|
|
2376
|
+
];
|
|
2377
|
+
const priorityOf = (value) => priorities.find((priority) => priority === value) ?? "none";
|
|
2378
|
+
const markdown = new TurndownService({
|
|
2379
|
+
headingStyle: "atx",
|
|
2380
|
+
codeBlockStyle: "fenced",
|
|
2381
|
+
bulletListMarker: "-"
|
|
2382
|
+
});
|
|
2383
|
+
const text = (html) => html ? markdown.turndown(html).trim() : "";
|
|
2384
|
+
const notFound = (error) => error instanceof ApiError && error.code === "not_found";
|
|
2385
|
+
function createPlaneReader(http) {
|
|
2386
|
+
const get = async (source, schema, path, query = {}) => {
|
|
2387
|
+
const url = new URL(`${source.baseUrl}/api/v1/workspaces/${encodeURIComponent(source.workspace)}/${path}`);
|
|
2388
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
|
|
2389
|
+
return readable("plane", schema, await readSource(http, "plane", {
|
|
2390
|
+
method: "GET",
|
|
2391
|
+
url: url.toString(),
|
|
2392
|
+
headers: {
|
|
2393
|
+
Accept: "application/json",
|
|
2394
|
+
"X-API-Key": source.token
|
|
2395
|
+
},
|
|
2396
|
+
body: ""
|
|
2397
|
+
}));
|
|
2398
|
+
};
|
|
2399
|
+
const all = async (source, schema, path) => {
|
|
2400
|
+
const found = [];
|
|
2401
|
+
for (let cursor = null;;) {
|
|
2402
|
+
const page = await get(source, listOf(schema), path, {
|
|
2403
|
+
per_page: perPage,
|
|
2404
|
+
...cursor ? { cursor } : {}
|
|
2405
|
+
});
|
|
2406
|
+
found.push(...page.results);
|
|
2407
|
+
if (!page.more || !page.next_cursor || page.next_cursor === cursor) return found;
|
|
2408
|
+
cursor = page.next_cursor;
|
|
2409
|
+
}
|
|
2410
|
+
};
|
|
2411
|
+
return {
|
|
2412
|
+
async projects(source) {
|
|
2413
|
+
return (await all(source, project, "projects/")).map((found) => ({
|
|
2414
|
+
ref: found.id,
|
|
2415
|
+
key: found.identifier,
|
|
2416
|
+
name: found.name,
|
|
2417
|
+
group: ""
|
|
2418
|
+
}));
|
|
2419
|
+
},
|
|
2420
|
+
async bundle(source, projectId, includeComments) {
|
|
2421
|
+
const found = await get(source, project, `projects/${encodeURIComponent(projectId)}/`);
|
|
2422
|
+
const path = `projects/${encodeURIComponent(found.id)}`;
|
|
2423
|
+
const out = bundleBuilder("plane");
|
|
2424
|
+
const workflow = (await all(source, state, `${path}/states/`)).map((entry) => ({
|
|
2425
|
+
ref: entry.id,
|
|
2426
|
+
name: out.clip(entry.name.trim(), 40),
|
|
2427
|
+
category: category(entry.name, planeCategory[entry.group] ?? "todo"),
|
|
2428
|
+
color: toneOf(entry.color)
|
|
2429
|
+
}));
|
|
2430
|
+
const labels = new Map((await all(source, label$1, `${path}/labels/`)).map((entry) => [entry.id, entry]));
|
|
2431
|
+
const people = new Map((await all(source, member$1, `${path}/members/`)).map((entry) => [entry.id, {
|
|
2432
|
+
ref: entry.id,
|
|
2433
|
+
name: entry.display_name || [entry.first_name, entry.last_name].filter(Boolean).join(" ") || entry.id,
|
|
2434
|
+
email: entry.email ?? ""
|
|
2435
|
+
}]));
|
|
2436
|
+
const person = (id) => id ? people.get(id) ?? formerMember(id) : null;
|
|
2437
|
+
let items, kind = "work-items";
|
|
2438
|
+
try {
|
|
2439
|
+
items = await all(source, item, `${path}/work-items/`);
|
|
2440
|
+
} catch (error) {
|
|
2441
|
+
if (!notFound(error)) throw error;
|
|
2442
|
+
kind = "issues";
|
|
2443
|
+
items = await all(source, item, `${path}/issues/`);
|
|
2444
|
+
}
|
|
2445
|
+
const key = (sequence) => `${found.identifier}-${sequence}`;
|
|
2446
|
+
const related = /* @__PURE__ */ new Set();
|
|
2447
|
+
let readsRelations = kind === "work-items";
|
|
2448
|
+
for (const work of items) {
|
|
2449
|
+
const names = out.labels(work.labels.flatMap((id) => {
|
|
2450
|
+
const tag = labels.get(id);
|
|
2451
|
+
return tag ? [{
|
|
2452
|
+
name: tag.name,
|
|
2453
|
+
color: tag.color
|
|
2454
|
+
}] : [];
|
|
2455
|
+
}));
|
|
2456
|
+
out.ticket(sourceTicket({
|
|
2457
|
+
ref: work.id,
|
|
2458
|
+
key: key(work.sequence_id),
|
|
2459
|
+
title: out.clip(work.name.trim(), 500) || key(work.sequence_id),
|
|
2460
|
+
description: out.clip(text(work.description_html), 1e5),
|
|
2461
|
+
priority: priorityOf(work.priority),
|
|
2462
|
+
kind: kindOf(names),
|
|
2463
|
+
labels: names,
|
|
2464
|
+
stateRef: work.state ?? workflow[0]?.ref ?? "",
|
|
2465
|
+
assigneeRef: out.assignee(work.assignees.flatMap((id) => person(id) ?? [])),
|
|
2466
|
+
parentRef: work.parent ?? null,
|
|
2467
|
+
createdByRef: out.person(person(work.created_by)),
|
|
2468
|
+
createdAt: isoTime(work.created_at),
|
|
2469
|
+
startAt: startTime(work.start_date),
|
|
2470
|
+
dueAt: dueTime(work.target_date),
|
|
2471
|
+
archived: false
|
|
2472
|
+
}));
|
|
2473
|
+
if (work.estimate_point) out.note("estimates", "Kanvil has no estimates; difficulty is left unset.");
|
|
2474
|
+
if (work.cycle_id) out.note("cycles", "Kanvil has no cycles; work items keep their state and dates.");
|
|
2475
|
+
if (work.attachment_count) out.note("attachments", "Files stay in Plane.", work.attachment_count);
|
|
2476
|
+
if (work.link_count) out.note("links", "Work item links stay in Plane.", work.link_count);
|
|
2477
|
+
if (readsRelations) try {
|
|
2478
|
+
const links = await get(source, relations$1, `${path}/work-items/${encodeURIComponent(work.id)}/relations/`);
|
|
2479
|
+
const add = (type, target, mutual) => {
|
|
2480
|
+
const pair = mutual ? [work.id, target].toSorted().join(":") : `${work.id}:${target}`;
|
|
2481
|
+
if (related.has(`${type}:${pair}`)) return;
|
|
2482
|
+
related.add(`${type}:${pair}`);
|
|
2483
|
+
out.relation({
|
|
2484
|
+
sourceRef: work.id,
|
|
2485
|
+
targetRef: target,
|
|
2486
|
+
type
|
|
2487
|
+
});
|
|
2488
|
+
};
|
|
2489
|
+
for (const other of links.blocking) add("blocks", other.issue_id, false);
|
|
2490
|
+
for (const other of links.duplicate) add("duplicate", other.issue_id, true);
|
|
2491
|
+
for (const other of links.relates_to) add("related", other.issue_id, true);
|
|
2492
|
+
const dated = links.start_before.length + links.start_after.length + links.finish_before.length + links.finish_after.length;
|
|
2493
|
+
if (dated) out.note("date dependencies", "Kanvil has no start or finish dependencies; the work items keep their dates.", dated);
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
if (!notFound(error)) throw error;
|
|
2496
|
+
readsRelations = false;
|
|
2497
|
+
}
|
|
2498
|
+
if (includeComments) for (const note of (await all(source, comment$1, `${path}/${kind}/${encodeURIComponent(work.id)}/comments/`)).toSorted((a, b) => a.created_at.localeCompare(b.created_at))) {
|
|
2499
|
+
const body = text(note.comment_html);
|
|
2500
|
+
if (body) out.comment({
|
|
2501
|
+
ref: note.id,
|
|
2502
|
+
ticketRef: work.id,
|
|
2503
|
+
authorRef: out.person(person(note.actor ?? note.created_by)),
|
|
2504
|
+
body: out.clip(body, 5e4),
|
|
2505
|
+
createdAt: isoTime(note.created_at)
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
if (!readsRelations) out.note("relations", "This Plane version does not list relations through its API.", null);
|
|
2510
|
+
if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
|
|
2511
|
+
out.note("archived, draft, and intake work items", "Plane's API does not list them; restore or publish them in Plane first.", null);
|
|
2512
|
+
try {
|
|
2513
|
+
const modules = await get(source, z.object({ total_count: z.number().default(0) }).catch({ total_count: 0 }), `${path}/modules/`, { per_page: 1 });
|
|
2514
|
+
if (modules.total_count) out.note("modules", "Kanvil has no modules; the work items import without them.", modules.total_count);
|
|
2515
|
+
} catch (error) {
|
|
2516
|
+
if (!notFound(error)) throw error;
|
|
2517
|
+
}
|
|
2518
|
+
return out.build({
|
|
2519
|
+
ref: found.id,
|
|
2520
|
+
key: found.identifier,
|
|
2521
|
+
name: found.name,
|
|
2522
|
+
description: found.description ?? ""
|
|
2523
|
+
}, workflow);
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
//#endregion
|
|
2528
|
+
//#region src/adapters/planka.ts
|
|
2529
|
+
const user = z.object({
|
|
2530
|
+
id: z.string(),
|
|
2531
|
+
name: z.string().nullish(),
|
|
2532
|
+
username: z.string().nullish(),
|
|
2533
|
+
email: z.string().nullish()
|
|
2534
|
+
});
|
|
2535
|
+
const card$1 = z.object({
|
|
2536
|
+
id: z.string(),
|
|
2537
|
+
name: z.string(),
|
|
2538
|
+
description: z.string().nullish(),
|
|
2539
|
+
dueDate: z.string().nullish(),
|
|
2540
|
+
listId: z.string(),
|
|
2541
|
+
prevListId: z.string().nullish(),
|
|
2542
|
+
creatorUserId: z.string().nullish(),
|
|
2543
|
+
createdAt: z.string(),
|
|
2544
|
+
listChangedAt: z.string().nullish(),
|
|
2545
|
+
stopwatch: z.unknown().nullish(),
|
|
2546
|
+
commentsTotal: z.number().optional()
|
|
2547
|
+
});
|
|
2548
|
+
const included = z.object({
|
|
2549
|
+
users: z.array(user).default([]),
|
|
2550
|
+
cards: z.array(card$1).default([]),
|
|
2551
|
+
cardLabels: z.array(z.object({
|
|
2552
|
+
cardId: z.string(),
|
|
2553
|
+
labelId: z.string()
|
|
2554
|
+
})).default([]),
|
|
2555
|
+
cardMemberships: z.array(z.object({
|
|
2556
|
+
cardId: z.string(),
|
|
2557
|
+
userId: z.string()
|
|
2558
|
+
})).default([]),
|
|
2559
|
+
taskLists: z.array(z.object({
|
|
2560
|
+
id: z.string(),
|
|
2561
|
+
cardId: z.string(),
|
|
2562
|
+
name: z.string().nullish(),
|
|
2563
|
+
position: z.number().nullish()
|
|
2564
|
+
})).optional(),
|
|
2565
|
+
tasks: z.array(z.object({
|
|
2566
|
+
cardId: z.string().nullish(),
|
|
2567
|
+
taskListId: z.string().nullish(),
|
|
2568
|
+
name: z.string(),
|
|
2569
|
+
isCompleted: z.boolean().default(false),
|
|
2570
|
+
position: z.number().nullish()
|
|
2571
|
+
})).default([]),
|
|
2572
|
+
attachments: z.array(z.object({ cardId: z.string() })).default([]),
|
|
2573
|
+
customFieldValues: z.array(z.object({ cardId: z.string() })).default([])
|
|
2574
|
+
});
|
|
2575
|
+
const board$1 = z.object({
|
|
2576
|
+
item: z.object({
|
|
2577
|
+
id: z.string(),
|
|
2578
|
+
name: z.string()
|
|
2579
|
+
}),
|
|
2580
|
+
included: included.extend({
|
|
2581
|
+
lists: z.array(z.object({
|
|
2582
|
+
id: z.string(),
|
|
2583
|
+
name: z.string().nullish(),
|
|
2584
|
+
type: z.string().nullish(),
|
|
2585
|
+
position: z.number().nullish()
|
|
2586
|
+
})).default([]),
|
|
2587
|
+
labels: z.array(z.object({
|
|
2588
|
+
id: z.string(),
|
|
2589
|
+
name: z.string().nullish(),
|
|
2590
|
+
color: z.string().nullish()
|
|
2591
|
+
})).default([])
|
|
2592
|
+
})
|
|
2593
|
+
});
|
|
2594
|
+
const cardPage = z.object({
|
|
2595
|
+
items: z.array(card$1),
|
|
2596
|
+
included: included.omit({ cards: true })
|
|
2597
|
+
});
|
|
2598
|
+
const notes = z.object({
|
|
2599
|
+
items: z.array(z.object({
|
|
2600
|
+
id: z.string(),
|
|
2601
|
+
type: z.string().optional(),
|
|
2602
|
+
text: z.string().optional(),
|
|
2603
|
+
data: z.object({ text: z.string().optional() }).nullish(),
|
|
2604
|
+
userId: z.string().nullish(),
|
|
2605
|
+
createdAt: z.string()
|
|
2606
|
+
})),
|
|
2607
|
+
included: z.object({ users: z.array(user).default([]) }).default({ users: [] })
|
|
2608
|
+
});
|
|
2609
|
+
const pageSize = 50;
|
|
2610
|
+
const byPosition$1 = (a, b) => (a.position ?? Number.MAX_SAFE_INTEGER) - (b.position ?? Number.MAX_SAFE_INTEGER);
|
|
2611
|
+
async function pages(read, stalled) {
|
|
2612
|
+
const found = [], seen = /* @__PURE__ */ new Set();
|
|
2613
|
+
for (let last;;) {
|
|
2614
|
+
const page = await read(last);
|
|
2615
|
+
const fresh = page.items.filter((item) => !seen.has(item.id));
|
|
2616
|
+
if (!fresh.length && page.items.length) {
|
|
2617
|
+
stalled();
|
|
2618
|
+
return found;
|
|
2619
|
+
}
|
|
2620
|
+
for (const item of fresh) seen.add(item.id);
|
|
2621
|
+
found.push({
|
|
2622
|
+
...page,
|
|
2623
|
+
items: fresh
|
|
2624
|
+
});
|
|
2625
|
+
if (page.items.length < pageSize) return found;
|
|
2626
|
+
last = page.items.at(-1);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
function grouped(items) {
|
|
2630
|
+
const map = /* @__PURE__ */ new Map();
|
|
2631
|
+
for (const entry of items) map.set(entry.cardId, [...map.get(entry.cardId) ?? [], entry]);
|
|
2632
|
+
return map;
|
|
2633
|
+
}
|
|
2634
|
+
function createPlankaReader(http) {
|
|
2635
|
+
const read = async (source, headers, schema, path, query = {}) => {
|
|
2636
|
+
const url = new URL(`${source.baseUrl}/api/${path}`);
|
|
2637
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value);
|
|
2638
|
+
return readable("planka", schema, await readSource(http, "planka", {
|
|
2639
|
+
method: "GET",
|
|
2640
|
+
url: url.toString(),
|
|
2641
|
+
headers: {
|
|
2642
|
+
Accept: "application/json",
|
|
2643
|
+
...headers
|
|
2644
|
+
},
|
|
2645
|
+
body: ""
|
|
2646
|
+
}));
|
|
2647
|
+
};
|
|
2648
|
+
const session = async (source, use) => {
|
|
2649
|
+
if (source.signIn === "api_key") return use({ "X-Api-Key": source.token });
|
|
2650
|
+
const { item: token } = readable("planka", z.object({ item: z.string() }), await readSource(http, "planka", {
|
|
2651
|
+
method: "POST",
|
|
2652
|
+
url: `${source.baseUrl}/api/access-tokens`,
|
|
2653
|
+
headers: { Accept: "application/json" },
|
|
2654
|
+
body: JSON.stringify({
|
|
2655
|
+
emailOrUsername: source.username,
|
|
2656
|
+
password: source.password
|
|
2657
|
+
})
|
|
2658
|
+
}, byStatus, { retry: false }));
|
|
2659
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
976
2660
|
try {
|
|
977
|
-
|
|
2661
|
+
return await use(headers);
|
|
978
2662
|
} finally {
|
|
979
|
-
|
|
2663
|
+
await http({
|
|
2664
|
+
method: "DELETE",
|
|
2665
|
+
url: `${source.baseUrl}/api/access-tokens/me`,
|
|
2666
|
+
headers,
|
|
2667
|
+
body: ""
|
|
2668
|
+
}).catch(() => void 0);
|
|
980
2669
|
}
|
|
2670
|
+
};
|
|
2671
|
+
return {
|
|
2672
|
+
projects: (source) => session(source, async (headers) => {
|
|
2673
|
+
const found = await read(source, headers, z.object({
|
|
2674
|
+
items: z.array(z.object({
|
|
2675
|
+
id: z.string(),
|
|
2676
|
+
name: z.string()
|
|
2677
|
+
})),
|
|
2678
|
+
included: z.object({ boards: z.array(z.object({
|
|
2679
|
+
id: z.string(),
|
|
2680
|
+
projectId: z.string(),
|
|
2681
|
+
name: z.string(),
|
|
2682
|
+
position: z.number().nullish()
|
|
2683
|
+
})).default([]) })
|
|
2684
|
+
}), "projects");
|
|
2685
|
+
return found.items.flatMap((project) => found.included.boards.filter((item) => item.projectId === project.id).toSorted(byPosition$1).map((item) => ({
|
|
2686
|
+
ref: item.id,
|
|
2687
|
+
key: "",
|
|
2688
|
+
name: item.name,
|
|
2689
|
+
group: project.name
|
|
2690
|
+
})));
|
|
2691
|
+
}),
|
|
2692
|
+
bundle: (source, boardId, includeComments) => session(source, async (headers) => {
|
|
2693
|
+
const { item, included: contents } = await read(source, headers, board$1, `boards/${encodeURIComponent(boardId)}`);
|
|
2694
|
+
const modern = contents.taskLists !== void 0;
|
|
2695
|
+
const out = bundleBuilder("planka");
|
|
2696
|
+
const lists = [...contents.lists].toSorted(byPosition$1);
|
|
2697
|
+
const states = lists.filter((list) => !list.type || ["active", "closed"].includes(list.type));
|
|
2698
|
+
const workflow = states.map((list) => ({
|
|
2699
|
+
ref: list.id,
|
|
2700
|
+
name: out.clip((list.name ?? "").trim(), 40) || "Untitled list",
|
|
2701
|
+
category: category(list.name ?? "", list.type === "closed" ? "done" : "todo"),
|
|
2702
|
+
color: "default"
|
|
2703
|
+
}));
|
|
2704
|
+
const merged = {
|
|
2705
|
+
...contents,
|
|
2706
|
+
cards: [...contents.cards],
|
|
2707
|
+
archived: /* @__PURE__ */ new Set()
|
|
2708
|
+
};
|
|
2709
|
+
for (const archive of lists.filter((list) => list.type === "archive")) for (const page of await pages((last) => read(source, headers, cardPage, `lists/${encodeURIComponent(archive.id)}/cards`, last?.listChangedAt ? {
|
|
2710
|
+
"before[listChangedAt]": last.listChangedAt,
|
|
2711
|
+
"before[id]": last.id
|
|
2712
|
+
} : {}), () => out.note("archived cards past the first page", "PLANKA answered the same page again; the archived cards it did not return are left out.", null))) {
|
|
2713
|
+
for (const found of page.items) merged.archived.add(found.id);
|
|
2714
|
+
merged.cards.push(...page.items);
|
|
2715
|
+
merged.users.push(...page.included.users);
|
|
2716
|
+
merged.cardLabels.push(...page.included.cardLabels);
|
|
2717
|
+
merged.cardMemberships.push(...page.included.cardMemberships);
|
|
2718
|
+
merged.taskLists?.push(...page.included.taskLists ?? []);
|
|
2719
|
+
merged.tasks.push(...page.included.tasks);
|
|
2720
|
+
merged.attachments.push(...page.included.attachments);
|
|
2721
|
+
merged.customFieldValues.push(...page.included.customFieldValues);
|
|
2722
|
+
}
|
|
2723
|
+
if (lists.some((list) => list.type === "trash")) out.note("deleted cards", "Cards in PLANKA's trash are left out.", null);
|
|
2724
|
+
const people = /* @__PURE__ */ new Map();
|
|
2725
|
+
const know = (users) => {
|
|
2726
|
+
for (const found of users) people.set(found.id, {
|
|
2727
|
+
ref: found.id,
|
|
2728
|
+
name: found.name || found.username || found.id,
|
|
2729
|
+
email: found.email ?? ""
|
|
2730
|
+
});
|
|
2731
|
+
};
|
|
2732
|
+
know(merged.users);
|
|
2733
|
+
const threads = /* @__PURE__ */ new Map();
|
|
2734
|
+
if (includeComments) for (const found of merged.cards) {
|
|
2735
|
+
if (found.commentsTotal === 0) continue;
|
|
2736
|
+
const thread = await pages((last) => read(source, headers, notes, `cards/${encodeURIComponent(found.id)}/${modern ? "comments" : "actions"}`, last ? { beforeId: last.id } : {}), () => out.note("comments past the first page", "PLANKA answered the same page again; older comments are left out.", null));
|
|
2737
|
+
for (const page of thread) know(page.included.users);
|
|
2738
|
+
threads.set(found.id, thread.flatMap((page) => page.items));
|
|
2739
|
+
}
|
|
2740
|
+
const person = (id) => id ? people.get(id) ?? formerMember(id) : null;
|
|
2741
|
+
const labels = new Map(contents.labels.map((label) => [label.id, label]));
|
|
2742
|
+
const cardLabels = grouped(merged.cardLabels), memberships = grouped(merged.cardMemberships), attachments = grouped(merged.attachments), values = grouped(merged.customFieldValues), taskLists = grouped(merged.taskLists ?? []);
|
|
2743
|
+
const known = new Set(states.map((list) => list.id));
|
|
2744
|
+
const fallback = states.find((list) => list.type === "closed")?.id ?? states.at(-1)?.id ?? "";
|
|
2745
|
+
for (const found of merged.cards) {
|
|
2746
|
+
const names = out.labels((cardLabels.get(found.id) ?? []).flatMap(({ labelId }) => {
|
|
2747
|
+
const label = labels.get(labelId);
|
|
2748
|
+
return label ? [{
|
|
2749
|
+
name: label.name?.trim() || label.color || "label",
|
|
2750
|
+
color: label.color
|
|
2751
|
+
}] : [];
|
|
2752
|
+
}));
|
|
2753
|
+
const checklists = modern ? (taskLists.get(found.id) ?? []).toSorted(byPosition$1).map((list) => ({
|
|
2754
|
+
name: list.name ?? "Tasks",
|
|
2755
|
+
items: merged.tasks.filter((task) => task.taskListId === list.id)
|
|
2756
|
+
})) : [{
|
|
2757
|
+
name: "Tasks",
|
|
2758
|
+
items: merged.tasks.filter((task) => task.cardId === found.id)
|
|
2759
|
+
}];
|
|
2760
|
+
const archived = merged.archived.has(found.id);
|
|
2761
|
+
const stateRef = known.has(found.listId) ? found.listId : found.prevListId && known.has(found.prevListId) ? found.prevListId : fallback;
|
|
2762
|
+
out.ticket(sourceTicket({
|
|
2763
|
+
ref: found.id,
|
|
2764
|
+
key: out.clip(found.name.trim(), 40),
|
|
2765
|
+
title: out.clip(found.name.trim(), 500) || "Untitled card",
|
|
2766
|
+
description: out.clip(withChecklists(found.description ?? "", checklists.map((list) => ({
|
|
2767
|
+
name: list.name,
|
|
2768
|
+
items: list.items.toSorted(byPosition$1).map((task) => ({
|
|
2769
|
+
name: task.name,
|
|
2770
|
+
done: task.isCompleted
|
|
2771
|
+
}))
|
|
2772
|
+
}))), 1e5),
|
|
2773
|
+
priority: "none",
|
|
2774
|
+
kind: kindOf(names),
|
|
2775
|
+
labels: names,
|
|
2776
|
+
stateRef,
|
|
2777
|
+
assigneeRef: out.assignee((memberships.get(found.id) ?? []).flatMap(({ userId }) => person(userId) ?? [])),
|
|
2778
|
+
parentRef: null,
|
|
2779
|
+
createdByRef: out.person(person(found.creatorUserId)),
|
|
2780
|
+
createdAt: isoTime(found.createdAt),
|
|
2781
|
+
dueAt: found.dueDate ? isoTime(found.dueDate) : null,
|
|
2782
|
+
archived
|
|
2783
|
+
}));
|
|
2784
|
+
const files = attachments.get(found.id)?.length ?? 0;
|
|
2785
|
+
if (files) out.note("attachments", "Files and links stay in PLANKA.", files);
|
|
2786
|
+
const filled = values.get(found.id)?.length ?? 0;
|
|
2787
|
+
if (filled) out.note("custom fields", "Custom field values stay in PLANKA.", filled);
|
|
2788
|
+
if (found.stopwatch) out.note("stopwatches", "Kanvil has no time tracking; recorded time stays in PLANKA.");
|
|
2789
|
+
for (const note of (threads.get(found.id) ?? []).toReversed()) {
|
|
2790
|
+
const body = (note.text ?? note.data?.text ?? "").trim();
|
|
2791
|
+
if (note.type && note.type !== "commentCard" || !body) continue;
|
|
2792
|
+
out.comment({
|
|
2793
|
+
ref: note.id,
|
|
2794
|
+
ticketRef: found.id,
|
|
2795
|
+
authorRef: out.person(person(note.userId)),
|
|
2796
|
+
body: out.clip(body, 5e4),
|
|
2797
|
+
createdAt: isoTime(note.createdAt)
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
if (!includeComments) out.note("comments", "Left out when the import was set up.", null);
|
|
2802
|
+
return out.build({
|
|
2803
|
+
ref: item.id,
|
|
2804
|
+
key: "",
|
|
2805
|
+
name: item.name,
|
|
2806
|
+
description: ""
|
|
2807
|
+
}, workflow);
|
|
2808
|
+
})
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
//#endregion
|
|
2812
|
+
//#region src/adapters/trello.ts
|
|
2813
|
+
const board = z.object({
|
|
2814
|
+
id: z.string(),
|
|
2815
|
+
name: z.string(),
|
|
2816
|
+
desc: z.string().default("")
|
|
2817
|
+
});
|
|
2818
|
+
const list = z.object({
|
|
2819
|
+
id: z.string(),
|
|
2820
|
+
name: z.string(),
|
|
2821
|
+
closed: z.boolean().default(false)
|
|
2822
|
+
});
|
|
2823
|
+
const label = z.object({
|
|
2824
|
+
name: z.string().nullish(),
|
|
2825
|
+
color: z.string().nullish()
|
|
2826
|
+
});
|
|
2827
|
+
const card = z.object({
|
|
2828
|
+
id: z.string(),
|
|
2829
|
+
idShort: z.number().optional(),
|
|
2830
|
+
name: z.string(),
|
|
2831
|
+
desc: z.string().default(""),
|
|
2832
|
+
due: z.string().nullish(),
|
|
2833
|
+
start: z.string().nullish(),
|
|
2834
|
+
dueComplete: z.boolean().default(false),
|
|
2835
|
+
idList: z.string(),
|
|
2836
|
+
idMembers: z.array(z.string()).default([]),
|
|
2837
|
+
labels: z.array(label).default([]),
|
|
2838
|
+
closed: z.boolean().default(false),
|
|
2839
|
+
badges: z.object({ attachments: z.number().default(0) }).optional()
|
|
2840
|
+
});
|
|
2841
|
+
const checklist = z.object({
|
|
2842
|
+
id: z.string(),
|
|
2843
|
+
idCard: z.string(),
|
|
2844
|
+
name: z.string(),
|
|
2845
|
+
pos: z.number().default(0),
|
|
2846
|
+
checkItems: z.array(z.object({
|
|
2847
|
+
name: z.string(),
|
|
2848
|
+
state: z.string(),
|
|
2849
|
+
pos: z.number().default(0)
|
|
2850
|
+
})).default([])
|
|
2851
|
+
});
|
|
2852
|
+
const member = z.object({
|
|
2853
|
+
id: z.string(),
|
|
2854
|
+
fullName: z.string().nullish(),
|
|
2855
|
+
username: z.string().default("")
|
|
2856
|
+
});
|
|
2857
|
+
const comment = z.object({
|
|
2858
|
+
id: z.string(),
|
|
2859
|
+
date: z.string(),
|
|
2860
|
+
data: z.object({
|
|
2861
|
+
text: z.string().default(""),
|
|
2862
|
+
card: z.object({ id: z.string() }).optional()
|
|
2863
|
+
}),
|
|
2864
|
+
memberCreator: member.nullish()
|
|
2865
|
+
});
|
|
2866
|
+
const pageLimit = 1e3;
|
|
2867
|
+
const byPosition = (a, b) => a.pos - b.pos;
|
|
2868
|
+
const createdAt = (id) => (/* @__PURE__ */ new Date(parseInt(id.slice(0, 8), 16) * 1e3)).toISOString();
|
|
2869
|
+
const personOf = (who) => ({
|
|
2870
|
+
ref: who.id,
|
|
2871
|
+
name: who.fullName || who.username || who.id,
|
|
2872
|
+
email: ""
|
|
2873
|
+
});
|
|
2874
|
+
const judge = (status, data) => status === 400 && data === void 0 ? "rejected" : byStatus(status);
|
|
2875
|
+
const all = async (read) => {
|
|
2876
|
+
const found = /* @__PURE__ */ new Map();
|
|
2877
|
+
for (let before = null;;) {
|
|
2878
|
+
const page = await read(before);
|
|
2879
|
+
const fresh = page.filter((item) => !found.has(item.id));
|
|
2880
|
+
for (const item of fresh) found.set(item.id, item);
|
|
2881
|
+
if (page.length < pageLimit || !fresh.length) return [...found.values()];
|
|
2882
|
+
before = fresh.map((item) => item.id).reduce((a, b) => a < b ? a : b);
|
|
981
2883
|
}
|
|
2884
|
+
};
|
|
2885
|
+
const paged = (before) => ({
|
|
2886
|
+
limit: pageLimit,
|
|
2887
|
+
...before ? { before } : {}
|
|
2888
|
+
});
|
|
2889
|
+
function createTrelloReader(http, url) {
|
|
2890
|
+
const get = async (source, schema, path, query = {}) => {
|
|
2891
|
+
const target = new URL(`${url}/1/${path}`);
|
|
2892
|
+
for (const [name, value] of Object.entries(query)) target.searchParams.set(name, String(value));
|
|
2893
|
+
return readable("trello", schema, await readSource(http, "trello", {
|
|
2894
|
+
method: "GET",
|
|
2895
|
+
url: target.toString(),
|
|
2896
|
+
headers: {
|
|
2897
|
+
Accept: "application/json",
|
|
2898
|
+
Authorization: `OAuth oauth_consumer_key="${source.apiKey}", oauth_token="${source.token}"`
|
|
2899
|
+
},
|
|
2900
|
+
body: ""
|
|
2901
|
+
}, judge));
|
|
2902
|
+
};
|
|
2903
|
+
return {
|
|
2904
|
+
async projects(source) {
|
|
2905
|
+
return (await get(source, z.array(board.pick({
|
|
2906
|
+
id: true,
|
|
2907
|
+
name: true
|
|
2908
|
+
})), "members/me/boards", {
|
|
2909
|
+
filter: "all",
|
|
2910
|
+
fields: "name"
|
|
2911
|
+
})).map((item) => ({
|
|
2912
|
+
ref: item.id,
|
|
2913
|
+
key: "",
|
|
2914
|
+
name: item.name,
|
|
2915
|
+
group: ""
|
|
2916
|
+
}));
|
|
2917
|
+
},
|
|
2918
|
+
async bundle(source, boardId, includeComments) {
|
|
2919
|
+
const found = await get(source, board, `boards/${encodeURIComponent(boardId)}`, { fields: "name,desc" });
|
|
2920
|
+
const path = `boards/${encodeURIComponent(found.id)}`;
|
|
2921
|
+
const out = bundleBuilder("trello");
|
|
2922
|
+
const lists = await get(source, z.array(list), `${path}/lists`, {
|
|
2923
|
+
filter: "all",
|
|
2924
|
+
fields: "name,closed"
|
|
2925
|
+
});
|
|
2926
|
+
const members = new Map((await get(source, z.array(member), `${path}/members`, { fields: "fullName,username" })).map((item) => [item.id, personOf(item)]));
|
|
2927
|
+
const cards = await all((before) => get(source, z.array(card), `${path}/cards/all`, {
|
|
2928
|
+
fields: "idShort,name,desc,due,start,dueComplete,idList,idMembers,labels,closed,badges",
|
|
2929
|
+
...paged(before)
|
|
2930
|
+
}));
|
|
2931
|
+
const checklists = /* @__PURE__ */ new Map();
|
|
2932
|
+
for (const item of await all((before) => get(source, z.array(checklist), `${path}/checklists`, {
|
|
2933
|
+
fields: "idCard,name,pos",
|
|
2934
|
+
checkItems: "all",
|
|
2935
|
+
checkItem_fields: "name,state,pos",
|
|
2936
|
+
...paged(before)
|
|
2937
|
+
}))) checklists.set(item.idCard, [...checklists.get(item.idCard) ?? [], item]);
|
|
2938
|
+
const used = new Set(cards.map((item) => item.idList));
|
|
2939
|
+
const closedLists = new Set(lists.filter((item) => item.closed).map((item) => item.id));
|
|
2940
|
+
const workflow = lists.filter((item) => !item.closed || used.has(item.id)).map((item) => ({
|
|
2941
|
+
ref: item.id,
|
|
2942
|
+
name: out.clip(item.name.trim(), 40),
|
|
2943
|
+
category: category(item.name, "todo"),
|
|
2944
|
+
color: "default"
|
|
2945
|
+
}));
|
|
2946
|
+
for (const item of cards) {
|
|
2947
|
+
const labels = out.labels(item.labels.map((tag) => ({
|
|
2948
|
+
name: tag.name?.trim() || tag.color || "label",
|
|
2949
|
+
color: tag.color
|
|
2950
|
+
})));
|
|
2951
|
+
const own = (checklists.get(item.id) ?? []).toSorted(byPosition);
|
|
2952
|
+
out.ticket(sourceTicket({
|
|
2953
|
+
ref: item.id,
|
|
2954
|
+
key: item.idShort === void 0 ? "" : `#${item.idShort}`,
|
|
2955
|
+
title: out.clip(item.name.trim(), 500) || "Untitled card",
|
|
2956
|
+
description: out.clip(withChecklists(item.desc, own.map((checks) => ({
|
|
2957
|
+
name: checks.name,
|
|
2958
|
+
items: checks.checkItems.toSorted(byPosition).map((check) => ({
|
|
2959
|
+
name: check.name,
|
|
2960
|
+
done: check.state === "complete"
|
|
2961
|
+
}))
|
|
2962
|
+
}))), 1e5),
|
|
2963
|
+
priority: "none",
|
|
2964
|
+
kind: kindOf(labels),
|
|
2965
|
+
labels,
|
|
2966
|
+
stateRef: item.idList,
|
|
2967
|
+
assigneeRef: out.assignee(item.idMembers.map((id) => members.get(id) ?? formerMember(id))),
|
|
2968
|
+
parentRef: null,
|
|
2969
|
+
createdByRef: null,
|
|
2970
|
+
createdAt: createdAt(item.id),
|
|
2971
|
+
startAt: item.start ? isoTime(item.start) : null,
|
|
2972
|
+
dueAt: item.due ? isoTime(item.due) : null,
|
|
2973
|
+
archived: item.closed || closedLists.has(item.idList)
|
|
2974
|
+
}));
|
|
2975
|
+
if (item.dueComplete) out.note("completed due dates", "Kanvil has no due-date checkmark; the cards keep their due date and list.");
|
|
2976
|
+
if (item.badges?.attachments) out.note("attachments", "Files and links stay in Trello.", item.badges.attachments);
|
|
2977
|
+
}
|
|
2978
|
+
if (includeComments) {
|
|
2979
|
+
const notes = await all((before) => get(source, z.array(comment), `${path}/actions`, {
|
|
2980
|
+
filter: "commentCard",
|
|
2981
|
+
fields: "data,date",
|
|
2982
|
+
memberCreator_fields: "fullName,username",
|
|
2983
|
+
...paged(before)
|
|
2984
|
+
}));
|
|
2985
|
+
for (const note of notes.toSorted((a, b) => a.date.localeCompare(b.date))) if (note.data.card && note.data.text.trim()) out.comment({
|
|
2986
|
+
ref: note.id,
|
|
2987
|
+
ticketRef: note.data.card.id,
|
|
2988
|
+
authorRef: out.person(note.memberCreator ? personOf(note.memberCreator) : null),
|
|
2989
|
+
body: out.clip(note.data.text, 5e4),
|
|
2990
|
+
createdAt: isoTime(note.date)
|
|
2991
|
+
});
|
|
2992
|
+
} else out.note("comments", "Left out when the import was set up.", null);
|
|
2993
|
+
const fields = await get(source, z.array(z.unknown()), `${path}/customFields`);
|
|
2994
|
+
if (fields.length) out.note("custom fields", "Power-Up custom fields stay in Trello.", fields.length);
|
|
2995
|
+
return out.build({
|
|
2996
|
+
ref: found.id,
|
|
2997
|
+
key: "",
|
|
2998
|
+
name: found.name,
|
|
2999
|
+
description: found.desc
|
|
3000
|
+
}, workflow);
|
|
3001
|
+
}
|
|
3002
|
+
};
|
|
982
3003
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
3004
|
+
//#endregion
|
|
3005
|
+
//#region src/adapters/import-sources.ts
|
|
3006
|
+
function createImportSources(http, urls) {
|
|
3007
|
+
const linear = createLinearReader(http, urls.linear), jira = createJiraReader(http), trello = createTrelloReader(http, urls.trello), plane = createPlaneReader(http), planka = createPlankaReader(http), kaneo = createKaneoReader(http);
|
|
3008
|
+
const reader = (source) => {
|
|
3009
|
+
switch (source.provider) {
|
|
3010
|
+
case "linear": return {
|
|
3011
|
+
projects: () => linear.projects(source.token),
|
|
3012
|
+
bundle: (project, comments) => linear.bundle(source.token, project, comments)
|
|
3013
|
+
};
|
|
3014
|
+
case "jira": return {
|
|
3015
|
+
projects: () => jira.projects(source),
|
|
3016
|
+
bundle: (project, comments) => jira.bundle(source, project, comments)
|
|
3017
|
+
};
|
|
3018
|
+
case "trello": return {
|
|
3019
|
+
projects: () => trello.projects(source),
|
|
3020
|
+
bundle: (project, comments) => trello.bundle(source, project, comments)
|
|
3021
|
+
};
|
|
3022
|
+
case "plane": return {
|
|
3023
|
+
projects: () => plane.projects(source),
|
|
3024
|
+
bundle: (project, comments) => plane.bundle(source, project, comments)
|
|
3025
|
+
};
|
|
3026
|
+
case "planka": return {
|
|
3027
|
+
projects: () => planka.projects(source),
|
|
3028
|
+
bundle: (project, comments) => planka.bundle(source, project, comments)
|
|
3029
|
+
};
|
|
3030
|
+
default: return {
|
|
3031
|
+
projects: () => kaneo.projects(source),
|
|
3032
|
+
bundle: (project, comments) => kaneo.bundle(source, project, comments)
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
};
|
|
3036
|
+
return {
|
|
3037
|
+
projects: (source) => reader(source).projects(),
|
|
3038
|
+
bundle: (source, project, { includeComments }) => reader(source).bundle(project, includeComments)
|
|
992
3039
|
};
|
|
993
|
-
const timer = config.KANVIL_PUSH_ENABLED ? setInterval(tick, 3e4) : void 0;
|
|
994
|
-
timer?.unref();
|
|
995
|
-
return { async close() {
|
|
996
|
-
clearInterval(timer);
|
|
997
|
-
await running;
|
|
998
|
-
} };
|
|
999
3040
|
}
|
|
1000
3041
|
//#endregion
|
|
1001
3042
|
//#region src/runtime-adapters.ts
|
|
1002
3043
|
const defaultAdapters = {
|
|
1003
3044
|
auth: createAuth,
|
|
1004
3045
|
realtime: createEventDelivery,
|
|
1005
|
-
|
|
3046
|
+
senders: defaultSenders,
|
|
1006
3047
|
objectStore: createObjectStore,
|
|
1007
3048
|
ticketIndex: createTicketIndex,
|
|
1008
|
-
jobs: createPollingJob
|
|
3049
|
+
jobs: createPollingJob,
|
|
3050
|
+
repositories: (config) => ({
|
|
3051
|
+
github: createGitHubDriver(config.KANVIL_GITHUB_API_URL),
|
|
3052
|
+
gitea: createGiteaDriver(),
|
|
3053
|
+
gitlab: createGitLabDriver()
|
|
3054
|
+
}),
|
|
3055
|
+
importSources: (config) => createImportSources(createHttpSender({
|
|
3056
|
+
allowPrivate: config.KANVIL_DELIVERY_ALLOW_PRIVATE,
|
|
3057
|
+
timeout: 3e4,
|
|
3058
|
+
responseLimit: 16777216
|
|
3059
|
+
}), {
|
|
3060
|
+
linear: config.KANVIL_LINEAR_API_URL,
|
|
3061
|
+
trello: config.KANVIL_TRELLO_API_URL
|
|
3062
|
+
})
|
|
1009
3063
|
};
|
|
1010
3064
|
//#endregion
|
|
1011
3065
|
//#region src/mcp.ts
|
|
@@ -1013,7 +3067,7 @@ function createMcp(auth, domain, access, config, limitWrite) {
|
|
|
1013
3067
|
const serve = (request, identity, limited = false) => createMcpHandler(() => {
|
|
1014
3068
|
const server = new McpServer({
|
|
1015
3069
|
name: "Kanvil",
|
|
1016
|
-
version:
|
|
3070
|
+
version: applicationVersion
|
|
1017
3071
|
});
|
|
1018
3072
|
for (const name of Object.keys(operations).filter(isOperation)) {
|
|
1019
3073
|
const spec = operations[name];
|
|
@@ -1072,6 +3126,66 @@ function createMcp(auth, domain, access, config, limitWrite) {
|
|
|
1072
3126
|
};
|
|
1073
3127
|
}
|
|
1074
3128
|
//#endregion
|
|
3129
|
+
//#region src/reminders.ts
|
|
3130
|
+
const overdueWindow = 6048e5;
|
|
3131
|
+
const source = {
|
|
3132
|
+
kind: "system",
|
|
3133
|
+
id: "due-reminders"
|
|
3134
|
+
};
|
|
3135
|
+
async function sendDueReminders(database, now = /* @__PURE__ */ new Date()) {
|
|
3136
|
+
return database.db.transaction(async (tx) => {
|
|
3137
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(7361080)`);
|
|
3138
|
+
const kind = sql`case when ${tickets.dueAt} <= ${now} then 'overdue' else 'due_soon' end`;
|
|
3139
|
+
const lead = sql`(coalesce(${notificationPreferences.reminderAmount}, 1) || ' ' || coalesce(${notificationPreferences.reminderUnit}, 'days'))::interval`;
|
|
3140
|
+
const due = await tx.select({
|
|
3141
|
+
kind,
|
|
3142
|
+
ticketId: tickets.id,
|
|
3143
|
+
projectId: tickets.projectId,
|
|
3144
|
+
key: tickets.key,
|
|
3145
|
+
title: tickets.title,
|
|
3146
|
+
dueAt: tickets.dueAt,
|
|
3147
|
+
...recipientColumns
|
|
3148
|
+
}).from(tickets).innerJoin(subscriptions, eq(subscriptions.ticketId, tickets.id)).innerJoin(user$1, eq(user$1.id, subscriptions.userId)).leftJoin(notificationPreferences, eq(notificationPreferences.userId, user$1.id)).where(and(isNotNull(tickets.dueAt), eq(tickets.archived, false), sql`not exists (select 1 from projects p where p.id = ${tickets.projectId} and p.archived_at is not null)`, notInArray(tickets.status, [...closedStates]), gt(tickets.dueAt, /* @__PURE__ */ new Date(now.getTime() - overdueWindow)), sql`${tickets.dueAt} - ${lead} <= ${now}`, sql`coalesce((${notificationPreferences.categories} ->> ${kind})::boolean, true)`, reachable(tickets.projectId), sql`not exists (
|
|
3149
|
+
select 1 from ${notifications} join ${events} on ${events.id} = ${notifications.eventId}
|
|
3150
|
+
where ${notifications.userId} = ${user$1.id} and ${events.ticketId} = ${tickets.id}
|
|
3151
|
+
and ${events.type} = 'ticket.' || ${kind}
|
|
3152
|
+
and (${events.data} ->> 'dueAt')::timestamptz = ${tickets.dueAt})`));
|
|
3153
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3154
|
+
for (const row of due) {
|
|
3155
|
+
const group = groups.get(`${row.ticketId}:${row.kind}`) ?? {
|
|
3156
|
+
row,
|
|
3157
|
+
recipients: []
|
|
3158
|
+
};
|
|
3159
|
+
group.recipients.push(row);
|
|
3160
|
+
groups.set(`${row.ticketId}:${row.kind}`, group);
|
|
3161
|
+
}
|
|
3162
|
+
const pending = [];
|
|
3163
|
+
for (const { row, recipients } of groups.values()) {
|
|
3164
|
+
const dueAt = row.dueAt?.toISOString();
|
|
3165
|
+
const [event] = await appendEvents(tx, [{
|
|
3166
|
+
projectId: row.projectId,
|
|
3167
|
+
ticketId: row.ticketId,
|
|
3168
|
+
actorId: null,
|
|
3169
|
+
type: `ticket.${row.kind}`,
|
|
3170
|
+
data: {
|
|
3171
|
+
dueAt,
|
|
3172
|
+
key: row.key,
|
|
3173
|
+
title: row.title,
|
|
3174
|
+
source
|
|
3175
|
+
}
|
|
3176
|
+
}]);
|
|
3177
|
+
const reminderKey = `${row.kind}:${row.ticketId}:${dueAt}`;
|
|
3178
|
+
if (event) pending.push(...recipients.map((recipient) => ({
|
|
3179
|
+
event,
|
|
3180
|
+
recipient,
|
|
3181
|
+
reminderKey
|
|
3182
|
+
})));
|
|
3183
|
+
}
|
|
3184
|
+
await notify(tx, pending);
|
|
3185
|
+
return pending.length;
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
//#endregion
|
|
1075
3189
|
//#region src/app.ts
|
|
1076
3190
|
const setupInput = z$1.object({
|
|
1077
3191
|
token: z$1.string().max(200),
|
|
@@ -1079,12 +3193,15 @@ const setupInput = z$1.object({
|
|
|
1079
3193
|
name: z$1.string().trim().min(1).max(120),
|
|
1080
3194
|
password: z$1.string().min(12).max(128)
|
|
1081
3195
|
}).strict();
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
3196
|
+
const toPreferences = (row) => notificationPreferencesSchema.parse(row ? {
|
|
3197
|
+
inApp: row.inApp,
|
|
3198
|
+
push: row.push,
|
|
3199
|
+
categories: row.categories,
|
|
3200
|
+
reminderLead: {
|
|
3201
|
+
amount: row.reminderAmount,
|
|
3202
|
+
unit: row.reminderUnit
|
|
3203
|
+
}
|
|
3204
|
+
} : {});
|
|
1088
3205
|
const digest = (value) => createHash("sha256").update(value).digest();
|
|
1089
3206
|
async function createApplication(database, config, overrides = {}) {
|
|
1090
3207
|
const adapters = {
|
|
@@ -1095,15 +3212,24 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1095
3212
|
const instance = createInstance(integrationDatabase, config);
|
|
1096
3213
|
await instance.read();
|
|
1097
3214
|
const blobs = createBlobStorage(integrationDatabase, instance, adapters.objectStore, adapters.jobs), search = createTicketSearch(integrationDatabase, instance, adapters.ticketIndex, adapters.jobs);
|
|
3215
|
+
const secrets = deliverySecrets(config), senders = adapters.senders(config), repositories = adapters.repositories(config);
|
|
1098
3216
|
const auth = adapters.auth(database, config), domain = createDomain(database, {
|
|
1099
3217
|
blobs,
|
|
1100
|
-
search
|
|
3218
|
+
search,
|
|
3219
|
+
secrets,
|
|
3220
|
+
publicUrl: config.KANVIL_URL,
|
|
3221
|
+
mail: senders.mail,
|
|
3222
|
+
sources: adapters.importSources(config)
|
|
1101
3223
|
}), access = createAccess(auth, domain, config, database), realtime = await adapters.realtime(config, instance, integrationDatabase);
|
|
1102
3224
|
const unsubscribeJobs = realtime.subscribe(() => {
|
|
1103
3225
|
blobs.sync().catch(() => {});
|
|
1104
3226
|
search.sync().catch(() => {});
|
|
1105
3227
|
});
|
|
1106
|
-
const
|
|
3228
|
+
const outbox = adapters.jobs("outbox", 5e3, () => deliverBatch(database, config, senders, secrets, repositories));
|
|
3229
|
+
const reminders = adapters.jobs("due-reminders", 6e4, () => sendDueReminders(database));
|
|
3230
|
+
const imports = adapters.jobs("imports", 2e3, async () => {
|
|
3231
|
+
if (await processImports(domain)) imports.trigger().catch(() => {});
|
|
3232
|
+
});
|
|
1107
3233
|
const accounts = createAccountAdministration(integrationDatabase, auth, config);
|
|
1108
3234
|
const app = new OpenAPIHono({ defaultHook: (result) => {
|
|
1109
3235
|
if (!result.success) return Response.json({ error: {
|
|
@@ -1167,20 +3293,23 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1167
3293
|
return Response.json({ status: "ok" });
|
|
1168
3294
|
});
|
|
1169
3295
|
const setupRequired = async () => (await database.pool.query("SELECT count(*)::int AS count FROM \"user\"")).rows[0]?.count === 0;
|
|
1170
|
-
app.get("/api/v1/info", async () =>
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
3296
|
+
app.get("/api/v1/info", async () => {
|
|
3297
|
+
const authentication = await instance.authentication(), providers = signInProviders(config);
|
|
3298
|
+
return Response.json({
|
|
3299
|
+
...await instance.read().then((row) => ({
|
|
3300
|
+
name: row.name,
|
|
3301
|
+
description: row.description
|
|
3302
|
+
})),
|
|
3303
|
+
version: applicationVersion,
|
|
3304
|
+
authentication,
|
|
3305
|
+
setupRequired: await setupRequired(),
|
|
3306
|
+
push: config.KANVIL_PUSH_ENABLED,
|
|
3307
|
+
email: !!config.SMTP_URL,
|
|
3308
|
+
emailCodes: emailCodesAvailable(config, authentication),
|
|
3309
|
+
oidc: providers.find((provider) => provider.id === "oidc") ?? null,
|
|
3310
|
+
providers
|
|
3311
|
+
});
|
|
3312
|
+
});
|
|
1184
3313
|
app.post("/api/v1/setup", async (c) => {
|
|
1185
3314
|
access.checkOrigin(c.req.raw);
|
|
1186
3315
|
const input = setupInput.parse(await c.req.json());
|
|
@@ -1200,6 +3329,7 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1200
3329
|
userId: created.user.id,
|
|
1201
3330
|
role: "admin"
|
|
1202
3331
|
});
|
|
3332
|
+
await tx.update(workspaces).set({ ownerId: created.user.id }).where(and(eq(workspaces.id, workspace.id), isNull(workspaces.ownerId)));
|
|
1203
3333
|
});
|
|
1204
3334
|
return Response.json({ ok: true }, { status: 201 });
|
|
1205
3335
|
});
|
|
@@ -1211,6 +3341,43 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1211
3341
|
const writes = createWriteLimiter(config.KANVIL_SESSION_WRITE_LIMIT);
|
|
1212
3342
|
const mcpHandler = createMcp(auth, domain, access, config, (key) => writes.consume(key));
|
|
1213
3343
|
app.all("/mcp", (c) => mcpHandler(c.req.raw));
|
|
3344
|
+
app.openAPIRegistry.registerPath({
|
|
3345
|
+
method: "get",
|
|
3346
|
+
path: "/api/calendar/{token}.ics",
|
|
3347
|
+
request: { params: z$1.object({ token: z$1.string() }) },
|
|
3348
|
+
responses: {
|
|
3349
|
+
200: {
|
|
3350
|
+
description: "iCalendar feed",
|
|
3351
|
+
content: { "text/calendar": { schema: z$1.string() } }
|
|
3352
|
+
},
|
|
3353
|
+
404: { description: "Feed unavailable" }
|
|
3354
|
+
}
|
|
3355
|
+
});
|
|
3356
|
+
app.get("/api/calendar/:file", async (c) => {
|
|
3357
|
+
const token = /^(.+)\.ics$/.exec(c.req.param("file"))?.[1], body = token ? await readCalendarFeed(database.db, token, config.KANVIL_URL) : null;
|
|
3358
|
+
if (body === null) fail(404, "not_found", "This calendar feed does not exist or was revoked");
|
|
3359
|
+
return new Response(body, { headers: {
|
|
3360
|
+
"Content-Type": "text/calendar; charset=utf-8",
|
|
3361
|
+
"Content-Disposition": "inline; filename=\"kanvil.ics\"",
|
|
3362
|
+
"Cache-Control": "no-store",
|
|
3363
|
+
"Referrer-Policy": "no-referrer"
|
|
3364
|
+
} });
|
|
3365
|
+
});
|
|
3366
|
+
app.openAPIRegistry.registerPath({
|
|
3367
|
+
method: "post",
|
|
3368
|
+
path: "/api/repository-hooks/{connectionId}",
|
|
3369
|
+
request: { params: z$1.object({ connectionId: z$1.uuid() }) },
|
|
3370
|
+
responses: {
|
|
3371
|
+
200: { description: "Already handled; nothing applied again" },
|
|
3372
|
+
202: { description: "Handled; the body names the outcome per linked ticket" },
|
|
3373
|
+
401: { description: "Signature does not match" },
|
|
3374
|
+
404: { description: "Connection unavailable" }
|
|
3375
|
+
}
|
|
3376
|
+
});
|
|
3377
|
+
app.post("/api/repository-hooks/:connectionId", async (c) => {
|
|
3378
|
+
const result = await receiveRepositoryWebhook(domain, repositories, secrets, c.req.param("connectionId"), c.req.raw.headers, await c.req.text());
|
|
3379
|
+
return c.json(result, result.outcome === "duplicate" ? 200 : 202);
|
|
3380
|
+
});
|
|
1214
3381
|
app.use("/api/v1/*", async (c, next) => {
|
|
1215
3382
|
access.checkOrigin(c.req.raw);
|
|
1216
3383
|
const actor = await access.authenticate(c.req.raw);
|
|
@@ -1228,29 +3395,39 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1228
3395
|
});
|
|
1229
3396
|
registerInstanceRoutes(app, database, instance, blobs, search, realtime, auth);
|
|
1230
3397
|
accounts.register(app);
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
},
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
3398
|
+
for (const [image, column, description] of [[
|
|
3399
|
+
"icon",
|
|
3400
|
+
projects.iconObjectId,
|
|
3401
|
+
"Private project icon"
|
|
3402
|
+
], [
|
|
3403
|
+
"background",
|
|
3404
|
+
projects.backgroundObjectId,
|
|
3405
|
+
"Private project background"
|
|
3406
|
+
]]) {
|
|
3407
|
+
app.openAPIRegistry.registerPath({
|
|
3408
|
+
method: "get",
|
|
3409
|
+
path: `/api/v1/projects/{projectId}/${image}`,
|
|
3410
|
+
security: [{ sessionCookie: [] }],
|
|
3411
|
+
request: { params: z$1.object({ projectId: z$1.string() }) },
|
|
3412
|
+
responses: {
|
|
3413
|
+
200: {
|
|
3414
|
+
description,
|
|
3415
|
+
content: {
|
|
3416
|
+
"image/png": { schema: z$1.string() },
|
|
3417
|
+
"image/jpeg": { schema: z$1.string() },
|
|
3418
|
+
"image/webp": { schema: z$1.string() }
|
|
3419
|
+
}
|
|
3420
|
+
},
|
|
3421
|
+
404: { description: `Project ${image} unavailable` }
|
|
3422
|
+
}
|
|
3423
|
+
});
|
|
3424
|
+
app.get(`/api/v1/projects/:projectId/${image}`, async (c) => {
|
|
3425
|
+
const project = await domain.execute("get_project", { projectId: c.req.param("projectId") }, c.get("actor"));
|
|
3426
|
+
const row = one(await database.db.select({ objectId: column }).from(projects).where(eq(projects.id, project.id)));
|
|
3427
|
+
if (!row.objectId) fail(404, "not_found", `This project has no uploaded ${image}`);
|
|
3428
|
+
return imageResponse(await blobs.get(row.objectId));
|
|
3429
|
+
});
|
|
3430
|
+
}
|
|
1254
3431
|
app.get("/api/v1/credentials", async (c) => {
|
|
1255
3432
|
const identity = c.get("actor");
|
|
1256
3433
|
if (identity.machine) fail(403, "forbidden", "Use a signed-in account to manage credentials");
|
|
@@ -1269,7 +3446,7 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1269
3446
|
app.post("/api/v1/credentials", async (c) => {
|
|
1270
3447
|
const identity = c.get("actor");
|
|
1271
3448
|
if (identity.machine) fail(403, "forbidden", "Use a signed-in account to manage credentials");
|
|
1272
|
-
const input =
|
|
3449
|
+
const input = credentialInput.parse(await c.req.json()), allowed = await domain.accessibleProjects(identity);
|
|
1273
3450
|
if (input.projectIds.some((id) => !allowed.some((row) => row.id === id))) fail(403, "forbidden", "Choose projects you can access");
|
|
1274
3451
|
const key = await auth.api.createApiKey({ body: {
|
|
1275
3452
|
userId: identity.id,
|
|
@@ -1306,14 +3483,23 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1306
3483
|
return Response.json({ cursor: row.value.toString() });
|
|
1307
3484
|
});
|
|
1308
3485
|
app.get("/api/v1/projects/:projectId/attachments/:id/content", async (c) => {
|
|
1309
|
-
const id = z$1.uuid().parse(c.req.param("id"));
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
3486
|
+
const id = z$1.uuid().parse(c.req.param("id")), projectId = c.req.param("projectId"), actor = c.get("actor");
|
|
3487
|
+
const attached = (await database.db.select().from(attachments).where(eq(attachments.id, id)))[0];
|
|
3488
|
+
let row, objectId;
|
|
3489
|
+
if (attached) {
|
|
3490
|
+
const authorized = await domain.execute("list_attachments", {
|
|
3491
|
+
projectId: attached.projectId,
|
|
3492
|
+
ticketId: attached.ticketId
|
|
3493
|
+
}, actor);
|
|
3494
|
+
requireRow(authorized.find((item) => item.id === id));
|
|
3495
|
+
row = attached;
|
|
3496
|
+
objectId = attached.objectId;
|
|
3497
|
+
} else {
|
|
3498
|
+
const visible = await domain.execute("get_project", { projectId }, actor);
|
|
3499
|
+
row = one(await database.db.select().from(stagedUploads).where(and(eq(stagedUploads.id, id), eq(stagedUploads.projectId, visible.id), eq(stagedUploads.actorId, actor.id), gt(stagedUploads.createdAt, stagedUploadCutoff()))));
|
|
3500
|
+
objectId = id;
|
|
3501
|
+
}
|
|
3502
|
+
const bytes = objectId ? (await blobs.get(objectId)).data : one(await database.db.select({ data: attachmentContent.data }).from(attachmentContent).where(eq(attachmentContent.id, id))).data;
|
|
1317
3503
|
if (bytes.length !== row.size) fail(500, "attachment_corrupt", "Attachment integrity check failed");
|
|
1318
3504
|
return new Response(bytes, { headers: {
|
|
1319
3505
|
"Content-Type": row.contentType,
|
|
@@ -1325,25 +3511,30 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1325
3511
|
});
|
|
1326
3512
|
app.get("/api/v1/notifications/preferences", async (c) => {
|
|
1327
3513
|
const row = (await database.db.select().from(notificationPreferences).where(eq(notificationPreferences.userId, c.get("actor").id)))[0];
|
|
1328
|
-
return Response.json(
|
|
1329
|
-
inApp: row.inApp,
|
|
1330
|
-
push: row.push
|
|
1331
|
-
} : {}));
|
|
3514
|
+
return Response.json(toPreferences(row));
|
|
1332
3515
|
});
|
|
1333
3516
|
app.put("/api/v1/notifications/preferences", async (c) => {
|
|
1334
3517
|
const identity = requireSession(c.get("actor"));
|
|
1335
|
-
const
|
|
3518
|
+
const { categories, reminderLead, ...channels } = notificationPreferencesInput.parse(await c.req.json());
|
|
3519
|
+
const values = {
|
|
3520
|
+
...channels,
|
|
3521
|
+
...reminderLead && {
|
|
3522
|
+
reminderAmount: reminderLead.amount,
|
|
3523
|
+
reminderUnit: reminderLead.unit
|
|
3524
|
+
}
|
|
3525
|
+
};
|
|
1336
3526
|
const row = one(await database.db.insert(notificationPreferences).values({
|
|
1337
3527
|
userId: identity.id,
|
|
1338
|
-
...
|
|
3528
|
+
...values,
|
|
3529
|
+
categories: categories ?? {}
|
|
1339
3530
|
}).onConflictDoUpdate({
|
|
1340
3531
|
target: notificationPreferences.userId,
|
|
1341
|
-
set:
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
}));
|
|
1346
|
-
return Response.json(
|
|
3532
|
+
set: {
|
|
3533
|
+
...values,
|
|
3534
|
+
...categories && { categories: sql`${notificationPreferences.categories} || ${JSON.stringify(categories)}::jsonb` }
|
|
3535
|
+
}
|
|
3536
|
+
}).returning());
|
|
3537
|
+
return Response.json(toPreferences(row));
|
|
1347
3538
|
});
|
|
1348
3539
|
app.post("/api/v1/notifications/device", async (c) => {
|
|
1349
3540
|
const identity = requireSession(c.get("actor"));
|
|
@@ -1375,13 +3566,19 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1375
3566
|
return Response.json(json(await database.db.select({
|
|
1376
3567
|
id: notifications.id,
|
|
1377
3568
|
readAt: notifications.readAt,
|
|
3569
|
+
category: notifications.category,
|
|
3570
|
+
createdAt: notifications.createdAt,
|
|
1378
3571
|
event: events
|
|
1379
|
-
}).from(notifications).innerJoin(events, eq(events.id, notifications.eventId)).where(and(eq(notifications.userId, identity.id), eq(notifications.inbox, true), inArray(notifications.projectId, allowed.map((row) => row.id)))).orderBy(desc(notifications.createdAt)).limit(100)));
|
|
3572
|
+
}).from(notifications).innerJoin(events, eq(events.id, notifications.eventId)).where(and(eq(notifications.userId, identity.id), eq(notifications.inbox, true), isNull(notifications.dismissedAt), inArray(notifications.projectId, allowed.map((row) => row.id)))).orderBy(desc(notifications.createdAt)).limit(100)));
|
|
1380
3573
|
});
|
|
1381
3574
|
app.post("/api/v1/notifications/read", async (c) => {
|
|
1382
|
-
requireSession(c.get("actor"));
|
|
1383
|
-
|
|
1384
|
-
|
|
3575
|
+
const identity = requireSession(c.get("actor")), input = notificationReadInput.parse(await c.req.json());
|
|
3576
|
+
await database.db.update(notifications).set({ readAt: input.read ? sql`coalesce(${notifications.readAt}, now())` : null }).where(and(eq(notifications.userId, identity.id), inArray(notifications.id, input.ids)));
|
|
3577
|
+
return Response.json({ ok: true });
|
|
3578
|
+
});
|
|
3579
|
+
app.post("/api/v1/notifications/dismiss", async (c) => {
|
|
3580
|
+
const identity = requireSession(c.get("actor")), input = notificationIdsInput.parse(await c.req.json());
|
|
3581
|
+
await database.db.update(notifications).set({ dismissedAt: /* @__PURE__ */ new Date() }).where(and(eq(notifications.userId, identity.id), inArray(notifications.id, input.ids), isNull(notifications.dismissedAt)));
|
|
1385
3582
|
return Response.json({ ok: true });
|
|
1386
3583
|
});
|
|
1387
3584
|
app.get("/api/v1/events", async (c) => {
|
|
@@ -1458,18 +3655,18 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1458
3655
|
await done;
|
|
1459
3656
|
});
|
|
1460
3657
|
});
|
|
1461
|
-
|
|
1462
|
-
const
|
|
3658
|
+
const register = (name, spec, base, secured, run) => {
|
|
3659
|
+
const params = {}, rest = {};
|
|
1463
3660
|
const keys = [...spec.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
|
|
1464
3661
|
for (const [key, value] of Object.entries(spec.input.shape)) if (keys.includes(key)) params[key] = value;
|
|
1465
3662
|
else rest[key] = value;
|
|
1466
3663
|
const method = spec.method === "GET" ? "get" : spec.method === "POST" ? "post" : spec.method === "PATCH" ? "patch" : "delete";
|
|
1467
3664
|
const route = createRoute({
|
|
1468
3665
|
method,
|
|
1469
|
-
path:
|
|
3666
|
+
path: `${base}${spec.path}`,
|
|
1470
3667
|
operationId: name,
|
|
1471
3668
|
description: spec.description,
|
|
1472
|
-
security: [{ bearerAuth: [] }],
|
|
3669
|
+
security: secured ? [{ bearerAuth: [] }] : [],
|
|
1473
3670
|
request: {
|
|
1474
3671
|
params: z$1.object(params),
|
|
1475
3672
|
...method === "get" ? { query: z$1.object(rest).strict() } : { body: {
|
|
@@ -1486,21 +3683,31 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1486
3683
|
description: "Invalid input",
|
|
1487
3684
|
content: { "application/json": { schema: errorSchema } }
|
|
1488
3685
|
},
|
|
1489
|
-
|
|
1490
|
-
|
|
3686
|
+
...secured ? {
|
|
3687
|
+
401: { description: "Authentication required" },
|
|
3688
|
+
403: { description: "Insufficient permissions" }
|
|
3689
|
+
} : {},
|
|
1491
3690
|
404: { description: "Not found" },
|
|
1492
|
-
|
|
1493
|
-
|
|
3691
|
+
...secured ? {
|
|
3692
|
+
409: { description: "Version, ownership, or dependency conflict" },
|
|
3693
|
+
429: { description: "Too many writes from this session; see Retry-After" }
|
|
3694
|
+
} : {}
|
|
1494
3695
|
}
|
|
1495
3696
|
});
|
|
1496
3697
|
app.openapi(route, async (c) => {
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
...values,
|
|
3698
|
+
return run({
|
|
3699
|
+
...spec.method === "GET" ? Object.fromEntries(Object.entries(c.req.queries()).map(([key, list]) => [key, list.length === 1 ? list[0] : list])) : z$1.record(z$1.string(), z$1.unknown()).parse(await c.req.json()),
|
|
1500
3700
|
...c.req.param()
|
|
1501
|
-
}, c.get("actor"))
|
|
3701
|
+
}, () => c.get("actor"));
|
|
1502
3702
|
});
|
|
1503
|
-
}
|
|
3703
|
+
};
|
|
3704
|
+
for (const name of Object.keys(operations).filter(isOperation)) register(name, operations[name], "/api/v1", true, async (values, actor) => Response.json(await domain.execute(name, values, actor())));
|
|
3705
|
+
app.use("/api/public/*", async (c, next) => {
|
|
3706
|
+
await next();
|
|
3707
|
+
c.header("Cache-Control", "no-store");
|
|
3708
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
3709
|
+
});
|
|
3710
|
+
for (const name of Object.keys(publicOperations).filter(isPublicOperation)) register(name, publicOperations[name], "/api/public", false, async (values) => Response.json(await domain.readPublic(name, values)));
|
|
1504
3711
|
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
|
1505
3712
|
type: "http",
|
|
1506
3713
|
scheme: "bearer",
|
|
@@ -1510,7 +3717,7 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1510
3717
|
openapi: "3.1.0",
|
|
1511
3718
|
info: {
|
|
1512
3719
|
title: "Kanvil API",
|
|
1513
|
-
version:
|
|
3720
|
+
version: applicationVersion
|
|
1514
3721
|
},
|
|
1515
3722
|
servers: [{ url: config.KANVIL_URL }]
|
|
1516
3723
|
});
|
|
@@ -1549,12 +3756,14 @@ async function createApplication(database, config, overrides = {}) {
|
|
|
1549
3756
|
search,
|
|
1550
3757
|
realtime,
|
|
1551
3758
|
close: async () => {
|
|
3759
|
+
await imports.close();
|
|
1552
3760
|
unsubscribeJobs();
|
|
1553
3761
|
await realtime.close();
|
|
1554
3762
|
await search.close();
|
|
1555
3763
|
await blobs.close();
|
|
1556
3764
|
await integrationDatabase.pool.end();
|
|
1557
|
-
await
|
|
3765
|
+
await outbox.close();
|
|
3766
|
+
await reminders.close();
|
|
1558
3767
|
}
|
|
1559
3768
|
};
|
|
1560
3769
|
}
|