@rse/ase 1.0.6 → 2.0.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.
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 +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +230 -116
  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} +3 -0
  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 +251 -76
  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 +225 -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 +79 -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,668 @@
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 { Issues, IssueNotes, IssueLinks, ProjectLabels, ProjectMilestones, ProjectMembers, Users, GitbeakerRequestError } from "@gitbeaker/rest";
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 labels, the task id mapping, and the timestamp of the common issue tracker parts */
11
+ const { LABEL_PROJECT, LABEL_DELETED, idOf, numberOf, stamp } = TaskIssues;
12
+ /* the "ase:After:<ids>" label carrying the blocking issues where GitLab lacks
13
+ blocking links (tier "Free"), its task ids separated by spaces */
14
+ const LABEL_AFTER_RE = /^ase:After:(.*)$/;
15
+ /* the header keys mapped onto native issue fields (or derived from them),
16
+ hence never carried by "ase:<key>:<value>" labels */
17
+ const nativeKeys = ["Type", "Id", "Created", "Modified", "Phase", "After", "Tags", "Assignee"];
18
+ /* the current time and a normalized timestamp as ISO timestamp (UTC, millisecond-precise) */
19
+ const now = () => new Date().toISOString();
20
+ const iso = (timestamp) => new Date(timestamp).toISOString();
21
+ /* the HTTP status of a (wrapped) request failure, and a "not found" (404) request failure mapped onto null */
22
+ const statusOf = (err) => err instanceof GitbeakerRequestError ? err.cause?.response.status :
23
+ err instanceof Error ? statusOf(err.cause) : undefined;
24
+ const found = (p) => p.catch((err) => {
25
+ if (statusOf(err) === 404)
26
+ return null;
27
+ throw err;
28
+ });
29
+ /* whether an issue is a live task (an issue of type "issue", not soft deleted) */
30
+ const live = (issue) => (issue.issue_type ?? "issue") === "issue" && !issue.labels.includes(LABEL_DELETED);
31
+ /* the GitLab storage plugin: a project is a GitLab project (registered by its
32
+ "ase:project" label) and a task plan is a live issue, its id being the issue
33
+ number ("iid") rendered through the mandatory "seq" task id scheme; the title maps
34
+ onto the issue title (and the "# TASK:" heading), the body onto the issue
35
+ description, "Status" onto the issue state refined by an "ase:Status:<state>"
36
+ label, "Tags" onto labels, "Assignee" onto the assignee, "Phase" onto the milestone,
37
+ "After" onto the "is blocked by" issue links (else an "ase:After:<ids>" label),
38
+ "Created"/"Modified" onto the issue timestamps, any other key (including "Group",
39
+ as GitLab has no parent issues) onto "ase:<key>:<value>" labels, and the attachments
40
+ onto the issue notes */
41
+ class GitLabTaskStoragePlugin {
42
+ ctx;
43
+ name = "gitlab";
44
+ url;
45
+ token;
46
+ source;
47
+ issues;
48
+ notes;
49
+ links;
50
+ labels;
51
+ milestones;
52
+ members;
53
+ users;
54
+ repos = new Map();
55
+ poll;
56
+ listener = null;
57
+ timer = null;
58
+ polling = false;
59
+ opened = now();
60
+ polls = new Map();
61
+ registry = new LRUCache({ max: 64, ttl: 60 * 1000 });
62
+ latest = new Map();
63
+ titles = new Map();
64
+ userIds = new Map();
65
+ blocking = new Map();
66
+ linked = new LRUCache({ max: 1024, ttl: 60 * 1000 });
67
+ recent = new Map();
68
+ loads = new LRUCache({ max: 256, ttl: 2 * 1000 });
69
+ constructor(ctx) {
70
+ this.ctx = ctx;
71
+ const options = ctx.options;
72
+ const token = options.token ?? process.env.GITLAB_TOKEN ?? "";
73
+ if (typeof token !== "string" || token === "")
74
+ throw new Error("task store: plugin \"gitlab\" requires the \"token\" option (or $GITLAB_TOKEN)");
75
+ this.token = token;
76
+ const url = options.url ?? process.env.GITLAB_HOST ?? "https://gitlab.com";
77
+ let base = null;
78
+ try {
79
+ base = new URL(typeof url === "string" && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? `https://${url}` : url);
80
+ }
81
+ catch {
82
+ /* reported below */
83
+ }
84
+ if (base === null || !/^https?:$/.test(base.protocol) || base.search !== "" || base.hash !== "")
85
+ throw new Error(`task store: plugin "gitlab" received invalid URL "${String(url)}" of the GitLab instance`);
86
+ this.url = base.href.replace(/\/+$/, "");
87
+ if (typeof options.repos !== "object" || options.repos === null || Array.isArray(options.repos))
88
+ throw new Error("task store: plugin \"gitlab\" requires the \"repos\" option (mapping project ids onto \"<namespace>/<project>\")");
89
+ for (const [prjId, spec] of Object.entries(options.repos)) {
90
+ if (typeof spec !== "string" || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/.test(spec) || !TaskFormat.ID_RE.test(prjId))
91
+ throw new Error(`task store: plugin "gitlab" received invalid project mapping "${prjId}: ${String(spec)}"`);
92
+ this.repos.set(prjId, spec);
93
+ }
94
+ this.poll = options.poll ?? 60;
95
+ if (typeof this.poll !== "number" || !Number.isFinite(this.poll) || (this.poll !== 0 && (this.poll < 1 || this.poll > 86400)))
96
+ throw new Error("task store: plugin \"gitlab\" requires a \"poll\" interval of 0 (disabled) resp. 1 to 86400 seconds");
97
+ /* create the used GitLab REST API resources, each with every request failure explained */
98
+ this.source = options.token !== undefined ? "the \"token\" option (resp. \"project.task.store.token\")" : "$GITLAB_TOKEN";
99
+ const config = { host: this.url, token };
100
+ this.issues = this.explained(new Issues(config));
101
+ this.notes = this.explained(new IssueNotes(config));
102
+ this.links = this.explained(new IssueLinks(config));
103
+ this.labels = this.explained(new ProjectLabels(config));
104
+ this.milestones = this.explained(new ProjectMilestones(config));
105
+ this.members = this.explained(new ProjectMembers(config));
106
+ this.users = this.explained(new Users(config));
107
+ }
108
+ /* explain a denied access, as the GitLab message names neither the token
109
+ source nor the missing permission */
110
+ explain(err) {
111
+ const status = statusOf(err);
112
+ if (status === 401 || status === 403) {
113
+ const m = /\/projects\/([^/?]+)/.exec(err instanceof GitbeakerRequestError ? err.cause?.request.url ?? "" : "");
114
+ const repo = m !== null ? ` to project "${decodeURIComponent(m[1])}"` : "";
115
+ throw new Error(`GitLab denied access${repo} (HTTP ${String(status)}: ${err.message}) -- ` +
116
+ `the token of ${this.source} needs the scope "api" and at least the role "Planner" ` +
117
+ "(resp. \"Reporter\" before GitLab 17.7) in the project", { cause: err });
118
+ }
119
+ throw err;
120
+ }
121
+ /* wrap a GitLab REST API resource to explain the failures of all its requests */
122
+ explained(resource) {
123
+ return new Proxy(resource, {
124
+ get: (target, key, receiver) => {
125
+ const value = Reflect.get(target, key, receiver);
126
+ if (typeof value !== "function")
127
+ return value;
128
+ return (...args) => {
129
+ const result = value.apply(target, args);
130
+ return result instanceof Promise ? result.catch((err) => this.explain(err)) : result;
131
+ };
132
+ }
133
+ });
134
+ }
135
+ /* the storage lifecycle: polling for changes while opened and observed
136
+ (without keeping the process alive) */
137
+ async open() {
138
+ this.opened = now();
139
+ if (this.poll > 0 && this.listener !== null) {
140
+ this.timer = setInterval(() => {
141
+ this.pollAll().catch(() => { });
142
+ }, this.poll * 1000);
143
+ this.timer.unref();
144
+ }
145
+ this.ctx.log("debug", `opened ${this.repos.size} projects on ${this.url} (polling every ${this.poll}s)`);
146
+ }
147
+ async close() {
148
+ if (this.timer !== null)
149
+ clearInterval(this.timer);
150
+ this.timer = null;
151
+ }
152
+ /* observe the changes of the issues made outside of this plugin instance */
153
+ watch(listener) {
154
+ this.listener = listener;
155
+ }
156
+ /* ==== project registry ==== */
157
+ /* the GitLab project path of a project */
158
+ repo(prjId) {
159
+ const path = this.repos.get(prjId);
160
+ if (path === undefined)
161
+ throw new Error(`project "${prjId}" is not mapped onto a GitLab project (see option "repos")`);
162
+ return path;
163
+ }
164
+ /* the registry entry of a project, read from the description of its "ase:project" label */
165
+ async registered(prjId) {
166
+ const cached = this.registry.get(prjId);
167
+ if (cached !== undefined)
168
+ return cached.value;
169
+ const path = this.repos.get(prjId);
170
+ let value = null;
171
+ if (path !== undefined) {
172
+ const label = await found(this.labels.show(path, LABEL_PROJECT, { includeAncestorGroups: false }));
173
+ if (label !== null)
174
+ value = TaskIssues.registryEntry(label.description);
175
+ }
176
+ this.registry.set(prjId, { value });
177
+ return value;
178
+ }
179
+ /* the project path, lifecycle model, and task id scheme of a registered project */
180
+ async context(prjId) {
181
+ const path = this.repo(prjId);
182
+ const entry = await this.registered(prjId);
183
+ if (entry === null)
184
+ throw new Error(`project "${prjId}" not registered`);
185
+ return { path, ...TaskIssues.registryContext(prjId, entry, "GitLab") };
186
+ }
187
+ /* conditionally request the first page of a listing via the entity tag of a
188
+ previous request (null if unchanged), as the GitLab REST API client
189
+ supports no per-request headers */
190
+ async conditional(path, query, etag) {
191
+ const url = `${this.url}/api/v4/projects/${encodeURIComponent(path)}/issues?${new URLSearchParams(query).toString()}`;
192
+ const request = new Request(url, {
193
+ headers: { "private-token": this.token, ...(etag !== undefined ? { "if-none-match": etag } : {}) },
194
+ signal: AbortSignal.timeout(60 * 1000)
195
+ });
196
+ const response = await fetch(request);
197
+ if (response.status === 304)
198
+ return null;
199
+ if (!response.ok) {
200
+ const text = await response.text();
201
+ this.explain(new GitbeakerRequestError(`${String(response.status)} ${response.statusText}`.trim(), { cause: { description: text, request, response } }));
202
+ }
203
+ return {
204
+ issues: await response.json(),
205
+ etag: response.headers.get("etag") ?? undefined,
206
+ more: (response.headers.get("x-next-page") ?? "") !== ""
207
+ };
208
+ }
209
+ /* the highest issue number of a project (conditionally requested), including
210
+ the other work item types, as they share the issue number sequence */
211
+ async latestNumber(path, fresh = false) {
212
+ const cached = this.latest.get(path);
213
+ /* reuse a number determined within the last 2 seconds (unless a fresh one
214
+ is required), as every single task store operation asks for it */
215
+ if (!fresh && cached !== undefined && Date.now() - cached.at < 2 * 1000)
216
+ return cached.number;
217
+ const r = await this.conditional(path, { scope: "all", state: "all", order_by: "created_at", sort: "desc", per_page: "1" }, cached?.etag);
218
+ if (r === null && cached !== undefined) {
219
+ cached.at = Date.now();
220
+ return cached.number;
221
+ }
222
+ const number = r?.issues[0]?.iid ?? 0;
223
+ if (r?.etag !== undefined)
224
+ this.latest.set(path, { etag: r.etag, number, at: Date.now() });
225
+ return number;
226
+ }
227
+ async projectList() {
228
+ const out = [];
229
+ for (const prjId of this.repos.keys()) {
230
+ const entry = await this.projectGet(prjId);
231
+ if (entry !== null)
232
+ out.push(entry);
233
+ }
234
+ return out;
235
+ }
236
+ /* get a project, with the highest issue number as the sequence number high-water
237
+ mark, so the next allocated task id is the one of the next created issue */
238
+ async projectGet(prjId) {
239
+ const entry = await this.registered(prjId);
240
+ if (entry === null)
241
+ return null;
242
+ return { id: prjId, ...entry, seqmark: await this.latestNumber(this.repo(prjId)) };
243
+ }
244
+ async projectSet(prjId, lifecycle, idscheme) {
245
+ const path = this.repo(prjId);
246
+ TaskIssues.requireSeqScheme(idscheme, "GitLab");
247
+ /* spare the requests of an unchanged (cached) registration, as the
248
+ in-process clients re-register the project on every opening */
249
+ const entry = await this.registered(prjId);
250
+ if (entry !== null && entry.lifecycle === lifecycle && entry.idscheme === idscheme)
251
+ return "updated";
252
+ const description = TaskIssues.registryDescription({ lifecycle, idscheme });
253
+ if (entry === null)
254
+ await this.labels.create(path, LABEL_PROJECT, "#5319e7", { description });
255
+ else
256
+ await this.labels.edit(path, LABEL_PROJECT, { color: "#5319e7", description });
257
+ this.registry.set(prjId, { value: { lifecycle, idscheme } });
258
+ return entry === null ? "created" : "updated";
259
+ }
260
+ async projectDelete(prjId) {
261
+ const path = this.repos.get(prjId);
262
+ if (path === undefined)
263
+ return false;
264
+ const result = await found(this.labels.remove(path, LABEL_PROJECT).then(() => true));
265
+ this.registry.delete(prjId);
266
+ return result !== null;
267
+ }
268
+ /* ==== issue mapping ==== */
269
+ /* fetch the live issue of an issue number (null if none) */
270
+ async fetch(path, n) {
271
+ if (n === 0)
272
+ return null;
273
+ const issue = await found(this.issues.show(n, { projectId: path }).then((data) => data));
274
+ return issue !== null && issue.iid === n && live(issue) ? issue : null;
275
+ }
276
+ /* the blocking issues of an issue within its project, from its issue links
277
+ (reused for 60 seconds per update time, as a listing needs them for every issue) */
278
+ async blockers(path, issue, fresh = false) {
279
+ const key = `${path}#${issue.iid}@${issue.updated_at}`;
280
+ if (fresh)
281
+ this.linked.delete(key);
282
+ let links = this.linked.get(key);
283
+ if (links === undefined) {
284
+ links = this.links.all(path, issue.iid).then((data) => data);
285
+ this.linked.set(key, links);
286
+ links.catch(() => {
287
+ this.linked.delete(key);
288
+ });
289
+ }
290
+ return (await links).filter((link) => link.link_type === "is_blocked_by" && link.project_id === issue.project_id);
291
+ }
292
+ /* the (non-system) notes of an issue */
293
+ async comments(path, n) {
294
+ const notes = await this.notes.all(path, n, { sort: "asc", orderBy: "created_at", perPage: 100 });
295
+ return notes.filter((note) => !note.system);
296
+ }
297
+ /* derive the header of an issue: the natively mapped keys, "After" from the
298
+ blocking issues plus its "ase:After:<ids>" label, "Status" from the issue state
299
+ (closed for the finished states) refined by its "ase:Status:<state>" label,
300
+ and all other keys from their "ase:<key>:<value>" labels */
301
+ header(lifecycle, scheme, issue, after) {
302
+ const header = {
303
+ Type: TaskFormat.TASK_TYPE,
304
+ Id: idOf(scheme, issue.iid),
305
+ Created: stamp(issue.created_at),
306
+ Modified: stamp(issue.updated_at)
307
+ };
308
+ if (issue.milestone !== undefined && issue.milestone !== null)
309
+ header.Phase = issue.milestone.title;
310
+ const ids = after.map((link) => idOf(scheme, link.iid));
311
+ for (const name of issue.labels) {
312
+ const m = LABEL_AFTER_RE.exec(name);
313
+ if (m !== null)
314
+ ids.push(...m[1].split(/\s+/).filter((id) => id !== "" && !ids.includes(id)));
315
+ }
316
+ if (ids.length > 0)
317
+ header.After = ids;
318
+ const status = TaskIssues.labelHeader(header, issue.labels, nativeKeys);
319
+ const assignee = issue.assignees?.[0]?.username;
320
+ if (assignee !== undefined)
321
+ header.Assignee = assignee;
322
+ const derived = TaskIssues.issueStatus(lifecycle, issue.state === "closed", status === "CANCELLED", status);
323
+ if (derived !== undefined)
324
+ header.Status = derived;
325
+ return header;
326
+ }
327
+ /* derive the attachment of a note (see the common issue tracker parts) */
328
+ attachment(note) {
329
+ return TaskIssues.commentAttachment(note.body, note.author?.username ?? "ghost", note.created_at, note.updated_at);
330
+ }
331
+ /* the listing entry of an issue */
332
+ async entry(path, lifecycle, scheme, issue) {
333
+ const header = this.header(lifecycle, scheme, issue, await this.blockers(path, issue));
334
+ return { id: idOf(scheme, issue.iid), title: issue.title, header, mtime: new Date(issue.updated_at) };
335
+ }
336
+ /* ==== issue updating ==== */
337
+ /* delete the "ase:<key>:<value>" labels no longer used by any issue */
338
+ async collectLabels(path, names) {
339
+ for (const name of names) {
340
+ if (!TaskIssues.LABEL_KEY_RE.test(name))
341
+ continue;
342
+ const issues = await this.issues.all({ projectId: path, labels: name, scope: "all", state: "all", perPage: 1, maxPages: 1 });
343
+ if (issues.length === 0)
344
+ await found(this.labels.remove(path, name));
345
+ }
346
+ }
347
+ /* the id of a project milestone by its title (created on demand) */
348
+ async milestone(path, title) {
349
+ let map = this.titles.get(path);
350
+ if (map === undefined) {
351
+ const milestones = await this.milestones.all(path, { perPage: 100 });
352
+ map = new Map(milestones.map((milestone) => [milestone.title, milestone.id]));
353
+ this.titles.set(path, map);
354
+ }
355
+ let id = map.get(title);
356
+ if (id === undefined) {
357
+ id = (await this.milestones.create(path, title)).id;
358
+ map.set(title, id);
359
+ }
360
+ return id;
361
+ }
362
+ /* the live issue a header key references by its task id, else an error */
363
+ async target(path, scheme, key, id) {
364
+ const issue = await this.fetch(path, numberOf(scheme, id));
365
+ if (issue === null)
366
+ throw new Error(`header key "${key}" references no existing task "${id}"`);
367
+ return issue;
368
+ }
369
+ /* the user id of a project member by its username (else null) */
370
+ async member(path, username) {
371
+ let id = this.userIds.get(username);
372
+ if (id === undefined) {
373
+ const users = await this.users.all({ username, perPage: 1, maxPages: 1 });
374
+ id = users[0]?.id ?? null;
375
+ this.userIds.set(username, id);
376
+ }
377
+ if (id === null || await found(this.members.show(path, id, { includeInherited: true })) === null)
378
+ return null;
379
+ return id;
380
+ }
381
+ /* determine the labels and assignees of a header: the tags, the "ase:<key>:<value>"
382
+ labels of the keys without native counterpart, and the assignee if it can be
383
+ assigned natively (keeping further native assignees), else its label */
384
+ async assign(path, header, issue) {
385
+ const labels = TaskIssues.headerLabels(header, nativeKeys);
386
+ let assigneeIds = [];
387
+ const assignee = header.Assignee;
388
+ if (typeof assignee === "string" && assignee !== "") {
389
+ const users = issue?.assignees ?? [];
390
+ let id;
391
+ if (users.some((user) => user.username === assignee))
392
+ assigneeIds = users.map((user) => user.id);
393
+ else if ((id = await this.member(path, assignee)) !== null)
394
+ assigneeIds = [id];
395
+ else
396
+ labels.push(`ase:Assignee:${assignee}`);
397
+ }
398
+ /* reject the labels GitLab cannot carry, as the label names are passed comma-separated */
399
+ for (const label of labels)
400
+ if (label.includes(","))
401
+ throw new Error(`label "${label}" cannot be carried by GitLab, as it contains a comma`);
402
+ return { labels, assigneeIds };
403
+ }
404
+ /* add a blocking issue natively; returns false if GitLab rejects blocking links
405
+ (remembered per project, as they require the tier "Premium") or the issues
406
+ are already linked otherwise (e.g. as related) */
407
+ async addBlocker(path, issue, blocker) {
408
+ try {
409
+ const link = await this.links.create(path, issue.iid, issue.project_id, blocker.iid, { linkType: "is_blocked_by" });
410
+ if (link.link_type === "is_blocked_by")
411
+ return true;
412
+ /* remove a link degraded to "relates_to" by a tier without blocking links */
413
+ const links = await this.links.all(path, issue.iid);
414
+ for (const other of links)
415
+ if (other.iid === blocker.iid && other.project_id === blocker.project_id)
416
+ await this.links.remove(path, issue.iid, other.issue_link_id);
417
+ this.blocking.set(path, false);
418
+ return false;
419
+ }
420
+ catch (err) {
421
+ const status = statusOf(err);
422
+ if (status === 403)
423
+ this.blocking.set(path, false);
424
+ else if (status !== 409)
425
+ throw err;
426
+ return false;
427
+ }
428
+ }
429
+ /* synchronize the blocking issues (within the project) natively as far as possible
430
+ (skipping the attempts once blocking links were rejected); returns whether all
431
+ of them are carried natively */
432
+ async syncBlockers(path, issue, after) {
433
+ const current = await this.blockers(path, issue, true);
434
+ const have = new Set(current.map((link) => link.iid));
435
+ const want = new Set(after.map((blocker) => blocker.iid));
436
+ let native = true;
437
+ for (const blocker of after)
438
+ if (!have.has(blocker.iid))
439
+ native = (this.blocking.get(path) ?? true) && await this.addBlocker(path, issue, blocker) && native;
440
+ for (const link of current)
441
+ if (!want.has(link.iid))
442
+ await found(this.links.remove(path, issue.iid, link.issue_link_id));
443
+ return native;
444
+ }
445
+ /* synchronize the notes with the attachments, touching changed ones only
446
+ (see the common issue tracker parts) */
447
+ async syncNotes(path, issue, attachments) {
448
+ const notes = issue.user_notes_count > 0 ? await this.comments(path, issue.iid) : [];
449
+ const ops = TaskIssues.attachmentSync(notes.map((note) => this.attachment(note)), attachments);
450
+ for (const op of ops) {
451
+ if (op.op === "delete")
452
+ await this.notes.remove(path, issue.iid, notes[op.index].id);
453
+ else if (op.op === "create")
454
+ await this.notes.create(path, issue.iid, TaskIssues.attachmentComment(op.attachment));
455
+ else
456
+ await this.notes.edit(path, issue.iid, notes[op.index].id, { body: TaskIssues.attachmentComment(op.attachment) });
457
+ }
458
+ }
459
+ /* remember an issue written by this plugin instance: its update time, so the change
460
+ detection does not report it as an external change, and the issue itself, so the
461
+ listings (which may lag behind the writes on GitLab) show it immediately */
462
+ written(prjId, issue) {
463
+ this.pollState(prjId).seen.set(issue.iid, issue.updated_at);
464
+ this.loads.delete(`${prjId}#${issue.iid}`);
465
+ let recent = this.recent.get(prjId);
466
+ if (recent === undefined) {
467
+ recent = new Map();
468
+ this.recent.set(prjId, recent);
469
+ }
470
+ recent.set(issue.iid, { issue, at: Date.now() });
471
+ }
472
+ /* overlay the recently written issues (of the last 60 seconds) onto a listing,
473
+ where the listed issue is not yet present or older */
474
+ overlay(prjId, issues) {
475
+ const recent = this.recent.get(prjId);
476
+ if (recent === undefined)
477
+ return issues;
478
+ const byNumber = new Map(issues.map((issue) => [issue.iid, issue]));
479
+ for (const [n, { issue, at }] of recent) {
480
+ const listed = byNumber.get(n);
481
+ if (Date.now() - at > 60 * 1000)
482
+ recent.delete(n);
483
+ else if (listed === undefined || iso(listed.updated_at) < iso(issue.updated_at))
484
+ byNumber.set(n, issue);
485
+ }
486
+ return [...byNumber.values()];
487
+ }
488
+ /* load a live issue together with its blocking issues and notes (the issue and
489
+ its notes in parallel), reusing a load of the last 2 seconds (dropped on any
490
+ own write or detected change of the issue), as e.g. the boards load the plan
491
+ and its attachments in separate operations */
492
+ load(prjId, path, n) {
493
+ const key = `${prjId}#${n}`;
494
+ const cached = this.loads.get(key);
495
+ if (cached !== undefined)
496
+ return cached;
497
+ const load = (async () => {
498
+ const [issue, notes] = await Promise.all([
499
+ this.fetch(path, n),
500
+ n === 0 ? [] : found(this.comments(path, n))
501
+ ]);
502
+ if (issue === null)
503
+ return null;
504
+ return { issue, after: await this.blockers(path, issue), notes: notes ?? [] };
505
+ })();
506
+ this.loads.set(key, load);
507
+ load.catch(() => {
508
+ this.loads.delete(key);
509
+ });
510
+ return load;
511
+ }
512
+ /* ==== task plans ==== */
513
+ async taskList(prjId) {
514
+ const { path, lifecycle, scheme } = await this.context(prjId);
515
+ const issues = this.overlay(prjId, await this.issues.all({ projectId: path, scope: "all", state: "all", perPage: 100 }));
516
+ return Promise.all(issues.filter((issue) => live(issue))
517
+ .map((issue) => this.entry(path, lifecycle, scheme, issue)));
518
+ }
519
+ async taskLoad(prjId, taskId) {
520
+ const { path, lifecycle, scheme } = await this.context(prjId);
521
+ const loaded = await this.load(prjId, path, numberOf(scheme, taskId));
522
+ if (loaded === null)
523
+ return null;
524
+ const { issue, after, notes } = loaded;
525
+ return {
526
+ header: this.header(lifecycle, scheme, issue, after),
527
+ body: TaskIssues.issueBody(issue.title, issue.description),
528
+ attachment: notes.map((note) => this.attachment(note))
529
+ };
530
+ }
531
+ /* create or update the issue of a task plan: all references are resolved
532
+ upfront, a new task can only be the next issue number, and the issue itself
533
+ is updated last, so its update time covers the link and note changes */
534
+ async taskSave(prjId, taskId, plan) {
535
+ const { path, lifecycle, scheme } = await this.context(prjId);
536
+ const number = numberOf(scheme, taskId);
537
+ if (number === 0)
538
+ throw new Error(`task id "${taskId}" is no issue number rendered through the "seq" task id scheme of the project`);
539
+ let issue = await this.fetch(path, number);
540
+ const result = issue !== null ? "updated" : "created";
541
+ const group = plan.header.Group;
542
+ if (typeof group === "string" && group !== "")
543
+ await this.target(path, scheme, "Group", group);
544
+ const after = await Promise.all((Array.isArray(plan.header.After) ? plan.header.After : [])
545
+ .map((id) => this.target(path, scheme, "After", id)));
546
+ /* keep the milestone of an unchanged "Phase" (which may be a group milestone) */
547
+ const phase = plan.header.Phase;
548
+ const milestone = typeof phase === "string" && phase !== "" ?
549
+ (issue?.milestone?.title === phase ? issue.milestone.id : await this.milestone(path, phase)) : null;
550
+ const { labels, assigneeIds } = await this.assign(path, plan.header, issue);
551
+ const status = TaskFormat.taskStatus(plan.header, lifecycle);
552
+ const closed = lifecycle.finished.includes(status);
553
+ const { title, body } = TaskIssues.planIssue(taskId, plan);
554
+ if (issue === null) {
555
+ /* reject a task id deviating from the next issue number, and
556
+ discard an issue which lost the race for this number */
557
+ const next = await this.latestNumber(path, true) + 1;
558
+ if (number !== next)
559
+ throw new Error(`task "${taskId}" cannot be created, as the next issue will be "${idOf(scheme, next)}"`);
560
+ issue = await this.issues.create(path, title, {
561
+ description: body, labels: labels.join(","), assigneeIds, ...(milestone !== null ? { milestoneId: milestone } : {})
562
+ });
563
+ if (issue.iid !== number) {
564
+ await this.issues.edit(path, issue.iid, { stateEvent: "close", addLabels: LABEL_DELETED });
565
+ throw new Error(`task "${taskId}" cannot be created, as issue "${idOf(scheme, number)}" was created concurrently`);
566
+ }
567
+ }
568
+ if (!await this.syncBlockers(path, issue, after))
569
+ labels.push(`ase:After:${after.map((blocker) => idOf(scheme, blocker.iid)).join(" ")}`);
570
+ await this.syncNotes(path, issue, plan.attachment);
571
+ const updated = await this.issues.edit(path, number, {
572
+ title, description: body, labels: labels.join(","), assigneeIds, milestoneId: milestone ?? 0,
573
+ ...(closed !== (issue.state === "closed") ? { stateEvent: closed ? "close" : "reopen" } : {})
574
+ });
575
+ this.written(prjId, updated);
576
+ await this.collectLabels(path, issue.labels.filter((name) => !labels.includes(name)));
577
+ return result;
578
+ }
579
+ /* soft delete the issue of a task plan: closed and marked as deleted */
580
+ async taskDelete(prjId, taskId) {
581
+ const { path, scheme } = await this.context(prjId);
582
+ const issue = await this.fetch(path, numberOf(scheme, taskId));
583
+ if (issue === null)
584
+ return false;
585
+ const updated = await this.issues.edit(path, issue.iid, {
586
+ addLabels: LABEL_DELETED, ...(issue.state !== "closed" ? { stateEvent: "close" } : {})
587
+ });
588
+ this.written(prjId, updated);
589
+ return true;
590
+ }
591
+ /* renaming is impossible, as the task id is the issue number */
592
+ async taskRename(prjId, oldId, newId) {
593
+ const { path, scheme } = await this.context(prjId);
594
+ if (await this.fetch(path, numberOf(scheme, oldId)) === null)
595
+ return false;
596
+ throw new Error(`task "${oldId}" cannot be renamed to "${newId}", as task ids are GitLab issue numbers`);
597
+ }
598
+ /* ==== change detection ==== */
599
+ /* the change detection state of a project (starting at the opening time) */
600
+ pollState(prjId) {
601
+ let state = this.polls.get(prjId);
602
+ if (state === undefined) {
603
+ state = { since: this.opened, seen: new Map() };
604
+ this.polls.set(prjId, state);
605
+ }
606
+ return state;
607
+ }
608
+ /* poll all registered projects, never overlapping itself */
609
+ async pollAll() {
610
+ if (this.polling)
611
+ return;
612
+ this.polling = true;
613
+ try {
614
+ for (const prjId of this.repos.keys())
615
+ await this.pollProject(prjId).catch((err) => {
616
+ this.ctx.log("warning", `polling project "${prjId}" failed: ${err instanceof Error ? err.message : String(err)}`);
617
+ });
618
+ }
619
+ finally {
620
+ this.polling = false;
621
+ }
622
+ }
623
+ /* poll the issues of a project updated since the last poll, conditionally via
624
+ the entity tag of the last poll (a "304" answer still counts against the rate
625
+ limit of GitLab), and report the issues not seen at their update time yet: as
626
+ added if created after the opening, as deleted if soft deleted, else as updated
627
+ (issues deleted on GitLab are not reported, as GitLab reports no deletions) */
628
+ async pollProject(prjId) {
629
+ const listener = this.listener;
630
+ if (listener === null || await this.registered(prjId) === null)
631
+ return;
632
+ const { path, lifecycle, scheme } = await this.context(prjId);
633
+ const state = this.pollState(prjId);
634
+ const params = { scope: "all", state: "all", order_by: "updated_at", sort: "asc", updated_after: state.since, per_page: "100" };
635
+ const r = await this.conditional(path, params, state.etag);
636
+ if (r === null)
637
+ return;
638
+ state.etag = r.etag;
639
+ let issues = r.issues;
640
+ if (r.more)
641
+ issues = await this.issues.all({
642
+ projectId: path, scope: "all", state: "all", orderBy: "updated_at", sort: "asc", updatedAfter: state.since, perPage: 100
643
+ });
644
+ const change = { added: [], updated: [], deleted: [] };
645
+ for (const issue of issues) {
646
+ if ((issue.issue_type ?? "issue") !== "issue")
647
+ continue;
648
+ if (iso(issue.updated_at) > state.since)
649
+ state.since = iso(issue.updated_at);
650
+ if (state.seen.get(issue.iid) === issue.updated_at)
651
+ continue;
652
+ const known = state.seen.has(issue.iid);
653
+ state.seen.set(issue.iid, issue.updated_at);
654
+ this.loads.delete(`${prjId}#${issue.iid}`);
655
+ if (!live(issue))
656
+ change.deleted.push(idOf(scheme, issue.iid));
657
+ else if (!known && iso(issue.created_at) >= this.opened)
658
+ change.added.push(await this.entry(path, lifecycle, scheme, issue));
659
+ else
660
+ change.updated.push(await this.entry(path, lifecycle, scheme, issue));
661
+ }
662
+ if (this.timer !== null && (change.added.length > 0 || change.updated.length > 0 || change.deleted.length > 0))
663
+ listener(prjId, change);
664
+ }
665
+ }
666
+ /* the plugin factory */
667
+ const factory = (ctx) => new GitLabTaskStoragePlugin(ctx);
668
+ export default factory;