@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.
- 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 +230 -116
- 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,696 @@
|
|
|
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 { Octokit } from "@octokit/core";
|
|
7
|
+
import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
|
|
8
|
+
import { paginateRest } from "@octokit/plugin-paginate-rest";
|
|
9
|
+
import { LRUCache } from "lru-cache";
|
|
10
|
+
import * as TaskFormat from "./ase-task-format.js";
|
|
11
|
+
import * as TaskIssues from "./ase-task-store-issues.js";
|
|
12
|
+
/* the GitHub REST API client: the core with the endpoint methods and the pagination */
|
|
13
|
+
const GitHub = Octokit.plugin(restEndpointMethods, paginateRest);
|
|
14
|
+
/* the reserved labels, the task id mapping, and the timestamp of the common issue tracker parts */
|
|
15
|
+
const { LABEL_PROJECT, LABEL_DELETED, LABEL_KEY_RE, idOf, numberOf, stamp } = TaskIssues;
|
|
16
|
+
/* the "ase:Group:<id>" and "ase:After:<ids>" labels carrying the parent resp. blocking
|
|
17
|
+
issues where the GitHub instance lacks sub-issues resp. issue dependencies (like
|
|
18
|
+
older GitHub Enterprise Server versions), the latter's task ids separated by spaces */
|
|
19
|
+
const LABEL_RELATION_RE = /^ase:(Group|After):(.*)$/;
|
|
20
|
+
/* the REST API base URL of the web URL of a GitHub instance: GitHub itself,
|
|
21
|
+
GitHub Enterprise Cloud with data residency ("<sub>.ghe.com"), or GitHub
|
|
22
|
+
Enterprise Server (with its REST API below "/api/v3") */
|
|
23
|
+
const apiBaseOf = (base) => base.hostname.toLowerCase() === "github.com" ? "https://api.github.com" :
|
|
24
|
+
/\.ghe\.com$/i.test(base.hostname) ? `https://api.${base.host}` :
|
|
25
|
+
`${base.href.replace(/\/+$/, "")}/api/v3`;
|
|
26
|
+
/* the header keys mapped onto native issue fields (or derived from them),
|
|
27
|
+
hence never carried by "ase:<key>:<value>" labels */
|
|
28
|
+
const nativeKeys = ["Type", "Id", "Created", "Modified", "Group", "Phase", "After", "Tags", "Assignee"];
|
|
29
|
+
/* the current time as ISO timestamp in the second-precise shape of GitHub */
|
|
30
|
+
const now = () => new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
31
|
+
/* map a "not found" (404) or "gone" (410) request failure onto null */
|
|
32
|
+
const statusOf = (err) => err?.status;
|
|
33
|
+
const found = (p) => p.catch((err) => {
|
|
34
|
+
if (statusOf(err) === 404 || statusOf(err) === 410)
|
|
35
|
+
return null;
|
|
36
|
+
throw err;
|
|
37
|
+
});
|
|
38
|
+
/* the label names of an issue, and whether an issue is a live task (no pull request, not soft deleted) */
|
|
39
|
+
const labelNames = (issue) => issue.labels.map((label) => typeof label === "string" ? label : label.name ?? "");
|
|
40
|
+
const live = (issue) => (issue.pull_request === undefined || issue.pull_request === null) && !labelNames(issue).includes(LABEL_DELETED);
|
|
41
|
+
/* whether an owner/repo pair denotes the given repository, the parent issue number
|
|
42
|
+
of an issue within it (else null), and whether an issue belongs to it */
|
|
43
|
+
const sameRepo = (loc, owner, repo) => owner.toLowerCase() === loc.owner.toLowerCase() && repo.toLowerCase() === loc.repo.toLowerCase();
|
|
44
|
+
const parentOf = (loc, issue) => {
|
|
45
|
+
const m = /\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)$/.exec(issue.parent_issue_url ?? "");
|
|
46
|
+
return m !== null && sameRepo(loc, m[1], m[2]) ? Number.parseInt(m[3], 10) : null;
|
|
47
|
+
};
|
|
48
|
+
const inRepo = (loc, issue) => {
|
|
49
|
+
const m = /\/repos\/([^/]+)\/([^/]+)$/.exec(issue.repository_url);
|
|
50
|
+
return m !== null && sameRepo(loc, m[1], m[2]);
|
|
51
|
+
};
|
|
52
|
+
/* the GitHub storage plugin: a project is a repository (registered by its
|
|
53
|
+
"ase:project" label) and a task plan is a live issue, its id being the issue
|
|
54
|
+
number rendered through the mandatory "seq" task id scheme; the title maps onto
|
|
55
|
+
the issue title (and the "# TASK:" heading), the body onto the issue body,
|
|
56
|
+
"Status" onto the issue state refined by an "ase:Status:<state>" label, "Tags"
|
|
57
|
+
onto labels, "Assignee" onto the assignee, "Phase" onto the milestone, "Group"
|
|
58
|
+
onto the parent issue, "After" onto the blocking issues, "Created"/"Modified"
|
|
59
|
+
onto the issue timestamps, any other key onto "ase:<key>:<value>" labels, and
|
|
60
|
+
the attachments onto the issue comments (where "Group" and "After" fall back
|
|
61
|
+
onto "ase:Group:<id>" resp. "ase:After:<ids>" labels on instances without
|
|
62
|
+
sub-issues resp. issue dependencies) */
|
|
63
|
+
class GitHubTaskStoragePlugin {
|
|
64
|
+
ctx;
|
|
65
|
+
name = "github";
|
|
66
|
+
url;
|
|
67
|
+
gh;
|
|
68
|
+
repos = new Map();
|
|
69
|
+
poll;
|
|
70
|
+
listener = null;
|
|
71
|
+
timer = null;
|
|
72
|
+
polling = false;
|
|
73
|
+
opened = now();
|
|
74
|
+
polls = new Map();
|
|
75
|
+
registry = new LRUCache({ max: 64, ttl: 60 * 1000 });
|
|
76
|
+
latest = new Map();
|
|
77
|
+
labels = new Map();
|
|
78
|
+
milestones = new Map();
|
|
79
|
+
unsupported = new Map();
|
|
80
|
+
recent = new Map();
|
|
81
|
+
loads = new LRUCache({ max: 256, ttl: 2 * 1000 });
|
|
82
|
+
constructor(ctx) {
|
|
83
|
+
this.ctx = ctx;
|
|
84
|
+
const options = ctx.options;
|
|
85
|
+
const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
|
|
86
|
+
if (typeof token !== "string" || token === "")
|
|
87
|
+
throw new Error("task store: plugin \"github\" requires the \"token\" option (or $GITHUB_TOKEN resp. $GH_TOKEN)");
|
|
88
|
+
const url = options.url ?? "https://github.com";
|
|
89
|
+
let base = null;
|
|
90
|
+
try {
|
|
91
|
+
base = new URL(typeof url === "string" && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? `https://${url}` : url);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* reported below */
|
|
95
|
+
}
|
|
96
|
+
if (base === null || !/^https?:$/.test(base.protocol) || base.search !== "" || base.hash !== "")
|
|
97
|
+
throw new Error(`task store: plugin "github" received invalid URL "${String(url)}" of the GitHub instance`);
|
|
98
|
+
this.url = base.href.replace(/\/+$/, "");
|
|
99
|
+
const apiBase = apiBaseOf(base);
|
|
100
|
+
if (typeof options.repos !== "object" || options.repos === null || Array.isArray(options.repos))
|
|
101
|
+
throw new Error("task store: plugin \"github\" requires the \"repos\" option (mapping project ids onto \"<owner>/<repo>\")");
|
|
102
|
+
for (const [prjId, spec] of Object.entries(options.repos)) {
|
|
103
|
+
const m = typeof spec === "string" ? /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(spec) : null;
|
|
104
|
+
if (m === null || !TaskFormat.ID_RE.test(prjId))
|
|
105
|
+
throw new Error(`task store: plugin "github" received invalid repository mapping "${prjId}: ${String(spec)}"`);
|
|
106
|
+
this.repos.set(prjId, { owner: m[1], repo: m[2] });
|
|
107
|
+
}
|
|
108
|
+
this.poll = options.poll ?? 60;
|
|
109
|
+
if (typeof this.poll !== "number" || !Number.isFinite(this.poll) || (this.poll !== 0 && (this.poll < 1 || this.poll > 86400)))
|
|
110
|
+
throw new Error("task store: plugin \"github\" requires a \"poll\" interval of 0 (disabled) resp. 1 to 86400 seconds");
|
|
111
|
+
/* route the diagnostics of Octokit into the log, as the standard
|
|
112
|
+
output may carry a protocol (like the one of an MCP server) */
|
|
113
|
+
this.gh = new GitHub({
|
|
114
|
+
auth: token,
|
|
115
|
+
baseUrl: apiBase,
|
|
116
|
+
userAgent: "ase-task-store-github",
|
|
117
|
+
log: {
|
|
118
|
+
debug: () => { },
|
|
119
|
+
info: () => { },
|
|
120
|
+
warn: (message) => ctx.log("warning", message),
|
|
121
|
+
error: (message) => ctx.log("error", message)
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
/* request the current REST API version, as the default one ("2022-11-28") is deprecated
|
|
125
|
+
(its breaking changes do not affect this plugin, as it uses "assignees" only), except
|
|
126
|
+
on a GitHub Enterprise Server, which answers an unsupported version with "410" */
|
|
127
|
+
if (!apiBase.endsWith("/api/v3"))
|
|
128
|
+
this.gh.hook.before("request", (request) => {
|
|
129
|
+
request.headers["x-github-api-version"] = "2026-03-10";
|
|
130
|
+
});
|
|
131
|
+
/* explain a denied access (but not an exceeded rate limit), as the
|
|
132
|
+
GitHub message names neither the token source nor the missing permission */
|
|
133
|
+
const source = options.token !== undefined ? "the \"token\" option (resp. \"project.task.store.token\")" :
|
|
134
|
+
process.env.GITHUB_TOKEN !== undefined ? "$GITHUB_TOKEN" : "$GH_TOKEN";
|
|
135
|
+
this.gh.hook.error("request", (err, request) => {
|
|
136
|
+
const status = statusOf(err);
|
|
137
|
+
if ((status === 401 || status === 403) && !/rate limit/i.test(err.message)) {
|
|
138
|
+
const repo = typeof request.owner === "string" && typeof request.repo === "string" ?
|
|
139
|
+
` to repository "${request.owner}/${request.repo}"` : "";
|
|
140
|
+
throw new Error(`GitHub denied access${repo} (HTTP ${String(status)}: ${err.message}) -- ` +
|
|
141
|
+
`the token of ${source} needs access to the repository with the permission ` +
|
|
142
|
+
"\"Issues: Read and write\" (fine-grained token) resp. the scope \"repo\" or \"public_repo\" (classic token)", { cause: err });
|
|
143
|
+
}
|
|
144
|
+
throw err;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/* the storage lifecycle: polling for changes while opened and observed
|
|
148
|
+
(without keeping the process alive) */
|
|
149
|
+
async open() {
|
|
150
|
+
this.opened = now();
|
|
151
|
+
if (this.poll > 0 && this.listener !== null) {
|
|
152
|
+
this.timer = setInterval(() => {
|
|
153
|
+
this.pollAll().catch(() => { });
|
|
154
|
+
}, this.poll * 1000);
|
|
155
|
+
this.timer.unref();
|
|
156
|
+
}
|
|
157
|
+
this.ctx.log("debug", `opened ${this.repos.size} repositories on ${this.url} (polling every ${this.poll}s)`);
|
|
158
|
+
}
|
|
159
|
+
async close() {
|
|
160
|
+
if (this.timer !== null)
|
|
161
|
+
clearInterval(this.timer);
|
|
162
|
+
this.timer = null;
|
|
163
|
+
}
|
|
164
|
+
/* observe the changes of the issues made outside of this plugin instance */
|
|
165
|
+
watch(listener) {
|
|
166
|
+
this.listener = listener;
|
|
167
|
+
}
|
|
168
|
+
/* ==== project registry ==== */
|
|
169
|
+
/* the repository of a project */
|
|
170
|
+
repo(prjId) {
|
|
171
|
+
const loc = this.repos.get(prjId);
|
|
172
|
+
if (loc === undefined)
|
|
173
|
+
throw new Error(`project "${prjId}" is not mapped onto a GitHub repository (see option "repos")`);
|
|
174
|
+
return loc;
|
|
175
|
+
}
|
|
176
|
+
/* the registry entry of a project, read from the description of its "ase:project" label */
|
|
177
|
+
async registered(prjId) {
|
|
178
|
+
const cached = this.registry.get(prjId);
|
|
179
|
+
if (cached !== undefined)
|
|
180
|
+
return cached.value;
|
|
181
|
+
const loc = this.repos.get(prjId);
|
|
182
|
+
let value = null;
|
|
183
|
+
if (loc !== undefined) {
|
|
184
|
+
const label = await found(this.gh.rest.issues.getLabel({ ...loc, name: LABEL_PROJECT }));
|
|
185
|
+
if (label !== null)
|
|
186
|
+
value = TaskIssues.registryEntry(label.data.description);
|
|
187
|
+
}
|
|
188
|
+
this.registry.set(prjId, { value });
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
/* the repository, lifecycle model, and task id scheme of a registered project */
|
|
192
|
+
async context(prjId) {
|
|
193
|
+
const loc = this.repo(prjId);
|
|
194
|
+
const entry = await this.registered(prjId);
|
|
195
|
+
if (entry === null)
|
|
196
|
+
throw new Error(`project "${prjId}" not registered`);
|
|
197
|
+
return { loc, ...TaskIssues.registryContext(prjId, entry, "GitHub") };
|
|
198
|
+
}
|
|
199
|
+
/* the highest issue or pull request number of a repository (conditionally requested) */
|
|
200
|
+
async latestNumber(loc, fresh = false) {
|
|
201
|
+
const key = `${loc.owner}/${loc.repo}`;
|
|
202
|
+
const cached = this.latest.get(key);
|
|
203
|
+
/* reuse a number determined within the last 2 seconds (unless a fresh one
|
|
204
|
+
is required), as every single task store operation asks for it */
|
|
205
|
+
if (!fresh && cached !== undefined && Date.now() - cached.at < 2 * 1000)
|
|
206
|
+
return cached.number;
|
|
207
|
+
try {
|
|
208
|
+
const r = await this.gh.request("GET /repos/{owner}/{repo}/issues", {
|
|
209
|
+
...loc, state: "all", sort: "created", direction: "desc", per_page: 1,
|
|
210
|
+
headers: cached !== undefined ? { "if-none-match": cached.etag } : {}
|
|
211
|
+
});
|
|
212
|
+
const number = r.data[0]?.number ?? 0;
|
|
213
|
+
if (r.headers.etag !== undefined)
|
|
214
|
+
this.latest.set(key, { etag: r.headers.etag, number, at: Date.now() });
|
|
215
|
+
return number;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (cached !== undefined && statusOf(err) === 304) {
|
|
219
|
+
cached.at = Date.now();
|
|
220
|
+
return cached.number;
|
|
221
|
+
}
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async projectList() {
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const prjId of this.repos.keys()) {
|
|
228
|
+
const entry = await this.projectGet(prjId);
|
|
229
|
+
if (entry !== null)
|
|
230
|
+
out.push(entry);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/* get a project, with the highest issue number as the sequence number high-water
|
|
235
|
+
mark, so the next allocated task id is the one of the next created issue */
|
|
236
|
+
async projectGet(prjId) {
|
|
237
|
+
const entry = await this.registered(prjId);
|
|
238
|
+
if (entry === null)
|
|
239
|
+
return null;
|
|
240
|
+
return { id: prjId, ...entry, seqmark: await this.latestNumber(this.repo(prjId)) };
|
|
241
|
+
}
|
|
242
|
+
async projectSet(prjId, lifecycle, idscheme) {
|
|
243
|
+
const loc = this.repo(prjId);
|
|
244
|
+
TaskIssues.requireSeqScheme(idscheme, "GitHub");
|
|
245
|
+
/* spare the requests of an unchanged (cached) registration, as the
|
|
246
|
+
in-process clients re-register the project on every opening */
|
|
247
|
+
const entry = await this.registered(prjId);
|
|
248
|
+
if (entry !== null && entry.lifecycle === lifecycle && entry.idscheme === idscheme)
|
|
249
|
+
return "updated";
|
|
250
|
+
const description = TaskIssues.registryDescription({ lifecycle, idscheme });
|
|
251
|
+
if (entry === null)
|
|
252
|
+
await this.gh.rest.issues.createLabel({ ...loc, name: LABEL_PROJECT, color: "5319e7", description });
|
|
253
|
+
else
|
|
254
|
+
await this.gh.rest.issues.updateLabel({ ...loc, name: LABEL_PROJECT, description });
|
|
255
|
+
this.registry.set(prjId, { value: { lifecycle, idscheme } });
|
|
256
|
+
return entry === null ? "created" : "updated";
|
|
257
|
+
}
|
|
258
|
+
async projectDelete(prjId) {
|
|
259
|
+
const loc = this.repos.get(prjId);
|
|
260
|
+
if (loc === undefined)
|
|
261
|
+
return false;
|
|
262
|
+
const result = await found(this.gh.rest.issues.deleteLabel({ ...loc, name: LABEL_PROJECT }));
|
|
263
|
+
this.registry.delete(prjId);
|
|
264
|
+
return result !== null;
|
|
265
|
+
}
|
|
266
|
+
/* ==== issue mapping ==== */
|
|
267
|
+
/* fetch the live issue of an issue number (null if none) */
|
|
268
|
+
async fetch(loc, n) {
|
|
269
|
+
if (n === 0)
|
|
270
|
+
return null;
|
|
271
|
+
const issue = await found(this.gh.rest.issues.get({ ...loc, issue_number: n }).then((r) => r.data));
|
|
272
|
+
return issue !== null && issue.number === n && inRepo(loc, issue) && live(issue) ? issue : null;
|
|
273
|
+
}
|
|
274
|
+
/* the blocking issues of an issue within its repository */
|
|
275
|
+
async blockers(loc, issue) {
|
|
276
|
+
if ((issue.issue_dependencies_summary?.total_blocked_by ?? 0) === 0)
|
|
277
|
+
return [];
|
|
278
|
+
const blockers = await this.gh.paginate(this.gh.rest.issues.listDependenciesBlockedBy, { ...loc, issue_number: issue.number, per_page: 100 });
|
|
279
|
+
return blockers.filter((blocker) => inRepo(loc, blocker));
|
|
280
|
+
}
|
|
281
|
+
/* the comments of an issue */
|
|
282
|
+
async comments(loc, issue) {
|
|
283
|
+
if (issue.comments === 0)
|
|
284
|
+
return [];
|
|
285
|
+
return await this.gh.paginate(this.gh.rest.issues.listComments, { ...loc, issue_number: issue.number, per_page: 100 });
|
|
286
|
+
}
|
|
287
|
+
/* derive the header of an issue: the natively mapped keys (with "Group" and
|
|
288
|
+
"After" also from their fallback labels), "Status" from the
|
|
289
|
+
issue state (closed for the finished states, "not_planned" for "CANCELLED")
|
|
290
|
+
refined by its "ase:Status:<state>" label, and all other keys from their
|
|
291
|
+
"ase:<key>:<value>" labels */
|
|
292
|
+
header(loc, lifecycle, scheme, issue, after) {
|
|
293
|
+
const header = {
|
|
294
|
+
Type: TaskFormat.TASK_TYPE,
|
|
295
|
+
Id: idOf(scheme, issue.number),
|
|
296
|
+
Created: stamp(issue.created_at),
|
|
297
|
+
Modified: stamp(issue.updated_at)
|
|
298
|
+
};
|
|
299
|
+
const parent = parentOf(loc, issue);
|
|
300
|
+
if (parent !== null)
|
|
301
|
+
header.Group = idOf(scheme, parent);
|
|
302
|
+
if (issue.milestone !== null)
|
|
303
|
+
header.Phase = issue.milestone.title;
|
|
304
|
+
const ids = after.map((blocker) => idOf(scheme, blocker.number));
|
|
305
|
+
for (const name of labelNames(issue)) {
|
|
306
|
+
const m = LABEL_RELATION_RE.exec(name);
|
|
307
|
+
if (m !== null && m[1] === "Group" && header.Group === undefined)
|
|
308
|
+
header.Group = m[2];
|
|
309
|
+
else if (m !== null && m[1] === "After")
|
|
310
|
+
ids.push(...m[2].split(/\s+/).filter((id) => id !== "" && !ids.includes(id)));
|
|
311
|
+
}
|
|
312
|
+
if (ids.length > 0)
|
|
313
|
+
header.After = ids;
|
|
314
|
+
const status = TaskIssues.labelHeader(header, labelNames(issue), nativeKeys);
|
|
315
|
+
const assignee = issue.assignees?.[0]?.login;
|
|
316
|
+
if (assignee !== undefined)
|
|
317
|
+
header.Assignee = assignee;
|
|
318
|
+
const derived = TaskIssues.issueStatus(lifecycle, issue.state === "closed", issue.state_reason === "not_planned", status);
|
|
319
|
+
if (derived !== undefined)
|
|
320
|
+
header.Status = derived;
|
|
321
|
+
return header;
|
|
322
|
+
}
|
|
323
|
+
/* derive the attachment of a comment (see the common issue tracker parts) */
|
|
324
|
+
attachment(comment) {
|
|
325
|
+
return TaskIssues.commentAttachment(comment.body, comment.user?.login ?? "ghost", comment.created_at, comment.updated_at);
|
|
326
|
+
}
|
|
327
|
+
/* the listing entry of an issue */
|
|
328
|
+
async entry(loc, lifecycle, scheme, issue) {
|
|
329
|
+
const header = this.header(loc, lifecycle, scheme, issue, await this.blockers(loc, issue));
|
|
330
|
+
return { id: idOf(scheme, issue.number), title: issue.title, header, mtime: new Date(issue.updated_at) };
|
|
331
|
+
}
|
|
332
|
+
/* ==== issue updating ==== */
|
|
333
|
+
/* ensure the existence of labels (with the known labels of a repository cached) */
|
|
334
|
+
async ensureLabels(loc, names) {
|
|
335
|
+
const key = `${loc.owner}/${loc.repo}`;
|
|
336
|
+
let known = this.labels.get(key);
|
|
337
|
+
if (known === undefined) {
|
|
338
|
+
const labels = await this.gh.paginate(this.gh.rest.issues.listLabelsForRepo, { ...loc, per_page: 100 });
|
|
339
|
+
known = new Set(labels.map((label) => label.name.toLowerCase()));
|
|
340
|
+
this.labels.set(key, known);
|
|
341
|
+
}
|
|
342
|
+
for (const name of names) {
|
|
343
|
+
if (known.has(name.toLowerCase()))
|
|
344
|
+
continue;
|
|
345
|
+
await this.gh.rest.issues.createLabel({ ...loc, name, color: name.startsWith("ase:") ? "c5def5" : "ededed" })
|
|
346
|
+
.catch((err) => {
|
|
347
|
+
/* tolerate a label created concurrently */
|
|
348
|
+
if (statusOf(err) !== 422)
|
|
349
|
+
throw err;
|
|
350
|
+
});
|
|
351
|
+
known.add(name.toLowerCase());
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/* delete the "ase:<key>:<value>" labels no longer used by any issue */
|
|
355
|
+
async collectLabels(loc, names) {
|
|
356
|
+
for (const name of names) {
|
|
357
|
+
if (!LABEL_KEY_RE.test(name) || name.includes(","))
|
|
358
|
+
continue;
|
|
359
|
+
const r = await this.gh.rest.issues.listForRepo({ ...loc, labels: name, state: "all", per_page: 1 });
|
|
360
|
+
if (r.data.length === 0) {
|
|
361
|
+
await found(this.gh.rest.issues.deleteLabel({ ...loc, name }));
|
|
362
|
+
this.labels.get(`${loc.owner}/${loc.repo}`)?.delete(name.toLowerCase());
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/* the number of a milestone by its title (created on demand) */
|
|
367
|
+
async milestone(loc, title) {
|
|
368
|
+
const key = `${loc.owner}/${loc.repo}`;
|
|
369
|
+
let map = this.milestones.get(key);
|
|
370
|
+
if (map === undefined) {
|
|
371
|
+
const milestones = await this.gh.paginate(this.gh.rest.issues.listMilestones, { ...loc, state: "all", per_page: 100 });
|
|
372
|
+
map = new Map(milestones.map((milestone) => [milestone.title, milestone.number]));
|
|
373
|
+
this.milestones.set(key, map);
|
|
374
|
+
}
|
|
375
|
+
let number = map.get(title);
|
|
376
|
+
if (number === undefined) {
|
|
377
|
+
number = (await this.gh.rest.issues.createMilestone({ ...loc, title })).data.number;
|
|
378
|
+
map.set(title, number);
|
|
379
|
+
}
|
|
380
|
+
return number;
|
|
381
|
+
}
|
|
382
|
+
/* the live issue a header key references by its task id, else an error */
|
|
383
|
+
async target(loc, scheme, key, id) {
|
|
384
|
+
const issue = await this.fetch(loc, numberOf(scheme, id));
|
|
385
|
+
if (issue === null)
|
|
386
|
+
throw new Error(`header key "${key}" references no existing task "${id}"`);
|
|
387
|
+
return issue;
|
|
388
|
+
}
|
|
389
|
+
/* determine the labels and assignees of a header: the tags, the "ase:<key>:<value>"
|
|
390
|
+
labels of the keys without native counterpart, and the assignee if it can be
|
|
391
|
+
assigned natively (keeping further native assignees), else its label */
|
|
392
|
+
async assign(loc, header, issue) {
|
|
393
|
+
const labels = TaskIssues.headerLabels(header, nativeKeys);
|
|
394
|
+
let assignees = [];
|
|
395
|
+
const assignee = header.Assignee;
|
|
396
|
+
if (typeof assignee === "string" && assignee !== "") {
|
|
397
|
+
const logins = issue?.assignees?.map((user) => user.login) ?? [];
|
|
398
|
+
if (logins.includes(assignee))
|
|
399
|
+
assignees = logins;
|
|
400
|
+
else if (await found(this.gh.rest.issues.checkUserCanBeAssigned({ ...loc, assignee })) !== null)
|
|
401
|
+
assignees = [assignee];
|
|
402
|
+
else
|
|
403
|
+
labels.push(`ase:Assignee:${assignee}`);
|
|
404
|
+
}
|
|
405
|
+
await this.ensureLabels(loc, labels);
|
|
406
|
+
return { labels, assignees };
|
|
407
|
+
}
|
|
408
|
+
/* run a relation request natively; returns false if the GitHub instance lacks the
|
|
409
|
+
relation (answering "404", remembered per repository, e.g. on older GitHub
|
|
410
|
+
Enterprise Server versions), so the relation has to fall back onto its label */
|
|
411
|
+
async native(loc, relation, request) {
|
|
412
|
+
const key = `${loc.owner}/${loc.repo}`;
|
|
413
|
+
let unsupported = this.unsupported.get(key);
|
|
414
|
+
if (unsupported?.has(relation))
|
|
415
|
+
return false;
|
|
416
|
+
try {
|
|
417
|
+
await request();
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (statusOf(err) !== 404)
|
|
422
|
+
throw err;
|
|
423
|
+
if (unsupported === undefined) {
|
|
424
|
+
unsupported = new Set();
|
|
425
|
+
this.unsupported.set(key, unsupported);
|
|
426
|
+
}
|
|
427
|
+
unsupported.add(relation);
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/* synchronize the parent issue (keeping a parent of a foreign repository if none is
|
|
432
|
+
wanted); returns whether it is carried natively */
|
|
433
|
+
async syncParent(loc, issue, parent) {
|
|
434
|
+
const current = parentOf(loc, issue);
|
|
435
|
+
if (parent !== null && parent.number !== current)
|
|
436
|
+
return this.native(loc, "Group", () => this.gh.rest.issues.addSubIssue({ ...loc, issue_number: parent.number, sub_issue_id: issue.id, replace_parent: true }));
|
|
437
|
+
else if (parent === null && current !== null)
|
|
438
|
+
await this.gh.rest.issues.removeSubIssue({ ...loc, issue_number: current, sub_issue_id: issue.id });
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
/* synchronize the blocking issues (within the repository); returns whether
|
|
442
|
+
all of them are carried natively */
|
|
443
|
+
async syncBlockers(loc, issue, after) {
|
|
444
|
+
const current = await this.blockers(loc, issue);
|
|
445
|
+
const have = new Set(current.map((blocker) => blocker.number));
|
|
446
|
+
const want = new Set(after.map((blocker) => blocker.number));
|
|
447
|
+
let native = true;
|
|
448
|
+
for (const blocker of after)
|
|
449
|
+
if (!have.has(blocker.number))
|
|
450
|
+
native = await this.native(loc, "After", () => this.gh.rest.issues.addBlockedByDependency({ ...loc, issue_number: issue.number, issue_id: blocker.id })) && native;
|
|
451
|
+
for (const blocker of current)
|
|
452
|
+
if (!want.has(blocker.number))
|
|
453
|
+
await this.gh.rest.issues.removeDependencyBlockedBy({ ...loc, issue_number: issue.number, issue_id: blocker.id });
|
|
454
|
+
return native;
|
|
455
|
+
}
|
|
456
|
+
/* synchronize the comments with the attachments, touching changed ones only
|
|
457
|
+
(see the common issue tracker parts) */
|
|
458
|
+
async syncComments(loc, issue, attachments) {
|
|
459
|
+
const comments = await this.comments(loc, issue);
|
|
460
|
+
const ops = TaskIssues.attachmentSync(comments.map((comment) => this.attachment(comment)), attachments);
|
|
461
|
+
for (const op of ops) {
|
|
462
|
+
if (op.op === "delete")
|
|
463
|
+
await this.gh.rest.issues.deleteComment({ ...loc, comment_id: comments[op.index].id });
|
|
464
|
+
else if (op.op === "create")
|
|
465
|
+
await this.gh.rest.issues.createComment({ ...loc, issue_number: issue.number, body: TaskIssues.attachmentComment(op.attachment) });
|
|
466
|
+
else
|
|
467
|
+
await this.gh.rest.issues.updateComment({ ...loc, comment_id: comments[op.index].id, body: TaskIssues.attachmentComment(op.attachment) });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/* remember an issue written by this plugin instance: its update time, so the change
|
|
471
|
+
detection does not report it as an external change, and the issue itself, so the
|
|
472
|
+
listings (which lag behind the writes on GitHub) show it immediately */
|
|
473
|
+
written(prjId, issue) {
|
|
474
|
+
this.pollState(prjId).seen.set(issue.number, issue.updated_at);
|
|
475
|
+
this.loads.delete(`${prjId}#${issue.number}`);
|
|
476
|
+
let recent = this.recent.get(prjId);
|
|
477
|
+
if (recent === undefined) {
|
|
478
|
+
recent = new Map();
|
|
479
|
+
this.recent.set(prjId, recent);
|
|
480
|
+
}
|
|
481
|
+
recent.set(issue.number, { issue, at: Date.now() });
|
|
482
|
+
}
|
|
483
|
+
/* overlay the recently written issues (of the last 60 seconds) onto a listing,
|
|
484
|
+
where the listed issue is not yet present or older */
|
|
485
|
+
overlay(prjId, issues) {
|
|
486
|
+
const recent = this.recent.get(prjId);
|
|
487
|
+
if (recent === undefined)
|
|
488
|
+
return issues;
|
|
489
|
+
const byNumber = new Map(issues.map((issue) => [issue.number, issue]));
|
|
490
|
+
for (const [n, { issue, at }] of recent) {
|
|
491
|
+
const listed = byNumber.get(n);
|
|
492
|
+
if (Date.now() - at > 60 * 1000)
|
|
493
|
+
recent.delete(n);
|
|
494
|
+
else if (listed === undefined || listed.updated_at < issue.updated_at)
|
|
495
|
+
byNumber.set(n, issue);
|
|
496
|
+
}
|
|
497
|
+
return [...byNumber.values()];
|
|
498
|
+
}
|
|
499
|
+
/* load a live issue together with its blocking issues and comments (the issue and
|
|
500
|
+
its comments in parallel), reusing a load of the last 2 seconds (dropped on any
|
|
501
|
+
own write or detected change of the issue), as e.g. the boards load the plan
|
|
502
|
+
and its attachments in separate operations */
|
|
503
|
+
load(prjId, loc, n) {
|
|
504
|
+
const key = `${prjId}#${n}`;
|
|
505
|
+
const cached = this.loads.get(key);
|
|
506
|
+
if (cached !== undefined)
|
|
507
|
+
return cached;
|
|
508
|
+
const load = (async () => {
|
|
509
|
+
const [issue, comments] = await Promise.all([
|
|
510
|
+
this.fetch(loc, n),
|
|
511
|
+
n === 0 ? [] : found(this.gh.paginate(this.gh.rest.issues.listComments, { ...loc, issue_number: n, per_page: 100 }))
|
|
512
|
+
]);
|
|
513
|
+
if (issue === null)
|
|
514
|
+
return null;
|
|
515
|
+
return { issue, after: await this.blockers(loc, issue), comments: comments ?? [] };
|
|
516
|
+
})();
|
|
517
|
+
this.loads.set(key, load);
|
|
518
|
+
load.catch(() => {
|
|
519
|
+
this.loads.delete(key);
|
|
520
|
+
});
|
|
521
|
+
return load;
|
|
522
|
+
}
|
|
523
|
+
/* ==== task plans ==== */
|
|
524
|
+
async taskList(prjId) {
|
|
525
|
+
const { loc, lifecycle, scheme } = await this.context(prjId);
|
|
526
|
+
const issues = this.overlay(prjId, await this.gh.paginate(this.gh.rest.issues.listForRepo, { ...loc, state: "all", per_page: 100 }));
|
|
527
|
+
return Promise.all(issues.filter((issue) => live(issue))
|
|
528
|
+
.map((issue) => this.entry(loc, lifecycle, scheme, issue)));
|
|
529
|
+
}
|
|
530
|
+
async taskLoad(prjId, taskId) {
|
|
531
|
+
const { loc, lifecycle, scheme } = await this.context(prjId);
|
|
532
|
+
const loaded = await this.load(prjId, loc, numberOf(scheme, taskId));
|
|
533
|
+
if (loaded === null)
|
|
534
|
+
return null;
|
|
535
|
+
const { issue, after, comments } = loaded;
|
|
536
|
+
return {
|
|
537
|
+
header: this.header(loc, lifecycle, scheme, issue, after),
|
|
538
|
+
body: TaskIssues.issueBody(issue.title, issue.body),
|
|
539
|
+
attachment: comments.map((comment) => this.attachment(comment))
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/* create or update the issue of a task plan: all references are resolved
|
|
543
|
+
upfront, a new task can only be the next issue number, and the issue itself
|
|
544
|
+
is updated last, so its update time covers the relation and comment changes */
|
|
545
|
+
async taskSave(prjId, taskId, plan) {
|
|
546
|
+
const { loc, lifecycle, scheme } = await this.context(prjId);
|
|
547
|
+
const number = numberOf(scheme, taskId);
|
|
548
|
+
if (number === 0)
|
|
549
|
+
throw new Error(`task id "${taskId}" is no issue number rendered through the "seq" task id scheme of the project`);
|
|
550
|
+
let issue = await this.fetch(loc, number);
|
|
551
|
+
const result = issue !== null ? "updated" : "created";
|
|
552
|
+
const group = plan.header.Group;
|
|
553
|
+
const parent = typeof group === "string" && group !== "" ? await this.target(loc, scheme, "Group", group) : null;
|
|
554
|
+
const after = await Promise.all((Array.isArray(plan.header.After) ? plan.header.After : [])
|
|
555
|
+
.map((id) => this.target(loc, scheme, "After", id)));
|
|
556
|
+
const phase = plan.header.Phase;
|
|
557
|
+
const milestone = typeof phase === "string" && phase !== "" ? await this.milestone(loc, phase) : null;
|
|
558
|
+
const { labels, assignees } = await this.assign(loc, plan.header, issue);
|
|
559
|
+
const status = TaskFormat.taskStatus(plan.header, lifecycle);
|
|
560
|
+
const closed = lifecycle.finished.includes(status);
|
|
561
|
+
const { title, body } = TaskIssues.planIssue(taskId, plan);
|
|
562
|
+
if (issue === null) {
|
|
563
|
+
/* reject a task id deviating from the next issue number, and
|
|
564
|
+
discard an issue which lost the race for this number */
|
|
565
|
+
const next = await this.latestNumber(loc, true) + 1;
|
|
566
|
+
if (number !== next)
|
|
567
|
+
throw new Error(`task "${taskId}" cannot be created, as the next issue will be "${idOf(scheme, next)}"`);
|
|
568
|
+
issue = (await this.gh.rest.issues.create({ ...loc, title, body, labels, assignees, milestone })).data;
|
|
569
|
+
if (issue.number !== number) {
|
|
570
|
+
await this.ensureLabels(loc, [LABEL_DELETED]);
|
|
571
|
+
await this.gh.rest.issues.update({
|
|
572
|
+
...loc, issue_number: issue.number, state: "closed", state_reason: "not_planned",
|
|
573
|
+
labels: [...labels, LABEL_DELETED]
|
|
574
|
+
});
|
|
575
|
+
throw new Error(`task "${taskId}" cannot be created, as issue "${idOf(scheme, number)}" was created concurrently`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/* fall back onto the labels of the relations not carried natively */
|
|
579
|
+
const fallback = [];
|
|
580
|
+
if (!await this.syncParent(loc, issue, parent) && parent !== null)
|
|
581
|
+
fallback.push(`ase:Group:${idOf(scheme, parent.number)}`);
|
|
582
|
+
if (!await this.syncBlockers(loc, issue, after))
|
|
583
|
+
fallback.push(`ase:After:${after.map((blocker) => idOf(scheme, blocker.number)).join(" ")}`);
|
|
584
|
+
for (const name of fallback)
|
|
585
|
+
if (name.length > 50)
|
|
586
|
+
throw new Error(`label "${name}" cannot be carried by GitHub, as it exceeds 50 characters`);
|
|
587
|
+
await this.ensureLabels(loc, fallback);
|
|
588
|
+
labels.push(...fallback);
|
|
589
|
+
await this.syncComments(loc, issue, plan.attachment);
|
|
590
|
+
const updated = (await this.gh.rest.issues.update({
|
|
591
|
+
...loc, issue_number: number, title, body, labels, assignees, milestone,
|
|
592
|
+
state: closed ? "closed" : "open",
|
|
593
|
+
...(closed ? { state_reason: status === "CANCELLED" ? "not_planned" : "completed" } : {})
|
|
594
|
+
})).data;
|
|
595
|
+
this.written(prjId, updated);
|
|
596
|
+
await this.collectLabels(loc, labelNames(issue).filter((name) => !labels.includes(name)));
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
/* soft delete the issue of a task plan: closed as "not planned" and marked as deleted */
|
|
600
|
+
async taskDelete(prjId, taskId) {
|
|
601
|
+
const { loc, scheme } = await this.context(prjId);
|
|
602
|
+
const issue = await this.fetch(loc, numberOf(scheme, taskId));
|
|
603
|
+
if (issue === null)
|
|
604
|
+
return false;
|
|
605
|
+
await this.ensureLabels(loc, [LABEL_DELETED]);
|
|
606
|
+
const updated = (await this.gh.rest.issues.update({
|
|
607
|
+
...loc, issue_number: issue.number, state: "closed", state_reason: "not_planned",
|
|
608
|
+
labels: [...labelNames(issue), LABEL_DELETED]
|
|
609
|
+
})).data;
|
|
610
|
+
this.written(prjId, updated);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
/* renaming is impossible, as the task id is the issue number */
|
|
614
|
+
async taskRename(prjId, oldId, newId) {
|
|
615
|
+
const { loc, scheme } = await this.context(prjId);
|
|
616
|
+
if (await this.fetch(loc, numberOf(scheme, oldId)) === null)
|
|
617
|
+
return false;
|
|
618
|
+
throw new Error(`task "${oldId}" cannot be renamed to "${newId}", as task ids are GitHub issue numbers`);
|
|
619
|
+
}
|
|
620
|
+
/* ==== change detection ==== */
|
|
621
|
+
/* the change detection state of a project (starting at the opening time) */
|
|
622
|
+
pollState(prjId) {
|
|
623
|
+
let state = this.polls.get(prjId);
|
|
624
|
+
if (state === undefined) {
|
|
625
|
+
state = { since: this.opened, seen: new Map() };
|
|
626
|
+
this.polls.set(prjId, state);
|
|
627
|
+
}
|
|
628
|
+
return state;
|
|
629
|
+
}
|
|
630
|
+
/* poll all registered projects, never overlapping itself */
|
|
631
|
+
async pollAll() {
|
|
632
|
+
if (this.polling)
|
|
633
|
+
return;
|
|
634
|
+
this.polling = true;
|
|
635
|
+
try {
|
|
636
|
+
for (const prjId of this.repos.keys())
|
|
637
|
+
await this.pollProject(prjId).catch((err) => {
|
|
638
|
+
this.ctx.log("warning", `polling project "${prjId}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
this.polling = false;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/* poll the issues of a project updated since the last poll, conditionally
|
|
646
|
+
via the entity tag of the last poll (a "304" answer is free of rate limit),
|
|
647
|
+
and report the issues not seen at their update time yet: as added if created
|
|
648
|
+
after the opening, as deleted if soft deleted, else as updated (issues deleted
|
|
649
|
+
on GitHub are not reported, as GitHub reports no deletions) */
|
|
650
|
+
async pollProject(prjId) {
|
|
651
|
+
const listener = this.listener;
|
|
652
|
+
if (listener === null || await this.registered(prjId) === null)
|
|
653
|
+
return;
|
|
654
|
+
const { loc, lifecycle, scheme } = await this.context(prjId);
|
|
655
|
+
const state = this.pollState(prjId);
|
|
656
|
+
const params = { ...loc, state: "all", sort: "updated", direction: "asc", since: state.since, per_page: 100 };
|
|
657
|
+
let issues;
|
|
658
|
+
try {
|
|
659
|
+
const r = await this.gh.request("GET /repos/{owner}/{repo}/issues", {
|
|
660
|
+
...params, headers: state.etag !== undefined ? { "if-none-match": state.etag } : {}
|
|
661
|
+
});
|
|
662
|
+
state.etag = r.headers.etag;
|
|
663
|
+
issues = r.data;
|
|
664
|
+
if (issues.length === params.per_page)
|
|
665
|
+
issues.push(...await this.gh.paginate("GET /repos/{owner}/{repo}/issues", { ...params, page: 2 }));
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
if (statusOf(err) === 304)
|
|
669
|
+
return;
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
const change = { added: [], updated: [], deleted: [] };
|
|
673
|
+
for (const issue of issues) {
|
|
674
|
+
if (issue.pull_request !== undefined && issue.pull_request !== null)
|
|
675
|
+
continue;
|
|
676
|
+
if (issue.updated_at > state.since)
|
|
677
|
+
state.since = issue.updated_at;
|
|
678
|
+
if (state.seen.get(issue.number) === issue.updated_at)
|
|
679
|
+
continue;
|
|
680
|
+
const known = state.seen.has(issue.number);
|
|
681
|
+
state.seen.set(issue.number, issue.updated_at);
|
|
682
|
+
this.loads.delete(`${prjId}#${issue.number}`);
|
|
683
|
+
if (!live(issue))
|
|
684
|
+
change.deleted.push(idOf(scheme, issue.number));
|
|
685
|
+
else if (!known && issue.created_at >= this.opened)
|
|
686
|
+
change.added.push(await this.entry(loc, lifecycle, scheme, issue));
|
|
687
|
+
else
|
|
688
|
+
change.updated.push(await this.entry(loc, lifecycle, scheme, issue));
|
|
689
|
+
}
|
|
690
|
+
if (this.timer !== null && (change.added.length > 0 || change.updated.length > 0 || change.deleted.length > 0))
|
|
691
|
+
listener(prjId, change);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/* the plugin factory */
|
|
695
|
+
const factory = (ctx) => new GitHubTaskStoragePlugin(ctx);
|
|
696
|
+
export default factory;
|