backlogsync 0.0.0-stage → 0.1.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/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +158 -2
- package/action.yml +65 -0
- package/bin/backlogsync.mjs +140 -0
- package/bin/lib/args.mjs +30 -0
- package/bin/lib/backlog.mjs +183 -0
- package/bin/lib/config.mjs +114 -0
- package/bin/lib/github.mjs +85 -0
- package/bin/lib/plan.mjs +53 -0
- package/bin/lib/sync.mjs +100 -0
- package/package.json +83 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to backlogsync. The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
4
|
+
versions follow [SemVer](https://semver.org/). Items reference their `BS-n` backlog id.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] — 2026-10-01
|
|
9
|
+
|
|
10
|
+
The first version on npm: 0.0.1's tool, unchanged in behaviour, published once one
|
|
11
|
+
repository had been migrated and its sync observed on GitHub.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- The 0.1.0 gate is met: skilltrigger migrated with the action pinned by commit, and its
|
|
15
|
+
first sync on GitHub created the one issue the dry run had planned and touched nothing
|
|
16
|
+
else (BS-10, BS-15).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- The README documents running a pinned commit locally through the GitHub tarball, since
|
|
20
|
+
`npx github:…` fails inside npm (BS-19).
|
|
21
|
+
- The README installs from npm and pins the action by its release tag,
|
|
22
|
+
`@backlogsync--v0.1.0`, or by commit; the tarball route stays for a commit that is not
|
|
23
|
+
released. CONTRIBUTING gives the first release's order: publish by hand, configure the
|
|
24
|
+
trusted publisher, push the tag (BS-9).
|
|
25
|
+
|
|
26
|
+
## [0.0.1] — 2026-10-01 — not released
|
|
27
|
+
|
|
28
|
+
The first version, in the repository only: the tool that replaces the `scripts/backlog.mjs`
|
|
29
|
+
seven repositories each carried, its tests against a fake GitHub API, and this
|
|
30
|
+
repository. The first version on npm is 0.1.0, after one repository has been migrated
|
|
31
|
+
and its sync observed on GitHub (BS-10).
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- `backlogsync check`: the backlog validated — ids well-formed and unique, metadata that
|
|
35
|
+
parses and holds known values, items under versioned milestones — and the roadmap
|
|
36
|
+
compared with what the backlog generates; exit 1 on either (BS-1, BS-2).
|
|
37
|
+
- `backlogsync roadmap`: `ROADMAP.md` in the layout the replaced copies produced, byte
|
|
38
|
+
for byte given the same name and regenerate command (BS-2).
|
|
39
|
+
- `backlogsync sync`: the one-way sync to issues and milestones over the GitHub REST
|
|
40
|
+
API, from `GITHUB_TOKEN` and `GITHUB_REPOSITORY`; `--dry-run` and `--milestones`; no
|
|
41
|
+
delete call; a pagination link to another origin refused (BS-3).
|
|
42
|
+
- Configuration in `package.json#backlogsync` or `.backlogsync.json`: `prefix`, `meta`,
|
|
43
|
+
`name`, `labels`, `backlog`, `roadmap`, `branch`, `regenerate` (BS-4).
|
|
44
|
+
- A composite action, `uses: Allan-Nava/backlogsync@<ref>`, and `release-drift.yml` as a
|
|
45
|
+
reusable workflow (BS-5).
|
|
46
|
+
- `scripts/compat.mjs`: the roadmap and the sync plan compared with a repository's own
|
|
47
|
+
script, on its real backlog and issues (BS-7).
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- Against the replaced copies: the sync applies by default and `--dry-run` plans;
|
|
51
|
+
`lint` and `check` are one command; labels are ensured only when an issue is created;
|
|
52
|
+
the lint also refuses an item under another prefix, an item ticked `[X]`, a repeated
|
|
53
|
+
milestone title and a `prio-` label listed by hand (BS-1, BS-3).
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- The "Source of trutm" and "Source of trugl" artefacts two copies wrote into issue
|
|
57
|
+
footers and milestone descriptions (BS-3).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Allan Nava
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,159 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/Allan-Nava/backlogsync/main/assets/logo.svg" width="72" height="72" alt=""></p>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# backlogsync — one backlog file, a generated roadmap, issues that follow
|
|
4
|
+
|
|
5
|
+
**backlogsync keeps `BACKLOG.md` the single source of truth for planned work**: it checks the file, generates `ROADMAP.md` from it, and syncs GitHub issues and milestones one way — from the file to GitHub, never back. A Node CLI with zero runtime dependencies, and a composite GitHub Action that runs the same code.
|
|
6
|
+
|
|
7
|
+
**Status:** 0.1.0, the first version on npm. It replaces the `scripts/backlog.mjs` that seven repositories each carried, and on 2026-10-01 it reproduced every one of their committed roadmaps byte for byte and planned the same sync, decision for decision, on their real issues (see [Compatibility](#compatibility)). The gate on 0.1.0 — one of those repositories migrated and its sync observed on GitHub — was met the same day by skilltrigger.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- **`backlogsync check`** — validates the backlog (ids well-formed and unique, metadata that parses and holds known values, every item under a milestone) and fails when `ROADMAP.md` is not what the backlog would generate. The CI gate.
|
|
12
|
+
- **`backlogsync roadmap`** — writes `ROADMAP.md`: a summary line, a table of milestones with progress bars, then every item by milestone.
|
|
13
|
+
- **`backlogsync sync`** — plans the issue sync, prints the whole plan, then applies it over the GitHub REST API: create the issue for an open item, close it when the item is ticked, reopen it when the item is unticked, retitle it when the title drifts, move it when the item moves to another milestone. `--dry-run` prints the plan and changes nothing.
|
|
14
|
+
|
|
15
|
+
The sync **never deletes**. There is no delete call in the code. An issue whose item has left the backlog is left alone; so is an issue filed by hand, a pull request, and an issue with another prefix.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Node 18 or later. No runtime dependencies, no build step, no install script.
|
|
20
|
+
|
|
21
|
+
**From npm**: `npx backlogsync check`, or `npm install --save-dev backlogsync` and `npx backlogsync check` from then on.
|
|
22
|
+
|
|
23
|
+
**As a GitHub Action**, pinned by release tag or by commit — `.github/workflows/backlog-issues.yml`:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
name: Backlog issues
|
|
27
|
+
on:
|
|
28
|
+
push:
|
|
29
|
+
branches: [main]
|
|
30
|
+
paths: [BACKLOG.md, .github/workflows/backlog-issues.yml]
|
|
31
|
+
workflow_dispatch:
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
issues: write
|
|
35
|
+
concurrency:
|
|
36
|
+
group: backlog-issues
|
|
37
|
+
cancel-in-progress: false
|
|
38
|
+
jobs:
|
|
39
|
+
sync:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v7
|
|
43
|
+
- uses: Allan-Nava/backlogsync@backlogsync--v0.1.0 # or @<sha>
|
|
44
|
+
with:
|
|
45
|
+
command: sync # or check, roadmap
|
|
46
|
+
# dry-run: "true"
|
|
47
|
+
# milestones: v0.1.0,v0.2.0
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The `concurrency` group matters: two runs racing would both see no issue for an item and open it twice. The action's inputs are `command`, `dry-run`, `milestones`, `config`, `working-directory` and `token` (default: the job's `GITHUB_TOKEN`). It runs `node "$GITHUB_ACTION_PATH/bin/backlogsync.mjs"` with the runner's Node.
|
|
51
|
+
|
|
52
|
+
**From a commit**, the pre-release route: for a repository that pins the action to a commit not yet released on npm and wants the same version locally — the GitHub tarball, because `npx github:Allan-Nava/backlogsync#<sha>` fails inside npm ("GitFetcher requires an Arborist constructor"):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx --yes https://codeload.github.com/Allan-Nava/backlogsync/tar.gz/<sha> check
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
skilltrigger's `npm run backlog` and `npm run roadmap`, from the 0.1.0 pilot, do exactly this.
|
|
59
|
+
|
|
60
|
+
**From a checkout**: `node <checkout>/bin/backlogsync.mjs check`, run in the repository that holds the backlog.
|
|
61
|
+
|
|
62
|
+
## The backlog
|
|
63
|
+
|
|
64
|
+
````markdown
|
|
65
|
+
## v0.2.0 — Title of the milestone <!-- ms: phase=next -->
|
|
66
|
+
|
|
67
|
+
- [ ] **ST-12 — Short name**: what it is, why it earns its place, what it
|
|
68
|
+
needs to touch. <!-- st: prio=high size=M labels=runner,docs -->
|
|
69
|
+
- [x] **ST-11 — Shipped item**: … <!-- st: prio=med size=S labels=docs ver=0.1.0 -->
|
|
70
|
+
````
|
|
71
|
+
|
|
72
|
+
- A **milestone** is a `## ` heading that starts with `vX.Y.Z` and carries `<!-- ms: phase=… -->`, phase one of `now`, `next`, `later`, `shipped`. Any other `## ` heading ends the milestone above it.
|
|
73
|
+
- An **item** is `- [ ] **<PREFIX>-n — Title**: body` and its indented continuation lines, ending with the metadata comment, whose key is the configured `meta` (here `st`). `prio` is `high`, `med` or `low`; `size` is `S`, `M`, `L` or `XL`; `labels` is a comma-separated list, at least one.
|
|
74
|
+
- `- [x]` marks it shipped, and a shipped item carries `ver=x.y.z` (or `ver=main` while merged and unreleased); an open item carries no `ver`.
|
|
75
|
+
- The **id never changes**. A new item takes the next free number.
|
|
76
|
+
- Fenced blocks are skipped whole, so the file can document its own format, as above.
|
|
77
|
+
|
|
78
|
+
The issue for an item is titled `<id> — <title>`; that prefix is the only link between the two, so the title is the one thing the sync rewrites. The body is the item's text with every HTML comment stripped, followed by a footer naming the backlog. It is written once, at creation.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
In `package.json` under `"backlogsync"`, or in `.backlogsync.json` — which is all a repository without a `package.json`, a Go module say, needs. Both at once is an error rather than a precedence rule: a repository that carries two has already drifted. `--config <file>` names any other file.
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"prefix": "ST",
|
|
87
|
+
"meta": "st",
|
|
88
|
+
"name": "skilltrigger",
|
|
89
|
+
"labels": {
|
|
90
|
+
"runner": { "color": "0e8a16", "description": "The serial runner" },
|
|
91
|
+
"docs": ["0075ca", "README, CONTRIBUTING, site"]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Key | Default | What it is |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `prefix` | — (required) | The id prefix: `ST` for `ST-1`. Upper-case letters and digits. |
|
|
99
|
+
| `meta` | the prefix, lower-cased | The metadata comment's key: `<!-- st: … -->`. |
|
|
100
|
+
| `name` | `package.json#name`, else the directory | The roadmap's title: `# Roadmap — <name>`. |
|
|
101
|
+
| `labels` | none: any label passes | Name → `{color, description}` (or `[color, description]`, the shape the replaced scripts used). When set, an item may only use these; the sync creates any that are missing. |
|
|
102
|
+
| `backlog`, `roadmap` | `BACKLOG.md`, `ROADMAP.md` | Paths, relative to the config's directory. |
|
|
103
|
+
| `branch` | `main` | The branch the issue footer links to. |
|
|
104
|
+
| `regenerate` | `npx backlogsync roadmap` | The command the roadmap and the stale-roadmap error tell a reader to run. The generated-by comment prints it without its leading `node` or `npx`. |
|
|
105
|
+
|
|
106
|
+
The sync takes `GITHUB_TOKEN` (or `GH_TOKEN`) and `GITHUB_REPOSITORY` from the environment, plus `GITHUB_API_URL` and `GITHUB_SERVER_URL` when set — Actions sets all four. A dry run on a public repository works without a token. Labels `prio-high`, `prio-med` and `prio-low` are added to every new issue from its `prio=`; a `labels` entry of the same name overrides one's colour.
|
|
107
|
+
|
|
108
|
+
Exit codes: `0` ok, `1` a problem in the backlog, a stale roadmap or a failed API call, `2` a usage or configuration error.
|
|
109
|
+
|
|
110
|
+
## Release drift
|
|
111
|
+
|
|
112
|
+
`.github/workflows/release-drift.yml` is also a reusable workflow: it fails when the version in the manifest has had no matching tag for two hours, because a merged release PR publishes nothing until someone pushes the tag. It runs on push and daily.
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
jobs:
|
|
116
|
+
drift:
|
|
117
|
+
uses: Allan-Nava/backlogsync/.github/workflows/release-drift.yml@backlogsync--v0.1.0 # or @<sha>
|
|
118
|
+
with:
|
|
119
|
+
version-file: VERSION # default package.json
|
|
120
|
+
tag-prefix: v # default <package name>--v, or v for a plain file
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`grace-hours` (default 2) and `changelog` (default `CHANGELOG.md`) are the other inputs. A version whose CHANGELOG heading says `not released`, or whose section opens with `Not released`, passes with a notice.
|
|
124
|
+
|
|
125
|
+
## Decisions
|
|
126
|
+
|
|
127
|
+
The seven copies agreed on the format, the lint rules, the roadmap layout and the planner; they differed in what each hard-coded. Each difference is either a key above or one choice, recorded here.
|
|
128
|
+
|
|
129
|
+
- **Prefix, meta key, roadmap name, label set** — different in every copy; `prefix`, `meta`, `name` and `labels`. The owner and repository came from a hard-coded URL; they now come from `GITHUB_REPOSITORY`.
|
|
130
|
+
- **The `prio-med` colour** — `fbca04` in six copies, `e4b429` in one. The default is the majority's; a `labels` entry overrides it. Existing labels are never recoloured.
|
|
131
|
+
- **"Source of trutm" and "Source of trugl"** — two copies carried a find-and-replace artefact in the issue footer and the milestone description. Fixed, not reproduced. Bodies and descriptions are written only on creation, so issues that already exist are not touched by it.
|
|
132
|
+
- **The release-drift marker** — one copy read `not released` on the CHANGELOG heading, one read a section opening with `Not released`, five read neither. The reusable workflow accepts both. One of the five has a section that opens with `Not released` and would fail daily once its grace window passed; the reusable workflow passes it.
|
|
133
|
+
- **Version file and tag shape** — `package.json` with `<name>--v<version>` in five, a `VERSION` file with `v<version>` in two: the `version-file` and `tag-prefix` inputs. The remediation hint is a plain `git tag … && git push …` for all.
|
|
134
|
+
- **Fixed, not configurable:** the `prio`, `size` and `phase` vocabularies and the `vX.Y.Z` milestone headings — identical in all seven, so a key would only invite drift.
|
|
135
|
+
|
|
136
|
+
Changed on purpose, and the same in every repository from now on:
|
|
137
|
+
|
|
138
|
+
- The sync calls the REST API itself rather than the `gh` CLI, and applies by default; `--dry-run` replaces the old `issues` / `issues --apply` pair. `lint` and `check` are one command, `check`; the old `stats` line is the roadmap's summary line.
|
|
139
|
+
- Labels are ensured only when an issue is about to be created, not on every run.
|
|
140
|
+
- The close and reopen comments and the issue footer name backlogsync rather than a script path.
|
|
141
|
+
- The lint is stricter in four ways the copies let pass silently: an item under another prefix, an item ticked `[X]`, two milestone headings with one title (the sync finds a milestone by title), and a `prio-` label listed by hand. None of the seven real backlogs trips them.
|
|
142
|
+
|
|
143
|
+
## Compatibility
|
|
144
|
+
|
|
145
|
+
`node scripts/compat.mjs <repo> … [--issues]` runs over checkouts of repositories that still carry their own `scripts/backlog.mjs`. It derives the config from that script, runs `backlogsync roadmap` over the repository's `BACKLOG.md` into a temporary directory and compares the result with its committed `ROADMAP.md` byte for byte; runs `backlogsync check`; and, with `--issues`, reads the issues with `gh issue list` and compares the new planner's decisions with the old script's on that same list. It writes nothing outside the temporary directory, and nothing it reads belongs in this repository.
|
|
146
|
+
|
|
147
|
+
On 2026-10-01, over the seven repositories: seven roadmaps byte-identical (1,905 to 4,448 bytes), seven checks passing, seven plans identical (17 to 40 decisions each, 118 issues read, none with anything left to change).
|
|
148
|
+
|
|
149
|
+
## What it never does
|
|
150
|
+
|
|
151
|
+
- Delete an issue, a milestone or a label.
|
|
152
|
+
- Write to the repository. `roadmap` writes one file locally; committing it is yours.
|
|
153
|
+
- Read anything back from GitHub into the backlog. The sync is one way.
|
|
154
|
+
- Send the token anywhere but the configured API origin: a pagination link to another origin is refused.
|
|
155
|
+
- Run on install. There is no install script, and no dependency.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
package/action.yml
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: backlogsync
|
|
2
|
+
description: BACKLOG.md as the single source of truth — check it, generate ROADMAP.md, sync GitHub issues and milestones one way.
|
|
3
|
+
author: Allan Nava
|
|
4
|
+
branding:
|
|
5
|
+
icon: list
|
|
6
|
+
color: orange
|
|
7
|
+
|
|
8
|
+
# A composite action, so a repository can adopt backlogsync by ref before any npm
|
|
9
|
+
# release exists: it runs the CLI from this checkout of the action, with the
|
|
10
|
+
# runner's own Node (18 or later — every GitHub-hosted image has one; add
|
|
11
|
+
# actions/setup-node before this step on a runner that does not).
|
|
12
|
+
#
|
|
13
|
+
# Inputs reach the shell as environment variables, never interpolated into the
|
|
14
|
+
# script, so a milestone list cannot become a command.
|
|
15
|
+
|
|
16
|
+
inputs:
|
|
17
|
+
command:
|
|
18
|
+
description: "check, roadmap or sync"
|
|
19
|
+
required: false
|
|
20
|
+
default: sync
|
|
21
|
+
dry-run:
|
|
22
|
+
description: "sync only: print the plan, change nothing"
|
|
23
|
+
required: false
|
|
24
|
+
default: "false"
|
|
25
|
+
milestones:
|
|
26
|
+
description: "sync only: limit to these milestones by version, comma-separated (e.g. v0.1.0). Empty means all."
|
|
27
|
+
required: false
|
|
28
|
+
default: ""
|
|
29
|
+
config:
|
|
30
|
+
description: "A config file instead of .backlogsync.json / package.json#backlogsync"
|
|
31
|
+
required: false
|
|
32
|
+
default: ""
|
|
33
|
+
working-directory:
|
|
34
|
+
description: "The repository directory, relative to the workspace"
|
|
35
|
+
required: false
|
|
36
|
+
default: "."
|
|
37
|
+
token:
|
|
38
|
+
description: "The token the sync uses; it needs issues: write. The default is the job's GITHUB_TOKEN."
|
|
39
|
+
required: false
|
|
40
|
+
default: ${{ github.token }}
|
|
41
|
+
|
|
42
|
+
runs:
|
|
43
|
+
using: composite
|
|
44
|
+
steps:
|
|
45
|
+
- shell: bash
|
|
46
|
+
working-directory: ${{ inputs.working-directory }}
|
|
47
|
+
env:
|
|
48
|
+
BS_COMMAND: ${{ inputs.command }}
|
|
49
|
+
BS_DRY_RUN: ${{ inputs.dry-run }}
|
|
50
|
+
BS_MILESTONES: ${{ inputs.milestones }}
|
|
51
|
+
BS_CONFIG: ${{ inputs.config }}
|
|
52
|
+
GITHUB_TOKEN: ${{ inputs.token }}
|
|
53
|
+
run: |
|
|
54
|
+
set -euo pipefail
|
|
55
|
+
case "$BS_COMMAND" in
|
|
56
|
+
check|roadmap|sync) ;;
|
|
57
|
+
*) echo "::error::command must be check, roadmap or sync, got: $BS_COMMAND"; exit 2 ;;
|
|
58
|
+
esac
|
|
59
|
+
args=("$BS_COMMAND")
|
|
60
|
+
if [ -n "$BS_CONFIG" ]; then args+=(--config "$BS_CONFIG"); fi
|
|
61
|
+
if [ "$BS_COMMAND" = sync ]; then
|
|
62
|
+
if [ "$BS_DRY_RUN" = true ]; then args+=(--dry-run); fi
|
|
63
|
+
if [ -n "$BS_MILESTONES" ]; then args+=(--milestones "$BS_MILESTONES"); fi
|
|
64
|
+
fi
|
|
65
|
+
node "$GITHUB_ACTION_PATH/bin/backlogsync.mjs" "${args[@]}"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// backlogsync — BACKLOG.md as the single source of truth: a generated ROADMAP.md and a
|
|
3
|
+
// one-way sync to GitHub issues and milestones.
|
|
4
|
+
//
|
|
5
|
+
// backlogsync roadmap write ROADMAP.md from BACKLOG.md
|
|
6
|
+
// backlogsync check validate BACKLOG.md and fail if ROADMAP.md is stale
|
|
7
|
+
// backlogsync sync sync the issues and milestones (GITHUB_TOKEN, GITHUB_REPOSITORY)
|
|
8
|
+
// --dry-run print the plan, change nothing
|
|
9
|
+
// --milestones v0.1.0,… limit to these milestones, by version
|
|
10
|
+
//
|
|
11
|
+
// Options for every command:
|
|
12
|
+
// --config <file> a config file instead of .backlogsync.json / package.json
|
|
13
|
+
// --backlog <file> read this backlog instead of the configured one
|
|
14
|
+
// --roadmap <file> write or compare this roadmap instead of the configured one
|
|
15
|
+
//
|
|
16
|
+
// Exit codes: 0 ok, 1 a problem found (or a failed API call), 2 usage or configuration.
|
|
17
|
+
import { appendFileSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { resolve } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { parseArgs } from './lib/args.mjs'
|
|
21
|
+
import { lint, parse, roadmap } from './lib/backlog.mjs'
|
|
22
|
+
import { ConfigError, loadConfig } from './lib/config.mjs'
|
|
23
|
+
import { client, GitHubError } from './lib/github.mjs'
|
|
24
|
+
import { sync } from './lib/sync.mjs'
|
|
25
|
+
|
|
26
|
+
const SELF = fileURLToPath(import.meta.url)
|
|
27
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
28
|
+
|
|
29
|
+
const usage = () =>
|
|
30
|
+
readFileSync(SELF, 'utf8')
|
|
31
|
+
.split('\n')
|
|
32
|
+
.slice(1, 16)
|
|
33
|
+
.map((l) => l.replace(/^\/\/ ?/, ''))
|
|
34
|
+
.join('\n')
|
|
35
|
+
|
|
36
|
+
class UsageError extends Error {}
|
|
37
|
+
|
|
38
|
+
export async function main(argv = process.argv.slice(2), env = process.env, out = console.log, errOut = console.error) {
|
|
39
|
+
const [cmd, ...rest] = argv
|
|
40
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
41
|
+
out(usage())
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
if (cmd === '--version' || cmd === '-v') {
|
|
45
|
+
out(VERSION)
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
if (!['roadmap', 'check', 'sync'].includes(cmd)) throw new UsageError(`unknown command "${cmd}" — try roadmap, check or sync`)
|
|
50
|
+
const spec = { config: 'string', backlog: 'string', roadmap: 'string' }
|
|
51
|
+
if (cmd === 'sync') Object.assign(spec, { 'dry-run': 'boolean', milestones: 'string' })
|
|
52
|
+
let args
|
|
53
|
+
try {
|
|
54
|
+
args = parseArgs(rest, spec)
|
|
55
|
+
} catch (e) {
|
|
56
|
+
throw new UsageError(e.message)
|
|
57
|
+
}
|
|
58
|
+
if (args.positionals.length) throw new UsageError(`unexpected argument "${args.positionals[0]}"`)
|
|
59
|
+
const cfg = loadConfig({ configPath: args.values.config })
|
|
60
|
+
if (args.values.backlog) cfg.backlog = resolve(args.values.backlog)
|
|
61
|
+
if (args.values.roadmap) cfg.roadmap = resolve(args.values.roadmap)
|
|
62
|
+
|
|
63
|
+
let text
|
|
64
|
+
try {
|
|
65
|
+
text = readFileSync(cfg.backlog, 'utf8')
|
|
66
|
+
} catch {
|
|
67
|
+
throw new ConfigError(`cannot read the backlog at ${args.values.backlog ?? cfg.backlogRel}`)
|
|
68
|
+
}
|
|
69
|
+
const model = parse(text, cfg)
|
|
70
|
+
const errors = lint(model, cfg)
|
|
71
|
+
if (errors.length) {
|
|
72
|
+
for (const e of errors) errOut(e)
|
|
73
|
+
errOut(`${errors.length} problem${errors.length === 1 ? '' : 's'} in ${cfg.backlogRel}`)
|
|
74
|
+
return 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (cmd === 'roadmap') {
|
|
78
|
+
writeFileSync(cfg.roadmap, roadmap(model, cfg))
|
|
79
|
+
out(`wrote ${args.values.roadmap ?? cfg.roadmapRel} — ${model.items.length} items, ${model.milestones.length} milestones`)
|
|
80
|
+
return 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (cmd === 'check') {
|
|
84
|
+
let current = null
|
|
85
|
+
try {
|
|
86
|
+
current = readFileSync(cfg.roadmap, 'utf8')
|
|
87
|
+
} catch {}
|
|
88
|
+
if (current !== roadmap(model, cfg)) {
|
|
89
|
+
errOut(`${cfg.roadmapRel} is ${current === null ? 'missing' : 'stale'} — run \`${cfg.regenerate}\` and commit the result`)
|
|
90
|
+
return 1
|
|
91
|
+
}
|
|
92
|
+
out(`ok — ${model.items.length} items, ${model.milestones.length} milestones; ${cfg.roadmapRel} is in step with ${cfg.backlogRel}`)
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// sync
|
|
97
|
+
const dryRun = Boolean(args.values['dry-run'])
|
|
98
|
+
const token = env.GITHUB_TOKEN || env.GH_TOKEN || ''
|
|
99
|
+
if (!token && !dryRun) throw new ConfigError('GITHUB_TOKEN is not set — a sync that changes issues needs one (--dry-run can do without on a public repository)')
|
|
100
|
+
let gh
|
|
101
|
+
try {
|
|
102
|
+
gh = client({ token, repo: env.GITHUB_REPOSITORY, apiUrl: env.GITHUB_API_URL || undefined, userAgent: `backlogsync/${VERSION}` })
|
|
103
|
+
} catch (e) {
|
|
104
|
+
throw new ConfigError(e.message)
|
|
105
|
+
}
|
|
106
|
+
const only = (args.values.milestones ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
107
|
+
const res = await sync(model, cfg, gh, { dryRun, only, server: env.GITHUB_SERVER_URL || undefined, log: out })
|
|
108
|
+
if (env.GITHUB_STEP_SUMMARY) {
|
|
109
|
+
try {
|
|
110
|
+
appendFileSync(env.GITHUB_STEP_SUMMARY, `### Backlog issue sync${dryRun ? ' — dry run' : ''}\n\n\`\`\`\n${res.summary}\n\`\`\`\n`)
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
return 0
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e instanceof UsageError || e instanceof ConfigError) {
|
|
116
|
+
errOut(`backlogsync: ${e.message}`)
|
|
117
|
+
if (e instanceof UsageError) errOut('run `backlogsync help` for usage')
|
|
118
|
+
return 2
|
|
119
|
+
}
|
|
120
|
+
if (e instanceof GitHubError) {
|
|
121
|
+
errOut(`backlogsync: ${e.message}`)
|
|
122
|
+
return 1
|
|
123
|
+
}
|
|
124
|
+
throw e
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Through npm's bin symlink argv[1] is the link, not this file.
|
|
129
|
+
const invoked = (() => {
|
|
130
|
+
try {
|
|
131
|
+
return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(SELF)
|
|
132
|
+
} catch {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
})()
|
|
136
|
+
if (invoked) {
|
|
137
|
+
main().then((code) => {
|
|
138
|
+
process.exitCode = code
|
|
139
|
+
})
|
|
140
|
+
}
|
package/bin/lib/args.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A small argument parser: util.parseArgs arrived in Node 18.3 and the floor is 18.0.
|
|
2
|
+
// `spec` maps a long option to 'boolean' or 'string'; anything else is an error.
|
|
3
|
+
export function parseArgs(argv, spec) {
|
|
4
|
+
const values = {}
|
|
5
|
+
const positionals = []
|
|
6
|
+
for (let i = 0; i < argv.length; i++) {
|
|
7
|
+
const a = argv[i]
|
|
8
|
+
if (a === '--') {
|
|
9
|
+
positionals.push(...argv.slice(i + 1))
|
|
10
|
+
break
|
|
11
|
+
}
|
|
12
|
+
if (!a.startsWith('--')) {
|
|
13
|
+
positionals.push(a)
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
const eq = a.indexOf('=')
|
|
17
|
+
const name = a.slice(2, eq < 0 ? undefined : eq)
|
|
18
|
+
const type = spec[name]
|
|
19
|
+
if (!type) throw new Error(`unknown option --${name}`)
|
|
20
|
+
if (type === 'boolean') {
|
|
21
|
+
if (eq >= 0) throw new Error(`--${name} takes no value`)
|
|
22
|
+
values[name] = true
|
|
23
|
+
} else {
|
|
24
|
+
const v = eq >= 0 ? a.slice(eq + 1) : argv[++i]
|
|
25
|
+
if (v === undefined || (eq < 0 && v.startsWith('--'))) throw new Error(`--${name} needs a value`)
|
|
26
|
+
values[name] = v
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { values, positionals }
|
|
30
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// BACKLOG.md, read and checked. Pure functions: text and config in, a model or a list
|
|
2
|
+
// of errors out. No filesystem, no network — the CLI and the sync pass the text in.
|
|
3
|
+
import { PHASES, PRIOS, SIZES } from './config.mjs'
|
|
4
|
+
|
|
5
|
+
export const DASH = ' — '
|
|
6
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
7
|
+
|
|
8
|
+
// `<!-- tag: k=v k=v -->` → { k: v }; null when the comment is absent. A value may
|
|
9
|
+
// itself carry `=`; only the first one splits.
|
|
10
|
+
export function meta(s, tag) {
|
|
11
|
+
const m = s.match(new RegExp(`<!--\\s*${esc(tag)}:([\\s\\S]*?)-->`))
|
|
12
|
+
if (!m) return null
|
|
13
|
+
const out = {}
|
|
14
|
+
for (const kv of m[1].trim().split(/\s+/).filter(Boolean)) {
|
|
15
|
+
const [k, ...v] = kv.split('=')
|
|
16
|
+
out[k] = v.join('=')
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Strip every HTML comment, including an unterminated one, so nothing that reads as
|
|
22
|
+
// markup survives into an issue body. Loop: one pass can leave a `<!--` behind.
|
|
23
|
+
export function stripComments(s) {
|
|
24
|
+
let body = s
|
|
25
|
+
for (let prev = null; prev !== body; ) {
|
|
26
|
+
prev = body
|
|
27
|
+
body = body.replace(/<!--[\s\S]*?(?:-->|$)/g, '')
|
|
28
|
+
}
|
|
29
|
+
return body.trim()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// → { milestones: [{ line, title, version, phase, items }], items: [...], errors }
|
|
33
|
+
//
|
|
34
|
+
// A milestone is a `## ` heading carrying `<!-- ms: phase=… -->`; any other `## `
|
|
35
|
+
// heading ends the current milestone. An item is `- [ ] **XX-n — Title**: body` and
|
|
36
|
+
// its indented continuation lines; fenced blocks are skipped whole.
|
|
37
|
+
export function parse(text, cfg) {
|
|
38
|
+
const { prefix, meta: tag } = cfg
|
|
39
|
+
const file = cfg.backlogRel ?? 'BACKLOG.md'
|
|
40
|
+
const errors = []
|
|
41
|
+
const err = (line, msg) => errors.push(`${file}:${line}: ${msg}`)
|
|
42
|
+
const milestones = []
|
|
43
|
+
const items = []
|
|
44
|
+
const itemRe = new RegExp(`^- \\[( |x)\\] \\*\\*(${esc(prefix)}-(\\d+))\\s+—\\s+(.+?)\\*\\*:?\\s*([\\s\\S]*)$`)
|
|
45
|
+
const startRe = new RegExp(`^- \\[[ x]\\] \\*\\*${esc(prefix)}-`)
|
|
46
|
+
// An item line under another prefix is a typo that would otherwise vanish silently.
|
|
47
|
+
const foreignRe = /^- \[[ xX]\] \*\*([A-Z][A-Z0-9]*)-\d+/
|
|
48
|
+
let ms = null
|
|
49
|
+
let fenced = false
|
|
50
|
+
let cur = null
|
|
51
|
+
|
|
52
|
+
const flush = () => {
|
|
53
|
+
if (!cur) return
|
|
54
|
+
const raw = cur.lines.join(' ').replace(/\s+/g, ' ').trim()
|
|
55
|
+
const m = raw.match(itemRe)
|
|
56
|
+
if (!m) err(cur.line, `item does not match \`- [ ] **${prefix}-n — Title**: body <!-- ${tag}: ... -->\``)
|
|
57
|
+
else {
|
|
58
|
+
items.push({
|
|
59
|
+
line: cur.line,
|
|
60
|
+
status: m[1] === 'x' ? 'shipped' : 'open',
|
|
61
|
+
id: m[2],
|
|
62
|
+
num: Number(m[3]),
|
|
63
|
+
title: m[4].trim(),
|
|
64
|
+
body: stripComments(m[5]),
|
|
65
|
+
meta: meta(raw, tag),
|
|
66
|
+
ms,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
cur = null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
text.split('\n').forEach((line, i) => {
|
|
73
|
+
const n = i + 1
|
|
74
|
+
if (line.startsWith('```')) {
|
|
75
|
+
fenced = !fenced
|
|
76
|
+
flush()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (fenced) return
|
|
80
|
+
const h = line.match(/^## (.+?)\s*(<!--[\s\S]*-->)?\s*$/)
|
|
81
|
+
if (h) {
|
|
82
|
+
flush()
|
|
83
|
+
const mm = meta(line, 'ms')
|
|
84
|
+
if (mm) {
|
|
85
|
+
const title = h[1].trim()
|
|
86
|
+
const version = title.match(/^(v\d+\.\d+\.\d+)\b/)?.[1] ?? null
|
|
87
|
+
ms = { line: n, title, version, phase: mm.phase ?? null, items: [] }
|
|
88
|
+
milestones.push(ms)
|
|
89
|
+
} else ms = null
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (startRe.test(line)) {
|
|
93
|
+
flush()
|
|
94
|
+
cur = { line: n, lines: [line] }
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const f = line.match(foreignRe)
|
|
98
|
+
if (f && f[1] !== prefix) err(n, `item id prefix "${f[1]}" is not the configured "${prefix}"`)
|
|
99
|
+
else if (/^- \[X\] \*\*/.test(line)) err(n, 'use a lower-case [x] to tick an item')
|
|
100
|
+
if (cur && /^\s+\S/.test(line)) {
|
|
101
|
+
cur.lines.push(line.trim())
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
flush()
|
|
105
|
+
})
|
|
106
|
+
flush()
|
|
107
|
+
|
|
108
|
+
for (const it of items) if (it.ms) it.ms.items.push(it)
|
|
109
|
+
return { milestones, items, errors }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// → ["BACKLOG.md:<line>: <problem>"]; empty when the backlog is sound.
|
|
113
|
+
export function lint(model, cfg) {
|
|
114
|
+
const file = cfg.backlogRel ?? 'BACKLOG.md'
|
|
115
|
+
const errors = [...model.errors]
|
|
116
|
+
const err = (line, msg) => errors.push(`${file}:${line}: ${msg}`)
|
|
117
|
+
const seen = new Map()
|
|
118
|
+
const titles = new Map()
|
|
119
|
+
for (const m of model.milestones) {
|
|
120
|
+
if (!m.version) err(m.line, `milestone heading must start with a version, vX.Y.Z — Theme: "${m.title}"`)
|
|
121
|
+
if (!PHASES.includes(m.phase)) err(m.line, `milestone phase must be one of ${PHASES.join('|')}, got "${m.phase}"`)
|
|
122
|
+
// The sync finds a milestone by its title; two headings with one title are one milestone.
|
|
123
|
+
if (titles.has(m.title)) err(m.line, `milestone "${m.title}" is already a heading on line ${titles.get(m.title)}`)
|
|
124
|
+
titles.set(m.title, m.line)
|
|
125
|
+
}
|
|
126
|
+
for (const it of model.items) {
|
|
127
|
+
if (seen.has(it.id)) err(it.line, `${it.id} is already used on line ${seen.get(it.id)}`)
|
|
128
|
+
seen.set(it.id, it.line)
|
|
129
|
+
if (!it.ms) err(it.line, `${it.id} is not under a milestone heading`)
|
|
130
|
+
if (!it.meta) {
|
|
131
|
+
err(it.line, `${it.id} has no <!-- ${cfg.meta}: ... --> metadata`)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (!PRIOS.includes(it.meta.prio)) err(it.line, `${it.id}: prio must be ${PRIOS.join('|')}`)
|
|
135
|
+
if (!SIZES.includes(it.meta.size)) err(it.line, `${it.id}: size must be ${SIZES.join('|')}`)
|
|
136
|
+
const labels = (it.meta.labels ?? '').split(',').filter(Boolean)
|
|
137
|
+
if (!labels.length) err(it.line, `${it.id}: at least one label`)
|
|
138
|
+
for (const l of labels) {
|
|
139
|
+
if (l.startsWith('prio-')) err(it.line, `${it.id}: "${l}" is added from prio=, do not list it`)
|
|
140
|
+
else if (cfg.labels && !cfg.labels[l]) err(it.line, `${it.id}: unknown label "${l}"`)
|
|
141
|
+
}
|
|
142
|
+
if (it.status === 'shipped' && !it.meta.ver) err(it.line, `${it.id} is shipped but has no ver=`)
|
|
143
|
+
if (it.status === 'open' && it.meta.ver) err(it.line, `${it.id} is open but carries ver=${it.meta.ver}`)
|
|
144
|
+
if (!it.body) err(it.line, `${it.id} has no body`)
|
|
145
|
+
}
|
|
146
|
+
return errors
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The comment line names the generator without its runner: `node x.mjs roadmap` and
|
|
150
|
+
// `npx backlogsync roadmap` print as `x.mjs roadmap` and `backlogsync roadmap`.
|
|
151
|
+
const generatedBy = (cmd) => cmd.replace(/^(?:node|npx)\s+/, '')
|
|
152
|
+
|
|
153
|
+
// The layout every replaced copy produced, byte for byte, given the same name and
|
|
154
|
+
// regenerate command (README, "Compatibility").
|
|
155
|
+
export function roadmap(model, cfg) {
|
|
156
|
+
const all = model.items
|
|
157
|
+
const shipped = all.filter((i) => i.status === 'shipped').length
|
|
158
|
+
const bar = (done, total) => {
|
|
159
|
+
const n = total ? Math.round((10 * done) / total) : 0
|
|
160
|
+
return `\`${'#'.repeat(n)}${'.'.repeat(10 - n)}\` ${total ? Math.round((100 * done) / total) : 0}%`
|
|
161
|
+
}
|
|
162
|
+
const backlog = cfg.backlogRel ?? 'BACKLOG.md'
|
|
163
|
+
const out = []
|
|
164
|
+
out.push(`# Roadmap — ${cfg.name}`, '', `<!-- GENERATED by ${generatedBy(cfg.regenerate)} — do not edit by hand. -->`, '')
|
|
165
|
+
out.push(`> This page is **generated** from [${backlog}](${backlog}), the single source of truth for planned work. Regenerate it with \`${cfg.regenerate}\` after editing the backlog — CI fails when the two disagree.`, '')
|
|
166
|
+
out.push(`**${all.length} items · ${shipped} shipped · ${all.length - shipped} open · ${model.milestones.length} milestones.**`, '')
|
|
167
|
+
out.push('## At a glance', '', '| Milestone | Phase | Progress | Open | Shipped |', '|---|---|---|---|---|')
|
|
168
|
+
for (const m of model.milestones) {
|
|
169
|
+
const s = m.items.filter((i) => i.status === 'shipped').length
|
|
170
|
+
out.push(`| **${m.title}** | ${m.phase} | ${bar(s, m.items.length)} | ${m.items.length - s} | ${s} |`)
|
|
171
|
+
}
|
|
172
|
+
out.push('')
|
|
173
|
+
for (const m of model.milestones) {
|
|
174
|
+
out.push(`## ${m.title}`, '')
|
|
175
|
+
for (const it of m.items) {
|
|
176
|
+
const tick = it.status === 'shipped' ? 'x' : ' '
|
|
177
|
+
const ver = it.meta?.ver ? ` · \`${it.meta.ver}\`` : ''
|
|
178
|
+
out.push(`- [${tick}] **${it.id}** — ${it.title} · ${it.meta?.prio ?? '?'} · ${it.meta?.size ?? '?'} · ${(it.meta?.labels ?? '').split(',').join(', ')}${ver}`)
|
|
179
|
+
}
|
|
180
|
+
out.push('')
|
|
181
|
+
}
|
|
182
|
+
return out.join('\n')
|
|
183
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Configuration: `.backlogsync.json`, or the `backlogsync` key of package.json, or the
|
|
2
|
+
// file named by --config. One source only — two that could disagree is an error, not a
|
|
3
|
+
// precedence rule, because a repository that carries both has already drifted.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export class ConfigError extends Error {}
|
|
8
|
+
|
|
9
|
+
// The three vocabularies are the same in every copy this tool replaces, so they are
|
|
10
|
+
// fixed rather than configurable (README, "Decisions").
|
|
11
|
+
export const PRIOS = ['high', 'med', 'low']
|
|
12
|
+
export const SIZES = ['S', 'M', 'L', 'XL']
|
|
13
|
+
export const PHASES = ['now', 'next', 'later', 'shipped']
|
|
14
|
+
|
|
15
|
+
// The priority labels every copy adds on creation. The colours are the majority's;
|
|
16
|
+
// a `labels` entry of the same name overrides one.
|
|
17
|
+
export const PRIO_LABELS = {
|
|
18
|
+
'prio-high': { color: 'b60205', description: 'High priority in BACKLOG.md' },
|
|
19
|
+
'prio-med': { color: 'fbca04', description: 'Medium priority in BACKLOG.md' },
|
|
20
|
+
'prio-low': { color: 'c2e0c6', description: 'Low priority in BACKLOG.md' },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_REGENERATE = 'npx backlogsync roadmap'
|
|
24
|
+
const KEYS = new Set(['prefix', 'meta', 'name', 'backlog', 'roadmap', 'labels', 'branch', 'regenerate'])
|
|
25
|
+
|
|
26
|
+
// → { root, source, prefix, meta, name, backlog, roadmap, labels, branch, regenerate }
|
|
27
|
+
// `backlog` and `roadmap` are absolute; `backlogRel` is the path the documents print.
|
|
28
|
+
export function loadConfig({ cwd = process.cwd(), configPath } = {}) {
|
|
29
|
+
let raw
|
|
30
|
+
let root
|
|
31
|
+
let source
|
|
32
|
+
const pkgPath = join(cwd, 'package.json')
|
|
33
|
+
const pkg = existsSync(pkgPath) ? readJson(pkgPath) : null
|
|
34
|
+
if (configPath) {
|
|
35
|
+
source = resolve(cwd, configPath)
|
|
36
|
+
raw = readJson(source)
|
|
37
|
+
root = dirname(source)
|
|
38
|
+
} else {
|
|
39
|
+
const file = join(cwd, '.backlogsync.json')
|
|
40
|
+
const inPkg = pkg && Object.hasOwn(pkg, 'backlogsync')
|
|
41
|
+
if (existsSync(file) && inPkg) throw new ConfigError('both .backlogsync.json and package.json#backlogsync exist — keep one')
|
|
42
|
+
if (existsSync(file)) {
|
|
43
|
+
source = file
|
|
44
|
+
raw = readJson(file)
|
|
45
|
+
} else if (inPkg) {
|
|
46
|
+
source = `${pkgPath}#backlogsync`
|
|
47
|
+
raw = pkg.backlogsync
|
|
48
|
+
} else throw new ConfigError('no configuration: add .backlogsync.json or a "backlogsync" key to package.json (at least {"prefix": "XX"})')
|
|
49
|
+
root = cwd
|
|
50
|
+
}
|
|
51
|
+
return normalise(raw, { root, source, pkgName: pkg?.name })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readJson(path) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new ConfigError(`${path}: ${e.code === 'ENOENT' ? 'not found' : e.message}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalise(raw, { root = process.cwd(), source = '(inline)', pkgName } = {}) {
|
|
63
|
+
const bad = (msg) => {
|
|
64
|
+
throw new ConfigError(`${source}: ${msg}`)
|
|
65
|
+
}
|
|
66
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) bad('must be a JSON object')
|
|
67
|
+
for (const k of Object.keys(raw)) if (!KEYS.has(k)) bad(`unknown key "${k}" (known: ${[...KEYS].join(', ')})`)
|
|
68
|
+
const str = (k, re, what) => {
|
|
69
|
+
if (raw[k] === undefined) return undefined
|
|
70
|
+
if (typeof raw[k] !== 'string' || !re.test(raw[k])) bad(`${k} must be ${what}, got ${JSON.stringify(raw[k])}`)
|
|
71
|
+
return raw[k]
|
|
72
|
+
}
|
|
73
|
+
const prefix = str('prefix', /^[A-Z][A-Z0-9]*$/, 'upper-case letters and digits, starting with a letter (e.g. "ST")')
|
|
74
|
+
if (!prefix) bad('prefix is required (e.g. "ST" for ST-1, ST-2 …)')
|
|
75
|
+
const meta = str('meta', /^[a-z][a-z0-9-]*$/, 'lower-case letters, digits and dashes') ?? prefix.toLowerCase()
|
|
76
|
+
const name = str('name', /\S/, 'a non-empty string') ?? pkgName ?? basename(resolve(root))
|
|
77
|
+
const backlogRel = str('backlog', /\S/, 'a path') ?? 'BACKLOG.md'
|
|
78
|
+
const roadmapRel = str('roadmap', /\S/, 'a path') ?? 'ROADMAP.md'
|
|
79
|
+
const branch = str('branch', /^[\w./-]+$/, 'a branch name') ?? 'main'
|
|
80
|
+
const regenerate = str('regenerate', /\S/, 'the command a reader runs to regenerate the roadmap') ?? DEFAULT_REGENERATE
|
|
81
|
+
|
|
82
|
+
let labels = null
|
|
83
|
+
if (raw.labels !== undefined) {
|
|
84
|
+
if (!raw.labels || typeof raw.labels !== 'object' || Array.isArray(raw.labels)) bad('labels must be an object of name → {color, description}')
|
|
85
|
+
labels = {}
|
|
86
|
+
for (const [lname, v] of Object.entries(raw.labels)) {
|
|
87
|
+
// [color, description] is the shape the replaced scripts used; both are accepted.
|
|
88
|
+
const [color, description = ''] = Array.isArray(v) ? v : [v?.color, v?.description ?? '']
|
|
89
|
+
if (typeof color !== 'string' || !/^[0-9a-fA-F]{6}$/.test(color)) bad(`labels.${lname}.color must be six hex digits, no #`)
|
|
90
|
+
if (typeof description !== 'string') bad(`labels.${lname}.description must be a string`)
|
|
91
|
+
labels[lname] = { color: color.toLowerCase(), description }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
root,
|
|
96
|
+
source,
|
|
97
|
+
prefix,
|
|
98
|
+
meta,
|
|
99
|
+
name,
|
|
100
|
+
backlogRel,
|
|
101
|
+
roadmapRel,
|
|
102
|
+
backlog: resolve(root, backlogRel),
|
|
103
|
+
roadmap: resolve(root, roadmapRel),
|
|
104
|
+
labels,
|
|
105
|
+
branch,
|
|
106
|
+
regenerate,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Every label the sync may need to create: the configured ones plus the priority
|
|
111
|
+
// labels, a configured entry winning over the default of the same name.
|
|
112
|
+
export function allLabels(cfg) {
|
|
113
|
+
return { ...(cfg.labels ?? {}), ...Object.fromEntries(Object.entries(PRIO_LABELS).filter(([k]) => !cfg.labels?.[k])) }
|
|
114
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A minimal GitHub REST client on node:http(s) — no fetch, which Node 18 still marks
|
|
2
|
+
// experimental and warns about on every run. Only the calls the sync makes, and no
|
|
3
|
+
// DELETE among them.
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import https from 'node:https'
|
|
6
|
+
|
|
7
|
+
export class GitHubError extends Error {
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.status = status
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function client({ token, repo, apiUrl = 'https://api.github.com', userAgent = 'backlogsync' }) {
|
|
15
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo ?? '')) throw new GitHubError(`GITHUB_REPOSITORY must be owner/name, got ${JSON.stringify(repo ?? '')}`)
|
|
16
|
+
const base = new URL(apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`)
|
|
17
|
+
|
|
18
|
+
function request(method, pathOrUrl, body) {
|
|
19
|
+
const url = new URL(pathOrUrl, base)
|
|
20
|
+
// A pagination link that points elsewhere must not receive the token.
|
|
21
|
+
if (url.origin !== base.origin) return Promise.reject(new GitHubError(`refusing to follow ${url.origin}: not ${base.origin}`))
|
|
22
|
+
const payload = body === undefined ? null : JSON.stringify(body)
|
|
23
|
+
const headers = {
|
|
24
|
+
accept: 'application/vnd.github+json',
|
|
25
|
+
'x-github-api-version': '2022-11-28',
|
|
26
|
+
'user-agent': userAgent,
|
|
27
|
+
}
|
|
28
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
29
|
+
if (payload) {
|
|
30
|
+
headers['content-type'] = 'application/json'
|
|
31
|
+
headers['content-length'] = Buffer.byteLength(payload)
|
|
32
|
+
}
|
|
33
|
+
const lib = url.protocol === 'http:' ? http : https
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const req = lib.request(url, { method, headers }, (res) => {
|
|
36
|
+
const chunks = []
|
|
37
|
+
res.on('data', (c) => chunks.push(c))
|
|
38
|
+
res.on('end', () => {
|
|
39
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
40
|
+
let data = null
|
|
41
|
+
try {
|
|
42
|
+
data = text ? JSON.parse(text) : null
|
|
43
|
+
} catch {
|
|
44
|
+
data = text
|
|
45
|
+
}
|
|
46
|
+
if (res.statusCode >= 200 && res.statusCode < 300) resolve({ data, headers: res.headers })
|
|
47
|
+
else {
|
|
48
|
+
const msg = (data && typeof data === 'object' && data.message) || `HTTP ${res.statusCode}`
|
|
49
|
+
reject(new GitHubError(`${method} ${url.pathname}: ${res.statusCode} ${msg}`, res.statusCode))
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
req.on('error', (e) => reject(new GitHubError(`${method} ${url.pathname}: ${e.message}`)))
|
|
54
|
+
if (payload) req.write(payload)
|
|
55
|
+
req.end()
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Follows rel="next" until there is none.
|
|
60
|
+
async function all(path) {
|
|
61
|
+
const out = []
|
|
62
|
+
let next = path
|
|
63
|
+
while (next) {
|
|
64
|
+
const { data, headers } = await request('GET', next)
|
|
65
|
+
if (!Array.isArray(data)) throw new GitHubError(`GET ${path}: expected a list`)
|
|
66
|
+
out.push(...data)
|
|
67
|
+
next = String(headers.link ?? '').match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const r = `repos/${repo}`
|
|
73
|
+
return {
|
|
74
|
+
repo,
|
|
75
|
+
request,
|
|
76
|
+
issues: () => all(`${r}/issues?state=all&per_page=100`),
|
|
77
|
+
labels: () => all(`${r}/labels?per_page=100`),
|
|
78
|
+
milestones: () => all(`${r}/milestones?state=all&per_page=100`),
|
|
79
|
+
createLabel: (name, color, description) => request('POST', `${r}/labels`, { name, color, description }).then((x) => x.data),
|
|
80
|
+
createMilestone: (title, description) => request('POST', `${r}/milestones`, { title, description }).then((x) => x.data),
|
|
81
|
+
createIssue: (fields) => request('POST', `${r}/issues`, fields).then((x) => x.data),
|
|
82
|
+
updateIssue: (num, fields) => request('PATCH', `${r}/issues/${num}`, fields).then((x) => x.data),
|
|
83
|
+
comment: (num, body) => request('POST', `${r}/issues/${num}/comments`, { body }).then((x) => x.data),
|
|
84
|
+
}
|
|
85
|
+
}
|
package/bin/lib/plan.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The planner: the backlog and the issues that exist → what to do. Pure, so every
|
|
2
|
+
// decision is tested without a network — each failure mode of a sync is a wrong
|
|
3
|
+
// decision here: a duplicate opened on every push, an issue closed for open work.
|
|
4
|
+
import { DASH } from './backlog.mjs'
|
|
5
|
+
|
|
6
|
+
// GitHub issues (REST shape) → Map(id → { num, state, title, milestone }). The id is
|
|
7
|
+
// the title's prefix, the only durable link back to the backlog. Pull requests are
|
|
8
|
+
// issues to the REST API and are left out. When two issues carry one id the oldest
|
|
9
|
+
// wins, so a stray duplicate never takes over the item's history.
|
|
10
|
+
export function existingFromIssues(list, cfg) {
|
|
11
|
+
const idRe = new RegExp(`^${cfg.prefix}-\\d+$`)
|
|
12
|
+
const map = new Map()
|
|
13
|
+
for (const i of [...list].sort((a, b) => a.number - b.number)) {
|
|
14
|
+
if (i.pull_request) continue
|
|
15
|
+
const id = String(i.title).split(DASH)[0]
|
|
16
|
+
if (!idRe.test(id) || map.has(id)) continue
|
|
17
|
+
map.set(id, { num: String(i.number), state: String(i.state).toLowerCase(), title: i.title, milestone: i.milestone?.title ?? '' })
|
|
18
|
+
}
|
|
19
|
+
return map
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// One or more actions per item, in backlog order:
|
|
23
|
+
// CREATE open item with no issue REOPEN open item whose issue is closed
|
|
24
|
+
// CLOSE shipped item whose issue is open RETITLE title drifted (any state)
|
|
25
|
+
// MILESTONE the issue sits under another milestone than the item's heading
|
|
26
|
+
// OK already right SKIP shipped and never had an issue
|
|
27
|
+
// Nothing is ever deleted: there is no action for it. An issue whose item left the
|
|
28
|
+
// backlog is not touched at all.
|
|
29
|
+
export function plan(model, existing, only = []) {
|
|
30
|
+
const actions = []
|
|
31
|
+
for (const it of model.items) {
|
|
32
|
+
if (only.length && !only.includes(it.ms?.version)) continue
|
|
33
|
+
const ex = existing.get(it.id)
|
|
34
|
+
const want = `${it.id}${DASH}${it.title}`
|
|
35
|
+
if (ex && ex.title !== want) actions.push(['RETITLE', it.id, ex.num])
|
|
36
|
+
if (ex && ex.milestone !== undefined && it.ms && ex.milestone !== it.ms.title) actions.push(['MILESTONE', it.id, ex.num])
|
|
37
|
+
if (it.status === 'open') {
|
|
38
|
+
if (!ex) actions.push(['CREATE', it.id, '-'])
|
|
39
|
+
else if (ex.state === 'closed') actions.push(['REOPEN', it.id, ex.num])
|
|
40
|
+
else actions.push(['OK', it.id, ex.num])
|
|
41
|
+
} else {
|
|
42
|
+
if (!ex) actions.push(['SKIP', it.id, '-'])
|
|
43
|
+
else if (ex.state === 'open') actions.push(['CLOSE', it.id, ex.num])
|
|
44
|
+
else actions.push(['OK', it.id, ex.num])
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return actions
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function summarise(actions) {
|
|
51
|
+
const count = (k) => actions.filter((a) => a[0] === k).length
|
|
52
|
+
return `${count('CREATE')} to create · ${count('RETITLE')} to retitle · ${count('MILESTONE')} to move · ${count('CLOSE')} to close · ${count('REOPEN')} to reopen · ${count('OK')} ok · ${count('SKIP')} skipped`
|
|
53
|
+
}
|
package/bin/lib/sync.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The one-way sync: BACKLOG.md → GitHub issues and milestones. The plan is computed
|
|
2
|
+
// first and printed in full before anything is applied, so a run's log says what it
|
|
3
|
+
// was about to do even when a later call fails.
|
|
4
|
+
import { DASH } from './backlog.mjs'
|
|
5
|
+
import { allLabels } from './config.mjs'
|
|
6
|
+
import { existingFromIssues, plan, summarise } from './plan.mjs'
|
|
7
|
+
|
|
8
|
+
export function issueBody(it, cfg, { server = 'https://github.com', repo }) {
|
|
9
|
+
const blob = `${server}/${repo}/blob/${cfg.branch}`
|
|
10
|
+
return `${it.body}
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Planned work, tracked in [${cfg.backlogRel}](${blob}/${cfg.backlogRel}) as \`${it.id}\` under **${it.ms.title}** (priority ${it.meta.prio}, size ${it.meta.size}).
|
|
15
|
+
|
|
16
|
+
\`${cfg.backlogRel}\` is the single source of truth: it carries the stable \`${cfg.prefix}-n\` id that commits and the CHANGELOG reference, and [${cfg.roadmapRel}](${blob}/${cfg.roadmapRel}) is generated from it. This issue is a view of that item, kept in step one way by backlogsync, so closing it means ticking the item in the backlog and regenerating the roadmap in the same commit.
|
|
17
|
+
`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CLOSE_COMMENT = 'Shipped: the backlog item is ticked in the backlog. Closed by backlogsync.'
|
|
21
|
+
export const REOPEN_COMMENT = 'Reopened: the backlog item is open again in the backlog. Reopened by backlogsync.'
|
|
22
|
+
export const MILESTONE_DESCRIPTION = 'Backlog milestone. Source of truth: BACKLOG.md'
|
|
23
|
+
|
|
24
|
+
// → { actions, summary, applied: [lines] }. `log` receives every line as it happens.
|
|
25
|
+
export async function sync(model, cfg, gh, { dryRun = false, only = [], server, log = console.log } = {}) {
|
|
26
|
+
const existing = existingFromIssues(await gh.issues(), cfg)
|
|
27
|
+
const actions = plan(model, existing, only)
|
|
28
|
+
for (const a of actions) log(a.join('\t'))
|
|
29
|
+
const summary = summarise(actions)
|
|
30
|
+
log('')
|
|
31
|
+
log(summary)
|
|
32
|
+
const todo = actions.filter((a) => !['OK', 'SKIP'].includes(a[0]))
|
|
33
|
+
if (dryRun) {
|
|
34
|
+
log('(dry run — nothing changed)')
|
|
35
|
+
return { actions, summary, applied: [] }
|
|
36
|
+
}
|
|
37
|
+
const applied = []
|
|
38
|
+
const note = (s) => {
|
|
39
|
+
applied.push(s)
|
|
40
|
+
log(s)
|
|
41
|
+
}
|
|
42
|
+
const byId = new Map(model.items.map((i) => [i.id, i]))
|
|
43
|
+
|
|
44
|
+
// Labels only matter to a CREATE: an existing issue's labels are left as they are.
|
|
45
|
+
if (todo.some((a) => a[0] === 'CREATE')) {
|
|
46
|
+
const have = new Set((await gh.labels()).map((l) => l.name))
|
|
47
|
+
for (const [name, { color, description }] of Object.entries(allLabels(cfg))) {
|
|
48
|
+
if (have.has(name)) continue
|
|
49
|
+
await gh.createLabel(name, color, description)
|
|
50
|
+
have.add(name)
|
|
51
|
+
note(` created label ${name}`)
|
|
52
|
+
}
|
|
53
|
+
// With no `labels` configured any label is allowed; one that does not exist yet is
|
|
54
|
+
// created grey rather than failing the issue.
|
|
55
|
+
const used = new Set(todo.filter((a) => a[0] === 'CREATE').flatMap((a) => byId.get(a[1]).meta.labels.split(',').filter(Boolean)))
|
|
56
|
+
for (const name of used) {
|
|
57
|
+
if (have.has(name)) continue
|
|
58
|
+
await gh.createLabel(name, 'ededed', '')
|
|
59
|
+
have.add(name)
|
|
60
|
+
note(` created label ${name}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let milestones = null
|
|
65
|
+
const milestoneNumber = async (title) => {
|
|
66
|
+
milestones ??= new Map((await gh.milestones()).map((m) => [m.title, m.number]))
|
|
67
|
+
if (!milestones.has(title)) {
|
|
68
|
+
const m = await gh.createMilestone(title, MILESTONE_DESCRIPTION.replace('BACKLOG.md', cfg.backlogRel))
|
|
69
|
+
milestones.set(title, m.number)
|
|
70
|
+
note(` created milestone ${title}`)
|
|
71
|
+
}
|
|
72
|
+
return milestones.get(title)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const repo = gh.repo
|
|
76
|
+
for (const [action, id, num] of todo) {
|
|
77
|
+
const it = byId.get(id)
|
|
78
|
+
const title = `${id}${DASH}${it.title}`
|
|
79
|
+
if (action === 'CREATE') {
|
|
80
|
+
const labels = [...it.meta.labels.split(',').filter(Boolean), `prio-${it.meta.prio}`]
|
|
81
|
+
const issue = await gh.createIssue({ title, body: issueBody(it, cfg, { server, repo }), milestone: await milestoneNumber(it.ms.title), labels })
|
|
82
|
+
note(` created ${id} #${issue.number}`)
|
|
83
|
+
} else if (action === 'RETITLE') {
|
|
84
|
+
await gh.updateIssue(num, { title })
|
|
85
|
+
note(` retitled ${id} #${num} -> ${title}`)
|
|
86
|
+
} else if (action === 'MILESTONE') {
|
|
87
|
+
await gh.updateIssue(num, { milestone: await milestoneNumber(it.ms.title) })
|
|
88
|
+
note(` moved ${id} #${num} -> ${it.ms.title}`)
|
|
89
|
+
} else if (action === 'CLOSE') {
|
|
90
|
+
await gh.comment(num, CLOSE_COMMENT)
|
|
91
|
+
await gh.updateIssue(num, { state: 'closed', state_reason: 'completed' })
|
|
92
|
+
note(` closed ${id} #${num}`)
|
|
93
|
+
} else if (action === 'REOPEN') {
|
|
94
|
+
await gh.updateIssue(num, { state: 'open' })
|
|
95
|
+
await gh.comment(num, REOPEN_COMMENT)
|
|
96
|
+
note(` reopened ${id} #${num}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { actions, summary, applied }
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backlogsync",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "BACKLOG.md as the single source of truth: a generated ROADMAP.md, a check for CI, and a one-way sync to GitHub issues and milestones. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"backlogsync": "bin/backlogsync.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"action.yml",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node bin/backlogsync.mjs check && node scripts/repo-check.mjs && node --test",
|
|
21
|
+
"build:site": "node site/build.mjs"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/Allan-Nava/backlogsync"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://allan-nava.github.io/backlogsync/",
|
|
28
|
+
"bugs": "https://github.com/Allan-Nava/backlogsync/issues",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"backlog",
|
|
31
|
+
"roadmap",
|
|
32
|
+
"github-issues",
|
|
33
|
+
"milestones",
|
|
34
|
+
"github-action",
|
|
35
|
+
"zero-dependency"
|
|
36
|
+
],
|
|
37
|
+
"author": "Allan Nava (https://github.com/Allan-Nava)",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"backlogsync": {
|
|
40
|
+
"prefix": "BS",
|
|
41
|
+
"meta": "bs",
|
|
42
|
+
"regenerate": "node bin/backlogsync.mjs roadmap",
|
|
43
|
+
"labels": {
|
|
44
|
+
"cli": {
|
|
45
|
+
"color": "b7552f",
|
|
46
|
+
"description": "The commands: roadmap, check, sync"
|
|
47
|
+
},
|
|
48
|
+
"sync": {
|
|
49
|
+
"color": "0e8a16",
|
|
50
|
+
"description": "The issue sync and the REST client"
|
|
51
|
+
},
|
|
52
|
+
"action": {
|
|
53
|
+
"color": "1d76db",
|
|
54
|
+
"description": "The composite action and the reusable workflows"
|
|
55
|
+
},
|
|
56
|
+
"migration": {
|
|
57
|
+
"color": "5319e7",
|
|
58
|
+
"description": "Replacing a repository's own backlog script"
|
|
59
|
+
},
|
|
60
|
+
"release": {
|
|
61
|
+
"color": "6f42c1",
|
|
62
|
+
"description": "Publishing and versioning"
|
|
63
|
+
},
|
|
64
|
+
"docs": {
|
|
65
|
+
"color": "0075ca",
|
|
66
|
+
"description": "README, CONTRIBUTING, site"
|
|
67
|
+
},
|
|
68
|
+
"project": {
|
|
69
|
+
"color": "6a737d",
|
|
70
|
+
"description": "Backlog, roadmap, repo hygiene"
|
|
71
|
+
},
|
|
72
|
+
"tests": {
|
|
73
|
+
"color": "d4c5f9",
|
|
74
|
+
"description": "Test coverage and the fake GitHub API"
|
|
75
|
+
},
|
|
76
|
+
"enhancement": {
|
|
77
|
+
"color": "a2eeef",
|
|
78
|
+
"description": "New capability"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"marked": "^17.0.0"
|
|
84
|
+
}
|
|
85
|
+
}
|