@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 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;AAQnE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,kFAAkF;IAClF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAwBD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAIvE"}
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}\nUsage: crew cleanup [--force] <task>\nExample: crew cleanup team-220`);
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("Usage: crew cleanup [--force] <task>\nExample: crew cleanup team-220");
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
- const result = await worktrees.teardown(config, entries, { force });
49
- recordCleanedUpRuns(config, result.removed);
50
- logTeardown(result);
51
- if (result.failures.length > 0) {
52
- throw result.failures[0]?.error;
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 options = parseArguments(argv);
58
- await cleanupWorkspace(config, options);
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.45.11",
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
+ }