specrails-desktop 2.3.0 → 2.4.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/client/dist/assets/ActivityFeedPage-DJJlZ3mF.js +1 -0
- package/client/dist/assets/AgentsPage-49JaEDjR.js +86 -0
- package/client/dist/assets/{AnalyticsPage-Dyyz1ht3.js → AnalyticsPage-BUd3gWYC.js} +1 -1
- package/client/dist/assets/{BarChart-CMdLa6Es.js → BarChart-HDe_YoUD.js} +2 -2
- package/client/dist/assets/CodePage-CqPPND47.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-CTNwb639.js → DesktopAnalyticsPage-CgvmSvF0.js} +1 -1
- package/client/dist/assets/DocsDialog-hHFd3Ejs.js +11 -0
- package/client/dist/assets/DocsPage-B4R1aksg.js +11 -0
- package/client/dist/assets/{ExportDropdown-DuoZcdYN.js → ExportDropdown-f4dwQjlT.js} +1 -1
- package/client/dist/assets/IntegrationsPage-CX2Ybxx0.js +3 -0
- package/client/dist/assets/JobDetailPage-DN2Jc8Ti.js +16 -0
- package/client/dist/assets/JobsPage-DmdpqijT.js +1 -0
- package/client/dist/assets/{cssMode-Cc6ozl-J.js → cssMode-DzNPAYFh.js} +1 -1
- package/client/dist/assets/{dist-js-H6hyhSuv.js → dist-js-COfIfLRE.js} +1 -1
- package/client/dist/assets/{dist-js-4UEGaKhD.js → dist-js-CvScGQU_.js} +1 -1
- package/client/dist/assets/{editor.main-CfXxHimg.js → editor.main-C7Rmw-hR.js} +2 -2
- package/client/dist/assets/{freemarker2-DP7J1gG3.js → freemarker2-Cszs4SVo.js} +1 -1
- package/client/dist/assets/{handlebars-BjRlucw6.js → handlebars-Dp7Lsuym.js} +1 -1
- package/client/dist/assets/{html-OumBQJ-U.js → html-BURidrEm.js} +1 -1
- package/client/dist/assets/{htmlMode-CStc3zXM.js → htmlMode--k5M7GjZ.js} +1 -1
- package/client/dist/assets/index-DBpvYrDK.css +2 -0
- package/client/dist/assets/index-DGIXKRHE.js +142 -0
- package/client/dist/assets/{integrations-Cublz3m6.js → integrations-2C7MkGT0.js} +1 -1
- package/client/dist/assets/{integrations-HIlUxXVs.js → integrations-BDC670cg.js} +1 -1
- package/client/dist/assets/integrations-BqUmRUef.js +1 -0
- package/client/dist/assets/{integrations-DmQYCUvN.js → integrations-C2jQtv-s.js} +1 -1
- package/client/dist/assets/{integrations-DRdbki5W.js → integrations-CB98NeH5.js} +1 -1
- package/client/dist/assets/{integrations-C3p12Ms6.js → integrations-CX4p_bij.js} +1 -1
- package/client/dist/assets/{integrations-DaC4SzzL.js → integrations-_SuVeQIG.js} +1 -1
- package/client/dist/assets/{integrations-Cr6hH7XR.js → integrations-eQPHAYsE.js} +1 -1
- package/client/dist/assets/{javascript-CMk--e7g.js → javascript-kJQz__44.js} +1 -1
- package/client/dist/assets/jira-C-ATCti0.js +1 -0
- package/client/dist/assets/jira-CmVfRM-b.js +1 -0
- package/client/dist/assets/jira-D7bkKAX8.js +1 -0
- package/client/dist/assets/jira-DKImM1YH.js +1 -0
- package/client/dist/assets/jira-DOw8bkIR.js +1 -0
- package/client/dist/assets/jira-DlA-wGp-.js +1 -0
- package/client/dist/assets/jira-Fob8EGxN.js +1 -0
- package/client/dist/assets/jira-xZA2lixb.js +1 -0
- package/client/dist/assets/{jsonMode-C2h3ZcjZ.js → jsonMode-v5JYPpnz.js} +1 -1
- package/client/dist/assets/{lib-Cs5FrUJI.js → lib-Bro9Z0gp.js} +1 -1
- package/client/dist/assets/{liquid-mI3KJrBE.js → liquid-Dl9I6gWt.js} +1 -1
- package/client/dist/assets/{lspLanguageFeatures-DU09ggWi.js → lspLanguageFeatures-CPlEe0NK.js} +1 -1
- package/client/dist/assets/{mdx-C41VDTR_.js → mdx-Byl7TtzQ.js} +1 -1
- package/client/dist/assets/{monaco.contribution-CPObAXMC.js → monaco.contribution-YMAkHQcQ.js} +2 -2
- package/client/dist/assets/{python-Y27rKQtk.js → python-jWQwT6j2.js} +1 -1
- package/client/dist/assets/{razor-Cd5-q9Bp.js → razor-BWS3sP-E.js} +1 -1
- package/client/dist/assets/setup-C0dzw8j4.js +1 -0
- package/client/dist/assets/setup-C1IA-9YS.js +1 -0
- package/client/dist/assets/setup-CpfjaNut.js +1 -0
- package/client/dist/assets/setup-D3rNZA9A.js +1 -0
- package/client/dist/assets/setup-UD2aanGs.js +1 -0
- package/client/dist/assets/setup-WP6WOYQh.js +1 -0
- package/client/dist/assets/setup-gzLG8T6F.js +1 -0
- package/client/dist/assets/setup-pjgmYHx6.js +1 -0
- package/client/dist/assets/specs-4lA_u79w.js +1 -0
- package/client/dist/assets/{specs-D2FzlLn9.js → specs-BHjxcjOf.js} +1 -1
- package/client/dist/assets/{specs-CZ1PsXsC.js → specs-CXNQzPk9.js} +1 -1
- package/client/dist/assets/{specs-Dyc5hYeE.js → specs-DFnc2Huj.js} +1 -1
- package/client/dist/assets/{specs-BFfu3u-a.js → specs-DZCLH2-l.js} +1 -1
- package/client/dist/assets/{specs-B__C8-8a.js → specs-DgmyAE3N.js} +1 -1
- package/client/dist/assets/{specs-DaUTrNF9.js → specs-DicWhvwi.js} +1 -1
- package/client/dist/assets/{specs-k0PyLDVt.js → specs-dkro6lSM.js} +1 -1
- package/client/dist/assets/{tsMode-B0y_xEci.js → tsMode-BbOGOuSV.js} +1 -1
- package/client/dist/assets/{typescript-BzK0OgwW.js → typescript-eBtFQJLs.js} +1 -1
- package/client/dist/assets/{useProjectCache-BZWYV-w-.js → useProjectCache-D9juBhsO.js} +1 -1
- package/client/dist/assets/{workers-rt--R2Qy.js → workers-BvicOoDf.js} +1 -1
- package/client/dist/assets/{xml-eX9QXAmI.js → xml-BJepAPyM.js} +1 -1
- package/client/dist/assets/{yaml-fcsNkpOt.js → yaml-DabgV-eA.js} +1 -1
- package/client/dist/index.html +12 -11
- package/docs/jira-integration-plan.md +321 -0
- package/package.json +1 -1
- package/server/dist/db.js +80 -0
- package/server/dist/feature-flags.js +11 -0
- package/server/dist/jira/jira-adf.js +113 -0
- package/server/dist/jira/jira-backlog-config.js +58 -0
- package/server/dist/jira/jira-client.js +279 -0
- package/server/dist/jira/jira-credential-store.js +103 -0
- package/server/dist/jira/jira-db.js +341 -0
- package/server/dist/jira/jira-issue-fields.js +428 -0
- package/server/dist/jira/jira-materializer.js +250 -0
- package/server/dist/jira/jira-status-resolver.js +211 -0
- package/server/dist/jira/jira-sync-manager.js +1014 -0
- package/server/dist/jira/types.js +9 -0
- package/server/dist/jira-router.js +304 -0
- package/server/dist/project-registry.js +43 -1
- package/server/dist/project-router-tickets.js +49 -1
- package/server/dist/project-router.js +4 -0
- package/server/dist/rails-router.js +12 -0
- package/client/dist/assets/ActivityFeedPage-3Veccrvk.js +0 -1
- package/client/dist/assets/AgentsPage-2mFPghP4.js +0 -86
- package/client/dist/assets/CodePage-D7Xwjhut.js +0 -2
- package/client/dist/assets/DocsDialog-D8yoyZDD.js +0 -11
- package/client/dist/assets/DocsPage-CeO-fAxy.js +0 -11
- package/client/dist/assets/IntegrationsPage-iIZ0UEzf.js +0 -3
- package/client/dist/assets/JobDetailPage-DgJHAH2m.js +0 -16
- package/client/dist/assets/JobsPage-Bv_RpRAE.js +0 -1
- package/client/dist/assets/index-CGHKpC-N.js +0 -142
- package/client/dist/assets/index-D17R4Cjc.css +0 -2
- package/client/dist/assets/integrations-D28q1kF6.js +0 -1
- package/client/dist/assets/setup--FMCsnQS.js +0 -1
- package/client/dist/assets/setup-B19ZpBNi.js +0 -1
- package/client/dist/assets/setup-BZPmkjSN.js +0 -1
- package/client/dist/assets/setup-BqYA02rS.js +0 -1
- package/client/dist/assets/setup-ChKQDHN9.js +0 -1
- package/client/dist/assets/setup-D2n9jMfM.js +0 -1
- package/client/dist/assets/setup-P3r6YP1D.js +0 -1
- package/client/dist/assets/setup-fnfEbwlv.js +0 -1
- package/client/dist/assets/specs-cKEh2LXt.js +0 -1
- /package/client/dist/assets/{abap-Bw6f2wDG.js → abap-s65oMlhi.js} +0 -0
- /package/client/dist/assets/{activity-BdrPln96.js → activity-BqqwnH_h.js} +0 -0
- /package/client/dist/assets/{activity-BEIp_Y1A.js → activity-C8qqEIoP.js} +0 -0
- /package/client/dist/assets/{activity-CpkRS8Sx.js → activity-CZVM4nlJ.js} +0 -0
- /package/client/dist/assets/{activity-DOUVEjJi.js → activity-Cyy07Tgo.js} +0 -0
- /package/client/dist/assets/{activity-DRwkql_y.js → activity-DlbWCa4y.js} +0 -0
- /package/client/dist/assets/{activity-DKCpESPt.js → activity-Dwq0heud.js} +0 -0
- /package/client/dist/assets/{activity-DcDQ7tjw.js → activity-qFTcMyW9.js} +0 -0
- /package/client/dist/assets/{addon-image-3WCl5Vhd.js → addon-image-CpF0L0jM.js} +0 -0
- /package/client/dist/assets/{addon-ligatures-C5OdliKs.js → addon-ligatures-hXysGZrA.js} +0 -0
- /package/client/dist/assets/{addon-webgl-BbX6pSjl.js → addon-webgl-Cn1slavz.js} +0 -0
- /package/client/dist/assets/{addspec-D33ocMxf.js → addspec-B1FTtI2a.js} +0 -0
- /package/client/dist/assets/{addspec-DFswZ0jK.js → addspec-BCT9vm_c.js} +0 -0
- /package/client/dist/assets/{addspec-DVZ15Jp8.js → addspec-DeDOztDr.js} +0 -0
- /package/client/dist/assets/{addspec-Fkv91Opc.js → addspec-DpRgmfmx.js} +0 -0
- /package/client/dist/assets/{addspec-BEeF5-zc.js → addspec-Dw-0Dg-4.js} +0 -0
- /package/client/dist/assets/{addspec-B5yl4Loj.js → addspec-rp496P_F.js} +0 -0
- /package/client/dist/assets/{addspec-DRE-jZv7.js → addspec-v8j6A7CD.js} +0 -0
- /package/client/dist/assets/{agents-DK-Dlc0i.js → agents-23iPejcA.js} +0 -0
- /package/client/dist/assets/{agents-Q6Ldfpxx.js → agents-BDx1RXcl.js} +0 -0
- /package/client/dist/assets/{agents-TeOSy-ax.js → agents-BFr3kUhK.js} +0 -0
- /package/client/dist/assets/{agents-Bm9rPqnt.js → agents-B_1L9xRg.js} +0 -0
- /package/client/dist/assets/{agents-1nCDWRmP.js → agents-BlPnx-mz.js} +0 -0
- /package/client/dist/assets/{agents-iTqjRajS.js → agents-DcxZHzNr.js} +0 -0
- /package/client/dist/assets/{agents-s87sMGzL.js → agents-G3shOewU.js} +0 -0
- /package/client/dist/assets/{agentstudio-B6Wb59E7.js → agentstudio-B-CMAQqy.js} +0 -0
- /package/client/dist/assets/{agentstudio-D3I62TLJ.js → agentstudio-Bk1eZcv4.js} +0 -0
- /package/client/dist/assets/{agentstudio-DuH9TogZ.js → agentstudio-ChxNuGAu.js} +0 -0
- /package/client/dist/assets/{agentstudio-Kw88_dUF.js → agentstudio-DNlme601.js} +0 -0
- /package/client/dist/assets/{agentstudio-BdidyBzZ.js → agentstudio-DpP9caEE.js} +0 -0
- /package/client/dist/assets/{agentstudio-BSnWLR63.js → agentstudio-Y3G0ddJ2.js} +0 -0
- /package/client/dist/assets/{agentstudio-BADhZ41e.js → agentstudio-kk9RB7Se.js} +0 -0
- /package/client/dist/assets/{aiedit-DJMny-D5.js → aiedit-5ETerMK1.js} +0 -0
- /package/client/dist/assets/{aiedit-D2ji6Qy0.js → aiedit-BBCrOpHq.js} +0 -0
- /package/client/dist/assets/{aiedit-DAhZTvtk.js → aiedit-BMtcGYNE.js} +0 -0
- /package/client/dist/assets/{aiedit-DvrcbwGv.js → aiedit-D9ddlgkM.js} +0 -0
- /package/client/dist/assets/{aiedit-WBSjT_C1.js → aiedit-De0SOH6S.js} +0 -0
- /package/client/dist/assets/{aiedit-BWxHGsYA.js → aiedit-DrfzQroF.js} +0 -0
- /package/client/dist/assets/{aiedit-DOcxERkU.js → aiedit-fMltW101.js} +0 -0
- /package/client/dist/assets/{analytics-C9Zc-rkM.js → analytics-BeTyviO8.js} +0 -0
- /package/client/dist/assets/{analytics-CrPCZRJ-.js → analytics-C4eEO260.js} +0 -0
- /package/client/dist/assets/{analytics-CYj0tfj7.js → analytics-C67cIA1b.js} +0 -0
- /package/client/dist/assets/{analytics-C6EzgtdE.js → analytics-CAguvW28.js} +0 -0
- /package/client/dist/assets/{analytics-CVx3YOc0.js → analytics-DBtt8Mgk.js} +0 -0
- /package/client/dist/assets/{analytics-CnY4kNG3.js → analytics-DUPtODxX.js} +0 -0
- /package/client/dist/assets/{analytics-BIdr0YfL.js → analytics-YIpQvPAc.js} +0 -0
- /package/client/dist/assets/{apex-Cw8_REBo.js → apex-BLUBIldB.js} +0 -0
- /package/client/dist/assets/{attachments-DYHGA2Dj.js → attachments-CCWasu-P.js} +0 -0
- /package/client/dist/assets/{attachments-Dd92KpUH.js → attachments-CHaDUfjB.js} +0 -0
- /package/client/dist/assets/{attachments-DzdU6DV6.js → attachments-CVSAbGNl.js} +0 -0
- /package/client/dist/assets/{attachments-Bcf6BG6V.js → attachments-Chg5poG1.js} +0 -0
- /package/client/dist/assets/{attachments-BW4L3l2L.js → attachments-DazTVJbH.js} +0 -0
- /package/client/dist/assets/{attachments-COcrGRFz.js → attachments-Dn-JImAK.js} +0 -0
- /package/client/dist/assets/{attachments-Bke8sCU4.js → attachments-LDA9kp2X.js} +0 -0
- /package/client/dist/assets/{azcli-Cz6HAoOw.js → azcli-DuWxh9mO.js} +0 -0
- /package/client/dist/assets/{bat-CcJ-xyqL.js → bat-UKoTejQm.js} +0 -0
- /package/client/dist/assets/{bicep-z1WDCKYz.js → bicep-4sTT4B3D.js} +0 -0
- /package/client/dist/assets/{browser-DGITz3fC.js → browser-BDd1dbFa.js} +0 -0
- /package/client/dist/assets/{browser-JsAIGCEW.js → browser-BWSgbfdX.js} +0 -0
- /package/client/dist/assets/{browser-M5-rbPlw.js → browser-D2Y_UAKA.js} +0 -0
- /package/client/dist/assets/{browser-BlYF4OOq.js → browser-DH9SGVfM.js} +0 -0
- /package/client/dist/assets/{browser-Bc-YdlVg.js → browser-DWOVYMlg.js} +0 -0
- /package/client/dist/assets/{browser-CT-ReZGt.js → browser-Dxc_VIRK.js} +0 -0
- /package/client/dist/assets/{browser-5ErDlJoR.js → browser-lTQwcDCI.js} +0 -0
- /package/client/dist/assets/{cameligo-BRewOpfa.js → cameligo-CAAryRYO.js} +0 -0
- /package/client/dist/assets/{chat-DwUm6W9z.js → chat-BO9MvVID.js} +0 -0
- /package/client/dist/assets/{chat-BEGuC03z.js → chat-CPgmgZOj.js} +0 -0
- /package/client/dist/assets/{chat-CboQguCi.js → chat-CUrG1eVg.js} +0 -0
- /package/client/dist/assets/{chat-DRCa9pOt.js → chat-CvOOKB2s.js} +0 -0
- /package/client/dist/assets/{chat-BEW60P_u.js → chat-DIh3hr6y.js} +0 -0
- /package/client/dist/assets/{chat-yoXwguQu.js → chat-UVVZqA57.js} +0 -0
- /package/client/dist/assets/{chat-BQNMD0PL.js → chat-mPn3UlMl.js} +0 -0
- /package/client/dist/assets/{clojure-DBjRWN6g.js → clojure-BlMERO1w.js} +0 -0
- /package/client/dist/assets/{clsx-DnqN-uhr.js → clsx-CnH-HMk3.js} +0 -0
- /package/client/dist/assets/{code-zCwBt3Uu.js → code-BwIz8agY.js} +0 -0
- /package/client/dist/assets/{code-g0qFMzyg.js → code-CD7yNSK0.js} +0 -0
- /package/client/dist/assets/{code-DDU0CRS0.js → code-CDFlxUFC.js} +0 -0
- /package/client/dist/assets/{code-L35Loak_.js → code-Cp3Fdng-.js} +0 -0
- /package/client/dist/assets/{code-D1z-YDt-.js → code-D24e1Crx.js} +0 -0
- /package/client/dist/assets/{code-BtsmPQLV.js → code-DtZBQTi9.js} +0 -0
- /package/client/dist/assets/{code-Coa8f2Sh.js → code-nKa0fkm_.js} +0 -0
- /package/client/dist/assets/{coffee-Cfk_XHGR.js → coffee-Cj8D-Wl1.js} +0 -0
- /package/client/dist/assets/{commands-sqrqsxyE.js → commands-B-MVT-2F.js} +0 -0
- /package/client/dist/assets/{commands-UD1NzmwX.js → commands-B0yFTp7e.js} +0 -0
- /package/client/dist/assets/{commands-DLrvnPNg.js → commands-BR1kDkHQ.js} +0 -0
- /package/client/dist/assets/{commands-CJxCry-o.js → commands-Cb21pDlG.js} +0 -0
- /package/client/dist/assets/{commands-CfgY-_of.js → commands-DWgp-8W1.js} +0 -0
- /package/client/dist/assets/{commands-B772IyDa.js → commands-ddsl1V91.js} +0 -0
- /package/client/dist/assets/{commands-BDDp6xFG.js → commands-t4frzhB0.js} +0 -0
- /package/client/dist/assets/{common-Dmm1GhdD.js → common-5ilvMOcH.js} +0 -0
- /package/client/dist/assets/{common-DltqHaAe.js → common-B4sqsKp7.js} +0 -0
- /package/client/dist/assets/{common-GbpxfPG8.js → common-BKpVwUIf.js} +0 -0
- /package/client/dist/assets/{common-DeDELLZJ.js → common-BzEC3kJU.js} +0 -0
- /package/client/dist/assets/{common-DnjcgkPH.js → common-CALKUpYm.js} +0 -0
- /package/client/dist/assets/{common-Dard9UNH.js → common-CTEbWVZS.js} +0 -0
- /package/client/dist/assets/{common-DCr6VzJ7.js → common-DQiza2Xp.js} +0 -0
- /package/client/dist/assets/{cpp-BVob6BaP.js → cpp-BPfKnaj_.js} +0 -0
- /package/client/dist/assets/{csharp-C4fbRuOu.js → csharp-gX-x5uD6.js} +0 -0
- /package/client/dist/assets/{csp-DthFP_vT.js → csp-DKGVt8SM.js} +0 -0
- /package/client/dist/assets/{css-CGMH0hcW.js → css-CPMdnAVq.js} +0 -0
- /package/client/dist/assets/{cypher-Pnf68BRV.js → cypher-ClMDrj9S.js} +0 -0
- /package/client/dist/assets/{dart-PMMOtxZX.js → dart-C4zbzpVv.js} +0 -0
- /package/client/dist/assets/{dashboard-BZBADHSj.js → dashboard--Y6yzMlf.js} +0 -0
- /package/client/dist/assets/{dashboard-I19DXBxw.js → dashboard--a4-6oYE.js} +0 -0
- /package/client/dist/assets/{dashboard-CB6Le1yN.js → dashboard-BiJ3CDTG.js} +0 -0
- /package/client/dist/assets/{dashboard-B4ixDVk8.js → dashboard-CiXjk63Z.js} +0 -0
- /package/client/dist/assets/{dashboard-C1MfeUHs.js → dashboard-Cx5VjCea.js} +0 -0
- /package/client/dist/assets/{dashboard-C7SK6xu5.js → dashboard-D7jg25XR.js} +0 -0
- /package/client/dist/assets/{dashboard-CoTpMOBM.js → dashboard-DpGYK2s1.js} +0 -0
- /package/client/dist/assets/{dockerfile-di1nsJCc.js → dockerfile-D9xw73D1.js} +0 -0
- /package/client/dist/assets/{ecl-D_WVcB5M.js → ecl-gqO8tIR9.js} +0 -0
- /package/client/dist/assets/{editor.api2-XLGzZfbc.js → editor.api2-BPnIxMjz.js} +0 -0
- /package/client/dist/assets/{elixir-OAdJEMOn.js → elixir-DSAhVF3_.js} +0 -0
- /package/client/dist/assets/{explore-D2EFgt8J.js → explore-BE5UmlbD.js} +0 -0
- /package/client/dist/assets/{explore-BV5Xxlsn.js → explore-BmTaI8dX.js} +0 -0
- /package/client/dist/assets/{explore-A8Ltoblq.js → explore-CCwkqoWq.js} +0 -0
- /package/client/dist/assets/{explore-4mFpnrKU.js → explore-CMdEoPDx.js} +0 -0
- /package/client/dist/assets/{explore-C3FSE42C.js → explore-CtdCL4QU.js} +0 -0
- /package/client/dist/assets/{explore-B9A3iN2W.js → explore-DHjxSkqQ.js} +0 -0
- /package/client/dist/assets/{explore-BrBJvfjP.js → explore-__BeALjE.js} +0 -0
- /package/client/dist/assets/{flow9-D3QEZjgn.js → flow9-DeQCSPOd.js} +0 -0
- /package/client/dist/assets/{format-command-CwGuwzGA.js → format-command-2VNoNnMv.js} +0 -0
- /package/client/dist/assets/{fsharp-BF0k_8N8.js → fsharp-CEfaXL-S.js} +0 -0
- /package/client/dist/assets/{go-BAQO5Jsz.js → go-Xp1OkZCh.js} +0 -0
- /package/client/dist/assets/{graphql-hdFVFkiV.js → graphql-BwRXrUwe.js} +0 -0
- /package/client/dist/assets/{hcl-DWnl1o-X.js → hcl-u06DtVFk.js} +0 -0
- /package/client/dist/assets/{ini-CB-6OVu3.js → ini-AmeIpFND.js} +0 -0
- /package/client/dist/assets/{java-d1CmfiHX.js → java-CyDbRQjX.js} +0 -0
- /package/client/dist/assets/{jobs-DPPT6bV6.js → jobs-8viuHLDV.js} +0 -0
- /package/client/dist/assets/{jobs-3j3_npyo.js → jobs-AW2eB5D-.js} +0 -0
- /package/client/dist/assets/{jobs-2N3RXDAM.js → jobs-BSm89DL5.js} +0 -0
- /package/client/dist/assets/{jobs-BqEbCCxD.js → jobs-BZ3sQHjZ.js} +0 -0
- /package/client/dist/assets/{jobs-cHYInoau.js → jobs-Bd8AdOTb.js} +0 -0
- /package/client/dist/assets/{jobs-DRzjWI9u.js → jobs-CRtsq_u0.js} +0 -0
- /package/client/dist/assets/{jobs-2f6Hdc72.js → jobs-CSRwFQ6K.js} +0 -0
- /package/client/dist/assets/{jobs-vGfzIDQa.js → jobs-CbEl7WMI.js} +0 -0
- /package/client/dist/assets/{julia-Bgv08lKa.js → julia-BqialFRG.js} +0 -0
- /package/client/dist/assets/{kotlin-u98kaVTf.js → kotlin-Dzz8TWAt.js} +0 -0
- /package/client/dist/assets/{less-CjYwpgg5.js → less-DHRJD3TR.js} +0 -0
- /package/client/dist/assets/{lexon-YTjaAFBB.js → lexon-5Y3QgTmT.js} +0 -0
- /package/client/dist/assets/{lua-BzmkWv27.js → lua-sKvhfPn5.js} +0 -0
- /package/client/dist/assets/{m3-CFwk9fw0.js → m3-DWDVwkFG.js} +0 -0
- /package/client/dist/assets/{markdown-CR5iMpSZ.js → markdown-CD_aSBxW.js} +0 -0
- /package/client/dist/assets/{mips-CcEalc17.js → mips-687T03hg.js} +0 -0
- /package/client/dist/assets/{msdax-BQbkawnr.js → msdax-C1St-dIV.js} +0 -0
- /package/client/dist/assets/{mysql-GTlaaW_P.js → mysql-BG7r8oBS.js} +0 -0
- /package/client/dist/assets/{nav-C2YXcbZS.js → nav-B05EYB0b.js} +0 -0
- /package/client/dist/assets/{nav-D2bOGSEg.js → nav-BNGCq-0y.js} +0 -0
- /package/client/dist/assets/{nav-BEL3MTwK.js → nav-BRInPX8a.js} +0 -0
- /package/client/dist/assets/{nav-CtYwmMgu.js → nav-Bf87DRHD.js} +0 -0
- /package/client/dist/assets/{nav-iH1V5j6o.js → nav-BkVzzFpc.js} +0 -0
- /package/client/dist/assets/{nav-0fwkrgHt.js → nav-BzFLtS1W.js} +0 -0
- /package/client/dist/assets/{nav-ClzOE4mA.js → nav-DBDbQOYn.js} +0 -0
- /package/client/dist/assets/{nav-B_G-TJDW.js → nav-X9sVtUWC.js} +0 -0
- /package/client/dist/assets/{objective-c-Byu1T5if.js → objective-c-Ds1-m05L.js} +0 -0
- /package/client/dist/assets/{pascal-BrfzBfRm.js → pascal-BKK9FpIi.js} +0 -0
- /package/client/dist/assets/{pascaligo-BXXKFUeo.js → pascaligo-SRS3nwtO.js} +0 -0
- /package/client/dist/assets/{perl-B3OikKq-.js → perl-B2hTOlrF.js} +0 -0
- /package/client/dist/assets/{pgsql-CTsa0Acc.js → pgsql-DIQJYNpL.js} +0 -0
- /package/client/dist/assets/{php-DiQh3FUW.js → php-BEaVe8X2.js} +0 -0
- /package/client/dist/assets/{pla-92uH8Fzm.js → pla-oPLHpZ-Q.js} +0 -0
- /package/client/dist/assets/{postiats-BbeWkKUr.js → postiats-D_vzrAzD.js} +0 -0
- /package/client/dist/assets/{powerquery-DgDMzpsm.js → powerquery-BKG6w-FH.js} +0 -0
- /package/client/dist/assets/{powershell-BfdUUzaG.js → powershell-B3dLhDt4.js} +0 -0
- /package/client/dist/assets/{protobuf-BojW2ftW.js → protobuf-DC8SGjcl.js} +0 -0
- /package/client/dist/assets/{pug-BxqTg3IU.js → pug-D5E-4fI0.js} +0 -0
- /package/client/dist/assets/{qsharp-BX_A-MW9.js → qsharp-6vJAWv0x.js} +0 -0
- /package/client/dist/assets/{r-D9BMnxvJ.js → r-CDwsEcbM.js} +0 -0
- /package/client/dist/assets/{redis-5cJqEQJJ.js → redis-CuQbbESS.js} +0 -0
- /package/client/dist/assets/{redshift-d8BBqiwb.js → redshift-B9e1k-qI.js} +0 -0
- /package/client/dist/assets/{restructuredtext-C8a6yIcZ.js → restructuredtext-BiJ5IwaU.js} +0 -0
- /package/client/dist/assets/{ruby-egeh-6KX.js → ruby-B0UAHY9b.js} +0 -0
- /package/client/dist/assets/{rust-a3r9IInB.js → rust-Dg_spmFr.js} +0 -0
- /package/client/dist/assets/{sb-y8iRIDei.js → sb-DjU66I8Q.js} +0 -0
- /package/client/dist/assets/{scala-BPDK2AmK.js → scala-qvStIdfG.js} +0 -0
- /package/client/dist/assets/{scheme-BIWUEoOs.js → scheme-FstEk5Rh.js} +0 -0
- /package/client/dist/assets/{scss-CA-PSzwg.js → scss-w0U3rQLK.js} +0 -0
- /package/client/dist/assets/{settings-CTcwN9RE.js → settings-5tzo0Rn3.js} +0 -0
- /package/client/dist/assets/{settings-D_dujJZI.js → settings-BDAW3trC.js} +0 -0
- /package/client/dist/assets/{settings-Bg0A3zoS.js → settings-BEWv3VEu.js} +0 -0
- /package/client/dist/assets/{settings-BgPqg2nv.js → settings-BORg56um.js} +0 -0
- /package/client/dist/assets/{settings-BSze3_9q.js → settings-D3LurcR5.js} +0 -0
- /package/client/dist/assets/{settings-CSJ0ahZ8.js → settings-DcqWIEM6.js} +0 -0
- /package/client/dist/assets/{settings-DYIV89nV.js → settings-Dfz8QbZS.js} +0 -0
- /package/client/dist/assets/{settings-DDcfx_ca.js → settings-yMubjqYw.js} +0 -0
- /package/client/dist/assets/{shell--LiT1Bja.js → shell-DJ78wREd.js} +0 -0
- /package/client/dist/assets/{solidity-DdqZccZg.js → solidity-1aGIVsdX.js} +0 -0
- /package/client/dist/assets/{sophia-S6-YxNG_.js → sophia-40LqcGjB.js} +0 -0
- /package/client/dist/assets/{sparql-BSf5kMp2.js → sparql-Cz5dqG_g.js} +0 -0
- /package/client/dist/assets/{sql-D7KgjR8G.js → sql-64f62Ni4.js} +0 -0
- /package/client/dist/assets/{st-BnoDa-Ml.js → st-gJe2yG8J.js} +0 -0
- /package/client/dist/assets/{swift-DEUHTkUX.js → swift-C6ME22mv.js} +0 -0
- /package/client/dist/assets/{systemverilog-Tqb_KPnW.js → systemverilog-CEWz259w.js} +0 -0
- /package/client/dist/assets/{tcl-BmBFS2qq.js → tcl-CcLVIi3m.js} +0 -0
- /package/client/dist/assets/{terminal-Bje4ziIa.js → terminal-BYtreaaF.js} +0 -0
- /package/client/dist/assets/{terminal-CSONJOex.js → terminal-C0xx0SjA.js} +0 -0
- /package/client/dist/assets/{terminal-DeWzh6ys.js → terminal-CPpK58RC.js} +0 -0
- /package/client/dist/assets/{terminal-C2WYcFHF.js → terminal-CdxkpafL.js} +0 -0
- /package/client/dist/assets/{terminal-DEqzGtcr.js → terminal-Ciia0wh2.js} +0 -0
- /package/client/dist/assets/{terminal-80yDMgMF.js → terminal-DHIkiWcs.js} +0 -0
- /package/client/dist/assets/{terminal-lkZYR4wJ.js → terminal-DY42QANg.js} +0 -0
- /package/client/dist/assets/{terminal-YOlsJCQj.js → terminal-DoxtVdma.js} +0 -0
- /package/client/dist/assets/{tickets-DYvafSaY.js → tickets-0rM0lIXd.js} +0 -0
- /package/client/dist/assets/{tickets-DNOANUXr.js → tickets-1UIGf_oA.js} +0 -0
- /package/client/dist/assets/{tickets-DlpC_iTg.js → tickets-9kdPXInd.js} +0 -0
- /package/client/dist/assets/{tickets-CF2PYelu.js → tickets-C6pwZwt4.js} +0 -0
- /package/client/dist/assets/{tickets-CB7N30gm.js → tickets-DAjtxAVb.js} +0 -0
- /package/client/dist/assets/{tickets-DU1aqsbr.js → tickets-DNmXcAwu.js} +0 -0
- /package/client/dist/assets/{tickets-clefmXLv.js → tickets-n23kDqJT.js} +0 -0
- /package/client/dist/assets/{tickets-DucYgtdl.js → tickets-tGx5AR5b.js} +0 -0
- /package/client/dist/assets/{twig-BQV8igWC.js → twig-DvsO-WjW.js} +0 -0
- /package/client/dist/assets/{typespec-DlFroUGY.js → typespec-Brkt3IAA.js} +0 -0
- /package/client/dist/assets/{vb-BlrJpIMX.js → vb-r121Uzxt.js} +0 -0
- /package/client/dist/assets/{wgsl-BWgIc6FZ.js → wgsl-BRX8uYh4.js} +0 -0
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Per-project Jira sync orchestrator. One instance lives on each ProjectContext
|
|
3
|
+
// beside QueueManager/ChatManager. It owns:
|
|
4
|
+
// - the inbound poll loop (JQL high-water + overlap → materializer),
|
|
5
|
+
// - the durable outbox drainer (FIFO-per-issue, idempotency-first, error
|
|
6
|
+
// classification → retry / dead-letter / auth-pause),
|
|
7
|
+
// - the two write-back hooks: onRailLaunch (todo→In Progress) and onJobOutcome
|
|
8
|
+
// (Done / revert + completion comment),
|
|
9
|
+
// and stays completely inert until the project configures a Jira connection.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.JiraSyncManager = void 0;
|
|
12
|
+
exports.backoffMs = backoffMs;
|
|
13
|
+
exports.formatJqlDate = formatJqlDate;
|
|
14
|
+
exports.buildCompletionComment = buildCompletionComment;
|
|
15
|
+
const ticket_store_1 = require("../ticket-store");
|
|
16
|
+
const jira_client_1 = require("./jira-client");
|
|
17
|
+
const jira_backlog_config_1 = require("./jira-backlog-config");
|
|
18
|
+
const jira_adf_1 = require("./jira-adf");
|
|
19
|
+
const jira_materializer_1 = require("./jira-materializer");
|
|
20
|
+
const jira_issue_fields_1 = require("./jira-issue-fields");
|
|
21
|
+
const jira_status_resolver_1 = require("./jira-status-resolver");
|
|
22
|
+
const jira_db_1 = require("./jira-db");
|
|
23
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
24
|
+
const DRAIN_INTERVAL_MS = 10_000;
|
|
25
|
+
const POLL_OVERLAP_MS = 2 * 60_000;
|
|
26
|
+
const MAX_DRAIN_BATCH = 8;
|
|
27
|
+
const SEARCH_FIELDS = ['summary', 'description', 'labels', 'status', 'priority', 'assignee', 'updated', 'issuetype', 'parent'];
|
|
28
|
+
/** Local priority → Jira priority NAME (standard scheme; best-effort). A custom
|
|
29
|
+
* scheme that rejects the name is retried without priority in executeUpdate. */
|
|
30
|
+
const PRIORITY_TO_JIRA = {
|
|
31
|
+
critical: 'Highest',
|
|
32
|
+
high: 'High',
|
|
33
|
+
medium: 'Medium',
|
|
34
|
+
low: 'Low',
|
|
35
|
+
};
|
|
36
|
+
class JiraSyncManager {
|
|
37
|
+
db;
|
|
38
|
+
projectId;
|
|
39
|
+
projectPath;
|
|
40
|
+
broadcast;
|
|
41
|
+
fetchImpl;
|
|
42
|
+
notifyLocalWriteCb;
|
|
43
|
+
pollTimer = null;
|
|
44
|
+
drainTimer = null;
|
|
45
|
+
/** When set, the outbox is paused pending re-auth (401). */
|
|
46
|
+
authPaused = false;
|
|
47
|
+
constructor(opts) {
|
|
48
|
+
this.db = opts.db;
|
|
49
|
+
this.projectId = opts.projectId;
|
|
50
|
+
this.projectPath = opts.projectPath;
|
|
51
|
+
this.broadcast = opts.broadcast;
|
|
52
|
+
this.fetchImpl = opts.fetchImpl;
|
|
53
|
+
this.notifyLocalWriteCb = opts.notifyLocalWrite;
|
|
54
|
+
if (opts.startTimers !== false)
|
|
55
|
+
this.start();
|
|
56
|
+
}
|
|
57
|
+
/** Suppress the file-watcher echo for a local write we just made. */
|
|
58
|
+
notifyLocalWrite(revision) {
|
|
59
|
+
try {
|
|
60
|
+
this.notifyLocalWriteCb?.(revision);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* best-effort */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Late-bind the watcher notifier (the TicketWatcher is constructed after us). */
|
|
67
|
+
setLocalWriteNotifier(fn) {
|
|
68
|
+
this.notifyLocalWriteCb = fn;
|
|
69
|
+
}
|
|
70
|
+
// ─── Lifecycle ─────────────────────────────────────────────────────────────
|
|
71
|
+
/** True when this project has an enabled connection with a token. */
|
|
72
|
+
isActive() {
|
|
73
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
74
|
+
return !!conn && conn.enabled && (0, jira_db_1.getDecryptedToken)(this.db, this.projectId) !== null;
|
|
75
|
+
}
|
|
76
|
+
start() {
|
|
77
|
+
// Only arm timers for projects that actually have a Jira connection — a
|
|
78
|
+
// non-Jira project must not wake its event loop every 10s for a no-op.
|
|
79
|
+
let conn = null;
|
|
80
|
+
try {
|
|
81
|
+
conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* tables may not exist on a stale DB */
|
|
85
|
+
}
|
|
86
|
+
if (!conn)
|
|
87
|
+
return;
|
|
88
|
+
// Recover any inflight ops left by a crash, then arm timers if active.
|
|
89
|
+
try {
|
|
90
|
+
(0, jira_db_1.resetInflight)(this.db);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* table may not exist on a stale DB */
|
|
94
|
+
}
|
|
95
|
+
if (this.pollTimer || this.drainTimer)
|
|
96
|
+
return;
|
|
97
|
+
this.pollTimer = setInterval(() => {
|
|
98
|
+
void this.pollOnce().catch(() => undefined);
|
|
99
|
+
}, POLL_INTERVAL_MS);
|
|
100
|
+
this.drainTimer = setInterval(() => {
|
|
101
|
+
void this.drainOnce().catch(() => undefined);
|
|
102
|
+
}, DRAIN_INTERVAL_MS);
|
|
103
|
+
// Unref so timers never keep the process alive on shutdown.
|
|
104
|
+
this.pollTimer.unref?.();
|
|
105
|
+
this.drainTimer.unref?.();
|
|
106
|
+
}
|
|
107
|
+
stop() {
|
|
108
|
+
if (this.pollTimer)
|
|
109
|
+
clearInterval(this.pollTimer);
|
|
110
|
+
if (this.drainTimer)
|
|
111
|
+
clearInterval(this.drainTimer);
|
|
112
|
+
this.pollTimer = null;
|
|
113
|
+
this.drainTimer = null;
|
|
114
|
+
}
|
|
115
|
+
buildClient() {
|
|
116
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
117
|
+
if (!conn)
|
|
118
|
+
return null;
|
|
119
|
+
const token = (0, jira_db_1.getDecryptedToken)(this.db, this.projectId);
|
|
120
|
+
if (!token)
|
|
121
|
+
return null;
|
|
122
|
+
return new jira_client_1.JiraClient({
|
|
123
|
+
baseUrl: conn.baseUrl,
|
|
124
|
+
deployment: conn.deployment,
|
|
125
|
+
apiVersion: conn.apiVersion,
|
|
126
|
+
authScheme: conn.authScheme,
|
|
127
|
+
accountEmail: conn.accountEmail,
|
|
128
|
+
token,
|
|
129
|
+
fetchImpl: this.fetchImpl,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Discover the sprint custom-field id (schema gh-sprint). Returns the field
|
|
134
|
+
* id, 'none' when no sprint field exists, or null when it couldn't be
|
|
135
|
+
* determined (transient failure → caller leaves it unchecked to retry).
|
|
136
|
+
*/
|
|
137
|
+
async discoverSprintField(client) {
|
|
138
|
+
const res = await client.getFields();
|
|
139
|
+
if (!res.ok)
|
|
140
|
+
return null;
|
|
141
|
+
const field = res.data.find((f) => f.schema?.custom === 'com.pyxis.greenhopper.jira:gh-sprint');
|
|
142
|
+
return field ? field.id : 'none';
|
|
143
|
+
}
|
|
144
|
+
// ─── Connect / disconnect ──────────────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Validate a candidate connection (credential + project) and persist it on
|
|
147
|
+
* success. Writes the backlog-config so core treats the cache as read-only.
|
|
148
|
+
*/
|
|
149
|
+
async connect(input) {
|
|
150
|
+
const detected = (0, jira_client_1.detectDeployment)(input.baseUrl);
|
|
151
|
+
const probe = new jira_client_1.JiraClient({
|
|
152
|
+
baseUrl: input.baseUrl,
|
|
153
|
+
deployment: detected.deployment,
|
|
154
|
+
apiVersion: detected.apiVersion,
|
|
155
|
+
authScheme: detected.authScheme,
|
|
156
|
+
accountEmail: input.accountEmail,
|
|
157
|
+
token: input.token,
|
|
158
|
+
fetchImpl: this.fetchImpl,
|
|
159
|
+
});
|
|
160
|
+
const me = await probe.myself();
|
|
161
|
+
if (!me.ok) {
|
|
162
|
+
return { ok: false, error: me.code === 'auth' ? 'Invalid Jira credentials' : `Connection failed: ${me.error}`, status: me.status };
|
|
163
|
+
}
|
|
164
|
+
const proj = await probe.getProject(input.jiraProjectKey);
|
|
165
|
+
if (!proj.ok) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: proj.code === 'not_found' ? `Jira project "${input.jiraProjectKey}" not found or no access` : `Project check failed: ${proj.error}`,
|
|
169
|
+
status: proj.status,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const connection = (0, jira_db_1.upsertConnection)(this.db, {
|
|
173
|
+
projectId: this.projectId,
|
|
174
|
+
baseUrl: input.baseUrl.replace(/\/+$/, ''),
|
|
175
|
+
deployment: detected.deployment,
|
|
176
|
+
apiVersion: detected.apiVersion,
|
|
177
|
+
authScheme: detected.authScheme,
|
|
178
|
+
accountEmail: input.accountEmail,
|
|
179
|
+
jiraProjectKey: proj.data.key,
|
|
180
|
+
jiraProjectId: proj.data.id,
|
|
181
|
+
token: input.token,
|
|
182
|
+
enabled: true,
|
|
183
|
+
statusMap: input.statusMap ?? null,
|
|
184
|
+
});
|
|
185
|
+
if (input.discardStatus !== undefined) {
|
|
186
|
+
(0, jira_db_1.setDiscardStatus)(this.db, this.projectId, input.discardStatus);
|
|
187
|
+
}
|
|
188
|
+
(0, jira_backlog_config_1.writeJiraBacklogConfig)(this.projectPath);
|
|
189
|
+
// Discover the sprint custom-field id (best-effort) so sprint capture works
|
|
190
|
+
// from the first poll. Non-fatal — the poll re-discovers if this fails.
|
|
191
|
+
try {
|
|
192
|
+
const fieldId = await this.discoverSprintField(probe);
|
|
193
|
+
if (fieldId !== null)
|
|
194
|
+
(0, jira_db_1.setSprintFieldId)(this.db, this.projectId, fieldId);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
/* non-fatal */
|
|
198
|
+
}
|
|
199
|
+
this.authPaused = false;
|
|
200
|
+
this.start();
|
|
201
|
+
// Kick an immediate first sync (best-effort).
|
|
202
|
+
void this.pollOnce().catch(() => undefined);
|
|
203
|
+
return { ok: true, connection: (0, jira_db_1.getConnection)(this.db, this.projectId) ?? connection };
|
|
204
|
+
}
|
|
205
|
+
throwawayClient(input) {
|
|
206
|
+
const detected = (0, jira_client_1.detectDeployment)(input.baseUrl);
|
|
207
|
+
return new jira_client_1.JiraClient({
|
|
208
|
+
baseUrl: input.baseUrl,
|
|
209
|
+
deployment: detected.deployment,
|
|
210
|
+
apiVersion: detected.apiVersion,
|
|
211
|
+
authScheme: detected.authScheme,
|
|
212
|
+
accountEmail: input.accountEmail,
|
|
213
|
+
token: input.token,
|
|
214
|
+
fetchImpl: this.fetchImpl,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Wizard step 1: validate credentials without persisting anything. */
|
|
218
|
+
async probeCredentials(input) {
|
|
219
|
+
const detected = (0, jira_client_1.detectDeployment)(input.baseUrl);
|
|
220
|
+
const me = await this.throwawayClient(input).myself();
|
|
221
|
+
if (!me.ok) {
|
|
222
|
+
return { ok: false, error: me.code === 'auth' ? 'Invalid email or token' : me.error, status: me.status };
|
|
223
|
+
}
|
|
224
|
+
return { ok: true, deployment: detected.deployment, displayName: me.data.displayName ?? me.data.emailAddress ?? null };
|
|
225
|
+
}
|
|
226
|
+
/** Wizard step 2: list the projects this credential can see. */
|
|
227
|
+
async discoverProjects(input) {
|
|
228
|
+
const res = await this.throwawayClient(input).searchProjects(input.query);
|
|
229
|
+
if (!res.ok)
|
|
230
|
+
return { ok: false, error: res.error, status: res.status };
|
|
231
|
+
return { ok: true, projects: res.data };
|
|
232
|
+
}
|
|
233
|
+
/** Wizard step 3 (optional): the chosen project's real status list for mapping. */
|
|
234
|
+
async discoverStatuses(input) {
|
|
235
|
+
const res = await this.throwawayClient(input).getProjectStatuses(input.projectKey);
|
|
236
|
+
if (!res.ok)
|
|
237
|
+
return { ok: false, error: res.error };
|
|
238
|
+
const seen = new Map();
|
|
239
|
+
for (const group of res.data) {
|
|
240
|
+
for (const s of group.statuses) {
|
|
241
|
+
if (!seen.has(s.id))
|
|
242
|
+
seen.set(s.id, { id: s.id, name: s.name, category: s.statusCategory?.key ?? 'indeterminate' });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, statuses: Array.from(seen.values()) };
|
|
246
|
+
}
|
|
247
|
+
/** Pause/resume sync without losing config (hot-swap back to local specs). */
|
|
248
|
+
setEnabled(enabled) {
|
|
249
|
+
(0, jira_db_1.setConnectionEnabled)(this.db, this.projectId, enabled);
|
|
250
|
+
if (enabled) {
|
|
251
|
+
(0, jira_backlog_config_1.writeJiraBacklogConfig)(this.projectPath);
|
|
252
|
+
this.start();
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
(0, jira_backlog_config_1.writeLocalBacklogConfig)(this.projectPath);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Configure (or clear) the status a discarded spec is moved to. */
|
|
259
|
+
setDiscardStatus(status) {
|
|
260
|
+
(0, jira_db_1.setDiscardStatus)(this.db, this.projectId, status);
|
|
261
|
+
}
|
|
262
|
+
/** Replace (or clear) the per-logical-state status map (post-connect edit). */
|
|
263
|
+
setStatusMap(statusMap) {
|
|
264
|
+
(0, jira_db_1.setStatusMap)(this.db, this.projectId, statusMap);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* List the connected project's real statuses (for the post-connect "move on
|
|
268
|
+
* discard" picker). Uses the stored credentials — no creds needed from caller.
|
|
269
|
+
*/
|
|
270
|
+
async listStatusesForConnection() {
|
|
271
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
272
|
+
if (!conn)
|
|
273
|
+
return { ok: false, error: 'No Jira connection configured' };
|
|
274
|
+
const client = this.buildClient();
|
|
275
|
+
if (!client)
|
|
276
|
+
return { ok: false, error: 'No Jira credentials' };
|
|
277
|
+
const res = await client.getProjectStatuses(conn.jiraProjectKey);
|
|
278
|
+
if (!res.ok)
|
|
279
|
+
return { ok: false, error: res.error };
|
|
280
|
+
const seen = new Map();
|
|
281
|
+
for (const group of res.data) {
|
|
282
|
+
for (const s of group.statuses) {
|
|
283
|
+
if (!seen.has(s.id))
|
|
284
|
+
seen.set(s.id, { id: s.id, name: s.name, category: s.statusCategory?.key ?? 'indeterminate' });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { ok: true, statuses: Array.from(seen.values()) };
|
|
288
|
+
}
|
|
289
|
+
/** Remove the connection entirely and restore local backlog config. */
|
|
290
|
+
disconnect() {
|
|
291
|
+
(0, jira_db_1.deleteConnection)(this.db, this.projectId);
|
|
292
|
+
(0, jira_backlog_config_1.writeLocalBacklogConfig)(this.projectPath);
|
|
293
|
+
this.stop();
|
|
294
|
+
}
|
|
295
|
+
// ─── Create a spec in Jira (Add Spec when source = Jira) ───────────────────
|
|
296
|
+
/**
|
|
297
|
+
* Create a Jira issue for a new spec, materialize it into the local cache, and
|
|
298
|
+
* return the minted local `#id`. The issue type defaults to "Task" (the most
|
|
299
|
+
* universally present type); customers with no Task type can override later.
|
|
300
|
+
*/
|
|
301
|
+
async createSpec(input) {
|
|
302
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
303
|
+
if (!conn)
|
|
304
|
+
return { ok: false, error: 'No Jira connection configured' };
|
|
305
|
+
const client = this.buildClient();
|
|
306
|
+
if (!client)
|
|
307
|
+
return { ok: false, error: 'No Jira credentials' };
|
|
308
|
+
const created = await client.createIssue({
|
|
309
|
+
projectKey: conn.jiraProjectKey,
|
|
310
|
+
issueType: input.issueType ?? 'Task',
|
|
311
|
+
summary: input.title,
|
|
312
|
+
description: input.description,
|
|
313
|
+
labels: input.labels,
|
|
314
|
+
priority: input.priority,
|
|
315
|
+
});
|
|
316
|
+
if (!created.ok) {
|
|
317
|
+
if (created.code === 'auth')
|
|
318
|
+
this.onAuth401();
|
|
319
|
+
return { ok: false, error: created.error || 'Jira issue create failed', status: created.status };
|
|
320
|
+
}
|
|
321
|
+
// Fetch the full issue so the cache reflects the real status/fields.
|
|
322
|
+
const full = await client.getIssue(created.data.id, SEARCH_FIELDS);
|
|
323
|
+
const issue = full.ok
|
|
324
|
+
? full.data
|
|
325
|
+
: { id: created.data.id, key: created.data.key, fields: { summary: input.title, labels: input.labels ?? [] } };
|
|
326
|
+
const r = (0, jira_materializer_1.upsertIssuesIntoStore)(this.db, this.projectPath, conn, [issue], new Set());
|
|
327
|
+
if (r.wrote)
|
|
328
|
+
this.notifyLocalWrite(r.revision);
|
|
329
|
+
const localId = r.changedLocalIds[0];
|
|
330
|
+
const t = readTicket(this.projectPath, localId);
|
|
331
|
+
if (t)
|
|
332
|
+
this.broadcast({ type: 'ticket_created', ticket: t, projectId: this.projectId, timestamp: t.updated_at });
|
|
333
|
+
return { ok: true, localId, jiraKey: created.data.key };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Promote an existing LOCAL ticket to a Jira issue (Add Spec on a Jira-backed
|
|
337
|
+
* project). Creates the issue, links it to the SAME local id (no new id minted,
|
|
338
|
+
* no duplicate ticket), and flips the cached ticket to `source:'jira'` with its
|
|
339
|
+
* key/url. Idempotent: a ticket already linked is a no-op. Best-effort — on
|
|
340
|
+
* failure the ticket simply stays local and the caller surfaces a warning.
|
|
341
|
+
*/
|
|
342
|
+
async promoteTicketToJira(localId) {
|
|
343
|
+
if (!this.isActive())
|
|
344
|
+
return { ok: false, error: 'jira not active' };
|
|
345
|
+
const existing = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
|
|
346
|
+
if (existing && !existing.tombstoned)
|
|
347
|
+
return { ok: true, jiraKey: existing.jiraKey, alreadyLinked: true };
|
|
348
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
349
|
+
if (!conn)
|
|
350
|
+
return { ok: false, error: 'no jira connection' };
|
|
351
|
+
const client = this.buildClient();
|
|
352
|
+
if (!client)
|
|
353
|
+
return { ok: false, error: 'no jira credentials' };
|
|
354
|
+
const file = (0, ticket_store_1.resolveTicketStoragePath)(this.projectPath);
|
|
355
|
+
const ticket = (0, ticket_store_1.readStore)(file).tickets[String(localId)];
|
|
356
|
+
if (!ticket)
|
|
357
|
+
return { ok: false, error: 'ticket not found' };
|
|
358
|
+
const created = await client.createIssue({
|
|
359
|
+
projectKey: conn.jiraProjectKey,
|
|
360
|
+
issueType: 'Task',
|
|
361
|
+
summary: ticket.title,
|
|
362
|
+
description: ticket.description || undefined,
|
|
363
|
+
labels: ticket.labels,
|
|
364
|
+
});
|
|
365
|
+
if (!created.ok) {
|
|
366
|
+
if (created.code === 'auth')
|
|
367
|
+
this.onAuth401();
|
|
368
|
+
return { ok: false, error: created.error || 'jira issue create failed' };
|
|
369
|
+
}
|
|
370
|
+
(0, jira_db_1.insertLinkWithId)(this.db, {
|
|
371
|
+
localId,
|
|
372
|
+
jiraIssueId: created.data.id,
|
|
373
|
+
jiraKey: created.data.key,
|
|
374
|
+
jiraProjectId: conn.jiraProjectId,
|
|
375
|
+
deployment: conn.deployment,
|
|
376
|
+
});
|
|
377
|
+
let updated;
|
|
378
|
+
const promoteStore = (0, ticket_store_1.mutateStore)(file, (s) => {
|
|
379
|
+
const t = s.tickets[String(localId)];
|
|
380
|
+
if (t) {
|
|
381
|
+
t.source = 'jira';
|
|
382
|
+
t.jira_key = created.data.key;
|
|
383
|
+
t.jira_url = (0, jira_materializer_1.issueUrl)(conn.baseUrl, created.data.key);
|
|
384
|
+
t.updated_at = new Date().toISOString();
|
|
385
|
+
updated = t;
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
this.notifyLocalWrite(promoteStore.revision);
|
|
389
|
+
if (updated) {
|
|
390
|
+
this.broadcast({ type: 'ticket_updated', ticket: updated, projectId: this.projectId, timestamp: updated.updated_at });
|
|
391
|
+
}
|
|
392
|
+
return { ok: true, jiraKey: created.data.key };
|
|
393
|
+
}
|
|
394
|
+
// ─── Inbound poll ──────────────────────────────────────────────────────────
|
|
395
|
+
/** The set of local ids with a pending/inflight outbox op (status is frozen). */
|
|
396
|
+
frozenLocalIds() {
|
|
397
|
+
const rows = this.db
|
|
398
|
+
.prepare(`SELECT DISTINCT l.local_id AS localId
|
|
399
|
+
FROM jira_outbox o JOIN jira_links l ON l.jira_issue_id = o.jira_issue_id
|
|
400
|
+
WHERE o.state IN ('pending','inflight') AND o.op_type IN ('transition','update')`)
|
|
401
|
+
.all();
|
|
402
|
+
return new Set(rows.map((r) => r.localId));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Inbound poll. `full=true` ignores the high-water mark and re-fetches the
|
|
406
|
+
* whole backlog — used by the manual "Sync now" so it back-fills any fields
|
|
407
|
+
* the cache is missing (e.g. sprint/epic data added after the last sync).
|
|
408
|
+
*/
|
|
409
|
+
async pollOnce(full = false) {
|
|
410
|
+
let conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
411
|
+
if (!conn || !conn.enabled)
|
|
412
|
+
return null;
|
|
413
|
+
const client = this.buildClient();
|
|
414
|
+
if (!client)
|
|
415
|
+
return null;
|
|
416
|
+
// Lazily discover the sprint custom-field id for connections made before the
|
|
417
|
+
// feature existed (null = not yet checked). Persist 'none' when there's none.
|
|
418
|
+
if (conn.sprintFieldId === null) {
|
|
419
|
+
const fieldId = await this.discoverSprintField(client);
|
|
420
|
+
if (fieldId !== null) {
|
|
421
|
+
(0, jira_db_1.setSprintFieldId)(this.db, this.projectId, fieldId);
|
|
422
|
+
// First time we find a real sprint field on an ALREADY-synced connection:
|
|
423
|
+
// reset the high-water so this poll re-fetches every issue and back-fills
|
|
424
|
+
// the sprint (and epic) data the cache is missing. One-time full re-sync.
|
|
425
|
+
if (fieldId !== 'none' && conn.highWaterMs && conn.highWaterMs > 0) {
|
|
426
|
+
(0, jira_db_1.setHighWater)(this.db, this.projectId, 0);
|
|
427
|
+
}
|
|
428
|
+
conn = (0, jira_db_1.getConnection)(this.db, this.projectId) ?? conn;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const searchFields = conn.sprintFieldId && conn.sprintFieldId !== 'none' ? [...SEARCH_FIELDS, conn.sprintFieldId] : SEARCH_FIELDS;
|
|
432
|
+
const frozen = this.frozenLocalIds();
|
|
433
|
+
let jql = `project = "${conn.jiraProjectKey}" ORDER BY updated ASC`;
|
|
434
|
+
if (!full && conn.highWaterMs && conn.highWaterMs > 0) {
|
|
435
|
+
const since = formatJqlDate(conn.highWaterMs - POLL_OVERLAP_MS);
|
|
436
|
+
jql = `project = "${conn.jiraProjectKey}" AND updated >= "${since}" ORDER BY updated ASC`;
|
|
437
|
+
}
|
|
438
|
+
let nextPageToken;
|
|
439
|
+
let totalUpserted = 0;
|
|
440
|
+
let maxUpdated = conn.highWaterMs ?? 0;
|
|
441
|
+
for (let page = 0; page < 50; page++) {
|
|
442
|
+
const res = await client.searchJql({ jql, fields: searchFields, nextPageToken, maxResults: 100 });
|
|
443
|
+
if (!res.ok) {
|
|
444
|
+
if (res.code === 'auth')
|
|
445
|
+
this.onAuth401();
|
|
446
|
+
else
|
|
447
|
+
this.broadcast({ type: 'jira.sync_error', projectId: this.projectId, reason: res.error });
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const issues = res.data.issues ?? [];
|
|
451
|
+
if (issues.length > 0) {
|
|
452
|
+
const r = (0, jira_materializer_1.upsertIssuesIntoStore)(this.db, this.projectPath, conn, issues, frozen);
|
|
453
|
+
// Suppress the watcher echo for our own write (avoids the full-board
|
|
454
|
+
// refresh flicker). When nothing changed, `wrote` is false and we also
|
|
455
|
+
// skip the granular broadcasts below — the board stays perfectly still.
|
|
456
|
+
if (r.wrote)
|
|
457
|
+
this.notifyLocalWrite(r.revision);
|
|
458
|
+
totalUpserted += r.upserted;
|
|
459
|
+
if (r.maxUpdatedMs > maxUpdated)
|
|
460
|
+
maxUpdated = r.maxUpdatedMs;
|
|
461
|
+
for (const localId of r.changedLocalIds) {
|
|
462
|
+
const t = readTicket(this.projectPath, localId);
|
|
463
|
+
if (t)
|
|
464
|
+
this.broadcast({ type: 'ticket_updated', ticket: t, projectId: this.projectId, timestamp: t.updated_at });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
nextPageToken = res.data.nextPageToken;
|
|
468
|
+
if (!nextPageToken || issues.length === 0)
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
if (maxUpdated > (conn.highWaterMs ?? 0))
|
|
472
|
+
(0, jira_db_1.setHighWater)(this.db, this.projectId, maxUpdated);
|
|
473
|
+
if (totalUpserted > 0)
|
|
474
|
+
this.broadcast({ type: 'jira.synced', projectId: this.projectId, upserted: totalUpserted, at: Date.now() });
|
|
475
|
+
return { upserted: totalUpserted };
|
|
476
|
+
}
|
|
477
|
+
// ─── Outbound write-back hooks ─────────────────────────────────────────────
|
|
478
|
+
/**
|
|
479
|
+
* Called from the rail-launch handler. For each Jira-linked ticket, enqueue an
|
|
480
|
+
* In Progress transition AND write in_progress into the local cache (because
|
|
481
|
+
* backlog-config write_access:false stops core from writing it). No-op for
|
|
482
|
+
* non-Jira projects / unlinked tickets.
|
|
483
|
+
*/
|
|
484
|
+
onRailLaunch(ticketIds, jobId) {
|
|
485
|
+
if (!this.isActive())
|
|
486
|
+
return;
|
|
487
|
+
const ops = [];
|
|
488
|
+
const linkedIds = [];
|
|
489
|
+
for (const localId of ticketIds) {
|
|
490
|
+
const link = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
|
|
491
|
+
if (!link || link.tombstoned)
|
|
492
|
+
continue;
|
|
493
|
+
linkedIds.push(localId);
|
|
494
|
+
ops.push({
|
|
495
|
+
jiraIssueId: link.jiraIssueId,
|
|
496
|
+
opType: 'transition',
|
|
497
|
+
idempotencyKey: `${jobId}:${localId}:transition:in_progress`,
|
|
498
|
+
payload: { localId, jiraIssueId: link.jiraIssueId, logicalState: 'in_progress' },
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (ops.length === 0)
|
|
502
|
+
return;
|
|
503
|
+
(0, jira_db_1.enqueueMany)(this.db, ops);
|
|
504
|
+
this.writeLocalStatus(linkedIds, 'in_progress');
|
|
505
|
+
this.broadcastOutboxState();
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Called after a Jira-backed spec is edited + saved locally. Pushes the changed
|
|
509
|
+
* editable fields (summary/description/labels/priority) to the Jira issue via a
|
|
510
|
+
* durable 'update' op. No-op for non-Jira / unlinked specs. While the op is
|
|
511
|
+
* pending the id is frozen, so the inbound poll won't revert the local edit.
|
|
512
|
+
* Status is intentionally NOT written here (Jira status needs a transition).
|
|
513
|
+
*/
|
|
514
|
+
onSpecEdited(localId, changes) {
|
|
515
|
+
if (!this.isActive())
|
|
516
|
+
return;
|
|
517
|
+
const link = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
|
|
518
|
+
if (!link || link.tombstoned)
|
|
519
|
+
return;
|
|
520
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
521
|
+
if (!conn)
|
|
522
|
+
return;
|
|
523
|
+
const fields = {};
|
|
524
|
+
if (typeof changes.title === 'string' && changes.title.trim())
|
|
525
|
+
fields.summary = changes.title.trim().slice(0, 250);
|
|
526
|
+
if (typeof changes.description === 'string')
|
|
527
|
+
fields.description = (0, jira_adf_1.bodyForDeployment)(changes.description, conn.deployment);
|
|
528
|
+
if (changes.labels !== undefined)
|
|
529
|
+
fields.labels = (changes.labels ?? []).filter((l) => typeof l === 'string');
|
|
530
|
+
if (changes.priority) {
|
|
531
|
+
const name = PRIORITY_TO_JIRA[changes.priority];
|
|
532
|
+
if (name)
|
|
533
|
+
fields.priority = { name };
|
|
534
|
+
}
|
|
535
|
+
if (Object.keys(fields).length === 0)
|
|
536
|
+
return;
|
|
537
|
+
const nonce = Date.now().toString(36);
|
|
538
|
+
(0, jira_db_1.enqueueMany)(this.db, [
|
|
539
|
+
{
|
|
540
|
+
jiraIssueId: link.jiraIssueId,
|
|
541
|
+
opType: 'update',
|
|
542
|
+
idempotencyKey: `update:${localId}:${nonce}`,
|
|
543
|
+
payload: { jiraIssueId: link.jiraIssueId, fields },
|
|
544
|
+
},
|
|
545
|
+
]);
|
|
546
|
+
this.broadcastOutboxState();
|
|
547
|
+
void this.drainOnce().catch(() => undefined);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Called from project-registry's onJobFinished AFTER the local cache mutation.
|
|
551
|
+
* Enqueues the Jira status transition + completion comment per linked ticket.
|
|
552
|
+
*/
|
|
553
|
+
onJobOutcome(args) {
|
|
554
|
+
if (!this.isActive())
|
|
555
|
+
return;
|
|
556
|
+
const needsReview = new Set(args.needsReviewIds ?? []);
|
|
557
|
+
const ops = [];
|
|
558
|
+
for (const localId of args.ticketIds) {
|
|
559
|
+
const link = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
|
|
560
|
+
if (!link || link.tombstoned)
|
|
561
|
+
continue;
|
|
562
|
+
const reviewing = needsReview.has(localId);
|
|
563
|
+
// Completion comment (always safe/additive). Marker makes it idempotent.
|
|
564
|
+
const commentText = buildCompletionComment(args, link.jiraKey, reviewing);
|
|
565
|
+
ops.push({
|
|
566
|
+
jiraIssueId: link.jiraIssueId,
|
|
567
|
+
opType: 'comment',
|
|
568
|
+
idempotencyKey: `${args.jobId}:${localId}:comment`,
|
|
569
|
+
payload: { jiraIssueId: link.jiraIssueId, text: commentText, marker: (0, jira_adf_1.commentMarker)(args.jobId, localId) },
|
|
570
|
+
});
|
|
571
|
+
// Status transition: success → done (unless needs_review); else revert → todo.
|
|
572
|
+
// needs_review keeps status unchanged in Jira (no equivalent) — comment only.
|
|
573
|
+
if (reviewing)
|
|
574
|
+
continue;
|
|
575
|
+
const logicalState = args.status === 'completed' ? 'done' : 'todo';
|
|
576
|
+
ops.push({
|
|
577
|
+
jiraIssueId: link.jiraIssueId,
|
|
578
|
+
opType: 'transition',
|
|
579
|
+
idempotencyKey: `${args.jobId}:${localId}:transition:${logicalState}`,
|
|
580
|
+
payload: { localId, jiraIssueId: link.jiraIssueId, logicalState },
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
if (ops.length === 0)
|
|
584
|
+
return;
|
|
585
|
+
(0, jira_db_1.enqueueMany)(this.db, ops);
|
|
586
|
+
this.broadcastOutboxState();
|
|
587
|
+
// Drain promptly (best-effort) so PMs see the update without waiting a tick.
|
|
588
|
+
void this.drainOnce().catch(() => undefined);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* "Discard" a Jira-backed spec: instead of a destructive local delete, move the
|
|
592
|
+
* linked issue to the user-configured discard status and (optionally) post a
|
|
593
|
+
* reason comment. The local cache is optimistically flipped to `cancelled` so
|
|
594
|
+
* the spec leaves the active board immediately; the inbound poll later
|
|
595
|
+
* reconciles it to the issue's real status (protected meanwhile by the
|
|
596
|
+
* pending-transition frozen guard).
|
|
597
|
+
*/
|
|
598
|
+
discardSpec(localId, comment) {
|
|
599
|
+
if (!this.isActive())
|
|
600
|
+
return { ok: false, reason: 'not-active' };
|
|
601
|
+
const link = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
|
|
602
|
+
if (!link || link.tombstoned)
|
|
603
|
+
return { ok: false, reason: 'no-link' };
|
|
604
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
605
|
+
const target = conn?.discardStatus ?? null;
|
|
606
|
+
if (!target)
|
|
607
|
+
return { ok: false, reason: 'not-configured' };
|
|
608
|
+
// Distinct per-action nonce so a re-discard isn't deduped by idempotency key.
|
|
609
|
+
const nonce = Date.now().toString(36);
|
|
610
|
+
const ops = [];
|
|
611
|
+
const trimmed = (comment ?? '').trim();
|
|
612
|
+
if (trimmed) {
|
|
613
|
+
ops.push({
|
|
614
|
+
jiraIssueId: link.jiraIssueId,
|
|
615
|
+
opType: 'comment',
|
|
616
|
+
idempotencyKey: `discard:${localId}:${nonce}:comment`,
|
|
617
|
+
payload: { jiraIssueId: link.jiraIssueId, text: trimmed, marker: (0, jira_adf_1.discardCommentMarker)(localId, nonce) },
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
ops.push({
|
|
621
|
+
jiraIssueId: link.jiraIssueId,
|
|
622
|
+
opType: 'transition',
|
|
623
|
+
idempotencyKey: `discard:${localId}:${nonce}:transition`,
|
|
624
|
+
payload: { localId, jiraIssueId: link.jiraIssueId, logicalState: 'cancelled', targetStatus: target },
|
|
625
|
+
});
|
|
626
|
+
(0, jira_db_1.enqueueMany)(this.db, ops);
|
|
627
|
+
this.writeLocalStatus([localId], 'cancelled');
|
|
628
|
+
this.broadcastOutboxState();
|
|
629
|
+
void this.drainOnce().catch(() => undefined);
|
|
630
|
+
return { ok: true };
|
|
631
|
+
}
|
|
632
|
+
// ─── Outbox drain ──────────────────────────────────────────────────────────
|
|
633
|
+
async drainOnce() {
|
|
634
|
+
if (this.authPaused)
|
|
635
|
+
return;
|
|
636
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
637
|
+
if (!conn || !conn.enabled)
|
|
638
|
+
return;
|
|
639
|
+
const client = this.buildClient();
|
|
640
|
+
if (!client)
|
|
641
|
+
return;
|
|
642
|
+
const batch = (0, jira_db_1.claimDrainable)(this.db, MAX_DRAIN_BATCH);
|
|
643
|
+
if (batch.length === 0)
|
|
644
|
+
return;
|
|
645
|
+
await Promise.all(batch.map((op) => this.executeOp(client, conn, op)));
|
|
646
|
+
this.broadcastOutboxState();
|
|
647
|
+
}
|
|
648
|
+
async executeOp(client, conn, op) {
|
|
649
|
+
try {
|
|
650
|
+
const payload = JSON.parse(op.payload);
|
|
651
|
+
if (op.opType === 'comment') {
|
|
652
|
+
await this.executeComment(client, op, payload);
|
|
653
|
+
}
|
|
654
|
+
else if (op.opType === 'transition') {
|
|
655
|
+
await this.executeTransition(client, conn, op, payload);
|
|
656
|
+
}
|
|
657
|
+
else if (op.opType === 'update') {
|
|
658
|
+
await this.executeUpdate(client, op, payload);
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, `unsupported op type ${op.opType}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
this.retryOrDead(op, err instanceof Error ? err.message : String(err));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async executeUpdate(client, op, payload) {
|
|
669
|
+
let res = await client.updateIssue(payload.jiraIssueId, payload.fields);
|
|
670
|
+
// The priority NAME may not match the instance's scheme. Don't let that lose
|
|
671
|
+
// the title/description/labels edit — retry once without priority.
|
|
672
|
+
if (!res.ok && res.code === 'validation' && 'priority' in payload.fields) {
|
|
673
|
+
const { priority: _drop, ...rest } = payload.fields;
|
|
674
|
+
void _drop;
|
|
675
|
+
if (Object.keys(rest).length > 0)
|
|
676
|
+
res = await client.updateIssue(payload.jiraIssueId, rest);
|
|
677
|
+
}
|
|
678
|
+
if (res.ok) {
|
|
679
|
+
(0, jira_db_1.markOutboxDone)(this.db, op.id);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (res.code === 'not_found') {
|
|
683
|
+
(0, jira_db_1.tombstoneLink)(this.db, payload.jiraIssueId);
|
|
684
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, 'issue deleted or inaccessible');
|
|
685
|
+
this.broadcastDegraded(null, 'linked Jira issue no longer reachable');
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this.handleHardError(op, res.code, res.status, res.retryAfterMs, res.error);
|
|
689
|
+
}
|
|
690
|
+
async executeComment(client, op, payload) {
|
|
691
|
+
// Idempotency: skip if a comment already carries this op's marker (stored as
|
|
692
|
+
// an invisible comment property, with a legacy body-scan fallback).
|
|
693
|
+
const existing = await client.getComments(payload.jiraIssueId);
|
|
694
|
+
if (existing.ok) {
|
|
695
|
+
const dup = existing.data.comments.some((c) => (0, jira_adf_1.commentHasMarker)(c, payload.marker));
|
|
696
|
+
if (dup) {
|
|
697
|
+
(0, jira_db_1.markOutboxDone)(this.db, op.id);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
else if (this.handleHardError(op, existing.code, existing.status, existing.retryAfterMs, existing.error)) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// Post the user-facing text only; the marker rides along as a hidden
|
|
705
|
+
// comment property so it never appears in the rendered comment.
|
|
706
|
+
const res = await client.addComment(payload.jiraIssueId, payload.text, payload.marker);
|
|
707
|
+
if (res.ok) {
|
|
708
|
+
(0, jira_db_1.markOutboxDone)(this.db, op.id);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
this.handleHardError(op, res.code, res.status, res.retryAfterMs, res.error);
|
|
712
|
+
}
|
|
713
|
+
async executeTransition(client, conn, op, payload) {
|
|
714
|
+
// Re-GET the live issue for idempotency-first: skip if already in target category.
|
|
715
|
+
const issue = await client.getIssue(payload.jiraIssueId, ['status']);
|
|
716
|
+
if (!issue.ok) {
|
|
717
|
+
if (issue.code === 'not_found') {
|
|
718
|
+
(0, jira_db_1.tombstoneLink)(this.db, payload.jiraIssueId);
|
|
719
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, 'issue deleted or inaccessible');
|
|
720
|
+
this.broadcastDegraded(null, 'linked Jira issue no longer reachable');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (this.handleHardError(op, issue.code, issue.status, issue.retryAfterMs, issue.error))
|
|
724
|
+
return;
|
|
725
|
+
this.retryOrDead(op, issue.error);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const currentCategory = issue.data.fields.status?.statusCategory?.key ?? 'indeterminate';
|
|
729
|
+
// A per-op explicit target (e.g. the discard "move-to" status) wins over the
|
|
730
|
+
// connection's per-logical-state status map.
|
|
731
|
+
const explicitTarget = payload.targetStatus ?? conn.statusMap?.[payload.logicalState];
|
|
732
|
+
const outcome = await (0, jira_status_resolver_1.walkToCategory)({
|
|
733
|
+
state: payload.logicalState,
|
|
734
|
+
currentCategory,
|
|
735
|
+
explicitTarget,
|
|
736
|
+
getTransitions: async () => {
|
|
737
|
+
const res = await client.getTransitions(payload.jiraIssueId);
|
|
738
|
+
if (!res.ok)
|
|
739
|
+
throw new JiraOpError(res.code, res.status, res.retryAfterMs, res.error);
|
|
740
|
+
return res.data.transitions;
|
|
741
|
+
},
|
|
742
|
+
applyTransition: async (transition) => {
|
|
743
|
+
const plan = (0, jira_status_resolver_1.buildTransitionFields)(transition, payload.logicalState);
|
|
744
|
+
const res = await client.transitionIssue(payload.jiraIssueId, transition.id, plan.fields);
|
|
745
|
+
if (!res.ok)
|
|
746
|
+
throw new JiraOpError(res.code, res.status, res.retryAfterMs, res.error);
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
switch (outcome.status) {
|
|
750
|
+
case 'noop':
|
|
751
|
+
case 'applied':
|
|
752
|
+
(0, jira_db_1.markOutboxDone)(this.db, op.id);
|
|
753
|
+
return;
|
|
754
|
+
case 'no_path':
|
|
755
|
+
case 'blocked':
|
|
756
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, outcome.reason);
|
|
757
|
+
this.broadcastDegraded(null, `status not synced — ${outcome.reason}; manual move may be needed`);
|
|
758
|
+
return;
|
|
759
|
+
case 'error':
|
|
760
|
+
this.retryOrDead(op, outcome.reason);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Centralised hard-error handling. Returns true when the error was terminal
|
|
766
|
+
* (op finished/parked/dead) and the caller must stop.
|
|
767
|
+
*/
|
|
768
|
+
handleHardError(op, code, status, retryAfterMs, error) {
|
|
769
|
+
if (code === 'auth') {
|
|
770
|
+
this.onAuth401();
|
|
771
|
+
// Park the op back to pending so it replays after re-auth.
|
|
772
|
+
(0, jira_db_1.markOutboxRetry)(this.db, op.id, new Date(Date.now() + 60_000).toISOString(), 'auth: token expired/revoked');
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
if (code === 'permission') {
|
|
776
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, `permission denied (403): ${error}`);
|
|
777
|
+
this.broadcastDegraded(null, 'your Jira account cannot perform this operation');
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
if (code === 'not_found') {
|
|
781
|
+
(0, jira_db_1.tombstoneLink)(this.db, op.jiraIssueId);
|
|
782
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, 'issue deleted or inaccessible');
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
if (code === 'validation' || code === 'no_transition') {
|
|
786
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, `validation error: ${error}`);
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
if (code === 'rate_limit') {
|
|
790
|
+
const delay = retryAfterMs ?? backoffMs(op.attempts);
|
|
791
|
+
(0, jira_db_1.markOutboxRetry)(this.db, op.id, new Date(Date.now() + delay).toISOString(), `rate limited (429)`);
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
// server / network → retry with backoff
|
|
795
|
+
this.retryOrDead(op, error);
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
retryOrDead(op, error) {
|
|
799
|
+
if (error.startsWith('JiraOpError:')) {
|
|
800
|
+
// Unwrap a thrown JiraOpError to classify it.
|
|
801
|
+
const parsed = JiraOpError.parse(error);
|
|
802
|
+
if (parsed) {
|
|
803
|
+
this.handleHardError(op, parsed.code, parsed.status, parsed.retryAfterMs, parsed.message);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const MAX_ATTEMPTS = 6;
|
|
808
|
+
if (op.attempts + 1 >= MAX_ATTEMPTS) {
|
|
809
|
+
(0, jira_db_1.markOutboxDead)(this.db, op.id, `exhausted retries: ${error}`);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
(0, jira_db_1.markOutboxRetry)(this.db, op.id, new Date(Date.now() + backoffMs(op.attempts)).toISOString(), error);
|
|
813
|
+
}
|
|
814
|
+
onAuth401() {
|
|
815
|
+
this.authPaused = true;
|
|
816
|
+
const pending = (0, jira_db_1.listOutbox)(this.db, { state: 'pending' }).length + (0, jira_db_1.listOutbox)(this.db, { state: 'inflight' }).length;
|
|
817
|
+
this.broadcast({ type: 'jira.auth_expired', projectId: this.projectId, pending });
|
|
818
|
+
}
|
|
819
|
+
/** Re-paste of a fresh token clears the auth-pause and drains the parked outbox. */
|
|
820
|
+
resumeAfterReauth() {
|
|
821
|
+
this.authPaused = false;
|
|
822
|
+
void this.drainOnce().catch(() => undefined);
|
|
823
|
+
}
|
|
824
|
+
// ─── Local cache helpers ───────────────────────────────────────────────────
|
|
825
|
+
writeLocalStatus(localIds, status) {
|
|
826
|
+
if (localIds.length === 0)
|
|
827
|
+
return;
|
|
828
|
+
try {
|
|
829
|
+
const file = (0, ticket_store_1.resolveTicketStoragePath)(this.projectPath);
|
|
830
|
+
const ids = new Set(localIds.map(String));
|
|
831
|
+
const now = new Date().toISOString();
|
|
832
|
+
const store = (0, ticket_store_1.mutateStore)(file, (s) => {
|
|
833
|
+
for (const id of ids) {
|
|
834
|
+
const t = s.tickets[id];
|
|
835
|
+
if (t && t.status !== status) {
|
|
836
|
+
t.status = status;
|
|
837
|
+
t.updated_at = now;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
this.notifyLocalWrite(store.revision);
|
|
842
|
+
for (const id of ids) {
|
|
843
|
+
const t = store.tickets[id];
|
|
844
|
+
if (t)
|
|
845
|
+
this.broadcast({ type: 'ticket_updated', ticket: t, projectId: this.projectId, timestamp: t.updated_at });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
console.error('[jira-sync] writeLocalStatus failed:', err);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
broadcastOutboxState() {
|
|
853
|
+
const counts = (0, jira_db_1.countOutboxByState)(this.db);
|
|
854
|
+
this.broadcast({ type: 'jira.outbox_changed', projectId: this.projectId, pending: counts.pending + counts.inflight, dead: counts.dead });
|
|
855
|
+
}
|
|
856
|
+
broadcastDegraded(jiraKey, reason) {
|
|
857
|
+
this.broadcast({ type: 'jira.degraded', projectId: this.projectId, jiraKey, reason });
|
|
858
|
+
}
|
|
859
|
+
// ─── Read-only details panel (issue fields + Development) ───────────────────
|
|
860
|
+
/**
|
|
861
|
+
* Build the read-only "Jira details" + "Development" payload for a Jira-backed
|
|
862
|
+
* spec. Resilient: the issue fetch is load-bearing, but a /field or dev-status
|
|
863
|
+
* failure still returns the fields (with humanized labels / empty development).
|
|
864
|
+
*/
|
|
865
|
+
async getSpecDetails(localId) {
|
|
866
|
+
if (!this.isActive())
|
|
867
|
+
return { ok: false, reason: 'not-active' };
|
|
868
|
+
const link = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
|
|
869
|
+
if (!link || link.tombstoned)
|
|
870
|
+
return { ok: false, reason: 'no-link' };
|
|
871
|
+
const conn = (0, jira_db_1.getConnection)(this.db, this.projectId);
|
|
872
|
+
const client = this.buildClient();
|
|
873
|
+
if (!conn || !client)
|
|
874
|
+
return { ok: false, reason: 'not-active' };
|
|
875
|
+
// 1) Fields — load-bearing.
|
|
876
|
+
const issueRes = await client.getIssueRaw(link.jiraIssueId);
|
|
877
|
+
if (!issueRes.ok) {
|
|
878
|
+
if (issueRes.code === 'auth')
|
|
879
|
+
this.onAuth401();
|
|
880
|
+
if (issueRes.code === 'not_found')
|
|
881
|
+
(0, jira_db_1.tombstoneLink)(this.db, link.jiraIssueId);
|
|
882
|
+
return { ok: false, reason: 'issue-error', status: issueRes.status };
|
|
883
|
+
}
|
|
884
|
+
// 2) /field metadata — best effort.
|
|
885
|
+
const metaRes = await client.getFieldsFull();
|
|
886
|
+
const fieldMeta = metaRes.ok ? metaRes.data : [];
|
|
887
|
+
// 3) Materialized ticket flags suppress already-shown info (no extra HTTP).
|
|
888
|
+
const ticket = (0, ticket_store_1.readStore)((0, ticket_store_1.resolveTicketStoragePath)(this.projectPath)).tickets[String(localId)];
|
|
889
|
+
const fields = (0, jira_issue_fields_1.formatIssueFields)({
|
|
890
|
+
fields: issueRes.data.fields,
|
|
891
|
+
fieldMeta,
|
|
892
|
+
baseUrl: conn.baseUrl,
|
|
893
|
+
alreadyShown: { hasEpicKey: !!ticket?.jira_epic_key, hasSprintName: !!ticket?.jira_sprint_name },
|
|
894
|
+
});
|
|
895
|
+
// 4) Development — best effort, fully isolated.
|
|
896
|
+
const development = await this.fetchDevelopment(client, link.jiraIssueId).catch(() => ({
|
|
897
|
+
pullRequests: [],
|
|
898
|
+
branches: [],
|
|
899
|
+
commits: [],
|
|
900
|
+
}));
|
|
901
|
+
return { ok: true, details: { fields, development } };
|
|
902
|
+
}
|
|
903
|
+
/** Two-call dev-status fetch (summary → detail per applicationType). Never throws. */
|
|
904
|
+
async fetchDevelopment(client, issueId) {
|
|
905
|
+
const empty = { pullRequests: [], branches: [], commits: [] };
|
|
906
|
+
const summary = await client.getDevStatusSummary(issueId);
|
|
907
|
+
if (!summary.ok) {
|
|
908
|
+
if (summary.code === 'auth')
|
|
909
|
+
this.onAuth401();
|
|
910
|
+
return empty;
|
|
911
|
+
}
|
|
912
|
+
const appTypesFor = (key) => Object.keys(summary.data.summary?.[key]?.byInstanceType ?? {});
|
|
913
|
+
const development = { pullRequests: [], branches: [], commits: [] };
|
|
914
|
+
const collect = async (key, dataType, sink) => {
|
|
915
|
+
for (const app of appTypesFor(key)) {
|
|
916
|
+
const res = await client.getDevStatusDetail(issueId, app, dataType);
|
|
917
|
+
if (res.ok)
|
|
918
|
+
sink(res.data);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
await collect('pullrequest', 'pullrequest', (d) => development.pullRequests.push(...(0, jira_issue_fields_1.normalizePullRequests)(d)));
|
|
922
|
+
await collect('branch', 'branch', (d) => development.branches.push(...(0, jira_issue_fields_1.normalizeBranches)(d)));
|
|
923
|
+
await collect('repository', 'repository', (d) => development.commits.push(...(0, jira_issue_fields_1.normalizeRepositoryCommits)(d)));
|
|
924
|
+
return development;
|
|
925
|
+
}
|
|
926
|
+
// ─── Read helpers for the router ───────────────────────────────────────────
|
|
927
|
+
listLinks() {
|
|
928
|
+
return (0, jira_db_1.listLinks)(this.db);
|
|
929
|
+
}
|
|
930
|
+
listOutbox(state) {
|
|
931
|
+
return (0, jira_db_1.listOutbox)(this.db, state ? { state } : {});
|
|
932
|
+
}
|
|
933
|
+
outboxCounts() {
|
|
934
|
+
return (0, jira_db_1.countOutboxByState)(this.db);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
exports.JiraSyncManager = JiraSyncManager;
|
|
938
|
+
// ─── Error envelope for thrown client failures inside the walk ─────────────────
|
|
939
|
+
class JiraOpError extends Error {
|
|
940
|
+
code;
|
|
941
|
+
status;
|
|
942
|
+
retryAfterMs;
|
|
943
|
+
constructor(code, status, retryAfterMs, message) {
|
|
944
|
+
super(`JiraOpError:${code}:${status}:${retryAfterMs ?? ''}:${message}`);
|
|
945
|
+
this.code = code;
|
|
946
|
+
this.status = status;
|
|
947
|
+
this.retryAfterMs = retryAfterMs;
|
|
948
|
+
}
|
|
949
|
+
static parse(msg) {
|
|
950
|
+
if (!msg.startsWith('JiraOpError:'))
|
|
951
|
+
return null;
|
|
952
|
+
const rest = msg.slice('JiraOpError:'.length);
|
|
953
|
+
const [code, statusStr, retryStr, ...msgParts] = rest.split(':');
|
|
954
|
+
return {
|
|
955
|
+
code,
|
|
956
|
+
status: parseInt(statusStr, 10) || 0,
|
|
957
|
+
retryAfterMs: retryStr ? parseInt(retryStr, 10) : undefined,
|
|
958
|
+
message: msgParts.join(':'),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// ─── Pure helpers ──────────────────────────────────────────────────────────────
|
|
963
|
+
function backoffMs(attempts) {
|
|
964
|
+
const base = Math.min(30_000, 2000 * 2 ** attempts);
|
|
965
|
+
const jitter = (attempts * 137) % 1000; // deterministic jitter (no Math.random)
|
|
966
|
+
return base + jitter;
|
|
967
|
+
}
|
|
968
|
+
/** Format an epoch-ms as Jira JQL date `"yyyy-MM-dd HH:mm"` (UTC). */
|
|
969
|
+
function formatJqlDate(ms) {
|
|
970
|
+
const d = new Date(Math.max(0, ms));
|
|
971
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
972
|
+
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
|
973
|
+
}
|
|
974
|
+
function buildCompletionComment(args, jiraKey, needsReview) {
|
|
975
|
+
if (needsReview) {
|
|
976
|
+
return 'Specrails: the implementation rail terminated abnormally after its Ship phase — the result needs review.';
|
|
977
|
+
}
|
|
978
|
+
const parts = [];
|
|
979
|
+
if (args.status === 'completed') {
|
|
980
|
+
parts.push('✅ Implementation completed by a Specrails rail.');
|
|
981
|
+
}
|
|
982
|
+
else if (args.status === 'canceled') {
|
|
983
|
+
parts.push('⏹️ The Specrails implementation rail was cancelled — the spec was returned to the backlog.');
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
parts.push('❌ The Specrails implementation rail failed — the spec was returned to the backlog.');
|
|
987
|
+
}
|
|
988
|
+
const meta = [`job ${args.jobId}`];
|
|
989
|
+
if (args.costUsd != null)
|
|
990
|
+
meta.push(`cost $${args.costUsd.toFixed(2)}`);
|
|
991
|
+
if (args.durationMs != null)
|
|
992
|
+
meta.push(`duration ${formatDuration(args.durationMs)}`);
|
|
993
|
+
parts.push(`(${meta.join(' · ')})`);
|
|
994
|
+
return parts.join('\n');
|
|
995
|
+
}
|
|
996
|
+
function formatDuration(ms) {
|
|
997
|
+
const s = Math.round(ms / 1000);
|
|
998
|
+
if (s < 60)
|
|
999
|
+
return `${s}s`;
|
|
1000
|
+
const m = Math.floor(s / 60);
|
|
1001
|
+
const rem = s % 60;
|
|
1002
|
+
return rem ? `${m}m ${rem}s` : `${m}m`;
|
|
1003
|
+
}
|
|
1004
|
+
// Read a single ticket back from the store for a ticket_updated broadcast.
|
|
1005
|
+
function readTicket(projectPath, localId) {
|
|
1006
|
+
try {
|
|
1007
|
+
const store = (0, ticket_store_1.readStore)((0, ticket_store_1.resolveTicketStoragePath)(projectPath));
|
|
1008
|
+
const t = store.tickets[String(localId)];
|
|
1009
|
+
return t ?? null;
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
}
|