@rse/ase 1.0.7 → 2.0.1

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 (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +357 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +228 -111
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +8 -1
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +269 -81
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +231 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +81 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
@@ -0,0 +1,666 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { giteaApi } from "gitea-js";
7
+ import { LRUCache } from "lru-cache";
8
+ import * as TaskFormat from "./ase-task-format.js";
9
+ import * as TaskIssues from "./ase-task-store-issues.js";
10
+ /* the reserved label, the task id mapping, and the timestamp of the common issue tracker parts */
11
+ const { LABEL_PROJECT, idOf, numberOf, stamp } = TaskIssues;
12
+ /* the header keys mapped onto native issue fields (or derived from them),
13
+ hence never carried by "ase:<key>:<value>" labels */
14
+ const nativeKeys = ["Type", "Id", "Created", "Modified", "Phase", "After", "Tags", "Assignee"];
15
+ /* the page size of the listings (the default maximum of Gitea) */
16
+ const PAGE = 50;
17
+ /* the current time and a normalized timestamp as ISO timestamp (UTC, millisecond-precise),
18
+ as Gitea delivers its timestamps in the time zone of the server */
19
+ const now = () => new Date().toISOString();
20
+ const iso = (timestamp) => new Date(timestamp).toISOString();
21
+ /* a failed request of the Gitea REST API with its HTTP status */
22
+ class GiteaError extends Error {
23
+ status;
24
+ constructor(message, status) {
25
+ super(message);
26
+ this.status = status;
27
+ }
28
+ }
29
+ /* the HTTP status of a request failure, and a "not found" (404) request failure mapped onto null */
30
+ const statusOf = (err) => err instanceof GiteaError ? err.status : undefined;
31
+ const found = (p) => p.catch((err) => {
32
+ if (statusOf(err) === 404)
33
+ return null;
34
+ throw err;
35
+ });
36
+ /* the label names of an issue, whether an issue is a live task (no pull request),
37
+ and whether an issue belongs to the given repository */
38
+ const labelNames = (issue) => (issue.labels ?? []).map((label) => label.name);
39
+ const live = (issue) => issue.pull_request === undefined || issue.pull_request === null;
40
+ const inRepo = (loc, issue) => issue.repository === undefined || issue.repository === null
41
+ || (issue.repository.owner.toLowerCase() === loc.owner.toLowerCase()
42
+ && issue.repository.name.toLowerCase() === loc.repo.toLowerCase());
43
+ /* the Gitea storage plugin: a project is a repository (registered by its
44
+ "ase:project" label, which also persists the sequence number high-water mark, as
45
+ Gitea never reuses the number of a deleted issue) and a task plan is a live issue,
46
+ its id being the issue number rendered through the mandatory "seq" task id scheme;
47
+ the title maps onto the issue title (and the "# TASK:" heading), the body onto
48
+ the issue body, "Status" onto the issue state refined by an "ase:Status:<state>"
49
+ label, "Tags" onto labels, "Assignee" onto the assignee, "Phase" onto the milestone,
50
+ "After" onto the dependencies (the blocking issues), "Created"/"Modified" onto the
51
+ issue timestamps, any other key (including "Group", as Gitea has no parent issues)
52
+ onto "ase:<key>:<value>" labels, and the attachments onto the issue comments */
53
+ class GiteaTaskStoragePlugin {
54
+ ctx;
55
+ name = "gitea";
56
+ url;
57
+ source;
58
+ gt;
59
+ repos = new Map();
60
+ poll;
61
+ listener = null;
62
+ timer = null;
63
+ polling = false;
64
+ opened = now();
65
+ polls = new Map();
66
+ registry = new LRUCache({ max: 64, ttl: 60 * 1000 });
67
+ latest = new Map();
68
+ labels = new Map();
69
+ milestones = new Map();
70
+ assignable = new LRUCache({ max: 64, ttl: 60 * 1000 });
71
+ depends = new LRUCache({ max: 1024, ttl: 60 * 1000 });
72
+ loads = new LRUCache({ max: 256, ttl: 2 * 1000 });
73
+ constructor(ctx) {
74
+ this.ctx = ctx;
75
+ const options = ctx.options;
76
+ const token = options.token ?? process.env.GITEA_TOKEN ?? "";
77
+ if (typeof token !== "string" || token === "")
78
+ throw new Error("task store: plugin \"gitea\" requires the \"token\" option (or $GITEA_TOKEN)");
79
+ const url = options.url ?? "https://gitea.com";
80
+ let base = null;
81
+ try {
82
+ base = new URL(typeof url === "string" && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? `https://${url}` : url);
83
+ }
84
+ catch {
85
+ /* reported below */
86
+ }
87
+ if (base === null || !/^https?:$/.test(base.protocol) || base.search !== "" || base.hash !== "")
88
+ throw new Error(`task store: plugin "gitea" received invalid URL "${String(url)}" of the Gitea instance`);
89
+ this.url = base.href.replace(/\/+$/, "");
90
+ if (typeof options.repos !== "object" || options.repos === null || Array.isArray(options.repos))
91
+ throw new Error("task store: plugin \"gitea\" requires the \"repos\" option (mapping project ids onto \"<owner>/<repo>\")");
92
+ for (const [prjId, spec] of Object.entries(options.repos)) {
93
+ const m = typeof spec === "string" ? /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(spec) : null;
94
+ if (m === null || !TaskFormat.ID_RE.test(prjId))
95
+ throw new Error(`task store: plugin "gitea" received invalid repository mapping "${prjId}: ${String(spec)}"`);
96
+ this.repos.set(prjId, { owner: m[1], repo: m[2] });
97
+ }
98
+ this.poll = options.poll ?? 60;
99
+ if (typeof this.poll !== "number" || !Number.isFinite(this.poll) || (this.poll !== 0 && (this.poll < 1 || this.poll > 86400)))
100
+ throw new Error("task store: plugin \"gitea\" requires a \"poll\" interval of 0 (disabled) resp. 1 to 86400 seconds");
101
+ /* create the Gitea REST API client, with every request bounded in time */
102
+ this.source = options.token !== undefined ? "the \"token\" option (resp. \"project.task.store.token\")" : "$GITEA_TOKEN";
103
+ this.gt = giteaApi(this.url, {
104
+ token,
105
+ customFetch: (input, init) => fetch(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(60 * 1000) })
106
+ });
107
+ }
108
+ /* convert a failed request (thrown as its response by the Gitea REST API client)
109
+ into an error, explaining a denied access, as the Gitea message names neither
110
+ the token source nor the missing permission */
111
+ explain(err, loc) {
112
+ if (!(err instanceof Response))
113
+ throw err;
114
+ const info = err.error;
115
+ const message = typeof info?.message === "string" && info.message !== "" ? info.message : err.statusText;
116
+ if (err.status === 401 || err.status === 403) {
117
+ const repo = loc !== undefined ? ` to repository "${loc.owner}/${loc.repo}"` : "";
118
+ throw new Error(`Gitea denied access${repo} (HTTP ${String(err.status)}: ${message}) -- ` +
119
+ `the token of ${this.source} needs the scopes "write:issue" and "read:repository" ` +
120
+ "and write access to the repository (resp. its administration, for deleting a task)");
121
+ }
122
+ throw new GiteaError(`Gitea request failed (HTTP ${String(err.status)}: ${message})`, err.status);
123
+ }
124
+ /* perform a request, delivering its data */
125
+ async call(loc, request) {
126
+ try {
127
+ return (await request).data;
128
+ }
129
+ catch (err) {
130
+ this.explain(err, loc);
131
+ }
132
+ }
133
+ /* perform a paginated listing request, delivering the data of all pages */
134
+ async all(loc, request) {
135
+ const out = [];
136
+ for (let page = 1;; page++) {
137
+ let r;
138
+ try {
139
+ r = await request({ page, limit: PAGE });
140
+ }
141
+ catch (err) {
142
+ this.explain(err, loc);
143
+ }
144
+ out.push(...r.data);
145
+ const total = Number.parseInt(r.headers.get("x-total-count") ?? "", 10);
146
+ if (r.data.length === 0 || (Number.isFinite(total) ? out.length >= total : r.data.length < PAGE))
147
+ return out;
148
+ }
149
+ }
150
+ /* the storage lifecycle: polling for changes while opened and observed
151
+ (without keeping the process alive) */
152
+ async open() {
153
+ this.opened = now();
154
+ if (this.poll > 0 && this.listener !== null) {
155
+ this.timer = setInterval(() => {
156
+ this.pollAll().catch(() => { });
157
+ }, this.poll * 1000);
158
+ this.timer.unref();
159
+ }
160
+ this.ctx.log("debug", `opened ${this.repos.size} repositories on ${this.url} (polling every ${this.poll}s)`);
161
+ }
162
+ async close() {
163
+ if (this.timer !== null)
164
+ clearInterval(this.timer);
165
+ this.timer = null;
166
+ }
167
+ /* observe the changes of the issues made outside of this plugin instance */
168
+ watch(listener) {
169
+ this.listener = listener;
170
+ }
171
+ /* ==== project registry ==== */
172
+ /* the repository of a project */
173
+ repo(prjId) {
174
+ const loc = this.repos.get(prjId);
175
+ if (loc === undefined)
176
+ throw new Error(`project "${prjId}" is not mapped onto a Gitea repository (see option "repos")`);
177
+ return loc;
178
+ }
179
+ /* the labels of a repository by their lower-cased name (freshly listed on request) */
180
+ async labelMap(loc, fresh = false) {
181
+ const key = `${loc.owner}/${loc.repo}`;
182
+ let map = this.labels.get(key);
183
+ if (map === undefined || fresh) {
184
+ const labels = await this.all(loc, (query) => this.gt.repos.issueListLabels(loc.owner, loc.repo, query));
185
+ map = new Map(labels.map((label) => [label.name.toLowerCase(), label]));
186
+ this.labels.set(key, map);
187
+ }
188
+ return map;
189
+ }
190
+ /* the registry entry of a project, read from the description of its "ase:project" label */
191
+ async registered(prjId) {
192
+ const cached = this.registry.get(prjId);
193
+ if (cached !== undefined)
194
+ return cached.value;
195
+ const loc = this.repos.get(prjId);
196
+ let value = null;
197
+ if (loc !== undefined) {
198
+ const label = (await this.labelMap(loc, true)).get(LABEL_PROJECT);
199
+ if (label !== undefined)
200
+ value = TaskIssues.registryEntry(label.description);
201
+ }
202
+ this.registry.set(prjId, { value });
203
+ return value;
204
+ }
205
+ /* the repository, lifecycle model, and task id scheme of a registered project */
206
+ async context(prjId) {
207
+ const loc = this.repo(prjId);
208
+ const entry = await this.registered(prjId);
209
+ if (entry === null)
210
+ throw new Error(`project "${prjId}" not registered`);
211
+ return { loc, ...TaskIssues.registryContext(prjId, entry, "Gitea") };
212
+ }
213
+ /* write the registry entry of a project into the description of its "ase:project" label */
214
+ async register(prjId, loc, entry, exists) {
215
+ const description = TaskIssues.registryDescription(entry);
216
+ const label = exists ? (await this.labelMap(loc, true)).get(LABEL_PROJECT) : undefined;
217
+ if (label === undefined)
218
+ await this.call(loc, this.gt.repos.issueCreateLabel(loc.owner, loc.repo, { name: LABEL_PROJECT, color: "#5319e7", description }));
219
+ else
220
+ await this.call(loc, this.gt.repos.issueEditLabel(loc.owner, loc.repo, label.id, { color: "#5319e7", description }));
221
+ this.labels.delete(`${loc.owner}/${loc.repo}`);
222
+ this.registry.set(prjId, { value: entry });
223
+ }
224
+ /* the highest issue or pull request number of a repository, as the listing
225
+ delivers the most recently created issue first */
226
+ async latestNumber(loc, fresh = false) {
227
+ const key = `${loc.owner}/${loc.repo}`;
228
+ const cached = this.latest.get(key);
229
+ /* reuse a number determined within the last 2 seconds (unless a fresh one
230
+ is required), as every single task store operation asks for it */
231
+ if (!fresh && cached !== undefined && Date.now() - cached.at < 2 * 1000)
232
+ return cached.number;
233
+ const issues = await this.call(loc, this.gt.repos.issueListIssues(loc.owner, loc.repo, { state: "all", page: 1, limit: 1 }));
234
+ const number = issues[0]?.number ?? 0;
235
+ this.latest.set(key, { number, at: Date.now() });
236
+ return number;
237
+ }
238
+ /* the high-water mark of the sequence numbers of a registered project: the highest
239
+ issue number, or the persisted one if higher (as numbers of deleted issues) */
240
+ async seqmark(loc, entry, fresh = false) {
241
+ return Math.max(await this.latestNumber(loc, fresh), entry?.seqmark ?? 0);
242
+ }
243
+ async projectList() {
244
+ const out = [];
245
+ for (const prjId of this.repos.keys()) {
246
+ const entry = await this.projectGet(prjId);
247
+ if (entry !== null)
248
+ out.push(entry);
249
+ }
250
+ return out;
251
+ }
252
+ /* get a project, with the sequence number high-water mark as the next
253
+ allocated task id is the one of the next created issue */
254
+ async projectGet(prjId) {
255
+ const entry = await this.registered(prjId);
256
+ if (entry === null)
257
+ return null;
258
+ return { id: prjId, lifecycle: entry.lifecycle, idscheme: entry.idscheme, seqmark: await this.seqmark(this.repo(prjId), entry) };
259
+ }
260
+ async projectSet(prjId, lifecycle, idscheme) {
261
+ const loc = this.repo(prjId);
262
+ TaskIssues.requireSeqScheme(idscheme, "Gitea");
263
+ /* spare the requests of an unchanged (cached) registration, as the
264
+ in-process clients re-register the project on every opening */
265
+ const entry = await this.registered(prjId);
266
+ if (entry !== null && entry.lifecycle === lifecycle && entry.idscheme === idscheme)
267
+ return "updated";
268
+ await this.register(prjId, loc, { lifecycle, idscheme, ...(entry?.seqmark !== undefined ? { seqmark: entry.seqmark } : {}) }, entry !== null);
269
+ return entry === null ? "created" : "updated";
270
+ }
271
+ /* persist the high-water mark of the sequence numbers for the number of a hard deleted
272
+ issue, if it exceeds the known one (deliberately not as the optional "projectMark"
273
+ method, as the server also marks allocated task ids, which have to stay the numbers
274
+ of the next created issues) */
275
+ async retire(prjId, seqmark) {
276
+ const loc = this.repo(prjId);
277
+ const entry = await this.registered(prjId);
278
+ if (entry !== null && seqmark > (entry.seqmark ?? 0))
279
+ await this.register(prjId, loc, { ...entry, seqmark }, true);
280
+ }
281
+ async projectDelete(prjId) {
282
+ const loc = this.repos.get(prjId);
283
+ if (loc === undefined)
284
+ return false;
285
+ const label = (await this.labelMap(loc, true)).get(LABEL_PROJECT);
286
+ const result = label !== undefined ?
287
+ await found(this.call(loc, this.gt.repos.issueDeleteLabel(loc.owner, loc.repo, label.id)).then(() => true)) : null;
288
+ this.labels.delete(`${loc.owner}/${loc.repo}`);
289
+ this.registry.delete(prjId);
290
+ return label !== undefined && result !== null;
291
+ }
292
+ /* ==== issue mapping ==== */
293
+ /* fetch the live issue of an issue number (null if none) */
294
+ async fetch(loc, n) {
295
+ if (n === 0)
296
+ return null;
297
+ const issue = await found(this.call(loc, this.gt.repos.issueGetIssue(loc.owner, loc.repo, n)));
298
+ return issue !== null && issue.number === n && inRepo(loc, issue) && live(issue) ? issue : null;
299
+ }
300
+ /* the blocking issues of an issue within its repository, from its dependencies (none if
301
+ disabled), reused for 60 seconds per update time, as a listing needs them for every issue */
302
+ async blockers(loc, issue, fresh = false) {
303
+ const key = `${loc.owner}/${loc.repo}#${issue.number}@${issue.updated_at}`;
304
+ if (fresh)
305
+ this.depends.delete(key);
306
+ let depends = this.depends.get(key);
307
+ if (depends === undefined) {
308
+ depends = found(this.all(loc, (query) => this.gt.repos.issueListIssueDependencies(loc.owner, loc.repo, String(issue.number), query)))
309
+ .then((issues) => (issues ?? []));
310
+ this.depends.set(key, depends);
311
+ depends.catch(() => {
312
+ this.depends.delete(key);
313
+ });
314
+ }
315
+ return (await depends).filter((blocker) => inRepo(loc, blocker));
316
+ }
317
+ /* the comments of an issue */
318
+ async comments(loc, n) {
319
+ return await this.call(loc, this.gt.repos.issueGetComments(loc.owner, loc.repo, n));
320
+ }
321
+ /* derive the header of an issue: the natively mapped keys, "Status" from the issue
322
+ state (closed for the finished states) refined by its "ase:Status:<state>" label
323
+ (where an open issue with a finished state could not be closed because of its
324
+ open blocking issues), and all other keys from their "ase:<key>:<value>" labels */
325
+ header(lifecycle, scheme, issue, after) {
326
+ const header = {
327
+ Type: TaskFormat.TASK_TYPE,
328
+ Id: idOf(scheme, issue.number),
329
+ Created: stamp(issue.created_at),
330
+ Modified: stamp(issue.updated_at)
331
+ };
332
+ if (issue.milestone !== undefined && issue.milestone !== null)
333
+ header.Phase = issue.milestone.title;
334
+ if (after.length > 0)
335
+ header.After = after.map((blocker) => idOf(scheme, blocker.number));
336
+ const status = TaskIssues.labelHeader(header, labelNames(issue), nativeKeys);
337
+ const assignee = issue.assignees?.[0]?.login;
338
+ if (assignee !== undefined)
339
+ header.Assignee = assignee;
340
+ const closed = issue.state === "closed";
341
+ const derived = !closed && status !== undefined && lifecycle.finished.includes(status) ? status :
342
+ TaskIssues.issueStatus(lifecycle, closed, status === "CANCELLED", status);
343
+ if (derived !== undefined)
344
+ header.Status = derived;
345
+ return header;
346
+ }
347
+ /* derive the attachment of a comment (see the common issue tracker parts) */
348
+ attachment(comment) {
349
+ return TaskIssues.commentAttachment(comment.body, comment.user?.login ?? "ghost", comment.created_at, comment.updated_at);
350
+ }
351
+ /* the listing entry of an issue */
352
+ async entry(loc, lifecycle, scheme, issue) {
353
+ const header = this.header(lifecycle, scheme, issue, await this.blockers(loc, issue));
354
+ return { id: idOf(scheme, issue.number), title: issue.title, header, mtime: new Date(issue.updated_at) };
355
+ }
356
+ /* ==== issue updating ==== */
357
+ /* the ids of labels, created on demand */
358
+ async ensureLabels(loc, names) {
359
+ const ids = [];
360
+ for (const name of names) {
361
+ let label = (await this.labelMap(loc)).get(name.toLowerCase());
362
+ if (label === undefined) {
363
+ label = await this.call(loc, this.gt.repos.issueCreateLabel(loc.owner, loc.repo, { name, color: name.startsWith("ase:") ? "#c5def5" : "#ededed" })).catch(async (err) => {
364
+ /* tolerate a label created concurrently */
365
+ const label = statusOf(err) === 422 ? (await this.labelMap(loc, true)).get(name.toLowerCase()) : undefined;
366
+ if (label === undefined)
367
+ throw err;
368
+ return label;
369
+ });
370
+ const map = await this.labelMap(loc);
371
+ map.set(name.toLowerCase(), label);
372
+ }
373
+ ids.push(label.id);
374
+ }
375
+ return ids;
376
+ }
377
+ /* delete the "ase:<key>:<value>" labels no longer used by any issue */
378
+ async collectLabels(loc, names) {
379
+ for (const name of names) {
380
+ if (!TaskIssues.LABEL_KEY_RE.test(name) || name.includes(","))
381
+ continue;
382
+ const label = (await this.labelMap(loc)).get(name.toLowerCase());
383
+ if (label === undefined)
384
+ continue;
385
+ const issues = await this.call(loc, this.gt.repos.issueListIssues(loc.owner, loc.repo, { state: "all", labels: name, page: 1, limit: 1 }));
386
+ if (issues.length === 0) {
387
+ await found(this.call(loc, this.gt.repos.issueDeleteLabel(loc.owner, loc.repo, label.id)));
388
+ this.labels.get(`${loc.owner}/${loc.repo}`)?.delete(name.toLowerCase());
389
+ }
390
+ }
391
+ }
392
+ /* the id of a milestone by its title (created on demand) */
393
+ async milestone(loc, title) {
394
+ const key = `${loc.owner}/${loc.repo}`;
395
+ let map = this.milestones.get(key);
396
+ if (map === undefined) {
397
+ const milestones = await this.all(loc, (query) => this.gt.repos.issueGetMilestonesList(loc.owner, loc.repo, { state: "all", ...query }));
398
+ map = new Map(milestones.map((milestone) => [milestone.title ?? "", milestone.id ?? 0]));
399
+ this.milestones.set(key, map);
400
+ }
401
+ let id = map.get(title);
402
+ if (id === undefined) {
403
+ id = (await this.call(loc, this.gt.repos.issueCreateMilestone(loc.owner, loc.repo, { title }))).id ?? 0;
404
+ map.set(title, id);
405
+ }
406
+ return id;
407
+ }
408
+ /* the live issue a header key references by its task id, else an error */
409
+ async target(loc, scheme, key, id) {
410
+ const issue = await this.fetch(loc, numberOf(scheme, id));
411
+ if (issue === null)
412
+ throw new Error(`header key "${key}" references no existing task "${id}"`);
413
+ return issue;
414
+ }
415
+ /* determine the labels and assignees of a header: the tags, the "ase:<key>:<value>"
416
+ labels of the keys without native counterpart, and the assignee if it can be
417
+ assigned natively (keeping further native assignees), else its label */
418
+ async assign(loc, header, issue) {
419
+ const labels = TaskIssues.headerLabels(header, nativeKeys);
420
+ let assignees = [];
421
+ const assignee = header.Assignee;
422
+ if (typeof assignee === "string" && assignee !== "") {
423
+ const logins = issue?.assignees?.map((user) => user.login) ?? [];
424
+ const key = `${loc.owner}/${loc.repo}`;
425
+ let users = this.assignable.get(key);
426
+ if (users === undefined) {
427
+ users = this.call(loc, this.gt.repos.repoGetAssignees(loc.owner, loc.repo))
428
+ .then((list) => new Set(list.map((user) => (user.login ?? "").toLowerCase())));
429
+ this.assignable.set(key, users);
430
+ users.catch(() => {
431
+ this.assignable.delete(key);
432
+ });
433
+ }
434
+ if (logins.includes(assignee))
435
+ assignees = logins;
436
+ else if ((await users).has(assignee.toLowerCase()))
437
+ assignees = [assignee];
438
+ else
439
+ labels.push(`ase:Assignee:${assignee}`);
440
+ }
441
+ return { labels, assignees };
442
+ }
443
+ /* synchronize the blocking issues (within the repository) */
444
+ async syncBlockers(loc, issue, after) {
445
+ const current = await this.blockers(loc, issue, true);
446
+ const have = new Set(current.map((blocker) => blocker.number));
447
+ const want = new Set(after.map((blocker) => blocker.number));
448
+ const meta = (blocker) => ({ owner: loc.owner, repo: loc.repo, index: blocker.number });
449
+ for (const blocker of after) {
450
+ if (have.has(blocker.number))
451
+ continue;
452
+ await this.call(loc, this.gt.repos.issueCreateIssueDependencies(loc.owner, loc.repo, String(issue.number), meta(blocker)))
453
+ .catch((err) => {
454
+ if (statusOf(err) === 404)
455
+ throw new Error(`Gitea rejected the dependency of issue #${issue.number} on issue #${blocker.number} ` +
456
+ `-- enable the issue dependencies in the settings of repository "${loc.owner}/${loc.repo}"`, { cause: err });
457
+ throw err;
458
+ });
459
+ }
460
+ for (const blocker of current)
461
+ if (!want.has(blocker.number))
462
+ await found(this.call(loc, this.gt.repos.issueRemoveIssueDependencies(loc.owner, loc.repo, String(issue.number), meta(blocker))));
463
+ }
464
+ /* synchronize the comments with the attachments, touching changed ones only
465
+ (see the common issue tracker parts) */
466
+ async syncComments(loc, issue, attachments) {
467
+ const comments = issue.comments > 0 ? await this.comments(loc, issue.number) : [];
468
+ const ops = TaskIssues.attachmentSync(comments.map((comment) => this.attachment(comment)), attachments);
469
+ for (const op of ops) {
470
+ if (op.op === "delete")
471
+ await this.call(loc, this.gt.repos.issueDeleteComment(loc.owner, loc.repo, comments[op.index].id));
472
+ else if (op.op === "create")
473
+ await this.call(loc, this.gt.repos.issueCreateComment(loc.owner, loc.repo, issue.number, { body: TaskIssues.attachmentComment(op.attachment) }));
474
+ else
475
+ await this.call(loc, this.gt.repos.issueEditComment(loc.owner, loc.repo, comments[op.index].id, { body: TaskIssues.attachmentComment(op.attachment) }));
476
+ }
477
+ }
478
+ /* remember an issue written by this plugin instance: its update time, so the
479
+ change detection does not report it as an external change */
480
+ written(prjId, issue) {
481
+ this.pollState(prjId).seen.set(issue.number, issue.updated_at);
482
+ this.loads.delete(`${prjId}#${issue.number}`);
483
+ }
484
+ /* load a live issue together with its blocking issues and comments (the issue and
485
+ its comments in parallel), reusing a load of the last 2 seconds (dropped on any
486
+ own write or detected change of the issue), as e.g. the boards load the plan
487
+ and its attachments in separate operations */
488
+ load(prjId, loc, n) {
489
+ const key = `${prjId}#${n}`;
490
+ const cached = this.loads.get(key);
491
+ if (cached !== undefined)
492
+ return cached;
493
+ const load = (async () => {
494
+ const [issue, comments] = await Promise.all([
495
+ this.fetch(loc, n),
496
+ n === 0 ? [] : found(this.comments(loc, n))
497
+ ]);
498
+ if (issue === null)
499
+ return null;
500
+ return { issue, after: await this.blockers(loc, issue), comments: comments ?? [] };
501
+ })();
502
+ this.loads.set(key, load);
503
+ load.catch(() => {
504
+ this.loads.delete(key);
505
+ });
506
+ return load;
507
+ }
508
+ /* ==== task plans ==== */
509
+ async taskList(prjId) {
510
+ const { loc, lifecycle, scheme } = await this.context(prjId);
511
+ const issues = await this.all(loc, (query) => this.gt.repos.issueListIssues(loc.owner, loc.repo, { state: "all", type: "issues", ...query }));
512
+ return Promise.all(issues.filter((issue) => live(issue))
513
+ .map((issue) => this.entry(loc, lifecycle, scheme, issue)));
514
+ }
515
+ async taskLoad(prjId, taskId) {
516
+ const { loc, lifecycle, scheme } = await this.context(prjId);
517
+ const loaded = await this.load(prjId, loc, numberOf(scheme, taskId));
518
+ if (loaded === null)
519
+ return null;
520
+ const { issue, after, comments } = loaded;
521
+ return {
522
+ header: this.header(lifecycle, scheme, issue, after),
523
+ body: TaskIssues.issueBody(issue.title, issue.body),
524
+ attachment: comments.map((comment) => this.attachment(comment))
525
+ };
526
+ }
527
+ /* create or update the issue of a task plan: all references are resolved
528
+ upfront, a new task can only be the next issue number, and the issue itself
529
+ is updated last, so its update time covers the relation, comment, and label
530
+ changes; an issue which cannot be closed because of its open blocking issues
531
+ stays open, with its finished state carried by the "ase:Status:<state>" label */
532
+ async taskSave(prjId, taskId, plan) {
533
+ const { loc, lifecycle, scheme } = await this.context(prjId);
534
+ const number = numberOf(scheme, taskId);
535
+ if (number === 0)
536
+ throw new Error(`task id "${taskId}" is no issue number rendered through the "seq" task id scheme of the project`);
537
+ let issue = await this.fetch(loc, number);
538
+ const result = issue !== null ? "updated" : "created";
539
+ const group = plan.header.Group;
540
+ if (typeof group === "string" && group !== "")
541
+ await this.target(loc, scheme, "Group", group);
542
+ const after = await Promise.all((Array.isArray(plan.header.After) ? plan.header.After : [])
543
+ .map((id) => this.target(loc, scheme, "After", id)));
544
+ const phase = plan.header.Phase;
545
+ const milestone = typeof phase === "string" && phase !== "" ? await this.milestone(loc, phase) : 0;
546
+ const { labels, assignees } = await this.assign(loc, plan.header, issue);
547
+ const labelIds = await this.ensureLabels(loc, labels);
548
+ const status = TaskFormat.taskStatus(plan.header, lifecycle);
549
+ const closed = lifecycle.finished.includes(status);
550
+ const { title, body } = TaskIssues.planIssue(taskId, plan);
551
+ if (issue === null) {
552
+ /* reject a task id deviating from the next issue number, and delete an issue
553
+ which lost the race for this number (retiring its number) */
554
+ const next = await this.seqmark(loc, await this.registered(prjId), true) + 1;
555
+ if (number !== next)
556
+ throw new Error(`task "${taskId}" cannot be created, as the next issue will be "${idOf(scheme, next)}"`);
557
+ issue = await this.call(loc, this.gt.repos.issueCreateIssue(loc.owner, loc.repo, {
558
+ title, body, labels: labelIds, assignees, ...(milestone !== 0 ? { milestone } : {})
559
+ }));
560
+ if (issue.number !== number) {
561
+ await this.call(loc, this.gt.repos.issueDelete(loc.owner, loc.repo, issue.number));
562
+ await this.retire(prjId, issue.number);
563
+ throw new Error(`task "${taskId}" cannot be created, as issue "${idOf(scheme, number)}" was created concurrently`);
564
+ }
565
+ }
566
+ await this.syncBlockers(loc, issue, after);
567
+ await this.syncComments(loc, issue, plan.attachment);
568
+ const current = (issue.labels ?? []).map((label) => label.id).sort((a, b) => a - b);
569
+ if (current.join(",") !== [...labelIds].sort((a, b) => a - b).join(","))
570
+ await this.call(loc, this.gt.repos.issueReplaceLabels(loc.owner, loc.repo, number, { labels: labelIds }));
571
+ const edit = { title, body, assignees, milestone };
572
+ let updated;
573
+ if (closed === (issue.state === "closed"))
574
+ updated = await this.call(loc, this.gt.repos.issueEditIssue(loc.owner, loc.repo, number, edit));
575
+ else {
576
+ updated = await this.call(loc, this.gt.repos.issueEditIssue(loc.owner, loc.repo, number, { ...edit, state: closed ? "closed" : "open" })).catch(async (err) => {
577
+ /* keep an issue with open blocking issues open, as Gitea refuses to close it
578
+ (after it applied all other changes) */
579
+ if (!closed || statusOf(err) !== 412)
580
+ throw err;
581
+ return await this.call(loc, this.gt.repos.issueGetIssue(loc.owner, loc.repo, number));
582
+ });
583
+ }
584
+ this.written(prjId, updated);
585
+ await this.collectLabels(loc, labelNames(issue).filter((name) => !labels.includes(name)));
586
+ return result;
587
+ }
588
+ /* hard delete the issue of a task plan (requiring the administration of the repository) */
589
+ async taskDelete(prjId, taskId) {
590
+ const { loc, scheme } = await this.context(prjId);
591
+ const issue = await this.fetch(loc, numberOf(scheme, taskId));
592
+ if (issue === null)
593
+ return false;
594
+ await this.call(loc, this.gt.repos.issueDelete(loc.owner, loc.repo, issue.number));
595
+ await this.retire(prjId, issue.number);
596
+ this.pollState(prjId).seen.delete(issue.number);
597
+ this.loads.delete(`${prjId}#${issue.number}`);
598
+ await this.collectLabels(loc, labelNames(issue));
599
+ return true;
600
+ }
601
+ /* renaming is impossible, as the task id is the issue number */
602
+ async taskRename(prjId, oldId, newId) {
603
+ const { loc, scheme } = await this.context(prjId);
604
+ if (await this.fetch(loc, numberOf(scheme, oldId)) === null)
605
+ return false;
606
+ throw new Error(`task "${oldId}" cannot be renamed to "${newId}", as task ids are Gitea issue numbers`);
607
+ }
608
+ /* ==== change detection ==== */
609
+ /* the change detection state of a project (starting at the opening time) */
610
+ pollState(prjId) {
611
+ let state = this.polls.get(prjId);
612
+ if (state === undefined) {
613
+ state = { since: this.opened, seen: new Map() };
614
+ this.polls.set(prjId, state);
615
+ }
616
+ return state;
617
+ }
618
+ /* poll all registered projects, never overlapping itself */
619
+ async pollAll() {
620
+ if (this.polling)
621
+ return;
622
+ this.polling = true;
623
+ try {
624
+ for (const prjId of this.repos.keys())
625
+ await this.pollProject(prjId).catch((err) => {
626
+ this.ctx.log("warning", `polling project "${prjId}" failed: ${err instanceof Error ? err.message : String(err)}`);
627
+ });
628
+ }
629
+ finally {
630
+ this.polling = false;
631
+ }
632
+ }
633
+ /* poll the issues of a project updated since the last poll (Gitea supports no
634
+ conditional requests), and report the issues not seen at their update time yet:
635
+ as added if created after the opening, else as updated (issues deleted on Gitea
636
+ are not reported, as Gitea reports no deletions) */
637
+ async pollProject(prjId) {
638
+ const listener = this.listener;
639
+ if (listener === null || await this.registered(prjId) === null)
640
+ return;
641
+ const { loc, lifecycle, scheme } = await this.context(prjId);
642
+ const state = this.pollState(prjId);
643
+ const issues = await this.all(loc, (query) => this.gt.repos.issueListIssues(loc.owner, loc.repo, { state: "all", type: "issues", since: state.since, ...query }));
644
+ const change = { added: [], updated: [] };
645
+ for (const issue of issues.reverse()) {
646
+ if (!live(issue))
647
+ continue;
648
+ if (iso(issue.updated_at) > state.since)
649
+ state.since = iso(issue.updated_at);
650
+ if (state.seen.get(issue.number) === issue.updated_at)
651
+ continue;
652
+ const known = state.seen.has(issue.number);
653
+ state.seen.set(issue.number, issue.updated_at);
654
+ this.loads.delete(`${prjId}#${issue.number}`);
655
+ if (!known && iso(issue.created_at) >= this.opened)
656
+ change.added.push(await this.entry(loc, lifecycle, scheme, issue));
657
+ else
658
+ change.updated.push(await this.entry(loc, lifecycle, scheme, issue));
659
+ }
660
+ if (this.timer !== null && (change.added.length > 0 || change.updated.length > 0))
661
+ listener(prjId, change);
662
+ }
663
+ }
664
+ /* the plugin factory */
665
+ const factory = (ctx) => new GiteaTaskStoragePlugin(ctx);
666
+ export default factory;