@dsdenes/devboard 0.1.560 → 0.1.562

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +709 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
package/lib/gitea.ts ADDED
@@ -0,0 +1,709 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
6
+ import type { ReadableStream as WebReadableStream } from "node:stream/web";
7
+ import {
8
+ configuredGiteaToken,
9
+ giteaBaseUrl,
10
+ type ForgeRemote,
11
+ } from "./forge";
12
+
13
+ /**
14
+ * The Gitea HTTP API, shared by the release feed, the issue console, Flow, the
15
+ * scout, the watchdog, the release script, and publish verification.
16
+ *
17
+ * Reads of a public repository need no credential; writes use `GITEA_TOKEN`.
18
+ * The instance address comes from configuration or the origin remote, never
19
+ * from a literal in this repository.
20
+ */
21
+
22
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
23
+
24
+ export interface GiteaTarget {
25
+ /** HTTP origin, without a trailing slash. */
26
+ baseUrl: string;
27
+ /** `owner/name`. */
28
+ project: string;
29
+ token?: string;
30
+ }
31
+
32
+ export interface GiteaRepository {
33
+ full_name?: unknown;
34
+ private?: unknown;
35
+ permissions?: unknown;
36
+ }
37
+
38
+ export interface GiteaUser {
39
+ login?: unknown;
40
+ }
41
+
42
+ export interface GiteaAsset {
43
+ id?: number;
44
+ name: string;
45
+ size?: number;
46
+ browser_download_url?: string;
47
+ }
48
+
49
+ export interface GiteaReleaseDocument {
50
+ id?: number;
51
+ tag_name?: unknown;
52
+ name?: unknown;
53
+ body?: unknown;
54
+ assets?: GiteaAsset[];
55
+ }
56
+
57
+ export interface GiteaIssueDocument {
58
+ number: number;
59
+ title: string;
60
+ url: string;
61
+ createdAt: string;
62
+ state: "open" | "closed";
63
+ body: string;
64
+ labels: string[];
65
+ }
66
+
67
+ export interface GiteaPullDocument {
68
+ number: number;
69
+ url: string;
70
+ state: "open" | "merged" | "closed";
71
+ draft: boolean;
72
+ title: string;
73
+ }
74
+
75
+ export interface GiteaWorkflowRun {
76
+ id: number;
77
+ headSha: string;
78
+ status: string;
79
+ conclusion: string | null;
80
+ url?: string;
81
+ }
82
+
83
+ export class GiteaError extends Error {
84
+ readonly status: number | undefined;
85
+ constructor(message: string, status?: number) {
86
+ super(message);
87
+ this.name = "GiteaError";
88
+ this.status = status;
89
+ }
90
+ }
91
+
92
+ function required(value: string, name: string): string {
93
+ if (value.trim().length === 0) throw new GiteaError(`${name} is not configured`);
94
+ return value;
95
+ }
96
+
97
+ /** Resolves the API target from explicit arguments, the environment, and the remote. */
98
+ export function giteaTarget(options: {
99
+ remote?: ForgeRemote;
100
+ project?: string;
101
+ baseUrl?: string;
102
+ token?: string;
103
+ environment?: Record<string, string | undefined>;
104
+ }): GiteaTarget | undefined {
105
+ const environment = options.environment ?? process.env;
106
+ const project = required(options.project ?? options.remote?.project ?? "", "the Gitea repository");
107
+ const baseUrl = required(
108
+ options.baseUrl ?? giteaBaseUrl(options.remote, environment) ?? "",
109
+ "the Gitea address (set DEVBOARD_GITEA_URL)",
110
+ );
111
+ const token = options.token ?? configuredGiteaToken(environment);
112
+ return {
113
+ baseUrl: baseUrl.replace(/\/+$/, ""),
114
+ project,
115
+ ...(token === undefined ? {} : { token }),
116
+ };
117
+ }
118
+
119
+ /** `/api/v1` under the configured origin. */
120
+ export function apiUrl(target: GiteaTarget, path: string): string {
121
+ const suffix = path.startsWith("/") ? path : `/${path}`;
122
+ return `${target.baseUrl}/api/v1${suffix}`;
123
+ }
124
+
125
+ /** `/repos/<owner>/<name>` with each path segment encoded. */
126
+ export function repoPath(target: GiteaTarget): string {
127
+ const segments = target.project.split("/").map(encodeURIComponent);
128
+ return `/repos/${segments.join("/")}`;
129
+ }
130
+
131
+ function headers(target: GiteaTarget, accept = "application/json"): Record<string, string> {
132
+ return {
133
+ Accept: accept,
134
+ ...(target.token === undefined || target.token.length === 0
135
+ ? {}
136
+ : { Authorization: `token ${target.token}` }),
137
+ };
138
+ }
139
+
140
+ function requestInit(target: GiteaTarget, init: RequestInit | undefined, accept?: string): RequestInit {
141
+ const method = init?.method ?? "GET";
142
+ const headers = new Headers(init?.headers);
143
+ headers.set("Accept", accept ?? "application/json");
144
+ if (target.token !== undefined && target.token.length > 0) {
145
+ headers.set("Authorization", `token ${target.token}`);
146
+ }
147
+ if (init?.body !== undefined && !(init.body instanceof FormData)) {
148
+ headers.set("Content-Type", "application/json");
149
+ }
150
+ return {
151
+ ...init,
152
+ method,
153
+ headers,
154
+ signal: init?.signal ?? AbortSignal.timeout(30_000),
155
+ };
156
+ }
157
+
158
+ function messageFromBody(body: string): string {
159
+ const first = body
160
+ .split("\n")
161
+ .map((line) => line.trim())
162
+ .find((line) => line.length > 0);
163
+ if (first === undefined) return "no response body";
164
+ try {
165
+ const parsed: unknown = JSON.parse(first);
166
+ if (typeof parsed === "object" && parsed !== null && "message" in parsed) {
167
+ const message = parsed.message;
168
+ if (typeof message === "string") return message;
169
+ }
170
+ } catch {
171
+ // The body is not JSON; the bounded first line is the diagnostic.
172
+ }
173
+ return first.length > 300 ? `${first.slice(0, 300)}…` : first;
174
+ }
175
+
176
+ /** One API call; a non-2xx status becomes a GiteaError with the server message. */
177
+ export async function giteaRequest(
178
+ target: GiteaTarget,
179
+ path: string,
180
+ init?: RequestInit,
181
+ fetchImpl: FetchLike = fetch,
182
+ accept?: string,
183
+ ): Promise<Response> {
184
+ const response = await fetchImpl(apiUrl(target, path), requestInit(target, init, accept));
185
+ if (response.ok) return response;
186
+ const body = await response.text().catch(() => "");
187
+ throw new GiteaError(
188
+ `${init?.method ?? "GET"} ${path} failed (HTTP ${response.status}): ${messageFromBody(body)}`,
189
+ response.status,
190
+ );
191
+ }
192
+
193
+ /** One JSON API call. */
194
+ export async function giteaJson<T>(
195
+ target: GiteaTarget,
196
+ path: string,
197
+ init?: RequestInit,
198
+ fetchImpl: FetchLike = fetch,
199
+ ): Promise<T> {
200
+ const response = await giteaRequest(target, path, init, fetchImpl);
201
+ const text = await response.text();
202
+ try {
203
+ return JSON.parse(text) as T;
204
+ } catch {
205
+ throw new GiteaError(`${init?.method ?? "GET"} ${path} returned invalid JSON`);
206
+ }
207
+ }
208
+
209
+ /** The repository document, whose `permissions` object the console probes. */
210
+ export async function giteaRepository(
211
+ target: GiteaTarget,
212
+ fetchImpl: FetchLike = fetch,
213
+ ): Promise<GiteaRepository> {
214
+ return await giteaJson<GiteaRepository>(target, repoPath(target), undefined, fetchImpl);
215
+ }
216
+
217
+ /** The account a token belongs to. */
218
+ export async function giteaUser(
219
+ target: GiteaTarget,
220
+ fetchImpl: FetchLike = fetch,
221
+ ): Promise<string> {
222
+ const document = await giteaJson<GiteaUser>(target, "/user", undefined, fetchImpl);
223
+ return typeof document.login === "string" ? document.login : "";
224
+ }
225
+
226
+ /** The newest published release, read without a credential on a public repo. */
227
+ export async function giteaLatestRelease(
228
+ target: GiteaTarget,
229
+ fetchImpl: FetchLike = fetch,
230
+ ): Promise<GiteaReleaseDocument> {
231
+ return await giteaJson<GiteaReleaseDocument>(
232
+ target,
233
+ `${repoPath(target)}/releases/latest`,
234
+ undefined,
235
+ fetchImpl,
236
+ );
237
+ }
238
+
239
+ /** One release by tag, or undefined when it does not exist. */
240
+ export async function giteaReleaseByTag(
241
+ target: GiteaTarget,
242
+ tag: string,
243
+ fetchImpl: FetchLike = fetch,
244
+ ): Promise<GiteaReleaseDocument | undefined> {
245
+ try {
246
+ return await giteaJson<GiteaReleaseDocument>(
247
+ target,
248
+ `${repoPath(target)}/releases/tags/${encodeURIComponent(tag)}`,
249
+ undefined,
250
+ fetchImpl,
251
+ );
252
+ } catch (error) {
253
+ if (error instanceof GiteaError && error.status === 404) return undefined;
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ export interface CreateReleaseInput {
259
+ tag: string;
260
+ title: string;
261
+ notes: string;
262
+ }
263
+
264
+ export async function giteaCreateRelease(
265
+ target: GiteaTarget,
266
+ input: CreateReleaseInput,
267
+ fetchImpl: FetchLike = fetch,
268
+ ): Promise<GiteaReleaseDocument> {
269
+ return await giteaJson<GiteaReleaseDocument>(
270
+ target,
271
+ `${repoPath(target)}/releases`,
272
+ {
273
+ method: "POST",
274
+ body: JSON.stringify({
275
+ tag_name: input.tag,
276
+ target_commitish: "main",
277
+ name: input.title,
278
+ body: input.notes,
279
+ draft: false,
280
+ prerelease: false,
281
+ }),
282
+ },
283
+ fetchImpl,
284
+ );
285
+ }
286
+
287
+ /** Uploads one release asset; the form field Gitea expects is `attachment`. */
288
+ export async function giteaUploadAsset(
289
+ target: GiteaTarget,
290
+ releaseId: number,
291
+ name: string,
292
+ bytes: Uint8Array,
293
+ fetchImpl: FetchLike = fetch,
294
+ ): Promise<void> {
295
+ const form = new FormData();
296
+ const copy = new Uint8Array(bytes.byteLength);
297
+ copy.set(bytes);
298
+ form.append("attachment", new Blob([copy]), name);
299
+ await giteaRequest(
300
+ target,
301
+ `${repoPath(target)}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
302
+ { method: "POST", body: form },
303
+ fetchImpl,
304
+ );
305
+ }
306
+
307
+ /** Downloads one asset into `destination`; a partial file never survives. */
308
+ export async function giteaDownloadAsset(
309
+ target: GiteaTarget,
310
+ asset: GiteaAsset,
311
+ destination: string,
312
+ options: { timeoutMs?: number; fetchImpl?: FetchLike } = {},
313
+ ): Promise<void> {
314
+ const fetchImpl = options.fetchImpl ?? fetch;
315
+ const timeoutMs = options.timeoutMs ?? 10 * 60_000;
316
+ const url =
317
+ asset.browser_download_url ??
318
+ (asset.id === undefined
319
+ ? undefined
320
+ : apiUrl(target, `${repoPath(target)}/releases/assets/${asset.id}`));
321
+ if (url === undefined) throw new GiteaError(`release asset ${asset.name} has no address`);
322
+ let response: Response;
323
+ try {
324
+ response = await fetchImpl(url, {
325
+ headers: headers(
326
+ target,
327
+ asset.browser_download_url === undefined ? "application/octet-stream" : "*/*",
328
+ ),
329
+ signal: AbortSignal.timeout(timeoutMs),
330
+ });
331
+ } catch (error) {
332
+ const timedOut =
333
+ error instanceof Error &&
334
+ (error.name === "TimeoutError" || error.name === "AbortError");
335
+ if (timedOut) {
336
+ throw new GiteaError(`release asset download timed out after ${timeoutMs} ms: ${asset.name}`);
337
+ }
338
+ throw error;
339
+ }
340
+ if (!response.ok) {
341
+ const body = await response.text().catch(() => "");
342
+ throw new GiteaError(
343
+ `downloading ${asset.name} failed (HTTP ${response.status}): ${messageFromBody(body)}`,
344
+ response.status,
345
+ );
346
+ }
347
+ if (response.body === null) throw new GiteaError(`downloading ${asset.name} returned no body`);
348
+ await mkdir(dirname(destination), { recursive: true });
349
+ try {
350
+ await pipeline(
351
+ // DOM and Node expose structurally different ReadableStream types.
352
+ Readable.fromWeb(response.body as unknown as WebReadableStream),
353
+ createWriteStream(destination),
354
+ );
355
+ } catch (error) {
356
+ await rm(destination, { force: true }).catch(() => undefined);
357
+ throw error;
358
+ }
359
+ }
360
+
361
+ function labelNames(value: unknown): string[] {
362
+ if (!Array.isArray(value)) return [];
363
+ const names: string[] = [];
364
+ for (const label of value) {
365
+ if (typeof label === "string") names.push(label);
366
+ else if (typeof label === "object" && label !== null) {
367
+ const name = (label as { name?: unknown }).name;
368
+ if (typeof name === "string") names.push(name);
369
+ }
370
+ }
371
+ return names;
372
+ }
373
+
374
+ function issueFromDocument(value: unknown): GiteaIssueDocument | undefined {
375
+ if (typeof value !== "object" || value === null) return undefined;
376
+ const record = value as Record<string, unknown>;
377
+ if (
378
+ typeof record.number !== "number" ||
379
+ !Number.isInteger(record.number) ||
380
+ typeof record.title !== "string" ||
381
+ typeof record.html_url !== "string"
382
+ ) {
383
+ return undefined;
384
+ }
385
+ const createdAt =
386
+ typeof record.created_at === "string"
387
+ ? record.created_at
388
+ : typeof record.createdAt === "string"
389
+ ? record.createdAt
390
+ : new Date(0).toISOString();
391
+ return {
392
+ number: record.number,
393
+ title: record.title,
394
+ url: record.html_url,
395
+ createdAt,
396
+ state: record.state === "closed" ? "closed" : "open",
397
+ body: typeof record.body === "string" ? record.body : "",
398
+ labels: labelNames(record.labels),
399
+ };
400
+ }
401
+
402
+ /** Every issue in the requested state, pull requests excluded. */
403
+ export async function giteaListIssues(
404
+ target: GiteaTarget,
405
+ state: "open" | "closed" | "all",
406
+ fetchImpl: FetchLike = fetch,
407
+ ): Promise<GiteaIssueDocument[]> {
408
+ const issues: GiteaIssueDocument[] = [];
409
+ const seen = new Set<number>();
410
+ for (let page = 1; page <= 20; page += 1) {
411
+ const document = await giteaJson<unknown>(
412
+ target,
413
+ `${repoPath(target)}/issues?state=${state}&type=issues&limit=100&page=${page}`,
414
+ undefined,
415
+ fetchImpl,
416
+ );
417
+ if (!Array.isArray(document) || document.length === 0) break;
418
+ for (const entry of document) {
419
+ const issue = issueFromDocument(entry);
420
+ if (issue === undefined || seen.has(issue.number)) continue;
421
+ seen.add(issue.number);
422
+ issues.push(issue);
423
+ }
424
+ if (document.length < 100) break;
425
+ }
426
+ return issues.sort((left, right) => left.number - right.number);
427
+ }
428
+
429
+ export interface CreateIssueInput {
430
+ title: string;
431
+ body: string;
432
+ labels?: number[];
433
+ }
434
+
435
+ export async function giteaCreateIssue(
436
+ target: GiteaTarget,
437
+ input: CreateIssueInput,
438
+ fetchImpl: FetchLike = fetch,
439
+ ): Promise<GiteaIssueDocument> {
440
+ const document = await giteaJson<unknown>(
441
+ target,
442
+ `${repoPath(target)}/issues`,
443
+ { method: "POST", body: JSON.stringify(input) },
444
+ fetchImpl,
445
+ );
446
+ const issue = issueFromDocument(document);
447
+ if (issue === undefined) throw new GiteaError("the created issue has no number or URL");
448
+ return issue;
449
+ }
450
+
451
+ export async function giteaCloseIssue(
452
+ target: GiteaTarget,
453
+ number: number,
454
+ fetchImpl: FetchLike = fetch,
455
+ ): Promise<void> {
456
+ await giteaRequest(
457
+ target,
458
+ `${repoPath(target)}/issues/${number}`,
459
+ { method: "PATCH", body: JSON.stringify({ state: "closed" }) },
460
+ fetchImpl,
461
+ );
462
+ }
463
+
464
+ export async function giteaCommentIssue(
465
+ target: GiteaTarget,
466
+ number: number,
467
+ body: string,
468
+ fetchImpl: FetchLike = fetch,
469
+ ): Promise<void> {
470
+ await giteaRequest(
471
+ target,
472
+ `${repoPath(target)}/issues/${number}/comments`,
473
+ { method: "POST", body: JSON.stringify({ body }) },
474
+ fetchImpl,
475
+ );
476
+ }
477
+
478
+ /** The trailing issue number of an issue URL, or undefined. */
479
+ export function issueNumberFromUrl(url: string): number | undefined {
480
+ const match = /\/issues\/(\d+)\/?$/.exec(url.trim());
481
+ if (match === null) return undefined;
482
+ const number = Number(match[1]);
483
+ return Number.isInteger(number) && number > 0 ? number : undefined;
484
+ }
485
+
486
+ export interface GiteaLabel {
487
+ id: number;
488
+ name: string;
489
+ }
490
+
491
+ export async function giteaLabels(
492
+ target: GiteaTarget,
493
+ fetchImpl: FetchLike = fetch,
494
+ ): Promise<GiteaLabel[]> {
495
+ const document = await giteaJson<unknown>(
496
+ target,
497
+ `${repoPath(target)}/labels?limit=100`,
498
+ undefined,
499
+ fetchImpl,
500
+ );
501
+ if (!Array.isArray(document)) return [];
502
+ const labels: GiteaLabel[] = [];
503
+ for (const entry of document) {
504
+ if (typeof entry !== "object" || entry === null) continue;
505
+ const record = entry as Record<string, unknown>;
506
+ if (typeof record.id === "number" && typeof record.name === "string") {
507
+ labels.push({ id: record.id, name: record.name });
508
+ }
509
+ }
510
+ return labels;
511
+ }
512
+
513
+ export async function giteaCreateLabel(
514
+ target: GiteaTarget,
515
+ name: string,
516
+ color: string,
517
+ description: string,
518
+ fetchImpl: FetchLike = fetch,
519
+ ): Promise<GiteaLabel> {
520
+ const document = await giteaJson<unknown>(
521
+ target,
522
+ `${repoPath(target)}/labels`,
523
+ { method: "POST", body: JSON.stringify({ name, color, description }) },
524
+ fetchImpl,
525
+ );
526
+ if (typeof document !== "object" || document === null) {
527
+ throw new GiteaError("the created label has no id");
528
+ }
529
+ const record = document as Record<string, unknown>;
530
+ if (typeof record.id !== "number" || typeof record.name !== "string") {
531
+ throw new GiteaError("the created label has no id or name");
532
+ }
533
+ return { id: record.id, name: record.name };
534
+ }
535
+
536
+ /** The label id, created when the repository does not have it yet. */
537
+ export async function giteaEnsureLabel(
538
+ target: GiteaTarget,
539
+ name: string,
540
+ color: string,
541
+ description: string,
542
+ fetchImpl: FetchLike = fetch,
543
+ ): Promise<number> {
544
+ const existing = (await giteaLabels(target, fetchImpl)).find((label) => label.name === name);
545
+ if (existing !== undefined) return existing.id;
546
+ return (await giteaCreateLabel(target, name, color, description, fetchImpl)).id;
547
+ }
548
+
549
+ /** Gitea marks a draft by the `WIP:` title prefix. */
550
+ export const DRAFT_PREFIX = "WIP: ";
551
+
552
+ export function draftTitle(title: string): string {
553
+ return title.startsWith(DRAFT_PREFIX) ? title : `${DRAFT_PREFIX}${title}`;
554
+ }
555
+
556
+ export function readyTitle(title: string): string {
557
+ return title.startsWith(DRAFT_PREFIX) ? title.slice(DRAFT_PREFIX.length) : title;
558
+ }
559
+
560
+ function pullFromDocument(value: unknown): GiteaPullDocument | undefined {
561
+ if (typeof value !== "object" || value === null) return undefined;
562
+ const record = value as Record<string, unknown>;
563
+ if (
564
+ typeof record.number !== "number" ||
565
+ typeof record.html_url !== "string" ||
566
+ typeof record.title !== "string"
567
+ ) {
568
+ return undefined;
569
+ }
570
+ const merged = record.merged === true || record.merged_at !== null && record.merged_at !== undefined;
571
+ const state =
572
+ merged === true ? "merged" : record.state === "closed" ? "closed" : "open";
573
+ return {
574
+ number: record.number,
575
+ url: record.html_url,
576
+ state,
577
+ draft: record.title.startsWith(DRAFT_PREFIX),
578
+ title: record.title,
579
+ };
580
+ }
581
+
582
+ export interface CreatePullInput {
583
+ head: string;
584
+ base: string;
585
+ title: string;
586
+ body: string;
587
+ }
588
+
589
+ export async function giteaCreatePull(
590
+ target: GiteaTarget,
591
+ input: CreatePullInput,
592
+ fetchImpl: FetchLike = fetch,
593
+ ): Promise<GiteaPullDocument> {
594
+ const document = await giteaJson<unknown>(
595
+ target,
596
+ `${repoPath(target)}/pulls`,
597
+ {
598
+ method: "POST",
599
+ body: JSON.stringify({
600
+ head: input.head,
601
+ base: input.base,
602
+ title: draftTitle(input.title),
603
+ body: input.body,
604
+ }),
605
+ },
606
+ fetchImpl,
607
+ );
608
+ const pull = pullFromDocument(document);
609
+ if (pull === undefined) throw new GiteaError("the created pull request has no number or URL");
610
+ return pull;
611
+ }
612
+
613
+ export async function giteaEditPull(
614
+ target: GiteaTarget,
615
+ number: number,
616
+ input: { title?: string; body?: string },
617
+ fetchImpl: FetchLike = fetch,
618
+ ): Promise<void> {
619
+ await giteaRequest(
620
+ target,
621
+ `${repoPath(target)}/pulls/${number}`,
622
+ { method: "PATCH", body: JSON.stringify(input) },
623
+ fetchImpl,
624
+ );
625
+ }
626
+
627
+ export async function giteaPull(
628
+ target: GiteaTarget,
629
+ number: number,
630
+ fetchImpl: FetchLike = fetch,
631
+ ): Promise<GiteaPullDocument> {
632
+ const document = await giteaJson<unknown>(
633
+ target,
634
+ `${repoPath(target)}/pulls/${number}`,
635
+ undefined,
636
+ fetchImpl,
637
+ );
638
+ const pull = pullFromDocument(document);
639
+ if (pull === undefined) throw new GiteaError("the pull request has no readable state");
640
+ return pull;
641
+ }
642
+
643
+ /** Merges with a merge commit: never squash, never rebase. */
644
+ export async function giteaMergePull(
645
+ target: GiteaTarget,
646
+ number: number,
647
+ fetchImpl: FetchLike = fetch,
648
+ ): Promise<void> {
649
+ await giteaRequest(
650
+ target,
651
+ `${repoPath(target)}/pulls/${number}/merge`,
652
+ { method: "POST", body: JSON.stringify({ Do: "merge" }) },
653
+ fetchImpl,
654
+ );
655
+ }
656
+
657
+ export async function giteaDeleteBranch(
658
+ target: GiteaTarget,
659
+ branch: string,
660
+ fetchImpl: FetchLike = fetch,
661
+ ): Promise<void> {
662
+ await giteaRequest(
663
+ target,
664
+ `${repoPath(target)}/branches/${encodeURIComponent(branch)}`,
665
+ { method: "DELETE" },
666
+ fetchImpl,
667
+ );
668
+ }
669
+
670
+ /** Workflow runs whose triggering commit is `headSha`. */
671
+ export async function giteaWorkflowRuns(
672
+ target: GiteaTarget,
673
+ headSha: string,
674
+ fetchImpl: FetchLike = fetch,
675
+ ): Promise<GiteaWorkflowRun[]> {
676
+ const document = await giteaJson<unknown>(
677
+ target,
678
+ `${repoPath(target)}/actions/runs?head_sha=${encodeURIComponent(headSha)}&limit=50`,
679
+ undefined,
680
+ fetchImpl,
681
+ );
682
+ let entries: unknown;
683
+ if (Array.isArray(document)) {
684
+ entries = document;
685
+ } else if (
686
+ typeof document === "object" &&
687
+ document !== null &&
688
+ "workflow_runs" in document
689
+ ) {
690
+ entries = document.workflow_runs;
691
+ } else {
692
+ return [];
693
+ }
694
+ if (!Array.isArray(entries)) return [];
695
+ const runs: GiteaWorkflowRun[] = [];
696
+ for (const entry of entries) {
697
+ if (typeof entry !== "object" || entry === null) continue;
698
+ const record = entry as Record<string, unknown>;
699
+ if (typeof record.id !== "number" || typeof record.head_sha !== "string") continue;
700
+ runs.push({
701
+ id: record.id,
702
+ headSha: record.head_sha,
703
+ status: typeof record.status === "string" ? record.status : "",
704
+ conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
705
+ ...(typeof record.html_url === "string" ? { url: record.html_url } : {}),
706
+ });
707
+ }
708
+ return runs;
709
+ }