@rse/ase 1.0.7 → 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.
- package/dst/ase-agent-definition-api.d.ts +184 -0
- package/dst/ase-agent-definition-api.js +6 -0
- package/dst/ase-agent-definition-builtin/condenser.js +42 -0
- package/dst/ase-agent-definition-builtin/editor.js +45 -0
- package/dst/ase-agent-definition-builtin/estimator.js +98 -0
- package/dst/ase-agent-definition-builtin/implementer.js +121 -0
- package/dst/ase-agent-definition-builtin/integrator.js +56 -0
- package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
- package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
- package/dst/ase-agent-definition-builtin/researcher.js +77 -0
- package/dst/ase-agent-definition-core.js +827 -0
- package/dst/ase-agent-fleet.js +772 -0
- package/dst/ase-agent-process.js +232 -0
- package/dst/ase-agent-relay-client.js +509 -0
- package/dst/ase-agent-relay-core.js +213 -0
- package/dst/ase-agent-relay-server-bind.js +242 -0
- package/dst/ase-agent-relay-server-cli.js +50 -0
- package/dst/ase-agent-runtime-claim.js +189 -0
- package/dst/ase-agent-runtime-engine.js +1100 -0
- package/dst/ase-agent-runtime-exec.js +275 -0
- package/dst/ase-agent-runtime-phase.js +581 -0
- package/dst/ase-agent-runtime-request.js +272 -0
- package/dst/ase-agent-runtime-tool.js +207 -0
- package/dst/ase-agent-runtime.js +1316 -0
- package/dst/ase-agent.js +65 -0
- package/dst/ase-artifact.js +6 -5
- package/dst/ase-config-core.js +465 -0
- package/dst/ase-config-schema.js +351 -0
- package/dst/ase-config-scope.js +172 -0
- package/dst/ase-config.js +25 -585
- package/dst/ase-hook.js +228 -111
- package/dst/ase-lib-daemon-cli.js +130 -0
- package/dst/ase-lib-hash.js +20 -0
- package/dst/{ase-log.js → ase-lib-log.js} +40 -7
- package/dst/ase-lib-server-bind.js +115 -0
- package/dst/ase-lib-server-cli.js +474 -0
- package/dst/{ase-table.js → ase-lib-table.js} +1 -1
- package/dst/{ase-version.js → ase-lib-version.js} +3 -0
- package/dst/ase-man.js +96 -0
- package/dst/ase-mcp.js +166 -13
- package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
- package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
- package/dst/ase-service-otel.js +1246 -0
- package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
- package/dst/ase-service-state.js +101 -0
- package/dst/ase-service.js +251 -76
- package/dst/ase-setup-common.js +141 -0
- package/dst/ase-setup-mcp.js +359 -0
- package/dst/ase-setup-settings.js +837 -0
- package/dst/ase-setup.js +113 -869
- package/dst/ase-spec.js +31 -19
- package/dst/ase-statusline.js +108 -43
- package/dst/ase-task-board-core.js +739 -0
- package/dst/ase-task-board-filter.js +127 -0
- package/dst/ase-task-board-flow.js +237 -0
- package/dst/ase-task-board-graph.js +497 -0
- package/dst/ase-task-board-stats.js +263 -0
- package/dst/ase-task-board-tui-control.js +775 -0
- package/dst/ase-task-board-tui-model.js +1008 -0
- package/dst/ase-task-board-tui-popup.js +626 -0
- package/dst/ase-task-board-tui-style.js +106 -0
- package/dst/ase-task-board-tui-view.js +847 -0
- package/dst/ase-task-board-tui.js +189 -0
- package/dst/ase-task-board-web-client.css +1 -0
- package/dst/ase-task-board-web-client.html +20 -0
- package/dst/ase-task-board-web-client.js +81 -0
- package/dst/ase-task-board-web-server.js +823 -0
- package/dst/ase-task-board.js +134 -0
- package/dst/ase-task-format.js +757 -0
- package/dst/ase-task-request.js +208 -0
- package/dst/ase-task-store-client.js +495 -0
- package/dst/ase-task-store-core.js +804 -0
- package/dst/ase-task-store-issues.js +237 -0
- package/dst/ase-task-store-plugin-api.js +6 -0
- package/dst/ase-task-store-plugin-ase.js +484 -0
- package/dst/ase-task-store-plugin-delegate.js +137 -0
- package/dst/ase-task-store-plugin-gitea.js +666 -0
- package/dst/ase-task-store-plugin-github.js +696 -0
- package/dst/ase-task-store-plugin-gitlab.js +668 -0
- package/dst/ase-task-store-server-bind.js +538 -0
- package/dst/ase-task-store-server-cli.js +154 -0
- package/dst/ase-task.js +1335 -357
- package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
- package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
- package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
- package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
- package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
- package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
- package/dst/ase-util.js +20 -0
- package/dst/ase.js +108 -32
- package/dst/man/ase-agent-adhoc.1 +515 -0
- package/dst/man/ase-agent-condenser.1 +193 -0
- package/dst/man/ase-agent-editor.1 +212 -0
- package/dst/man/ase-agent-estimator.1 +214 -0
- package/dst/man/ase-agent-fleet.1 +185 -0
- package/dst/man/ase-agent-implementer.1 +233 -0
- package/dst/man/ase-agent-integrator.1 +213 -0
- package/dst/man/ase-agent-qualifier.1 +214 -0
- package/dst/man/ase-agent-rebooter.1 +193 -0
- package/dst/man/ase-agent-relay.1 +128 -0
- package/dst/man/ase-agent-researcher.1 +217 -0
- package/dst/man/ase-agent.1 +264 -0
- package/dst/man/ase-artifact-list.1 +38 -0
- package/dst/man/ase-artifact-name.1 +39 -0
- package/dst/man/ase-artifact.1 +56 -0
- package/dst/man/ase-config-delete.1 +45 -0
- package/dst/man/ase-config-edit.1 +48 -0
- package/dst/man/ase-config-get.1 +46 -0
- package/dst/man/ase-config-init.1 +60 -0
- package/dst/man/ase-config-list.1 +42 -0
- package/dst/man/ase-config-set.1 +52 -0
- package/dst/man/ase-config.1 +225 -0
- package/dst/man/ase-hook-permission-request.1 +36 -0
- package/dst/man/ase-hook-pre-tool-use.1 +59 -0
- package/dst/man/ase-hook-session-end.1 +47 -0
- package/dst/man/ase-hook-session-persona.1 +37 -0
- package/dst/man/ase-hook-session-start.1 +81 -0
- package/dst/man/ase-hook-stop.1 +34 -0
- package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
- package/dst/man/ase-hook.1 +51 -0
- package/dst/man/ase-man.1 +67 -0
- package/dst/man/ase-mcp.1 +61 -0
- package/dst/man/ase-service-log.1 +43 -0
- package/dst/man/ase-service-send.1 +47 -0
- package/dst/man/ase-service-start.1 +54 -0
- package/dst/man/ase-service-status.1 +46 -0
- package/dst/man/ase-service-stop.1 +37 -0
- package/dst/man/ase-service.1 +85 -0
- package/dst/man/ase-setup-disable.1 +44 -0
- package/dst/man/ase-setup-enable.1 +45 -0
- package/dst/man/ase-setup-install.1 +73 -0
- package/dst/man/ase-setup-mcp.1 +124 -0
- package/dst/man/ase-setup-status.1 +60 -0
- package/dst/man/ase-setup-statusline.1 +92 -0
- package/dst/man/ase-setup-telemetry.1 +92 -0
- package/dst/man/ase-setup-uninstall.1 +59 -0
- package/dst/man/ase-setup-update.1 +64 -0
- package/dst/man/ase-setup.1 +93 -0
- package/dst/man/ase-spec-export.1 +62 -0
- package/dst/man/ase-spec-lint.1 +40 -0
- package/dst/man/ase-spec-preview.1 +40 -0
- package/dst/man/ase-spec.1 +46 -0
- package/dst/man/ase-statusline.1 +189 -0
- package/dst/man/ase-task-assign.1 +71 -0
- package/dst/man/ase-task-board.1 +384 -0
- package/dst/man/ase-task-delete.1 +35 -0
- package/dst/man/ase-task-edit.1 +46 -0
- package/dst/man/ase-task-idscheme.1 +65 -0
- package/dst/man/ase-task-lifecycle.1 +57 -0
- package/dst/man/ase-task-list.1 +47 -0
- package/dst/man/ase-task-load.1 +36 -0
- package/dst/man/ase-task-newid.1 +61 -0
- package/dst/man/ase-task-purge.1 +37 -0
- package/dst/man/ase-task-rename.1 +44 -0
- package/dst/man/ase-task-request.1 +61 -0
- package/dst/man/ase-task-save.1 +48 -0
- package/dst/man/ase-task-status.1 +55 -0
- package/dst/man/ase-task-store.1 +194 -0
- package/dst/man/ase-task-view.1 +40 -0
- package/dst/man/ase-task.1 +160 -0
- package/dst/man/ase-util-compat.1 +25 -0
- package/dst/man/ase-util-diagram.1 +76 -0
- package/dst/man/ase-util-meta.1 +45 -0
- package/dst/man/ase-util-metric.1 +54 -0
- package/dst/man/ase-util-mint.1 +59 -0
- package/dst/man/ase-util-worktree.1 +72 -0
- package/dst/man/ase-util.1 +50 -0
- package/dst/man/ase.1 +124 -0
- package/package.json +79 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-meta-diagram.md +12 -8
- package/plugin/agents/ase-repo-review.md +322 -0
- package/plugin/hooks/hooks-codex.json +5 -0
- package/plugin/hooks/hooks-copilot.json +5 -0
- package/plugin/hooks/hooks.json +8 -0
- package/plugin/meta/ase-common-approach.md +117 -0
- package/plugin/meta/ase-common-changeset.md +256 -0
- package/plugin/meta/ase-common-code.md +10 -173
- package/plugin/meta/ase-common-grill.md +317 -0
- package/plugin/meta/ase-common-resolve.md +305 -0
- package/plugin/meta/ase-common-task.md +83 -4
- package/plugin/meta/ase-constitution.md +0 -2
- package/plugin/meta/ase-format-specbook.md +208 -25
- package/plugin/meta/ase-format-specbook.yaml +242 -851
- package/plugin/meta/ase-format-task.md +669 -125
- package/plugin/meta/ase-persona.md +3 -4
- package/plugin/meta/ase-skill.md +9 -0
- package/plugin/mods/ase-mods.ts +409 -0
- package/plugin/mods/claude-code.d.ts +13417 -0
- package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
- package/plugin/skills/ase-arch-analyze/help.md +2 -2
- package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
- package/plugin/skills/ase-code-analyze/help.md +29 -4
- package/plugin/skills/ase-code-edit/SKILL.md +107 -181
- package/plugin/skills/ase-code-edit/help.md +118 -31
- package/plugin/skills/ase-code-lint/SKILL.md +64 -13
- package/plugin/skills/ase-code-lint/help.md +32 -2
- package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
- package/plugin/skills/ase-docs-proofread/help.md +1 -1
- package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
- package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
- package/plugin/skills/ase-help-intent/data.md +2164 -1087
- package/plugin/skills/ase-help-intent/help.md +1 -1
- package/plugin/skills/ase-help-skill/catalog.md +12 -9
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
- package/plugin/skills/ase-meta-config/SKILL.md +3 -4
- package/plugin/skills/ase-meta-config/help.md +31 -10
- package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
- package/plugin/skills/ase-meta-mint/help.md +10 -5
- package/plugin/skills/ase-meta-why/help.md +1 -1
- package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
- package/plugin/skills/ase-meta-workflow/help.md +4 -4
- package/plugin/skills/ase-meta-workflow/sample.md +3 -3
- package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
- package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
- package/plugin/skills/ase-repo-merge/help.md +107 -0
- package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
- package/plugin/skills/ase-repo-resolve/help.md +102 -0
- package/plugin/skills/ase-repo-review/SKILL.md +406 -0
- package/plugin/skills/ase-repo-review/help.md +181 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
- package/plugin/skills/ase-spec-edit/help.md +107 -26
- package/plugin/skills/ase-sync-reconcile/help.md +1 -1
- package/plugin/skills/ase-task-assign/SKILL.md +111 -0
- package/plugin/skills/ase-task-assign/help.md +119 -0
- package/plugin/skills/ase-task-condense/SKILL.md +21 -21
- package/plugin/skills/ase-task-condense/help.md +12 -10
- package/plugin/skills/ase-task-delete/SKILL.md +1 -1
- package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
- package/plugin/skills/ase-task-dissect/help.md +10 -9
- package/plugin/skills/ase-task-edit/SKILL.md +379 -94
- package/plugin/skills/ase-task-edit/help.md +95 -22
- package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
- package/plugin/skills/ase-task-estimate/help.md +158 -0
- package/plugin/skills/ase-task-grill/SKILL.md +246 -138
- package/plugin/skills/ase-task-grill/help.md +129 -26
- package/plugin/skills/ase-task-id/SKILL.md +8 -1
- package/plugin/skills/ase-task-id/help.md +6 -3
- package/plugin/skills/ase-task-implement/SKILL.md +535 -145
- package/plugin/skills/ase-task-implement/help.md +138 -47
- package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
- package/plugin/skills/ase-task-integrate/help.md +187 -0
- package/plugin/skills/ase-task-list/SKILL.md +55 -23
- package/plugin/skills/ase-task-list/help.md +45 -21
- package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
- package/plugin/skills/ase-task-reboot/help.md +28 -8
- package/plugin/skills/ase-task-rename/SKILL.md +10 -2
- package/plugin/skills/ase-task-research/SKILL.md +386 -0
- package/plugin/skills/ase-task-research/help.md +102 -0
- package/plugin/skills/ase-task-status/SKILL.md +137 -0
- package/plugin/skills/ase-task-status/help.md +80 -0
- package/plugin/skills/ase-task-view/SKILL.md +49 -24
- package/plugin/skills/ase-task-view/help.md +19 -9
- package/plugin/agents/ase-meta-review.md +0 -187
- package/plugin/skills/ase-code-craft/SKILL.md +0 -264
- package/plugin/skills/ase-code-craft/help.md +0 -122
- package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
- package/plugin/skills/ase-code-refactor/help.md +0 -109
- package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
- package/plugin/skills/ase-code-resolve/help.md +0 -116
- package/plugin/skills/ase-meta-review/SKILL.md +0 -210
- package/plugin/skills/ase-meta-review/help.md +0 -96
- package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
- package/plugin/skills/ase-task-preflight/help.md +0 -82
- /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
- /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
- /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
- /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
- /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;
|