@clipboard-health/groundcrew 4.45.11 → 4.46.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/README.md +1 -0
- package/dist/cli.js +2 -2
- package/dist/commands/cleanupWorkspace.d.ts +11 -0
- package/dist/commands/cleanupWorkspace.d.ts.map +1 -1
- package/dist/commands/cleanupWorkspace.js +77 -10
- package/package.json +3 -2
- package/task-sources/README.md +41 -0
- package/task-sources/jira/README.md +182 -0
- package/task-sources/jira/jira.sh +210 -0
- package/task-sources/jira/source.json +39 -0
package/README.md
CHANGED
|
@@ -109,6 +109,7 @@ crew resume [--new] <TASK> # reopen a paused task (-
|
|
|
109
109
|
crew open <pr> | --branch <name> [--repo <owner/repo>] # iterate on an existing PR or branch
|
|
110
110
|
[--prompt <text> | --prompt-file <path>] [--task <id>] [--dry-run]
|
|
111
111
|
crew cleanup [--force] <TASK> # tear down every worktree for a task
|
|
112
|
+
crew cleanup [--force] --all # tear down every idle worktree (no live workspace)
|
|
112
113
|
crew upgrade [<version>] # reinstall crew globally through npm
|
|
113
114
|
```
|
|
114
115
|
|
package/dist/cli.js
CHANGED
|
@@ -147,8 +147,8 @@ const SUBCOMMANDS = {
|
|
|
147
147
|
invoke: statusCli,
|
|
148
148
|
},
|
|
149
149
|
cleanup: {
|
|
150
|
-
summary: "Tear down a worktree",
|
|
151
|
-
usage: "[--force] <task>",
|
|
150
|
+
summary: "Tear down a worktree, or every idle worktree with --all",
|
|
151
|
+
usage: "[--force] <task> | [--force] --all",
|
|
152
152
|
invoke: cleanupWorkspaceCli,
|
|
153
153
|
},
|
|
154
154
|
stop: {
|
|
@@ -4,6 +4,17 @@ export interface CleanupWorkspaceOptions {
|
|
|
4
4
|
/** Default false. The automated cleanup path keeps in-flight uncommitted work. */
|
|
5
5
|
force?: boolean;
|
|
6
6
|
}
|
|
7
|
+
export interface CleanupAllOptions {
|
|
8
|
+
/** Default false. Force-remove even worktrees with uncommitted work. */
|
|
9
|
+
force?: boolean;
|
|
10
|
+
}
|
|
7
11
|
export declare function cleanupWorkspace(config: ResolvedConfig, options: CleanupWorkspaceOptions): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Tear down every local worktree whose task is not currently in use — that is,
|
|
14
|
+
* has no live workspace session. Worktrees backed by a running session are left
|
|
15
|
+
* untouched. Uncommitted work is still protected by teardown's dirtiness guard
|
|
16
|
+
* unless `--force` is passed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function cleanupAllWorkspaces(config: ResolvedConfig, options: CleanupAllOptions): Promise<void>;
|
|
8
19
|
export declare function cleanupWorkspaceCli(argv: string[]): Promise<void>;
|
|
9
20
|
//# sourceMappingURL=cleanupWorkspace.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"cleanupWorkspace.d.ts","sourceRoot":"","sources":["../../src/commands/cleanupWorkspace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAcnE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,kFAAkF;IAClF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AA+DD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAwBf;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAQvE"}
|
|
@@ -5,24 +5,60 @@ import { log } from "../lib/util.js";
|
|
|
5
5
|
import { workspaces } from "../lib/workspaces.js";
|
|
6
6
|
import { worktrees } from "../lib/worktrees.js";
|
|
7
7
|
import { logTeardown } from "./teardownReporter.js";
|
|
8
|
+
const USAGE = [
|
|
9
|
+
"Usage: crew cleanup [--force] <task>",
|
|
10
|
+
" crew cleanup [--force] --all",
|
|
11
|
+
"Example: crew cleanup team-220",
|
|
12
|
+
].join("\n");
|
|
8
13
|
function parseArguments(argv) {
|
|
9
14
|
let force = false;
|
|
15
|
+
let all = false;
|
|
10
16
|
const positionals = [];
|
|
11
17
|
for (const argument of argv) {
|
|
12
18
|
if (argument === "--force") {
|
|
13
19
|
force = true;
|
|
14
20
|
continue;
|
|
15
21
|
}
|
|
22
|
+
if (argument === "--all") {
|
|
23
|
+
all = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
16
26
|
if (argument.startsWith("-")) {
|
|
17
|
-
throw new Error(`Unknown option: ${argument}\
|
|
27
|
+
throw new Error(`Unknown option: ${argument}\n${USAGE}`);
|
|
18
28
|
}
|
|
19
29
|
positionals.push(argument);
|
|
20
30
|
}
|
|
31
|
+
if (all) {
|
|
32
|
+
if (positionals.length > 0) {
|
|
33
|
+
throw new Error(`crew cleanup --all takes no task argument.\n${USAGE}`);
|
|
34
|
+
}
|
|
35
|
+
return { mode: "all", force };
|
|
36
|
+
}
|
|
21
37
|
const [task, ...extras] = positionals;
|
|
22
38
|
if (task === undefined || task.length === 0 || extras.length > 0) {
|
|
23
|
-
throw new Error(
|
|
39
|
+
throw new Error(USAGE);
|
|
40
|
+
}
|
|
41
|
+
return { mode: "task", task: task.toLowerCase(), force };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A worktree is "in use" when its task has a live workspace session — present
|
|
45
|
+
* in the probe and not exited. An exited session is a dead agent, so its
|
|
46
|
+
* worktree is idle and safe to reap. An `unavailable` probe is "we don't know",
|
|
47
|
+
* handled by the caller (never inferred as idle).
|
|
48
|
+
*/
|
|
49
|
+
function isWorkspaceInUse(probe, task) {
|
|
50
|
+
if (probe.kind !== "ok" || !probe.names.has(task)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return probe.exitedNames?.has(task) !== true;
|
|
54
|
+
}
|
|
55
|
+
async function teardownEntries(config, entries, force) {
|
|
56
|
+
const result = await worktrees.teardown(config, entries, { force });
|
|
57
|
+
recordCleanedUpRuns(config, result.removed);
|
|
58
|
+
logTeardown(result);
|
|
59
|
+
if (result.failures.length > 0) {
|
|
60
|
+
throw result.failures[0]?.error;
|
|
24
61
|
}
|
|
25
|
-
return { task: task.toLowerCase(), force };
|
|
26
62
|
}
|
|
27
63
|
export async function cleanupWorkspace(config, options) {
|
|
28
64
|
const { task, force = false } = options;
|
|
@@ -45,15 +81,46 @@ export async function cleanupWorkspace(config, options) {
|
|
|
45
81
|
log(`No worktree found for ${task}; cleared stale run-state.`);
|
|
46
82
|
return;
|
|
47
83
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
84
|
+
await teardownEntries(config, entries, force);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Tear down every local worktree whose task is not currently in use — that is,
|
|
88
|
+
* has no live workspace session. Worktrees backed by a running session are left
|
|
89
|
+
* untouched. Uncommitted work is still protected by teardown's dirtiness guard
|
|
90
|
+
* unless `--force` is passed.
|
|
91
|
+
*/
|
|
92
|
+
export async function cleanupAllWorkspaces(config, options) {
|
|
93
|
+
const { force = false } = options;
|
|
94
|
+
const entries = worktrees.list(config);
|
|
95
|
+
if (entries.length === 0) {
|
|
96
|
+
log("No worktrees found; nothing to clean up.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const workspaceProbe = await workspaces.probe(config);
|
|
100
|
+
if (workspaceProbe.kind === "unavailable") {
|
|
101
|
+
log("Workspace probe unavailable; cannot tell which worktrees are in use, leaving all intact.");
|
|
102
|
+
return;
|
|
53
103
|
}
|
|
104
|
+
const idle = [];
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (isWorkspaceInUse(workspaceProbe, entry.task)) {
|
|
107
|
+
log(`Skipping ${entry.task} (${entry.repository}); workspace in use.`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
idle.push(entry);
|
|
111
|
+
}
|
|
112
|
+
if (idle.length === 0) {
|
|
113
|
+
log("No idle worktrees to clean up.");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await teardownEntries(config, idle, force);
|
|
54
117
|
}
|
|
55
118
|
export async function cleanupWorkspaceCli(argv) {
|
|
56
119
|
const config = await loadConfig();
|
|
57
|
-
const
|
|
58
|
-
|
|
120
|
+
const parsed = parseArguments(argv);
|
|
121
|
+
if (parsed.mode === "all") {
|
|
122
|
+
await cleanupAllWorkspaces(config, { force: parsed.force });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await cleanupWorkspace(config, { task: parsed.task, force: parsed.force });
|
|
59
126
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clipboard-health/groundcrew",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.46.0",
|
|
4
4
|
"description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"crew.config.example.ts",
|
|
29
29
|
"dist",
|
|
30
30
|
"docs",
|
|
31
|
-
"static"
|
|
31
|
+
"static",
|
|
32
|
+
"task-sources"
|
|
32
33
|
],
|
|
33
34
|
"type": "module",
|
|
34
35
|
"main": "./dist/index.js",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Task sources
|
|
2
|
+
|
|
3
|
+
First-class, connectable [task sources](../docs/task-sources.md) for groundcrew.
|
|
4
|
+
Each subdirectory is a self-contained source that can be installed into a user's
|
|
5
|
+
`crew.config.json`.
|
|
6
|
+
|
|
7
|
+
## Anatomy of a source
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
| ------------- | ---------------------------------------------------------------------------- |
|
|
11
|
+
| `source.json` | Install manifest (see below). The canonical description of the wiring. |
|
|
12
|
+
| `README.md` | Human setup guide. Its config block renders the same wiring as the manifest. |
|
|
13
|
+
| script(s) | The executable(s) the manifest lists in `files` (e.g. `jira.sh`). |
|
|
14
|
+
|
|
15
|
+
## Manifest (`source.json`)
|
|
16
|
+
|
|
17
|
+
A machine-readable description that the
|
|
18
|
+
[crew-config](https://github.com/clipboard-health/groundcrew-config) TUI reads to
|
|
19
|
+
install a source. groundcrew itself does **not** load it at runtime — it is a
|
|
20
|
+
contract between this repo (which produces sources) and crew-config (which writes
|
|
21
|
+
them into `crew.config.json`).
|
|
22
|
+
|
|
23
|
+
| Field | Consumed by | Purpose |
|
|
24
|
+
| --------------- | ------------ | -------------------------------------------------------- |
|
|
25
|
+
| `name`, `kind` | crew.config | written into the `sources[]` entry |
|
|
26
|
+
| `commands` | crew.config | command templates, written verbatim (`${id}` is runtime) |
|
|
27
|
+
| `env` | crew.config | environment defaults, written verbatim |
|
|
28
|
+
| `installDir` | install step | where `files` are copied |
|
|
29
|
+
| `files` | install step | scripts copied to `installDir`, made executable |
|
|
30
|
+
| `prerequisites` | install step | binaries to check on PATH, with install/setup hints |
|
|
31
|
+
| `secrets` | install step | secrets to prompt for and write to a file |
|
|
32
|
+
| `description` | TUI / docs | human-readable summary |
|
|
33
|
+
|
|
34
|
+
The command keys (`verify`, `listTasks`, `getTask`, `markInProgress`,
|
|
35
|
+
`markInReview`, `markDone`) match groundcrew's shell adapter contract;
|
|
36
|
+
`listTasks` is required.
|
|
37
|
+
|
|
38
|
+
## Available sources
|
|
39
|
+
|
|
40
|
+
- [`jira/`](./jira/README.md) — JIRA issues via the
|
|
41
|
+
[`jira` CLI](https://github.com/ankitpokhrel/jira-cli).
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# JIRA shell source
|
|
2
|
+
|
|
3
|
+
A ready-to-use [`shell` task source](../../docs/task-sources.md) that feeds JIRA
|
|
4
|
+
issues into groundcrew using the [`jira` CLI](https://github.com/ankitpokhrel/jira-cli).
|
|
5
|
+
Everything lives in one script, [`jira.sh`](./jira.sh), which the shell adapter
|
|
6
|
+
calls with a subcommand per operation:
|
|
7
|
+
|
|
8
|
+
| Subcommand | Adapter command | What it does |
|
|
9
|
+
| ---------------------------- | ---------------------------------------------- | ---------------------------------------------------------------- |
|
|
10
|
+
| `jira.sh verify` | `verify` | Checks the token and connectivity (`jira me`). |
|
|
11
|
+
| `jira.sh list` | `listTasks` | Prints a `ShellIssue[]` JSON array for the configured JQL. |
|
|
12
|
+
| `jira.sh get <KEY>` | `getTask` | Prints one `ShellIssue`, or exits `3` when the key is not found. |
|
|
13
|
+
| `jira.sh move <KEY> <STATE>` | `markInProgress` / `markInReview` / `markDone` | Transitions the issue. |
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- [`jira` CLI](https://github.com/ankitpokhrel/jira-cli) — `brew install ankitpokhrel/jira-cli/jira-cli`, then `jira init`.
|
|
18
|
+
- [`jq`](https://jqlang.github.io/jq/) — `brew install jq`.
|
|
19
|
+
|
|
20
|
+
> **Note:** `list` and `get` detect "no results" and "not found" by matching the
|
|
21
|
+
> `jira` CLI's stderr wording (it exposes no machine-readable signal for either).
|
|
22
|
+
> Those strings are validated against **jira-cli 1.7.x**; re-verify them if you
|
|
23
|
+
> upgrade the CLI, since a reworded message would be misread as a real failure.
|
|
24
|
+
|
|
25
|
+
### What `jira init` sets up
|
|
26
|
+
|
|
27
|
+
`jira init` is a one-time wizard that writes `~/.config/.jira/.config.yml` — the
|
|
28
|
+
non-secret connection details every `jira` command (and therefore `jira.sh`)
|
|
29
|
+
relies on. It prompts for:
|
|
30
|
+
|
|
31
|
+
- **Installation** (`Cloud` vs. on-prem Server/Data Center) and the **server
|
|
32
|
+
URL** the CLI talks to.
|
|
33
|
+
- Your **login** email and **auth type** (`basic` = email + API token on Cloud).
|
|
34
|
+
- A **default project** and **board**. The default project matters here: the
|
|
35
|
+
`list` JQL has no `project = …` clause, so it implicitly scopes to whatever
|
|
36
|
+
project `jira init` selected.
|
|
37
|
+
|
|
38
|
+
It also introspects your instance and caches every custom-field definition, so
|
|
39
|
+
the config file can be large (hundreds of KB) — that is expected.
|
|
40
|
+
|
|
41
|
+
Note that `jira init` does **not** store the API token. jira-cli reads the token
|
|
42
|
+
from the OS keyring or the `JIRA_API_TOKEN` environment variable; this source
|
|
43
|
+
supplies it via the latter from a gitignored file (see [Setup](#setup) step 2).
|
|
44
|
+
So `jira init` owns the connection (server, login, project) and `jira.sh` layers
|
|
45
|
+
the token on per invocation.
|
|
46
|
+
|
|
47
|
+
## Setup
|
|
48
|
+
|
|
49
|
+
1. **Install the script** where your config references it:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
mkdir -p ~/.config/groundcrew
|
|
53
|
+
cp task-sources/jira/jira.sh ~/.config/groundcrew/jira.sh
|
|
54
|
+
chmod +x ~/.config/groundcrew/jira.sh
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
2. **Store the API token** in a gitignored file so it stays scoped to this
|
|
58
|
+
source instead of your global environment ([create one here](https://id.atlassian.com/manage-profile/security/api-tokens)):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
echo '<your-token>' > ~/.config/groundcrew/jira.token
|
|
62
|
+
chmod 600 ~/.config/groundcrew/jira.token
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The script reads the token from this file, trims surrounding whitespace (so a
|
|
66
|
+
trailing newline or CRLF is fine), and exports `JIRA_API_TOKEN` only into its
|
|
67
|
+
own process — nothing global, and no secret in your config file.
|
|
68
|
+
|
|
69
|
+
3. **Label your issues** so groundcrew knows what to pick up and where to
|
|
70
|
+
dispatch it:
|
|
71
|
+
|
|
72
|
+
| Label | Example | Effect |
|
|
73
|
+
| ---------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
74
|
+
| `groundcrew` | `groundcrew` | Opts the issue in. The default JQL fetches only issues carrying this label, so work is dispatched explicitly rather than every open issue being swept up. The label name is just a JQL convention — change the `labels = …` clause in `JIRA_GROUNDCREW_JQL` to use a different one. |
|
|
75
|
+
| `repo:<name>` | `repo:wild-horses` | Sets `repository: "wild-horses"`. Use a bare repository name when your config's `knownRepositories` lists it by name — no owner needed. |
|
|
76
|
+
| `repo:<Owner>__<name>` | `repo:ClipboardHealth__groundcrew` | Sets `repository: "ClipboardHealth/groundcrew"`. JIRA labels cannot contain `/`, so the slash is encoded as exactly **two** underscores (`__`). Every `__` decodes to `/`, so only two-component `Owner/name` repos are supported — a repository whose owner or name itself contains `__` cannot be expressed. |
|
|
77
|
+
| `agent:<name>` | `agent:claude` | **Optional** — omit it to use `JIRA_DEFAULT_AGENT` (`claude` in the sample config). Only add this label to override the default for a specific issue. |
|
|
78
|
+
|
|
79
|
+
So a typical dispatchable issue carries just `groundcrew` + a `repo:` label.
|
|
80
|
+
An issue without a `repo:` label is listed but not dispatchable (and with no
|
|
81
|
+
`agent:` label and no `JIRA_DEFAULT_AGENT`, its agent is `null`).
|
|
82
|
+
|
|
83
|
+
4. **Add the source** to your `crew.config.json` (or `crew.config.ts`):
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"kind": "shell",
|
|
88
|
+
"name": "jira",
|
|
89
|
+
"commands": {
|
|
90
|
+
"verify": "~/.config/groundcrew/jira.sh verify",
|
|
91
|
+
"listTasks": "~/.config/groundcrew/jira.sh list",
|
|
92
|
+
"getTask": "~/.config/groundcrew/jira.sh get ${id}",
|
|
93
|
+
"markInProgress": "~/.config/groundcrew/jira.sh move ${id} \"$JIRA_STATE_IN_PROGRESS\"",
|
|
94
|
+
"markInReview": "~/.config/groundcrew/jira.sh move ${id} \"$JIRA_STATE_IN_REVIEW\"",
|
|
95
|
+
"markDone": "~/.config/groundcrew/jira.sh move ${id} \"$JIRA_STATE_DONE\""
|
|
96
|
+
},
|
|
97
|
+
"env": {
|
|
98
|
+
"JIRA_GROUNDCREW_JQL": "labels = groundcrew AND (statusCategory != Done OR (statusCategory = Done AND updated >= -7d))",
|
|
99
|
+
"JIRA_REVIEW_PATTERN": "review",
|
|
100
|
+
"JIRA_TODO_PATTERN": "",
|
|
101
|
+
"JIRA_DEFAULT_AGENT": "claude",
|
|
102
|
+
"JIRA_STATE_IN_PROGRESS": "In Progress",
|
|
103
|
+
"JIRA_STATE_IN_REVIEW": "In Review",
|
|
104
|
+
"JIRA_STATE_DONE": "Done"
|
|
105
|
+
},
|
|
106
|
+
"timeouts": {
|
|
107
|
+
"listTasks": 60000,
|
|
108
|
+
"markInReview": 15000,
|
|
109
|
+
"markDone": 15000
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Configuration knobs
|
|
115
|
+
|
|
116
|
+
The script reads these from the source's `env` block:
|
|
117
|
+
|
|
118
|
+
| Variable | Default | Purpose |
|
|
119
|
+
| --------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
120
|
+
| `JIRA_GROUNDCREW_JQL` | `labels = groundcrew AND (statusCategory != Done OR (statusCategory = Done AND updated >= -7d))` | Which issues `list` returns, capped at the first 20 (`--paginate 0:20` in `jira.sh`). Defaults to open issues plus those done in the last 7 days (so groundcrew can clean up their worktrees). Omit `ORDER BY` (jira-cli adds its own). |
|
|
121
|
+
| `JIRA_REVIEW_PATTERN` | `review` | Case-insensitive regex; matching In-Progress status names map to `in-review`. |
|
|
122
|
+
| `JIRA_TODO_PATTERN` | _(empty -> off)_ | Case-insensitive regex; matching In-Progress status names map to `todo` so groundcrew dispatches them as new work. Set it to e.g. `acknowledged` for an "Acknowledged" triage status. Checked before `JIRA_REVIEW_PATTERN`. |
|
|
123
|
+
| `JIRA_DEFAULT_AGENT` | _(empty -> `null`)_ | Agent used when an issue has no `agent:` label. |
|
|
124
|
+
| `JIRA_TOKEN_FILE` | `~/.config/groundcrew/jira.token` | Token file path. |
|
|
125
|
+
| `JIRA_STATE_*` | `In Progress` / `In Review` / `Done` | Native JIRA state names used by `move`. Match your project's workflow. |
|
|
126
|
+
|
|
127
|
+
## Status mapping
|
|
128
|
+
|
|
129
|
+
JIRA Cloud groups statuses into three `statusCategory` keys; groundcrew needs
|
|
130
|
+
five. The catch-all `indeterminate` ("In Progress") category covers in-flight
|
|
131
|
+
workflow, so `in-review` and `todo` are recovered from the status _name_: a name
|
|
132
|
+
matching `JIRA_TODO_PATTERN` demotes to `todo` (so e.g. an "Acknowledged" triage
|
|
133
|
+
status is dispatched as new work); a name matching `JIRA_REVIEW_PATTERN` promotes
|
|
134
|
+
to `in-review`. The todo pattern is checked first.
|
|
135
|
+
|
|
136
|
+
| JIRA `statusCategory.key` | Canonical status |
|
|
137
|
+
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
138
|
+
| `new` | `todo` |
|
|
139
|
+
| `indeterminate` | `in-progress`; `todo` when the name matches `JIRA_TODO_PATTERN`, else `in-review` when it matches `JIRA_REVIEW_PATTERN` |
|
|
140
|
+
| `done` | `done` |
|
|
141
|
+
| anything else | `other` |
|
|
142
|
+
|
|
143
|
+
## Notes
|
|
144
|
+
|
|
145
|
+
- `list` returns at most 20 issues (the `--paginate 0:20` bound in `jira.sh`).
|
|
146
|
+
Raise that bound or narrow `JIRA_GROUNDCREW_JQL` if you have more eligible
|
|
147
|
+
issues than that.
|
|
148
|
+
- The default JQL keeps tickets done within the last 7 days in the list, not
|
|
149
|
+
just open ones. groundcrew only tears down a task's worktree once `list`/`get`
|
|
150
|
+
report it `done`, so dropping done tickets immediately would leak worktrees;
|
|
151
|
+
the 7-day window gives cleanup time to run, then the ticket falls out so the
|
|
152
|
+
steady-state list stays small. The window is keyed on `updated`, so a done
|
|
153
|
+
ticket edited within 7 days stays listed (harmless — groundcrew never
|
|
154
|
+
re-dispatches a `done` task). Shorten or drop the
|
|
155
|
+
`statusCategory = Done AND updated >= -7d` clause in `JIRA_GROUNDCREW_JQL` if
|
|
156
|
+
your source closes out worktrees some other way.
|
|
157
|
+
- The default JQL has no `project = …` clause, so it implicitly scopes to the
|
|
158
|
+
default project `jira init` selected (see [What `jira init` sets up](#what-jira-init-sets-up)).
|
|
159
|
+
On a multi-project instance, add an explicit `project = "ENG"` clause to
|
|
160
|
+
`JIRA_GROUNDCREW_JQL` to constrain which project `list` sweeps.
|
|
161
|
+
- A query that matches nothing is the steady state (no issue is ready to
|
|
162
|
+
dispatch). jira-cli treats "no results" as an error and exits non-zero, but
|
|
163
|
+
`list` folds that into an empty array and exits `0`, so the shell adapter sees
|
|
164
|
+
"no tasks" instead of throwing a fetch failure on every poll. Genuine failures
|
|
165
|
+
(auth, network) still print to stderr and propagate as a non-zero exit.
|
|
166
|
+
- `jira issue list --raw` returns a reduced shape (no `id`, `self`, or
|
|
167
|
+
`statusCategory`), so `list` reads the matching keys and then enriches each one
|
|
168
|
+
with `jira issue view <key> --raw` — the full REST issue the transform needs.
|
|
169
|
+
That means one extra API call per listed issue, so a tighter
|
|
170
|
+
`JIRA_GROUNDCREW_JQL` keeps `list` fast.
|
|
171
|
+
- Cloud descriptions are rich text (ADF); the script flattens their text nodes
|
|
172
|
+
into a plain description and prepends a `Repository:`/issue-URL header, since
|
|
173
|
+
groundcrew uses the description as the agent's prompt.
|
|
174
|
+
- Issue comments are appended to that description under a `--- Comments ---`
|
|
175
|
+
heading (oldest first, each headed by author and timestamp), since `ShellIssue`
|
|
176
|
+
has no comments field and the description is what the agent sees. Comment text
|
|
177
|
+
is flattened the same way as the description, so `@mentions` come through as
|
|
178
|
+
their display text. jira-cli returns only the first page of comments, so a
|
|
179
|
+
heavily-commented issue may be truncated.
|
|
180
|
+
- Writeback (`move`) uses the native state names from `JIRA_STATE_*`; if a
|
|
181
|
+
transition name does not exist in your workflow, `jira issue move` fails and
|
|
182
|
+
groundcrew surfaces the error.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# JIRA <-> groundcrew shell source (single file).
|
|
4
|
+
# The shell adapter's commands.* point at one of these subcommands:
|
|
5
|
+
# jira.sh verify token + connectivity check (commands.verify)
|
|
6
|
+
# jira.sh list ShellIssue[] JSON on stdout (commands.listTasks)
|
|
7
|
+
# jira.sh get <KEY> one ShellIssue, exit 3 if absent (commands.getTask)
|
|
8
|
+
# jira.sh move <KEY> <STATE> transition the issue (markInProgress/InReview/Done)
|
|
9
|
+
#
|
|
10
|
+
# Requires the `jira` CLI (https://github.com/ankitpokhrel/jira-cli) and `jq`.
|
|
11
|
+
#
|
|
12
|
+
# Auth: the API token is read from a gitignored file so it is scoped to this
|
|
13
|
+
# source and never global. Set it once:
|
|
14
|
+
# printf '%s' '<token>' > ~/.config/groundcrew/jira.token
|
|
15
|
+
# chmod 600 ~/.config/groundcrew/jira.token
|
|
16
|
+
#
|
|
17
|
+
# Knobs (set via the source's `env` block in crew.config):
|
|
18
|
+
# JIRA_GROUNDCREW_JQL JQL for `list` (default: open + last-7-days-done issues labeled groundcrew; no ORDER BY — see below)
|
|
19
|
+
# JIRA_REVIEW_PATTERN case-insensitive regex; matching In-Progress status names -> in-review (default: review)
|
|
20
|
+
# JIRA_TODO_PATTERN case-insensitive regex; matching In-Progress status names -> todo (default: empty -> off)
|
|
21
|
+
# JIRA_DEFAULT_AGENT agent when an issue has no agent:<x> label (default: empty -> null)
|
|
22
|
+
# JIRA_TOKEN_FILE token path (default: ~/.config/groundcrew/jira.token)
|
|
23
|
+
set -euo pipefail
|
|
24
|
+
|
|
25
|
+
TOKEN_FILE="${JIRA_TOKEN_FILE:-${HOME}/.config/groundcrew/jira.token}"
|
|
26
|
+
# No ORDER BY here: jira-cli appends its own (see --order-by below), and a
|
|
27
|
+
# second ORDER BY in the JQL makes JIRA reject the query with a 400.
|
|
28
|
+
# The default gates dispatch on an explicit `groundcrew` label so only issues
|
|
29
|
+
# you opt in are picked up — see the README's labeling step. It also keeps
|
|
30
|
+
# tickets done within the last 7 days in the list: groundcrew only tears down a
|
|
31
|
+
# task's worktree once `list`/`get` report it `done`, so dropping done tickets
|
|
32
|
+
# immediately would leak worktrees. The 7-day window lets cleanup happen, then
|
|
33
|
+
# the ticket falls out so the steady-state list stays small.
|
|
34
|
+
LIST_JQL="${JIRA_GROUNDCREW_JQL:-labels = groundcrew AND (statusCategory != Done OR (statusCategory = Done AND updated >= -7d))}"
|
|
35
|
+
REVIEW_PATTERN="${JIRA_REVIEW_PATTERN:-review}"
|
|
36
|
+
TODO_PATTERN="${JIRA_TODO_PATTERN:-}"
|
|
37
|
+
DEFAULT_AGENT="${JIRA_DEFAULT_AGENT:-}"
|
|
38
|
+
|
|
39
|
+
if [[ -r "${TOKEN_FILE}" ]]; then
|
|
40
|
+
JIRA_API_TOKEN="$(cat "${TOKEN_FILE}")"
|
|
41
|
+
# Trim surrounding whitespace so a token written with `echo`, saved with a
|
|
42
|
+
# trailing newline, CRLF line endings, or stray spaces still authenticates.
|
|
43
|
+
JIRA_API_TOKEN="${JIRA_API_TOKEN#"${JIRA_API_TOKEN%%[![:space:]]*}"}"
|
|
44
|
+
JIRA_API_TOKEN="${JIRA_API_TOKEN%"${JIRA_API_TOKEN##*[![:space:]]}"}"
|
|
45
|
+
# An empty (or whitespace-only) file would export a blank token and surface a
|
|
46
|
+
# cryptic auth failure deep in a `jira` call; reject it up front instead.
|
|
47
|
+
if [[ -z "${JIRA_API_TOKEN}" ]]; then
|
|
48
|
+
echo "jira token file is empty: ${TOKEN_FILE}" >&2
|
|
49
|
+
exit 1
|
|
50
|
+
fi
|
|
51
|
+
export JIRA_API_TOKEN
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Reshape one JIRA REST issue -> one groundcrew ShellIssue. Shared by list & get
|
|
55
|
+
# so the two code paths can never drift. Labels carry dispatch metadata:
|
|
56
|
+
# repo:Owner__name -> repository "Owner/name" (__ decodes to /)
|
|
57
|
+
# agent:<name> -> agent "<name>"
|
|
58
|
+
# `read -d ''` reads until a NUL; the heredoc has none, so `read` consumes the
|
|
59
|
+
# whole body but returns 1 at EOF. The `|| true` keeps that expected non-zero
|
|
60
|
+
# from tripping `set -e` — JQ_TRANSFORM is still fully populated.
|
|
61
|
+
read -r -d '' JQ_TRANSFORM <<'JQ' || true
|
|
62
|
+
def adfText: [.. | .text? // empty] | join(" ");
|
|
63
|
+
# A rich-text field is either a plain string (REST v2) or an ADF object (v3);
|
|
64
|
+
# flatten both to text. Shared by the description and each comment body.
|
|
65
|
+
def bodyText: if type == "string" then . elif type == "object" then adfText else "" end;
|
|
66
|
+
def labelValue($p):
|
|
67
|
+
((.fields.labels // []) | map(select(startswith($p))) | .[0] // null)
|
|
68
|
+
| if . == null then null else ltrimstr($p) end;
|
|
69
|
+
def browseUrl:
|
|
70
|
+
((.self // "") | capture("^(?<b>https?://[^/]+)") | .b) as $base
|
|
71
|
+
| if ($base // "") == "" then null else $base + "/browse/" + .key end;
|
|
72
|
+
# JIRA has only three status *categories* (new/indeterminate/done) but groundcrew
|
|
73
|
+
# needs five canonical states. The indeterminate ("In Progress") category is the
|
|
74
|
+
# catch-all for in-flight workflow, so review and todo are recovered from the
|
|
75
|
+
# status *name*: a name matching $tp (e.g. "Acknowledged") demotes to todo so
|
|
76
|
+
# groundcrew dispatches it as new work; a name matching $rp promotes to in-review.
|
|
77
|
+
# $tp is checked first and only when non-empty (an empty regex matches every name).
|
|
78
|
+
def canonStatus($rp; $tp):
|
|
79
|
+
(.fields.status.statusCategory.key // "") as $c
|
|
80
|
+
| (.fields.status.name // "") as $n
|
|
81
|
+
| if $c == "done" then "done"
|
|
82
|
+
elif $c == "new" then "todo"
|
|
83
|
+
elif $c == "indeterminate" then
|
|
84
|
+
(if ($tp != "" and ($n | test($tp; "i"))) then "todo"
|
|
85
|
+
elif ($n | test($rp; "i")) then "in-review"
|
|
86
|
+
else "in-progress" end)
|
|
87
|
+
else "other" end;
|
|
88
|
+
# Render the issue's comments (oldest first) as one text block, each headed by
|
|
89
|
+
# author and timestamp. groundcrew feeds `description` to the agent as its
|
|
90
|
+
# prompt, and ShellIssue has no comments field, so the discussion is folded in
|
|
91
|
+
# here. NOTE: jira-cli returns only the first page of comments (the REST view
|
|
92
|
+
# endpoint paginates), so heavily-commented issues may be truncated.
|
|
93
|
+
def commentsText:
|
|
94
|
+
((.fields.comment.comments // [])
|
|
95
|
+
| map("[" + (.author.displayName // "Unknown")
|
|
96
|
+
+ (if (.created // "") == "" then "" else " (" + .created + ")" end)
|
|
97
|
+
+ "]\n" + (.body | bodyText))
|
|
98
|
+
| join("\n\n"));
|
|
99
|
+
def toShellIssue($rp; $tp; $da):
|
|
100
|
+
(labelValue("repo:") | if . == null then null else gsub("__"; "/") end) as $repo
|
|
101
|
+
| (labelValue("agent:") // (if $da == "" then null else $da end)) as $agent
|
|
102
|
+
| (.fields.description | bodyText) as $body
|
|
103
|
+
| commentsText as $comments
|
|
104
|
+
| ([ (if $body == "" then empty else $body end),
|
|
105
|
+
(if $comments == "" then empty else "--- Comments ---\n\n" + $comments end) ] | join("\n\n")) as $content
|
|
106
|
+
| browseUrl as $url
|
|
107
|
+
| ([ (if $repo != null then "Repository: " + $repo else empty end),
|
|
108
|
+
(if $url != null then "Issue: " + $url else empty end) ] | join("\n")) as $hdr
|
|
109
|
+
| { id: .key,
|
|
110
|
+
title: (.fields.summary // .key),
|
|
111
|
+
description: (if $hdr == "" then $content else $hdr + "\n\n" + $content end),
|
|
112
|
+
status: canonStatus($rp; $tp),
|
|
113
|
+
repository: $repo,
|
|
114
|
+
agent: $agent,
|
|
115
|
+
assignee: (.fields.assignee.displayName // "Unassigned"),
|
|
116
|
+
updatedAt: (.fields.updated // ""),
|
|
117
|
+
blockers: [],
|
|
118
|
+
hasMoreBlockers: false,
|
|
119
|
+
url: $url,
|
|
120
|
+
sourceRef: { key: .key, nativeId: .id } }
|
|
121
|
+
| if .url == null then del(.url) else . end;
|
|
122
|
+
JQ
|
|
123
|
+
|
|
124
|
+
require_token() {
|
|
125
|
+
[[ -r "${TOKEN_FILE}" ]] || {
|
|
126
|
+
echo "jira token file not found/readable: ${TOKEN_FILE}" >&2
|
|
127
|
+
exit 1
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
cmd="${1:-}"
|
|
132
|
+
shift || true
|
|
133
|
+
case "${cmd}" in
|
|
134
|
+
verify)
|
|
135
|
+
require_token
|
|
136
|
+
jira me >/dev/null
|
|
137
|
+
;;
|
|
138
|
+
list)
|
|
139
|
+
require_token
|
|
140
|
+
# jira-cli's `issue list --raw` returns a reduced array (no id/self/
|
|
141
|
+
# statusCategory), so we read just the keys and enrich each via `issue view`,
|
|
142
|
+
# whose `--raw` is the full REST issue the transform needs. `jq -s` slurps
|
|
143
|
+
# the stream of per-issue objects into one array.
|
|
144
|
+
#
|
|
145
|
+
# jira-cli exits non-zero (and prints to stderr) when a query matches
|
|
146
|
+
# nothing. groundcrew's shell adapter treats any non-zero `listTasks` exit
|
|
147
|
+
# as a fetch failure, so a no-match query (the steady state when no issue
|
|
148
|
+
# carries the dispatch label) must NOT propagate that exit. Capture the list
|
|
149
|
+
# separately: fold "no results" into an empty array, but still surface real
|
|
150
|
+
# failures (auth, network) instead of masking them as "no tasks".
|
|
151
|
+
list_err="$(mktemp)"
|
|
152
|
+
trap 'rm -f "${list_err}"' EXIT
|
|
153
|
+
if ! list_raw="$(jira issue list -q "${LIST_JQL}" --order-by updated --reverse --raw --paginate 0:20 2>"${list_err}")"; then
|
|
154
|
+
# jira-cli has no machine-readable "empty result" signal, so we match its
|
|
155
|
+
# stderr wording. These strings are validated against jira-cli 1.7.x and
|
|
156
|
+
# may drift on upgrade (see README Prerequisites); the same caveat applies
|
|
157
|
+
# to the not-found match in `get` below.
|
|
158
|
+
if grep -qiE "no result|no issues" "${list_err}"; then
|
|
159
|
+
list_raw="[]"
|
|
160
|
+
else
|
|
161
|
+
cat "${list_err}" >&2
|
|
162
|
+
exit 1
|
|
163
|
+
fi
|
|
164
|
+
fi
|
|
165
|
+
# Enrich each listed key with its full issue: 1 `list` + 1 `view` per key
|
|
166
|
+
# (≤21 calls at the 20-issue cap), which can approach the 60s `listTasks`
|
|
167
|
+
# timeout on a wide JQL — see README Notes. A failed `view` logs and skips
|
|
168
|
+
# that one key rather than `|| true` silently dropping it from the array.
|
|
169
|
+
printf '%s' "${list_raw:-[]}" \
|
|
170
|
+
| jq -r 'if type == "array" then .[].key else empty end' \
|
|
171
|
+
| while IFS= read -r key; do
|
|
172
|
+
[[ -n "${key}" ]] || continue
|
|
173
|
+
jira issue view "${key}" --raw 2>/dev/null \
|
|
174
|
+
|| { echo "jira.sh list: skipping ${key} (view failed)" >&2; continue; }
|
|
175
|
+
done \
|
|
176
|
+
| jq -s -c --arg rp "${REVIEW_PATTERN}" --arg tp "${TODO_PATTERN}" --arg da "${DEFAULT_AGENT}" \
|
|
177
|
+
"${JQ_TRANSFORM} [ .[] | toShellIssue(\$rp; \$tp; \$da) ]"
|
|
178
|
+
;;
|
|
179
|
+
get)
|
|
180
|
+
require_token
|
|
181
|
+
key="${1:?usage: jira.sh get <KEY>}"
|
|
182
|
+
get_err="$(mktemp)"
|
|
183
|
+
trap 'rm -f "${get_err}"' EXIT
|
|
184
|
+
if ! out="$(jira issue view "${key}" --raw 2>"${get_err}")"; then
|
|
185
|
+
# jira-cli exits non-zero for a genuinely missing key AND for transient
|
|
186
|
+
# auth/network failures. Only the former is the not-found sentinel (exit 3);
|
|
187
|
+
# surface the rest as a retryable error so groundcrew does not treat a live
|
|
188
|
+
# task as vanished. Not-found wording validated vs jira-cli 1.7.x (see S3).
|
|
189
|
+
if grep -qiE "not found|does not exist|404" "${get_err}"; then
|
|
190
|
+
exit 3
|
|
191
|
+
fi
|
|
192
|
+
cat "${get_err}" >&2
|
|
193
|
+
exit 1
|
|
194
|
+
fi
|
|
195
|
+
[[ -n "${out}" ]] || exit 3
|
|
196
|
+
printf '%s' "${out}" \
|
|
197
|
+
| jq -c --arg rp "${REVIEW_PATTERN}" --arg tp "${TODO_PATTERN}" --arg da "${DEFAULT_AGENT}" \
|
|
198
|
+
"${JQ_TRANSFORM} toShellIssue(\$rp; \$tp; \$da)"
|
|
199
|
+
;;
|
|
200
|
+
move)
|
|
201
|
+
require_token
|
|
202
|
+
key="${1:?usage: jira.sh move <KEY> <STATE>}"
|
|
203
|
+
state="${2:?missing STATE}"
|
|
204
|
+
jira issue move "${key}" "${state}" >/dev/null
|
|
205
|
+
;;
|
|
206
|
+
*)
|
|
207
|
+
echo "usage: jira.sh {verify|list|get <KEY>|move <KEY> <STATE>}" >&2
|
|
208
|
+
exit 2
|
|
209
|
+
;;
|
|
210
|
+
esac
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jira",
|
|
3
|
+
"kind": "shell",
|
|
4
|
+
"description": "Feed JIRA issues into groundcrew via the jira CLI.",
|
|
5
|
+
"installDir": "~/.config/groundcrew",
|
|
6
|
+
"files": ["jira.sh"],
|
|
7
|
+
"prerequisites": [
|
|
8
|
+
{
|
|
9
|
+
"bin": "jira",
|
|
10
|
+
"install": "brew install ankitpokhrel/jira-cli/jira-cli",
|
|
11
|
+
"setup": "jira init"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"bin": "jq",
|
|
15
|
+
"install": "brew install jq"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"secrets": [
|
|
19
|
+
{
|
|
20
|
+
"env": "JIRA_API_TOKEN",
|
|
21
|
+
"file": "jira.token",
|
|
22
|
+
"mode": "0600",
|
|
23
|
+
"url": "https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"env": {
|
|
27
|
+
"JIRA_STATE_IN_PROGRESS": "In Progress",
|
|
28
|
+
"JIRA_STATE_IN_REVIEW": "In Review",
|
|
29
|
+
"JIRA_STATE_DONE": "Done"
|
|
30
|
+
},
|
|
31
|
+
"commands": {
|
|
32
|
+
"verify": "~/.config/groundcrew/jira.sh verify",
|
|
33
|
+
"listTasks": "~/.config/groundcrew/jira.sh list",
|
|
34
|
+
"getTask": "~/.config/groundcrew/jira.sh get ${id}",
|
|
35
|
+
"markInProgress": "~/.config/groundcrew/jira.sh move ${id} \"$JIRA_STATE_IN_PROGRESS\"",
|
|
36
|
+
"markInReview": "~/.config/groundcrew/jira.sh move ${id} \"$JIRA_STATE_IN_REVIEW\"",
|
|
37
|
+
"markDone": "~/.config/groundcrew/jira.sh move ${id} \"$JIRA_STATE_DONE\""
|
|
38
|
+
}
|
|
39
|
+
}
|