@miroshnik/elves 2026.1019.0 → 2026.1019.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/elves.ts +159 -7
- package/dist/github/scripts/github.js +50 -13
- package/package.json +2 -2
- package/skills/github/reference.md +54 -24
- package/skills/github/scripts/github.ts +74 -13
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ No exaggeration: the spec format isn't the main token cost; what costs more is h
|
|
|
155
155
|
You need `git` and [Bun](https://bun.sh) ≥ 1.2 — the installer (`bunx`) and the skill scripts run under it; a project's CI on Node adds the `oven-sh/setup-bun` step; the `github`, `est` and `dashboard` skills need [`gh`](https://cli.github.com) with the `project` scope (`gh auth refresh -s project`); `task-env` needs Docker with Compose (Docker Desktop on macOS).
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
|
-
# onto the machine — the canon, skills and the hook for every agent it has; or a global package:
|
|
158
|
+
# onto the machine — the canon, skills and the hook for every agent it has, and the elves command on PATH; or a global package:
|
|
159
159
|
# bun add -g @miroshnik/elves && elves install -g
|
|
160
160
|
bunx @miroshnik/elves install -g
|
|
161
161
|
|
|
@@ -193,7 +193,7 @@ An agent's sandbox (Claude Code's Bash) doesn't let bunx write its cache `~/.bun
|
|
|
193
193
|
- Updating — `update` bumps the package version: `package.json` and the lockfile, a few lines in the branch of the current task, committed with it. A project with a copy of the flow of an earlier release moves to a thin one in a task of its own, one per project — `check` prints its `task new`, the hook names the canon loaded twice until then: `update` removes the copy, its symlinks and `.gitignore` exceptions, leads imports and CI paths into the copy to the package and lists what else names `.agents`.
|
|
194
194
|
- The manifest, the shim, `AGENTS.md` and `.claude/settings.json`, if the project's git ignores them (`.agents/` or `.claude/` in `.gitignore`), are un-ignored by `install` and `update` with exceptions — an Elves block at the end of `.gitignore`, committed with them; what an exception can't un-ignore (a directory excluded by a personal rule), and anything ignored until `update` runs — `❌` in the output of the install and of `check`.
|
|
195
195
|
|
|
196
|
-
**Onto the machine** (`-g`) — the canon, the skills and the hook: a thin project keeps no copy of the canon, its agents get it from the machine. `~/.agents/elves/` — `AGENTS.md`, `claude/CLAUDE.md`, `docs/*.md`; Claude Code — symlinks `~/.claude/rules/elves.md` and `elves-claude.md` (loaded in every project without the external-imports dialog), `~/.claude/skills` and the `SessionStart` hook in `~/.claude/settings.json`; the other agents — their global rule file linking to the canon (`~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.copilot/copilot-instructions.md`, `~/.config/opencode/AGENTS.md`, `~/.config/amp/AGENTS.md`; a file of your own stays) and `~/.agents/skills`. A project that still has a copy loads the canon twice until it moves — the hook names it. A Claude Code cloud session gets the same from the project's `SessionStart` hook — `npm i -g bun@latest @miroshnik/elves && elves install -g` (the image's Bun lags behind), then `check --hook`; the machine's hook isn't there (the cloud doesn't read `~/.claude/settings.json`), an environment's setup script with the same command is an optional faster start. In an Elves clone with `--link` the machine's canon is the clone; its `.claude/CLAUDE.md` keeps the root `AGENTS.md` from loading a second time.
|
|
196
|
+
**Onto the machine** (`-g`) — the canon, the skills and the hook: a thin project keeps no copy of the canon, its agents get it from the machine. `~/.agents/elves/` — `AGENTS.md`, `claude/CLAUDE.md`, `docs/*.md`; Claude Code — symlinks `~/.claude/rules/elves.md` and `elves-claude.md` (loaded in every project without the external-imports dialog), `~/.claude/skills` and the `SessionStart` hook in `~/.claude/settings.json`; the other agents — their global rule file linking to the canon (`~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.copilot/copilot-instructions.md`, `~/.config/opencode/AGENTS.md`, `~/.config/amp/AGENTS.md`; a file of your own stays) and `~/.agents/skills`. The `elves` command in the terminal — in `~/.local/bin`, or the first directory of `PATH` inside the home directory you can write to: with `--link` a symlink to the clone's `bin/elves.ts`, otherwise a shim to the machine's release (`bunx @miroshnik/elves@<version>`) that `update -g` rewrites; an `elves` of your own (a script, a global package) stays, and `check -g` names it, a command missing or of another release, an `elves` earlier on `PATH`, and — when `PATH` has no such directory — the command that adds `~/.local/bin`. A project that still has a copy loads the canon twice until it moves — the hook names it. A Claude Code cloud session gets the same from the project's `SessionStart` hook — `npm i -g bun@latest @miroshnik/elves && elves install -g` (the image's Bun lags behind), then `check --hook`; the machine's hook isn't there (the cloud doesn't read `~/.claude/settings.json`), an environment's setup script with the same command is an optional faster start. In an Elves clone with `--link` the machine's canon is the clone; its `.claude/CLAUDE.md` keeps the root `AGENTS.md` from loading a second time.
|
|
197
197
|
|
|
198
198
|
**Personal configuration** — everything that depends on specific repositories (the registry for `est`, your own model prices, notes) — lives outside the flow, in `~/.config/elves`; another directory — the `ELVES_CONFIG_DIR` variable, read by all skill scripts. `ELVES_PRIVATE=<path>` with `install -g` makes `~/.config/elves` a symlink to a private checkout; with `ELVES_CONFIG_DIR` the symlink isn't needed — the variable points at the checkout itself.
|
|
199
199
|
|
package/bin/elves.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* bunx @miroshnik/elves install — into the project: the git root (or the current directory)
|
|
8
8
|
* bunx @miroshnik/elves install -g — machine-wide: the canon, skills and hook
|
|
9
|
-
* elves install -g — the same from the npm package (npm i -g @miroshnik/elves)
|
|
9
|
+
* elves install -g — the same from the npm package (npm i -g @miroshnik/elves);
|
|
10
|
+
* install -g puts the elves command on PATH (`installCommand`)
|
|
10
11
|
* bun <Elves clone>/bin/elves.ts install -g --link — from a clone: symlinks to the clone, edits show at once
|
|
11
12
|
* bunx @miroshnik/elves check [-g] — is the install behind: 0 — no, 1 — yes, 2 — can't tell
|
|
12
13
|
* bunx @miroshnik/elves update [-g] — bring the install up to date
|
|
@@ -76,7 +77,7 @@
|
|
|
76
77
|
* proxy name) instead of holding copies. No dependencies, only node: APIs.
|
|
77
78
|
*/
|
|
78
79
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
79
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
80
|
+
import { accessSync, constants as fsConstants, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
80
81
|
import os from "node:os";
|
|
81
82
|
import path from "node:path";
|
|
82
83
|
import { isatty } from "node:tty";
|
|
@@ -223,7 +224,10 @@ terminal — the previous one. In an Elves clone writes only auto and language,
|
|
|
223
224
|
from the clone itself.
|
|
224
225
|
-g — onto the machine: the canon in ~/.agents/elves with Claude Code's rules ~/.claude/rules/elves*.md and the global
|
|
225
226
|
rule files of Codex, Gemini CLI, Copilot, OpenCode and Amp linking to it (someone else's file stays), the skills
|
|
226
|
-
(~/.agents/skills, ~/.claude/skills)
|
|
227
|
+
(~/.agents/skills, ~/.claude/skills), the SessionStart hook and the elves command in the terminal — in ~/.local/bin or
|
|
228
|
+
the first directory of PATH inside the home directory that can be written: with --link a symlink to the clone's
|
|
229
|
+
bin/elves.ts, otherwise a shim to the machine's release (bunx ${PACKAGE}@<version>); someone else's elves there stays
|
|
230
|
+
(!), no such directory — no command, check -g names how to add one; without a terminal too — a cloud session's project hook
|
|
227
231
|
or setup script (CLAUDE_CODE_REMOTE=true) gets the canon and skills without the hook: the cloud doesn't read
|
|
228
232
|
~/.claude/settings.json. From the npm package (npm i -g / bun add -g ${PACKAGE}) — the release of its version, without
|
|
229
233
|
the network. --link — from an Elves clone: symlinks to the clone instead of copies, edits show up immediately.
|
|
@@ -242,7 +246,9 @@ mode (auto) and the language, holds the compose file to the task isolation rules
|
|
|
242
246
|
docker compose; the task-env skill), names a test that runs standards and architecture again next to test:spec (○, the
|
|
243
247
|
ready test without them) and warns (!) if a script in slot calls turbo and turbo.json doesn't pass the queue variables
|
|
244
248
|
to tasks or a line leads into a file of the flow copy that update doesn't rewrite — the file, the line number and its
|
|
245
|
-
text (the exit code stays the same);
|
|
249
|
+
text (the exit code stays the same); for the machine the elves command missing or of another release is falling
|
|
250
|
+
behind, someone else's elves in its place or one earlier on PATH — !, no directory for it — ! with the command that
|
|
251
|
+
adds ~/.local/bin to PATH; a thin project behind — update in this session's task (the package version in
|
|
246
252
|
package.json and the lockfile), a project with a copy of the flow — the move to a thin project in a task of its own: the
|
|
247
253
|
task new command of the github skill, one task per project. For the machine names the task-env proxy of *.localhost
|
|
248
254
|
addresses — running, not running or running
|
|
@@ -260,7 +266,7 @@ Examples:
|
|
|
260
266
|
update: `elves update [-g]
|
|
261
267
|
|
|
262
268
|
Brings it up to date: a --link clone — git pull --ff-only, the machine's copy — install of the latest release: the
|
|
263
|
-
canon, the skills and the
|
|
269
|
+
canon, the skills, the hook and the elves shim of that release; a project — install of the latest release: ${PACKAGE} in package.json and the
|
|
264
270
|
lockfile, a few lines in the branch of the current task; a project with a copy of the flow moves to a thin one (install
|
|
265
271
|
--help), by hand after it — a check of the project that knows the queue wrapper by the copy's path (. .agents/slot.sh
|
|
266
272
|
now), AI_DEV_SLOT* in the project's code → ELVES_SLOT*, formatting the shortened imports, git add -A before the first
|
|
@@ -1794,6 +1800,150 @@ function agentRules(home: string, has: (dir: string, cmd?: string) => boolean) {
|
|
|
1794
1800
|
}
|
|
1795
1801
|
}
|
|
1796
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* The `elves` command in the terminal (#632): `bunx @miroshnik/elves …` and `bun <clone>/bin/elves.ts …` aren't a
|
|
1805
|
+
* command, and the global package's bin directory (`~/.bun/bin`) may not be on PATH at all — Bun from Homebrew.
|
|
1806
|
+
* Where — `commandDir`; what — with `--link` a symlink to the clone's `bin/elves.ts` (its `sh` shebang works through a
|
|
1807
|
+
* symlink), otherwise a shim to the machine's release through bunx (`commandShim`): the bunx directory is temporary,
|
|
1808
|
+
* and the command matches the canon on the machine — `update -g` rewrites it with the release. Ours (`ownCommand`) is
|
|
1809
|
+
* replaced; someone else's `elves` in that directory — a script of the user's, a global package — stays with a `!`
|
|
1810
|
+
* line, and so does an `elves` earlier on PATH that the shell runs instead. No directory for it — no command: the `!`
|
|
1811
|
+
* line of `check -g` has the command that adds `~/.local/bin` to PATH. Not in a cloud session: there the flow comes
|
|
1812
|
+
* by `npm i -g` (`CLOUD_FLOW`), and its `elves` is the command.
|
|
1813
|
+
*/
|
|
1814
|
+
const COMMAND = "elves";
|
|
1815
|
+
/** The shim's line by which the install knows its own file. */
|
|
1816
|
+
const COMMAND_MARK = "# Elves: the machine's elves command";
|
|
1817
|
+
/** What a dry run names next to the command's path. */
|
|
1818
|
+
const COMMAND_NOTE = "the elves command in the terminal";
|
|
1819
|
+
|
|
1820
|
+
/** PATH directories: absolute, in order, without repeats. */
|
|
1821
|
+
const pathDirs = () => [...new Set((process.env.PATH ?? "").split(path.delimiter).filter((d) => path.isAbsolute(d)).map((d) => path.resolve(d)))];
|
|
1822
|
+
|
|
1823
|
+
/** A directory the install can write into. */
|
|
1824
|
+
function writableDir(dir: string) {
|
|
1825
|
+
try {
|
|
1826
|
+
accessSync(dir, fsConstants.W_OK);
|
|
1827
|
+
return statSync(dir).isDirectory();
|
|
1828
|
+
} catch {
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/** A file the shell runs: a regular file (through symlinks) with the execute bit. */
|
|
1834
|
+
function runnable(file: string) {
|
|
1835
|
+
try {
|
|
1836
|
+
accessSync(file, fsConstants.X_OK);
|
|
1837
|
+
return statSync(file).isFile();
|
|
1838
|
+
} catch {
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/** Under dir, dir itself aside — by the path as given or by the real one (macOS /var → /private/var). */
|
|
1844
|
+
function inside(dir: string, p: string) {
|
|
1845
|
+
const real = (x: string) => {
|
|
1846
|
+
try {
|
|
1847
|
+
return realpathSync(x);
|
|
1848
|
+
} catch {
|
|
1849
|
+
return x;
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
return [
|
|
1853
|
+
[dir, p],
|
|
1854
|
+
[real(dir), real(p)],
|
|
1855
|
+
].some(([d, x]) => {
|
|
1856
|
+
const rel = path.relative(d!, x!);
|
|
1857
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* The install's own command: a symlink to a clone's `bin/elves.ts` — not into node_modules, that is a global package;
|
|
1863
|
+
* a clone gone or moved too — or a file with COMMAND_MARK.
|
|
1864
|
+
*/
|
|
1865
|
+
function ownCommand(file: string) {
|
|
1866
|
+
const st = lstat(file);
|
|
1867
|
+
if (st?.isSymbolicLink()) {
|
|
1868
|
+
const to = readlinkSync(file);
|
|
1869
|
+
return /(?:^|\/)bin\/elves\.ts$/.test(to) && !to.split("/").includes("node_modules");
|
|
1870
|
+
}
|
|
1871
|
+
if (!st?.isFile() || st.size > 4096) return false;
|
|
1872
|
+
try {
|
|
1873
|
+
return readFileSync(file, "utf8").includes(COMMAND_MARK);
|
|
1874
|
+
} catch {
|
|
1875
|
+
return false;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* The command's directory: where PATH already has ours; otherwise `~/.local/bin` — if PATH has it (missing — the
|
|
1881
|
+
* install creates it); otherwise the first directory of PATH inside the home directory that can be written — not
|
|
1882
|
+
* outside it: system directories need root, and the install doesn't ask for it. null — no such directory on PATH.
|
|
1883
|
+
*/
|
|
1884
|
+
function commandDir(home: string): string | null {
|
|
1885
|
+
const dirs = pathDirs();
|
|
1886
|
+
const own = dirs.find((d) => ownCommand(path.join(d, COMMAND)));
|
|
1887
|
+
if (own) return own;
|
|
1888
|
+
const mine = dirs.filter((d) => inside(home, d));
|
|
1889
|
+
const local = path.join(home, ".local/bin");
|
|
1890
|
+
if (mine.includes(local) && (!lstat(local) || writableDir(local))) return local;
|
|
1891
|
+
return mine.find(writableDir) ?? null;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* The shim of a machine with a copy: the release it was installed from by its npm package — the bunx package of the
|
|
1896
|
+
* same version as the canon; a release unknown — by the SHA (a clone without `--link`, a package pinned by SHA),
|
|
1897
|
+
* without it — the latest release.
|
|
1898
|
+
*/
|
|
1899
|
+
function commandShim(src: string) {
|
|
1900
|
+
const version = release ? npmVersion(release) : null;
|
|
1901
|
+
const sha = version ? null : sourceSha(src);
|
|
1902
|
+
const spec = version ? `${PACKAGE}@${version}` : sha ? `github:${SOURCE}#${sha}` : PACKAGE;
|
|
1903
|
+
const what = version ? `release ${release}` : sha ? `Elves ${short(sha)}` : "the latest release";
|
|
1904
|
+
return ["#!/bin/sh", `${COMMAND_MARK} — ${what}; set by \`${RUN} install -g\`, update -g rewrites it`, `exec bunx --silent ${spec} "$@"`, ""].join("\n");
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* No directory for the command: what the owner runs — `~/.local/bin` into PATH in the shell's profile and the same
|
|
1909
|
+
* install with it on PATH.
|
|
1910
|
+
*/
|
|
1911
|
+
function pathCommand(src: string, linkMode: boolean) {
|
|
1912
|
+
const shell = path.basename(process.env.SHELL ?? "");
|
|
1913
|
+
const profile = shell === "zsh" ? "~/.zshrc" : shell === "bash" ? "~/.bashrc" : "~/.profile";
|
|
1914
|
+
const again = linkMode ? `bun --no-env-file ${shWord(path.join(src, "bin/elves.ts"))} install -g --link` : `${RUN} update -g`;
|
|
1915
|
+
return `mkdir -p ~/.local/bin && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ${profile} && PATH="$HOME/.local/bin:$PATH" ${again}`;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/** The `elves` command (see COMMAND): a dry run names what it would change and the `!` lines. */
|
|
1919
|
+
function installCommand(src: string, home: string, linkMode: boolean) {
|
|
1920
|
+
if (remote()) return;
|
|
1921
|
+
const shown = (p: string) => (inside(home, p) ? `~/${path.relative(home, p)}` : p);
|
|
1922
|
+
const say = (file: string, text: string) => (dry ? void change("!", file, { note: text }) : warn(`${shown(file)} — ${text}`));
|
|
1923
|
+
const dir = commandDir(home);
|
|
1924
|
+
if (!dir) {
|
|
1925
|
+
const text = `${COMMAND_NOTE} isn't installed: PATH has no directory inside the home directory that can be written — add ~/.local/bin: ${pathCommand(src, linkMode)}`;
|
|
1926
|
+
return dry ? void change("!", path.join(home, ".local/bin", COMMAND), { note: text }) : void note(text);
|
|
1927
|
+
}
|
|
1928
|
+
const dst = path.join(dir, COMMAND);
|
|
1929
|
+
const st = lstat(dst);
|
|
1930
|
+
if (st && !ownCommand(dst)) return say(dst, `someone else's elves (a script of the user's, a global package) — left as is; the machine's command goes here once it's removed: ${RUN} update -g`);
|
|
1931
|
+
const target = path.join(src, "bin/elves.ts");
|
|
1932
|
+
const text = linkMode ? linkText(target, dst, home) : commandShim(src);
|
|
1933
|
+
const same = linkMode ? Boolean(st?.isSymbolicLink()) && readlinkSync(dst) === text : Boolean(st?.isFile()) && runnable(dst) && readFileSync(dst, "utf8") === text;
|
|
1934
|
+
if (!same && dry) change(st ? "~" : "+", dst, { note: COMMAND_NOTE });
|
|
1935
|
+
else if (!same) {
|
|
1936
|
+
if (st) rmSync(dst);
|
|
1937
|
+
mkdirSync(dir, { recursive: true });
|
|
1938
|
+
if (linkMode) symlinkSync(text, dst);
|
|
1939
|
+
else writeFileSync(dst, text, { mode: 0o755 });
|
|
1940
|
+
note(`${shown(dst)} — ${COMMAND_NOTE}: ${linkMode ? `→ ${target}` : (/^exec (.*) "\$@"$/m.exec(text)?.[1] ?? "")}`);
|
|
1941
|
+
}
|
|
1942
|
+
const dirs = pathDirs();
|
|
1943
|
+
const first = dirs.slice(0, dirs.indexOf(dir)).map((d) => path.join(d, COMMAND)).find(runnable);
|
|
1944
|
+
if (first) say(first, `comes first on PATH: elves runs it, not ${shown(dst)} — remove it or put ${shown(dir)} before ${shown(path.dirname(first))} in PATH`);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1797
1947
|
/**
|
|
1798
1948
|
* The URL of a GitHub origin on the repository's name before the rename (LEGACY_SOURCE, the name is case-insensitive) —
|
|
1799
1949
|
* the same URL on SOURCE: the form, the credentials, the port and `.git` stay; another repository or host — null.
|
|
@@ -1835,8 +1985,8 @@ const remote = () => process.env.CLAUDE_CODE_REMOTE === "true";
|
|
|
1835
1985
|
/**
|
|
1836
1986
|
* The machine (#557): the canon `~/.agents/elves/` — a copy or, with `--link`, a symlink to the clone — with Claude
|
|
1837
1987
|
* Code's rules `~/.claude/rules/elves*.md` (the same `installRules` and `claudeRules` as in a project: what a previous
|
|
1838
|
-
* install left under the flow's former name moves), the other agents' rule files (`agentRules`), skills
|
|
1839
|
-
* Claude Code is on the machine — `~/.claude`, `claude` on PATH or a cloud session: its project hook (`cloudHook`) or
|
|
1988
|
+
* install left under the flow's former name moves), the other agents' rule files (`agentRules`), skills, the `elves`
|
|
1989
|
+
* command in the terminal (`installCommand`) and the hook. Claude Code is on the machine — `~/.claude`, `claude` on PATH or a cloud session: its project hook (`cloudHook`) or
|
|
1840
1990
|
* its environment's setup script runs in a fresh home. The cloud doesn't read `~/.claude/settings.json` — no hook there.
|
|
1841
1991
|
*/
|
|
1842
1992
|
function installGlobal(src: string, linkMode: boolean) {
|
|
@@ -1879,6 +2029,8 @@ function installGlobal(src: string, linkMode: boolean) {
|
|
|
1879
2029
|
note(`${CONFIG} → ${priv}`);
|
|
1880
2030
|
}
|
|
1881
2031
|
}
|
|
2032
|
+
// the last step: an agent's sandbox may refuse a directory of PATH — the canon, the skills and the hook are in place
|
|
2033
|
+
installCommand(src, home, linkMode);
|
|
1882
2034
|
}
|
|
1883
2035
|
|
|
1884
2036
|
/**
|
|
@@ -11,12 +11,14 @@ import path from "node:path";
|
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
12
|
var N = locale_default.names(locale_default.projectLanguage());
|
|
13
13
|
var canonViews = () => [
|
|
14
|
-
{ name: N.views.board, layout: "BOARD_LAYOUT" },
|
|
15
|
-
{ name: N.views.table, layout: "TABLE_LAYOUT" },
|
|
14
|
+
{ name: N.views.board, layout: "BOARD_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "SUB_ISSUES_PROGRESS"] },
|
|
15
|
+
{ name: N.views.table, layout: "TABLE_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "ASSIGNEES", "SUB_ISSUES_PROGRESS", N.fields.estimate], groupBy: STATUS },
|
|
16
16
|
{ name: N.views.roadmap, layout: "ROADMAP_LAYOUT" }
|
|
17
17
|
];
|
|
18
|
+
var BUILTIN_COLUMNS = { TITLE: "Title", ASSIGNEES: "Assignees", LABELS: "Labels", SUB_ISSUES_PROGRESS: "Sub-issues progress" };
|
|
18
19
|
var STATUS = "Status";
|
|
19
20
|
var statusOptions = () => [N.status.backlog, N.status.inProgress, N.status.done];
|
|
21
|
+
var STATUS_COLORS = ["GREEN", "YELLOW", "PURPLE"];
|
|
20
22
|
var numberFields = () => [N.fields.estimate, N.fields.actual, N.fields.tokens, N.fields.cost];
|
|
21
23
|
var issueTypes = () => [N.issueTypes.task, N.issueTypes.bug, N.issueTypes.epic];
|
|
22
24
|
var PRIORITY = "Priority";
|
|
@@ -36,6 +38,12 @@ var ISSUE_LABEL_LIMIT = 100;
|
|
|
36
38
|
var WORKFLOW_ADDED = "Item added to project";
|
|
37
39
|
var WORKFLOW_CLOSED = "Item closed";
|
|
38
40
|
var WORKFLOW_AUTO_ADD = "Auto-add to project";
|
|
41
|
+
var WORKFLOW_MERGED = "Pull request merged";
|
|
42
|
+
var WORKFLOW_AUTO_CLOSE = "Auto-close issue";
|
|
43
|
+
var WORKFLOW_SUB_ISSUES = "Auto-add sub-issues to project";
|
|
44
|
+
var WORKFLOW_ARCHIVE = "Auto-archive items";
|
|
45
|
+
var WORKFLOWS = [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_MERGED, WORKFLOW_AUTO_CLOSE, WORKFLOW_AUTO_ADD, WORKFLOW_SUB_ISSUES, WORKFLOW_ARCHIVE];
|
|
46
|
+
var ARCHIVE_FILTER = "is:issue is:closed updated:<@today-2w";
|
|
39
47
|
var DEFAULT_TEMPLATE = "miroshnik/6";
|
|
40
48
|
var RULESET = "elves";
|
|
41
49
|
var FORMER_RULESET = "ai-dev";
|
|
@@ -122,6 +130,8 @@ var Q = {
|
|
|
122
130
|
id number name layout filter
|
|
123
131
|
sortByFields(first: 5) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } }
|
|
124
132
|
verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
|
|
133
|
+
groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
|
|
134
|
+
fields(first: 50) { nodes { ... on ProjectV2FieldCommon { id name dataType } } }
|
|
125
135
|
}
|
|
126
136
|
}
|
|
127
137
|
fields(first: 100) {
|
|
@@ -650,7 +660,9 @@ function loadProject(io, id, withItems = true) {
|
|
|
650
660
|
layout: v.layout,
|
|
651
661
|
filter: v.filter ?? null,
|
|
652
662
|
sortBy: (v.sortByFields?.nodes ?? []).map((s) => s.field?.name).filter(Boolean),
|
|
653
|
-
columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f) => f?.name).filter(Boolean)
|
|
663
|
+
columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f) => f?.name).filter(Boolean),
|
|
664
|
+
groupBy: (v.groupByFields?.nodes ?? []).map((f) => f?.name).filter(Boolean),
|
|
665
|
+
columns: (v.fields?.nodes ?? []).filter((f) => f?.id).map((f) => ({ id: f.id, name: f.name, dataType: f.dataType }))
|
|
654
666
|
})),
|
|
655
667
|
fields: p.fields.nodes.filter((f) => f && f.id).map((f) => ({ id: f.id, name: f.name, dataType: f.dataType, isIssueField: !!f.isIssueField, options: f.options ?? null })),
|
|
656
668
|
workflows: p.workflows.nodes.filter(Boolean),
|
|
@@ -692,7 +704,12 @@ function analyze(s) {
|
|
|
692
704
|
for (const other of s.repo.linked.filter((x) => x.id !== p.id)) {
|
|
693
705
|
link(`another project is linked: ${q(other.title)} (${other.url})`, { kind: "confirm", text: `unlink project ${q(other.title)} from the repository`, mutations: [{ op: "UnlinkProject", input: { projectId: other.id, repositoryId: s.repo.id } }] });
|
|
694
706
|
}
|
|
695
|
-
const
|
|
707
|
+
const fieldAlias = fieldAliases();
|
|
708
|
+
const columnKey = (f) => BUILTIN_FIELDS.has(f.dataType) ? f.dataType : fieldAlias[f.name] ?? f.name;
|
|
709
|
+
const columnName = (key) => p.fields.find((f) => columnKey(f) === key)?.name ?? BUILTIN_COLUMNS[key] ?? key;
|
|
710
|
+
const columnFields = (keys) => keys.map((k) => p.fields.find((f) => columnKey(f) === k)).filter((f) => !!f);
|
|
711
|
+
const configuration = (keys) => ({ visibleFieldIds: columnFields(keys).map((f) => f.id) });
|
|
712
|
+
const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, columns and grouping, no filter, no others`);
|
|
696
713
|
const canonNames = new Set(canonViews().map((v) => v.name));
|
|
697
714
|
const spare = p.views.filter((v) => !canonNames.has(v.name));
|
|
698
715
|
for (const c of canonViews()) {
|
|
@@ -703,7 +720,8 @@ function analyze(s) {
|
|
|
703
720
|
spare.splice(spare.indexOf(cand), 1);
|
|
704
721
|
views(`missing ${q(c.name)}`, guarded(`rename view ${q(cand.name)} → ${q(c.name)}`, { op: "UpdateView", input: { viewId: cand.id, name: c.name } }));
|
|
705
722
|
} else {
|
|
706
|
-
|
|
723
|
+
const input = { projectId: p.id, name: c.name, layout: c.layout, ...c.columns ? { configuration: configuration(c.columns) } : {} };
|
|
724
|
+
views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input }));
|
|
707
725
|
}
|
|
708
726
|
continue;
|
|
709
727
|
}
|
|
@@ -714,13 +732,26 @@ function analyze(s) {
|
|
|
714
732
|
if (c.layout === "BOARD_LAYOUT" && v.layout === c.layout && v.columnsBy[0] !== STATUS) {
|
|
715
733
|
views(`${q(c.name)} columns not by Status`, ui(`${q(c.name)}: View options → Column by → Status → Save`, `${p.url}/views/${v.number}`));
|
|
716
734
|
}
|
|
735
|
+
if (c.columns) {
|
|
736
|
+
const have = v.columns.map(columnKey);
|
|
737
|
+
const missing = c.columns.filter((k) => !have.includes(k)).map((k) => q(columnName(k)));
|
|
738
|
+
const extra = v.columns.filter((f) => !c.columns.includes(columnKey(f))).map((f) => q(f.name));
|
|
739
|
+
if (missing.length || extra.length) {
|
|
740
|
+
const why = [missing.length ? `missing ${missing.join(", ")}` : "", extra.length ? `extra ${extra.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
741
|
+
const set = columnFields(c.columns).map((f) => f.name).join(", ");
|
|
742
|
+
views(`${q(c.name)} columns: ${why}`, api(`${q(c.name)}: columns ${set}`, { op: "UpdateView", input: { viewId: v.id, configuration: configuration(c.columns) } }));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (c.groupBy && v.layout === c.layout && v.groupBy[0] !== c.groupBy) {
|
|
746
|
+
views(`${q(c.name)} not grouped by ${c.groupBy}`, ui(`${q(c.name)}: View options → Group by → ${c.groupBy} → Save`, `${p.url}/views/${v.number}`));
|
|
747
|
+
}
|
|
717
748
|
}
|
|
718
749
|
for (const v of spare)
|
|
719
750
|
views(`extra ${q(v.name)}`, guarded(`delete view ${q(v.name)}`, { op: "DeleteView", input: { viewId: v.id } }));
|
|
720
|
-
const status = add("status", `${STATUS}: ${statusOptions().join(" → ")}`);
|
|
751
|
+
const status = add("status", `${STATUS}: ${statusOptions().join(" → ")} (${STATUS_COLORS.join(", ")})`);
|
|
721
752
|
const sf = p.fields.find((f) => f.name === STATUS && f.options);
|
|
722
753
|
if (!sf) {
|
|
723
|
-
status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name) => ({ name, color:
|
|
754
|
+
status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name, i) => ({ name, color: STATUS_COLORS[i], description: "" })) } }));
|
|
724
755
|
} else if (sf.options.map((o) => o.name).join(`
|
|
725
756
|
`) !== statusOptions().join(`
|
|
726
757
|
`)) {
|
|
@@ -730,15 +761,17 @@ function analyze(s) {
|
|
|
730
761
|
return i < 0 ? null : left.splice(i, 1)[0];
|
|
731
762
|
};
|
|
732
763
|
const aliases = statusAliases();
|
|
733
|
-
const next = statusOptions().map((name) => {
|
|
764
|
+
const next = statusOptions().map((name, i) => {
|
|
734
765
|
const o = take((x) => x.name === name) ?? take((x) => aliases[x.name.toLowerCase()] === name);
|
|
735
|
-
return o ? { id: o.id, name, color:
|
|
766
|
+
return o ? { id: o.id, name, color: STATUS_COLORS[i], description: o.description ?? "" } : { name, color: STATUS_COLORS[i], description: "" };
|
|
736
767
|
});
|
|
737
768
|
const drop = left.length ? `; delete ${left.map((o) => q(o.name)).join(", ")}` : "";
|
|
738
769
|
status(`options: ${sf.options.map((o) => o.name).join(", ") || "none"}`, guarded(`${STATUS}: ${statusOptions().join(", ")} (rename the existing ones keeping the id${drop})`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
|
|
770
|
+
} else if (sf.options.some((o, i) => o.color !== STATUS_COLORS[i])) {
|
|
771
|
+
const next = sf.options.map((o, i) => ({ id: o.id, name: o.name, color: STATUS_COLORS[i], description: o.description ?? "" }));
|
|
772
|
+
status(`colors: ${sf.options.map((o) => `${o.name} ${o.color}`).join(", ")}`, api(`${STATUS}: colors ${STATUS_COLORS.join(", ")} (keeping the id)`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
|
|
739
773
|
}
|
|
740
774
|
const fields = add("fields", `Fields ${numberFields().map(q).join(", ")} (Number), no other custom ones`);
|
|
741
|
-
const fieldAlias = fieldAliases();
|
|
742
775
|
const renamed = new Set;
|
|
743
776
|
for (const name of numberFields()) {
|
|
744
777
|
const f = p.fields.find((x) => x.name === name);
|
|
@@ -804,13 +837,17 @@ function analyze(s) {
|
|
|
804
837
|
types(`extra ${q(x.name)}`, { kind: "confirm", text: `disable type ${q(x.name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: x.id, isEnabled: false } }] });
|
|
805
838
|
}
|
|
806
839
|
}
|
|
807
|
-
const wf = add("workflows", `Workflows ${
|
|
840
|
+
const wf = add("workflows", `Workflows ${WORKFLOWS.map(q).join(", ")} are on`);
|
|
808
841
|
const how = {
|
|
809
842
|
[WORKFLOW_ADDED]: `Edit → Set value → ${STATUS}: ${N.status.backlog}`,
|
|
810
843
|
[WORKFLOW_CLOSED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
|
|
811
|
-
[
|
|
844
|
+
[WORKFLOW_MERGED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
|
|
845
|
+
[WORKFLOW_AUTO_CLOSE]: `Edit → ${STATUS}: ${N.status.done}`,
|
|
846
|
+
[WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`,
|
|
847
|
+
[WORKFLOW_SUB_ISSUES]: "Edit",
|
|
848
|
+
[WORKFLOW_ARCHIVE]: `Edit → filter ${ARCHIVE_FILTER} (Enter)`
|
|
812
849
|
};
|
|
813
|
-
for (const name of
|
|
850
|
+
for (const name of WORKFLOWS) {
|
|
814
851
|
const w = p.workflows.find((x) => x.name === name);
|
|
815
852
|
if (w?.enabled)
|
|
816
853
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miroshnik/elves",
|
|
3
|
-
"version": "2026.1019.
|
|
3
|
+
"version": "2026.1019.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Elves — a development flow with AI agents: shared rules and skills for any agent; install — bunx @miroshnik/elves install -g; the installer and skill scripts — TypeScript under Bun, tests — bun test in tests/",
|
|
6
6
|
"repository": {
|
|
@@ -71,5 +71,5 @@
|
|
|
71
71
|
"eslint-plugin-boundaries": "^7.2.0",
|
|
72
72
|
"knip": "^6.41.0"
|
|
73
73
|
},
|
|
74
|
-
"gitHead": "
|
|
74
|
+
"gitHead": "672760816ee853bd6c6cedf7bd917a123ee0fb41"
|
|
75
75
|
}
|
|
@@ -97,9 +97,15 @@ Every repository has **exactly one** GitHub Project linked to the repo
|
|
|
97
97
|
per epic/area (an area = an epic inside the shared project). All tasks are
|
|
98
98
|
added to the project right on creation (`github task new`). Required:
|
|
99
99
|
|
|
100
|
-
- **Three views**, with exactly these names
|
|
101
|
-
|
|
102
|
-
-
|
|
100
|
+
- **Three views**, with exactly these names, as in the template:
|
|
101
|
+
- `Board` (BOARD_LAYOUT, columns by `Status`) — cards show Title, Status,
|
|
102
|
+
Labels, Sub-issues progress;
|
|
103
|
+
- `Table` (TABLE_LAYOUT, grouped by `Status`) — columns Title, Status,
|
|
104
|
+
Labels, Assignees, Sub-issues progress, «Estimate, h»;
|
|
105
|
+
- `Roadmap` (ROADMAP_LAYOUT).
|
|
106
|
+
- **The `Status` field** with options `Backlog` → `In progress` → `Done`,
|
|
107
|
+
colored `GREEN`, `YELLOW`, `PURPLE` (a new GitHub project's colors);
|
|
108
|
+
option descriptions are the project's own.
|
|
103
109
|
I change the status **myself, immediately**, without waiting to be asked
|
|
104
110
|
(`github task status <N> <status>`):
|
|
105
111
|
- a new task — I set Backlog on creation;
|
|
@@ -118,12 +124,15 @@ added to the project right on creation (`github task new`). Required:
|
|
|
118
124
|
(those closed as not planned don't count — they are no longer in the
|
|
119
125
|
project).
|
|
120
126
|
Safety net — the project's built-in workflows "Item added to project" →
|
|
121
|
-
Backlog, "Item closed"
|
|
122
|
-
the
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
Backlog, "Item closed" and "Pull request merged" → Done, "Auto-close issue"
|
|
128
|
+
(Done → the issue is closed), "Auto-add to project" (new open issues of
|
|
129
|
+
the repository get into the project by themselves) and "Auto-add sub-issues
|
|
130
|
+
to project": the status gets set even if there is no one to set it (a cloud
|
|
131
|
+
session, another agent, closing from the GitHub UI). The agent sets the
|
|
132
|
+
status anyway; a task closed as not planned it removes from the project —
|
|
133
|
+
otherwise the workflow puts it into Done. "Auto-archive items" (`is:issue
|
|
134
|
+
is:closed updated:<@today-2w`) takes a task closed and untouched for two
|
|
135
|
+
weeks off the board and the table.
|
|
127
136
|
- **The `Estimate, h` field** (Number) — the agent's active hours
|
|
128
137
|
(`AGENTS.md`, section "Estimate and actual — the `est` skill"). Set **only
|
|
129
138
|
by `github task new`** (the `est` script inside) — from analogs with
|
|
@@ -149,8 +158,11 @@ added to the project right on creation (`github task new`). Required:
|
|
|
149
158
|
unblocked. A personal account has no issue fields — no priority is kept.
|
|
150
159
|
`github task new` sets it (`--priority`, `Medium` by default).
|
|
151
160
|
|
|
152
|
-
Nothing extra: there are no other fields (Iteration, Readiness…) or
|
|
153
|
-
until they are added here.
|
|
161
|
+
Nothing extra: there are no other fields (Iteration, Readiness…), views or
|
|
162
|
+
columns until they are added here. What the public API doesn't read — the
|
|
163
|
+
slice by a field, the sub-issue hierarchy, column widths, the project's
|
|
164
|
+
description and README — isn't in the canon: a copy of the template carries
|
|
165
|
+
it, nothing checks it. Readiness — the status and *blocked by*; stages —
|
|
154
166
|
milestones (the `Milestone` field).
|
|
155
167
|
|
|
156
168
|
The project — the `github` skill: `project check` once per session before
|
|
@@ -313,16 +325,23 @@ description is rewritten.
|
|
|
313
325
|
|
|
314
326
|
1. The project is linked to the repository, it is the only one, its title =
|
|
315
327
|
the repository name.
|
|
316
|
-
2. Views `Board` (BOARD, columns by `Status`), `Table` (TABLE
|
|
317
|
-
(ROADMAP), filters empty, no others
|
|
318
|
-
|
|
328
|
+
2. Views `Board` (BOARD, columns by `Status`), `Table` (TABLE, grouped by
|
|
329
|
+
`Status`), `Roadmap` (ROADMAP), filters empty, no others; the columns of
|
|
330
|
+
`Board` and `Table` — the canon's set (a built-in field by its type, the
|
|
331
|
+
rest by name), `fix` sets them in the template's order. The roadmap's
|
|
332
|
+
columns aren't compared: the API gives it the default ones.
|
|
333
|
+
3. `Status`: `Backlog` → `In progress` → `Done`, colors `GREEN`, `YELLOW`,
|
|
334
|
+
`PURPLE`; descriptions aren't compared.
|
|
319
335
|
4. Fields «Estimate, h», «Actual, h», «Tokens, M», «Cost, $» (Number), no
|
|
320
336
|
custom fields beyond the canon.
|
|
321
337
|
5. Organization only: `Priority` — the organization's issue field, connected,
|
|
322
338
|
`Table` and `Board` are sorted by it; issue types — exactly «Task»,
|
|
323
339
|
«Bug», «Epic». A personal account has none — a `➖` line.
|
|
324
|
-
6.
|
|
325
|
-
"
|
|
340
|
+
6. The template's workflows are on: "Item added to project" (→ Backlog),
|
|
341
|
+
"Item closed" and "Pull request merged" (→ Done), "Auto-close issue",
|
|
342
|
+
"Auto-add to project" (`is:issue is:open`), "Auto-add sub-issues to
|
|
343
|
+
project", "Auto-archive items" (`is:issue is:closed updated:<@today-2w`).
|
|
344
|
+
Others aren't checked.
|
|
326
345
|
7. Closed tasks — in Done; those closed without being done (not planned,
|
|
327
346
|
duplicate) — outside the project.
|
|
328
347
|
8. The repository's open tasks — in the project, each has a `Status`.
|
|
@@ -379,7 +398,8 @@ description is rewritten.
|
|
|
379
398
|
### fix: who does what
|
|
380
399
|
|
|
381
400
|
- **API — the script itself:** linking, a template copy or a new project,
|
|
382
|
-
missing views and fields, view layout, removing a filter,
|
|
401
|
+
missing views and fields, view layout and columns, removing a filter,
|
|
402
|
+
`Status` options and colors,
|
|
383
403
|
the `Priority` issue field, task statuses, adding open tasks, removing tasks
|
|
384
404
|
closed without being done from the project, decision labels (creation,
|
|
385
405
|
color), the default-branch ruleset (creation, editing its own), merge
|
|
@@ -409,14 +429,19 @@ description is rewritten.
|
|
|
409
429
|
deleted (options were replaced without ids): pick it again.
|
|
410
430
|
- "Item added to project": trigger `issue, pull request` (default), Set
|
|
411
431
|
value → `Status: Backlog`.
|
|
412
|
-
- "Item closed": Set value → `Status: Done`.
|
|
432
|
+
- "Item closed", "Pull request merged": Set value → `Status: Done`.
|
|
433
|
+
- "Auto-close issue": `Status: Done` → close the issue.
|
|
413
434
|
- "Auto-add to project": the repository — this project's, filter
|
|
414
435
|
`is:issue is:open`, Enter — the "See N existing items" counter should
|
|
415
436
|
update. Without `is:open` an updated closed task (a not planned one too)
|
|
416
437
|
comes back into the project. Auto-add doesn't add already existing tasks —
|
|
417
438
|
`fix` adds them.
|
|
418
|
-
-
|
|
419
|
-
|
|
439
|
+
- "Auto-add sub-issues to project": nothing to set — Save and turn on.
|
|
440
|
+
- "Auto-archive items": filter `is:issue is:closed updated:<@today-2w`,
|
|
441
|
+
Enter.
|
|
442
|
+
- **Sorting by Priority, board columns, table grouping:** the view → the
|
|
443
|
+
`View` button on the right → `Sort by: Priority` / `Column by: Status` /
|
|
444
|
+
`Group by: Status` → `Save view` → the dialog
|
|
420
445
|
"Save display options for …?" → `Save`. Escape closes the dialog without
|
|
421
446
|
saving: the sorting stays only in the URL (`?sortedBy…`), and the tab's
|
|
422
447
|
unsaved marker lies — `check` verifies the result.
|
|
@@ -435,8 +460,9 @@ description is rewritten.
|
|
|
435
460
|
Done and leaves a step to check the target in the UI.
|
|
436
461
|
- **`Status` options are renamed keeping the same id**
|
|
437
462
|
(`updateProjectV2Field`, `singleSelectOptions[].id`): task values and the
|
|
438
|
-
"Item closed" target are kept. Replacing options without
|
|
439
|
-
task statuses vanish, "Item closed" sets the deleted
|
|
463
|
+
"Item closed" target are kept, recoloring too. Replacing options without
|
|
464
|
+
ids gives new ids — task statuses vanish, "Item closed" sets the deleted
|
|
465
|
+
`Done`.
|
|
440
466
|
- **The template** — the elves project (`miroshnik/6`): everything the API
|
|
441
467
|
lacks is configured there once. A copy (`copyProjectV2`) carries over views
|
|
442
468
|
with their settings, fields and configured workflows, except auto-add; it
|
|
@@ -444,12 +470,16 @@ description is rewritten.
|
|
|
444
470
|
is unavailable (someone else's account) or `--template none` — the project is
|
|
445
471
|
created from scratch (`createProjectV2`) and brought up via the API:
|
|
446
472
|
`Todo / In Progress / Done` → the canon with the same ids, `View 1` →
|
|
447
|
-
`Table`, the rest is created.
|
|
473
|
+
`Table`, the rest is created; the table's grouping is a UI step.
|
|
448
474
|
- **The view filter is empty:** `iteration:@current` without an Iteration field
|
|
449
475
|
silently hides tasks from the board.
|
|
450
476
|
- **`Priority` is the organization's issue field** (`isIssueField`), not a
|
|
451
477
|
project field of the same name. The API reads the sorting (`sortByFields`)
|
|
452
|
-
but doesn't set it; the board grouping too
|
|
478
|
+
but doesn't set it; the board grouping and the table grouping too
|
|
479
|
+
(`verticalGroupByFields`, `groupByFields`).
|
|
480
|
+
- **A view's columns are set** (`updateProjectV2View`,
|
|
481
|
+
`configuration.visibleFieldIds` in order) and read (`fields` of the view; a
|
|
482
|
+
roadmap without configured columns gives the default ones).
|
|
453
483
|
- **A project that has been used** — it has tasks.
|
|
454
484
|
|
|
455
485
|
## Task and PR commands — details
|
|
@@ -57,13 +57,27 @@ type Any = any;
|
|
|
57
57
|
*/
|
|
58
58
|
let N: Names = locale.names(locale.projectLanguage());
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Views as in the template: columns by `columnKey` in the template's order, the table grouped by Status. The roadmap's
|
|
62
|
+
* columns aren't compared: the API gives it the default ones, it has no configured columns.
|
|
63
|
+
*/
|
|
64
|
+
interface CanonView {
|
|
65
|
+
name: string;
|
|
66
|
+
layout: string;
|
|
67
|
+
columns?: string[];
|
|
68
|
+
groupBy?: string;
|
|
69
|
+
}
|
|
70
|
+
const canonViews = (): CanonView[] => [
|
|
71
|
+
{ name: N.views.board, layout: "BOARD_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "SUB_ISSUES_PROGRESS"] },
|
|
72
|
+
{ name: N.views.table, layout: "TABLE_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "ASSIGNEES", "SUB_ISSUES_PROGRESS", N.fields.estimate], groupBy: STATUS },
|
|
63
73
|
{ name: N.views.roadmap, layout: "ROADMAP_LAYOUT" },
|
|
64
74
|
];
|
|
75
|
+
/** A built-in column's name when the project has no such field yet (a column key is its type). */
|
|
76
|
+
const BUILTIN_COLUMNS: Record<string, string> = { TITLE: "Title", ASSIGNEES: "Assignees", LABELS: "Labels", SUB_ISSUES_PROGRESS: "Sub-issues progress" };
|
|
65
77
|
export const STATUS = "Status";
|
|
66
78
|
const statusOptions = () => [N.status.backlog, N.status.inProgress, N.status.done];
|
|
79
|
+
/** The template's colors of the Status options, in order — GitHub's colors of a new project's Todo / In Progress / Done. */
|
|
80
|
+
const STATUS_COLORS = ["GREEN", "YELLOW", "PURPLE"];
|
|
67
81
|
const numberFields = () => [N.fields.estimate, N.fields.actual, N.fields.tokens, N.fields.cost];
|
|
68
82
|
const issueTypes = () => [N.issueTypes.task, N.issueTypes.bug, N.issueTypes.epic];
|
|
69
83
|
export const PRIORITY = "Priority";
|
|
@@ -98,6 +112,14 @@ const ISSUE_LABEL_LIMIT = 100;
|
|
|
98
112
|
export const WORKFLOW_ADDED = "Item added to project";
|
|
99
113
|
export const WORKFLOW_CLOSED = "Item closed";
|
|
100
114
|
export const WORKFLOW_AUTO_ADD = "Auto-add to project";
|
|
115
|
+
const WORKFLOW_MERGED = "Pull request merged";
|
|
116
|
+
const WORKFLOW_AUTO_CLOSE = "Auto-close issue";
|
|
117
|
+
const WORKFLOW_SUB_ISSUES = "Auto-add sub-issues to project";
|
|
118
|
+
const WORKFLOW_ARCHIVE = "Auto-archive items";
|
|
119
|
+
/** The template's workflows, all on. */
|
|
120
|
+
const WORKFLOWS = [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_MERGED, WORKFLOW_AUTO_CLOSE, WORKFLOW_AUTO_ADD, WORKFLOW_SUB_ISSUES, WORKFLOW_ARCHIVE];
|
|
121
|
+
/** The template's auto-archive filter: a task closed and untouched for two weeks leaves the board and the table. */
|
|
122
|
+
const ARCHIVE_FILTER = "is:issue is:closed updated:<@today-2w";
|
|
101
123
|
/** The template: a new project is its copy (views, fields, configured workflows except auto-add). */
|
|
102
124
|
export const DEFAULT_TEMPLATE = "miroshnik/6";
|
|
103
125
|
/**
|
|
@@ -203,6 +225,9 @@ interface View {
|
|
|
203
225
|
filter: string | null;
|
|
204
226
|
sortBy: string[];
|
|
205
227
|
columnsBy: string[];
|
|
228
|
+
groupBy: string[];
|
|
229
|
+
/** The visible fields (`fields` of the view). */
|
|
230
|
+
columns: { id: string; name: string; dataType: string }[];
|
|
206
231
|
}
|
|
207
232
|
interface Option {
|
|
208
233
|
id: string;
|
|
@@ -388,6 +413,8 @@ export const Q = {
|
|
|
388
413
|
id number name layout filter
|
|
389
414
|
sortByFields(first: 5) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } }
|
|
390
415
|
verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
|
|
416
|
+
groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
|
|
417
|
+
fields(first: 50) { nodes { ... on ProjectV2FieldCommon { id name dataType } } }
|
|
391
418
|
}
|
|
392
419
|
}
|
|
393
420
|
fields(first: 100) {
|
|
@@ -1014,6 +1041,8 @@ function loadProject(io: Io, id: string, withItems = true): Project {
|
|
|
1014
1041
|
filter: v.filter ?? null,
|
|
1015
1042
|
sortBy: (v.sortByFields?.nodes ?? []).map((s: Any) => s.field?.name).filter(Boolean),
|
|
1016
1043
|
columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f: Any) => f?.name).filter(Boolean),
|
|
1044
|
+
groupBy: (v.groupByFields?.nodes ?? []).map((f: Any) => f?.name).filter(Boolean),
|
|
1045
|
+
columns: (v.fields?.nodes ?? []).filter((f: Any) => f?.id).map((f: Any) => ({ id: f.id, name: f.name, dataType: f.dataType })),
|
|
1017
1046
|
})),
|
|
1018
1047
|
fields: p.fields.nodes
|
|
1019
1048
|
.filter((f: Any) => f && f.id)
|
|
@@ -1066,8 +1095,17 @@ export function analyze(s: State): Check[] {
|
|
|
1066
1095
|
link(`another project is linked: ${q(other.title)} (${other.url})`, { kind: "confirm", text: `unlink project ${q(other.title)} from the repository`, mutations: [{ op: "UnlinkProject", input: { projectId: other.id, repositoryId: s.repo.id } }] });
|
|
1067
1096
|
}
|
|
1068
1097
|
|
|
1098
|
+
// Columns: a built-in field by its type, the rest by name; a numeric field in another dictionary language is the same
|
|
1099
|
+
// column — the Fields item renames it keeping the id.
|
|
1100
|
+
const fieldAlias = fieldAliases();
|
|
1101
|
+
const columnKey = (f: { name: string; dataType: string }) => (BUILTIN_FIELDS.has(f.dataType) ? f.dataType : (fieldAlias[f.name] ?? f.name));
|
|
1102
|
+
const columnName = (key: string) => p.fields.find((f) => columnKey(f) === key)?.name ?? BUILTIN_COLUMNS[key] ?? key;
|
|
1103
|
+
// a field the project doesn't have yet is skipped: the step's text changes when it appears, and the next round sets it
|
|
1104
|
+
const columnFields = (keys: string[]) => keys.map((k) => p.fields.find((f) => columnKey(f) === k)).filter((f): f is Field => !!f);
|
|
1105
|
+
const configuration = (keys: string[]) => ({ visibleFieldIds: columnFields(keys).map((f) => f.id) });
|
|
1106
|
+
|
|
1069
1107
|
// Views: by name; missing — rename an extra one of the same layout or create one; extra ones — delete.
|
|
1070
|
-
const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, no filter, no others`);
|
|
1108
|
+
const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, columns and grouping, no filter, no others`);
|
|
1071
1109
|
const canonNames = new Set<string>(canonViews().map((v) => v.name));
|
|
1072
1110
|
const spare = p.views.filter((v) => !canonNames.has(v.name));
|
|
1073
1111
|
for (const c of canonViews()) {
|
|
@@ -1078,7 +1116,8 @@ export function analyze(s: State): Check[] {
|
|
|
1078
1116
|
spare.splice(spare.indexOf(cand), 1);
|
|
1079
1117
|
views(`missing ${q(c.name)}`, guarded(`rename view ${q(cand.name)} → ${q(c.name)}`, { op: "UpdateView", input: { viewId: cand.id, name: c.name } }));
|
|
1080
1118
|
} else {
|
|
1081
|
-
|
|
1119
|
+
const input = { projectId: p.id, name: c.name, layout: c.layout, ...(c.columns ? { configuration: configuration(c.columns) } : {}) };
|
|
1120
|
+
views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input }));
|
|
1082
1121
|
}
|
|
1083
1122
|
continue;
|
|
1084
1123
|
}
|
|
@@ -1087,14 +1126,29 @@ export function analyze(s: State): Check[] {
|
|
|
1087
1126
|
if (c.layout === "BOARD_LAYOUT" && v.layout === c.layout && v.columnsBy[0] !== STATUS) {
|
|
1088
1127
|
views(`${q(c.name)} columns not by Status`, ui(`${q(c.name)}: View options → Column by → Status → Save`, `${p.url}/views/${v.number}`));
|
|
1089
1128
|
}
|
|
1129
|
+
if (c.columns) {
|
|
1130
|
+
const have = v.columns.map(columnKey);
|
|
1131
|
+
const missing = c.columns.filter((k) => !have.includes(k)).map((k) => q(columnName(k)));
|
|
1132
|
+
const extra = v.columns.filter((f) => !c.columns!.includes(columnKey(f))).map((f) => q(f.name));
|
|
1133
|
+
if (missing.length || extra.length) {
|
|
1134
|
+
const why = [missing.length ? `missing ${missing.join(", ")}` : "", extra.length ? `extra ${extra.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
1135
|
+
const set = columnFields(c.columns).map((f) => f.name).join(", ");
|
|
1136
|
+
views(`${q(c.name)} columns: ${why}`, api(`${q(c.name)}: columns ${set}`, { op: "UpdateView", input: { viewId: v.id, configuration: configuration(c.columns) } }));
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
// the API reads the grouping but doesn't set it
|
|
1140
|
+
if (c.groupBy && v.layout === c.layout && v.groupBy[0] !== c.groupBy) {
|
|
1141
|
+
views(`${q(c.name)} not grouped by ${c.groupBy}`, ui(`${q(c.name)}: View options → Group by → ${c.groupBy} → Save`, `${p.url}/views/${v.number}`));
|
|
1142
|
+
}
|
|
1090
1143
|
}
|
|
1091
1144
|
for (const v of spare) views(`extra ${q(v.name)}`, guarded(`delete view ${q(v.name)}`, { op: "DeleteView", input: { viewId: v.id } }));
|
|
1092
1145
|
|
|
1093
|
-
// Status: options by name or by a known English name — with the same id; missing ones — new
|
|
1094
|
-
|
|
1146
|
+
// Status: options by name or by a known English name — with the same id; missing ones — new; colors — the template's,
|
|
1147
|
+
// descriptions are the project's own.
|
|
1148
|
+
const status = add("status", `${STATUS}: ${statusOptions().join(" → ")} (${STATUS_COLORS.join(", ")})`);
|
|
1095
1149
|
const sf = p.fields.find((f) => f.name === STATUS && f.options);
|
|
1096
1150
|
if (!sf) {
|
|
1097
|
-
status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name) => ({ name, color:
|
|
1151
|
+
status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name, i) => ({ name, color: STATUS_COLORS[i], description: "" })) } }));
|
|
1098
1152
|
} else if (sf.options!.map((o) => o.name).join("\n") !== statusOptions().join("\n")) {
|
|
1099
1153
|
const left = [...sf.options!];
|
|
1100
1154
|
const take = (pred: (o: Option) => boolean) => {
|
|
@@ -1102,18 +1156,21 @@ export function analyze(s: State): Check[] {
|
|
|
1102
1156
|
return i < 0 ? null : left.splice(i, 1)[0]!;
|
|
1103
1157
|
};
|
|
1104
1158
|
const aliases = statusAliases();
|
|
1105
|
-
const next = statusOptions().map((name) => {
|
|
1159
|
+
const next = statusOptions().map((name, i) => {
|
|
1106
1160
|
const o = take((x) => x.name === name) ?? take((x) => aliases[x.name.toLowerCase()] === name);
|
|
1107
|
-
return o ? { id: o.id, name, color:
|
|
1161
|
+
return o ? { id: o.id, name, color: STATUS_COLORS[i]!, description: o.description ?? "" } : { name, color: STATUS_COLORS[i]!, description: "" };
|
|
1108
1162
|
});
|
|
1109
1163
|
const drop = left.length ? `; delete ${left.map((o) => q(o.name)).join(", ")}` : "";
|
|
1110
1164
|
status(`options: ${sf.options!.map((o) => o.name).join(", ") || "none"}`, guarded(`${STATUS}: ${statusOptions().join(", ")} (rename the existing ones keeping the id${drop})`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
|
|
1165
|
+
} else if (sf.options!.some((o, i) => o.color !== STATUS_COLORS[i])) {
|
|
1166
|
+
// recoloring keeps the ids — the tasks' values and the workflows' targets, no confirmation needed
|
|
1167
|
+
const next = sf.options!.map((o, i) => ({ id: o.id, name: o.name, color: STATUS_COLORS[i]!, description: o.description ?? "" }));
|
|
1168
|
+
status(`colors: ${sf.options!.map((o) => `${o.name} ${o.color}`).join(", ")}`, api(`${STATUS}: colors ${STATUS_COLORS.join(", ")} (keeping the id)`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
|
|
1111
1169
|
}
|
|
1112
1170
|
|
|
1113
1171
|
// Fields: four numeric ones; a field in another dictionary language is renamed with the same id (the tasks' values are
|
|
1114
1172
|
// kept); no custom fields beyond the canon; in an organization Priority is an issue field.
|
|
1115
1173
|
const fields = add("fields", `Fields ${numberFields().map(q).join(", ")} (Number), no other custom ones`);
|
|
1116
|
-
const fieldAlias = fieldAliases();
|
|
1117
1174
|
const renamed = new Set<Field>();
|
|
1118
1175
|
for (const name of numberFields()) {
|
|
1119
1176
|
const f = p.fields.find((x) => x.name === name);
|
|
@@ -1177,14 +1234,18 @@ export function analyze(s: State): Check[] {
|
|
|
1177
1234
|
}
|
|
1178
1235
|
|
|
1179
1236
|
// Workflows: the API doesn't turn them on or show the target — only the UI. An unconfigured one isn't in the list.
|
|
1180
|
-
const wf = add("workflows", `Workflows ${
|
|
1237
|
+
const wf = add("workflows", `Workflows ${WORKFLOWS.map(q).join(", ")} are on`);
|
|
1181
1238
|
const how: Record<string, string> = {
|
|
1182
1239
|
[WORKFLOW_ADDED]: `Edit → Set value → ${STATUS}: ${N.status.backlog}`,
|
|
1183
1240
|
[WORKFLOW_CLOSED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
|
|
1241
|
+
[WORKFLOW_MERGED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
|
|
1242
|
+
[WORKFLOW_AUTO_CLOSE]: `Edit → ${STATUS}: ${N.status.done}`,
|
|
1184
1243
|
// is:open — otherwise an updated closed task (not planned too) comes back into the project
|
|
1185
1244
|
[WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`,
|
|
1245
|
+
[WORKFLOW_SUB_ISSUES]: "Edit",
|
|
1246
|
+
[WORKFLOW_ARCHIVE]: `Edit → filter ${ARCHIVE_FILTER} (Enter)`,
|
|
1186
1247
|
};
|
|
1187
|
-
for (const name of
|
|
1248
|
+
for (const name of WORKFLOWS) {
|
|
1188
1249
|
const w = p.workflows.find((x) => x.name === name);
|
|
1189
1250
|
if (w?.enabled) continue;
|
|
1190
1251
|
// an unconfigured workflow isn't in the API — a link to the list, it's in the menu on the left there
|