@devflow-one/cli 0.1.1 → 0.1.2
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 +91 -5
- package/package.json +4 -2
- package/src/cli.js +76 -40
- package/src/client.js +13 -3
- package/src/commands.js +44 -27
- package/src/context.js +249 -0
- package/src/contextResolver.js +288 -0
- package/src/gitFacts.js +47 -0
package/README.md
CHANGED
|
@@ -11,16 +11,89 @@ provider API logic lives here — the owning browser profile executes the action
|
|
|
11
11
|
|
|
12
12
|
## How it works
|
|
13
13
|
|
|
14
|
-
- Each command maps to one gateway action
|
|
15
|
-
|
|
14
|
+
- Each command maps to one gateway action. **Routing is inferred from the
|
|
15
|
+
current directory** — no flags needed inside a configured repo; the command
|
|
16
|
+
supplies its own args.
|
|
16
17
|
- The gateway routes the action to the connected browser profile that owns the
|
|
17
18
|
project key, waits for the profile's result, and returns it.
|
|
18
19
|
- Discovery: `list-actions` and `list-profiles` read straight from the live
|
|
19
20
|
gateway registry (advertised actions; connected profiles + owned keys).
|
|
21
|
+
`where` shows what the current directory resolves to.
|
|
20
22
|
- Failures come back as structured errors (`NO_PROFILE`, `AMBIGUOUS_PROFILE`,
|
|
21
23
|
`TIMEOUT`, `DESTRUCTIVE_BLOCKED`, or an unreachable daemon), printed with their
|
|
22
24
|
code (`--json` for the machine-readable envelope).
|
|
23
25
|
|
|
26
|
+
## Routing: inferred, flags override
|
|
27
|
+
|
|
28
|
+
Run a command inside a repo you've configured in the extension and it routes
|
|
29
|
+
itself — the CLI asks the desktop daemon which project owns this checkout:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
cd ~/work/acme-web # a configured repo, on branch ABC-123-login
|
|
33
|
+
devflow mr find # → finds the PR for ABC-123, no flags at all
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Inference goes through these sources, highest priority first. A lower one never
|
|
37
|
+
overrides a value a higher one already supplied:
|
|
38
|
+
|
|
39
|
+
| # | Source | Supplies |
|
|
40
|
+
| - | ------ | -------- |
|
|
41
|
+
| 1 | Flags — `--project` / `--issue` / `--repo` / `--profile` | whichever you pass |
|
|
42
|
+
| 2 | Env — `DEVFLOW_PROJECT` / `DEVFLOW_ISSUE` / `DEVFLOW_REPO` / `DEVFLOW_PROFILE` | whichever are set |
|
|
43
|
+
| 3 | Git remote `origin`, matched against your configured repos | project, repo, profile |
|
|
44
|
+
| 4 | The checkout path, matched against a repo's configured local path | project, repo, profile |
|
|
45
|
+
| 5 | The current branch name (`feature/ABC-123-login` → `ABC-123`) | issue key |
|
|
46
|
+
| 6 | A Jira-key-shaped ancestor directory (`~/work/ABC-123/src`) | issue key |
|
|
47
|
+
|
|
48
|
+
Every flag is therefore an **override**, never a requirement:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
devflow mr find # fully inferred
|
|
52
|
+
devflow mr find --issue ZZ-9 # the flag wins over the branch
|
|
53
|
+
devflow branch create x --project OTHER # act on a project other than this repo's
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Ambiguity is never guessed. If two configured repos match the checkout, the
|
|
57
|
+
command **fails naming both** and tells you which flag settles it:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
error [UsageError]: "branch create" could not be routed — this checkout matches
|
|
61
|
+
several configured projects (ABC (web via p1), XYZ (web-fork via p2)).
|
|
62
|
+
Pass --project (or --repo) to choose one.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `devflow where`
|
|
66
|
+
|
|
67
|
+
Shows what this directory resolves to, where each value came from, and whether
|
|
68
|
+
the owning Chrome profile is actually connected — so you can see *why* a call
|
|
69
|
+
would fail before making it:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
$ devflow where
|
|
73
|
+
projectKey ABC (git remote)
|
|
74
|
+
issueKey ABC-123 (branch name)
|
|
75
|
+
repo repo-1 (git remote)
|
|
76
|
+
profile profile-a (git remote) — connected
|
|
77
|
+
|
|
78
|
+
$ devflow where --json # same thing, machine-readable
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It never routes and never acts, so it still reports when nothing matches (or
|
|
82
|
+
when the desktop app isn't running) instead of failing.
|
|
83
|
+
|
|
84
|
+
### The owning Chrome profile must be open
|
|
85
|
+
|
|
86
|
+
Provider credentials live in the browser extension, so the profile that owns a
|
|
87
|
+
project is what actually executes the action. When the daemon knows the profile
|
|
88
|
+
but it isn't connected, the CLI says so by name rather than a bare `NO_PROFILE`:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
error [NO_PROFILE]: Chrome profile "profile-a" owns ABC but is not connected,
|
|
92
|
+
so this action cannot run. Open that Chrome profile and retry.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`devflow where` flags the same condition up front.
|
|
96
|
+
|
|
24
97
|
## Prerequisites
|
|
25
98
|
|
|
26
99
|
- The **DevFlow desktop app must be running** — it hosts the gateway and writes
|
|
@@ -69,19 +142,28 @@ The file is written owner-only (`0600`). Override for CI / non-standard installs
|
|
|
69
142
|
| `DEVFLOW_GATEWAY_TOKEN` | Provide the token directly (skips the file lookup). |
|
|
70
143
|
| `DEVFLOW_GATEWAY_PORT` | Gateway port (default `8787`). |
|
|
71
144
|
|
|
145
|
+
Routing defaults can also come from the environment — handy in CI or a shell
|
|
146
|
+
profile. They lose to the equivalent flag and win over anything inferred:
|
|
147
|
+
`DEVFLOW_PROJECT`, `DEVFLOW_ISSUE`, `DEVFLOW_REPO`, `DEVFLOW_PROFILE`.
|
|
148
|
+
|
|
72
149
|
## Usage
|
|
73
150
|
|
|
74
151
|
```sh
|
|
75
152
|
# discovery
|
|
76
153
|
devflow list-actions
|
|
77
154
|
devflow list-profiles # connected profiles + owned keys
|
|
155
|
+
devflow where # what this directory resolves to
|
|
156
|
+
|
|
157
|
+
# inside a configured repo, routing is inferred — no flags
|
|
158
|
+
devflow mr find # issue key from the branch
|
|
159
|
+
devflow branch create feat/x
|
|
78
160
|
|
|
79
|
-
#
|
|
161
|
+
# or route explicitly, overriding whatever the directory implies
|
|
80
162
|
devflow branch create feat/x --project ABC
|
|
81
163
|
devflow mr create "Title" feat/x --body "desc" --project ABC
|
|
82
164
|
|
|
83
165
|
# routed by issue key (project key = prefix before the last '-')
|
|
84
|
-
devflow mr find ABC-123
|
|
166
|
+
devflow mr find ABC-123
|
|
85
167
|
devflow pipeline status 42 deadbeef --issue ABC-9
|
|
86
168
|
|
|
87
169
|
# commit a managed worktree with the project's commit-message template
|
|
@@ -89,7 +171,7 @@ devflow commit template /wt/ABC-123 --values '{"summary":"fix login"}' --project
|
|
|
89
171
|
devflow commit template /wt/ABC-123 --no-push --project ABC # commit locally, skip push
|
|
90
172
|
|
|
91
173
|
# assembled fix-review-comments prompt (read-only; runs no AI, makes no writes)
|
|
92
|
-
devflow mr fix-prompt 42
|
|
174
|
+
devflow mr fix-prompt 42 # issue key inferred from the branch
|
|
93
175
|
|
|
94
176
|
# notify the project's configured Slack channel(s)
|
|
95
177
|
devflow slack notify "Review fixes ready on {pr}" --values '{"pr":"#42"}' --project ABC
|
|
@@ -109,6 +191,10 @@ devflow branch delete old --project ABC # refused
|
|
|
109
191
|
devflow branch delete old --project ABC --force # executes
|
|
110
192
|
```
|
|
111
193
|
|
|
194
|
+
Inference never softens this gate — a destructive command run with fully
|
|
195
|
+
inferred routing still refuses without `--force`. Confirmation is always yours
|
|
196
|
+
to give explicitly, precisely because inference makes mis-targeting easier.
|
|
197
|
+
|
|
112
198
|
## Verify
|
|
113
199
|
|
|
114
200
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devflow-one/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Terminal CLI wrapping the local DevFlow gateway so shell scripts can trigger repo actions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"devflow",
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
"node": ">=20"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
|
-
"
|
|
45
|
+
"sync:shared": "node ../scripts/sync-context-resolver.mjs",
|
|
46
|
+
"test": "node --test \"src/tests/**/*.test.js\"",
|
|
47
|
+
"prepublishOnly": "npm run sync:shared"
|
|
46
48
|
},
|
|
47
49
|
"devDependencies": {
|
|
48
50
|
"release-it": "^20.0.1"
|
package/src/cli.js
CHANGED
|
@@ -6,60 +6,60 @@ import { parseArgs } from "./args.js";
|
|
|
6
6
|
import { resolveConfig, ConfigError } from "./config.js";
|
|
7
7
|
import { gatewayRequest, GatewayError } from "./client.js";
|
|
8
8
|
import { resolveCommand, listCommands, UsageError } from "./commands.js";
|
|
9
|
+
import {
|
|
10
|
+
createContextQuery,
|
|
11
|
+
resolveCliContext,
|
|
12
|
+
projectKeyFrom,
|
|
13
|
+
assertRoutable,
|
|
14
|
+
offlineProfileError,
|
|
15
|
+
formatWhere,
|
|
16
|
+
} from "./context.js";
|
|
9
17
|
|
|
10
18
|
const USAGE = `devflow — trigger DevFlow repo actions via the local gateway
|
|
11
19
|
|
|
12
20
|
Usage:
|
|
13
|
-
devflow <command> [args] [--issue KEY
|
|
21
|
+
devflow <command> [args] [--issue KEY] [--project KEY] [--profile ID] [--json]
|
|
14
22
|
|
|
15
23
|
Commands:
|
|
16
24
|
${listCommands()
|
|
17
25
|
.map((c) => ` ${c}`)
|
|
18
26
|
.join("\n")}
|
|
19
27
|
|
|
20
|
-
Routing
|
|
28
|
+
Routing is inferred from the current directory — git remote, checkout path and
|
|
29
|
+
branch name — so inside a configured repo no routing flag is needed. Every flag
|
|
30
|
+
below is an override, applied ahead of anything inferred:
|
|
21
31
|
--issue ABC-123 Route by Jira issue key (project key = prefix before last '-').
|
|
22
32
|
--project ABC Route by project key directly.
|
|
23
33
|
--profile <id> Force a specific Chrome profile (disambiguates ambiguity).
|
|
24
34
|
--repo <id|url> Select a repo when the project owns several.
|
|
25
35
|
|
|
36
|
+
Run \`devflow where\` to see what the current directory resolves to.
|
|
37
|
+
|
|
26
38
|
Destructive actions (branch delete, mr close):
|
|
27
39
|
--force, --yes Confirm a destructive action; required or it is refused.
|
|
40
|
+
Never inferred — inference makes mis-targeting easier.
|
|
28
41
|
|
|
29
42
|
Output:
|
|
30
43
|
--json Print machine-readable JSON (envelope) for scripts.
|
|
31
44
|
|
|
32
45
|
Environment:
|
|
33
46
|
DEVFLOW_GATEWAY_PORT Gateway port (default 8787).
|
|
34
|
-
DEVFLOW_GATEWAY_TOKEN Override the auto-read token file
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
function buildRoutingBody(flags) {
|
|
38
|
-
const body = {};
|
|
39
|
-
|
|
40
|
-
if (flags.issue) body.issueId = String(flags.issue);
|
|
41
|
-
if (flags.project) body.projectKey = String(flags.project);
|
|
42
|
-
if (flags.profile) body.profile = String(flags.profile);
|
|
43
|
-
|
|
44
|
-
return body;
|
|
45
|
-
}
|
|
47
|
+
DEVFLOW_GATEWAY_TOKEN Override the auto-read token file.
|
|
48
|
+
DEVFLOW_PROJECT / DEVFLOW_ISSUE / DEVFLOW_REPO / DEVFLOW_PROFILE
|
|
49
|
+
Routing defaults, overridden by the flags above.`;
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* and the gateway routing rule (`issueKeyToProjectKey`).
|
|
52
|
+
* Attach the routing fields to an action request body. Built from the resolved
|
|
53
|
+
* context, so an inferred issue/project routes exactly like an explicit one.
|
|
51
54
|
*/
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (flags.issue) {
|
|
56
|
-
const issue = String(flags.issue);
|
|
57
|
-
const lastDash = issue.lastIndexOf("-");
|
|
55
|
+
function buildRoutingBody(context) {
|
|
56
|
+
const body = {};
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
if (context.issueKey) body.issueId = context.issueKey;
|
|
59
|
+
if (context.projectKey) body.projectKey = context.projectKey;
|
|
60
|
+
if (context.profile) body.profile = context.profile;
|
|
61
61
|
|
|
62
|
-
return
|
|
62
|
+
return body;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Print the result of a GET/POST call, honoring --json. */
|
|
@@ -93,7 +93,11 @@ function printError(err, json, stderr) {
|
|
|
93
93
|
/**
|
|
94
94
|
* Run the CLI once.
|
|
95
95
|
* @param {string[]} argv args after node/script (i.e. `process.argv.slice(2)`)
|
|
96
|
-
* @param {{
|
|
96
|
+
* @param {{
|
|
97
|
+
* stdout?: (s: string) => void, stderr?: (s: string) => void,
|
|
98
|
+
* request?: typeof gatewayRequest, cwd?: string,
|
|
99
|
+
* env?: Record<string, string | undefined>,
|
|
100
|
+
* }} [io] `cwd`/`env` are injectable so inference is testable without chdir.
|
|
97
101
|
* @returns {Promise<number>} exit code
|
|
98
102
|
*/
|
|
99
103
|
export async function run(argv, io = {}) {
|
|
@@ -125,13 +129,42 @@ export async function run(argv, io = {}) {
|
|
|
125
129
|
try {
|
|
126
130
|
config = resolveConfig();
|
|
127
131
|
} catch (err) {
|
|
128
|
-
if (err instanceof ConfigError)
|
|
132
|
+
if (!(err instanceof ConfigError)) throw err;
|
|
133
|
+
|
|
134
|
+
// `where` is a diagnostic: it must still report what the checkout implies
|
|
135
|
+
// when there is no daemon to ask, since that is exactly when it is consulted.
|
|
136
|
+
if (command.method !== "LOCAL") {
|
|
129
137
|
printError(err, Boolean(flags.json), stderr);
|
|
130
138
|
|
|
131
139
|
return 1;
|
|
132
140
|
}
|
|
133
141
|
|
|
134
|
-
|
|
142
|
+
config = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const resolveRouting = () =>
|
|
146
|
+
resolveCliContext(flags, {
|
|
147
|
+
cwd: io.cwd ?? process.cwd(),
|
|
148
|
+
env: io.env ?? process.env,
|
|
149
|
+
queryContext: config
|
|
150
|
+
? createContextQuery(config, request)
|
|
151
|
+
: async () => {
|
|
152
|
+
throw new Error("the DevFlow desktop app is not running (no gateway token).");
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// `where` explains the resolution instead of acting on it, so it never routes
|
|
157
|
+
// and never fails on an unroutable directory.
|
|
158
|
+
if (command.method === "LOCAL") {
|
|
159
|
+
const context = await resolveRouting();
|
|
160
|
+
|
|
161
|
+
if (flags.json) {
|
|
162
|
+
printResult(context, true, stdout);
|
|
163
|
+
} else {
|
|
164
|
+
stdout(formatWhere(context));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return 0;
|
|
135
168
|
}
|
|
136
169
|
|
|
137
170
|
try {
|
|
@@ -143,16 +176,13 @@ export async function run(argv, io = {}) {
|
|
|
143
176
|
return 0;
|
|
144
177
|
}
|
|
145
178
|
|
|
146
|
-
// POST action: routing
|
|
147
|
-
const
|
|
179
|
+
// POST action: routing comes from flags where given, inference otherwise.
|
|
180
|
+
const context = await resolveRouting();
|
|
148
181
|
|
|
149
|
-
|
|
150
|
-
throw new UsageError(
|
|
151
|
-
`"${key}" needs a target: pass --issue, --project, or --profile. Usage: ${command.usage}`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
182
|
+
assertRoutable(key, command, context, flags);
|
|
154
183
|
|
|
155
184
|
// Destructive gate: refuse before touching the gateway unless confirmed.
|
|
185
|
+
// Deliberately independent of inference — a confirmed target is never guessed.
|
|
156
186
|
const confirmed = Boolean(flags.force || flags.yes);
|
|
157
187
|
|
|
158
188
|
if (command.destructive && !confirmed) {
|
|
@@ -161,14 +191,20 @@ export async function run(argv, io = {}) {
|
|
|
161
191
|
);
|
|
162
192
|
}
|
|
163
193
|
|
|
194
|
+
// The owning profile is known but closed: say so by name rather than letting
|
|
195
|
+
// the gateway answer with a bare NO_PROFILE.
|
|
196
|
+
const offline = offlineProfileError(context);
|
|
197
|
+
|
|
198
|
+
if (offline) throw offline;
|
|
199
|
+
|
|
164
200
|
const action = command.resolveAction ? command.resolveAction(flags) : command.action;
|
|
165
|
-
const args = command.buildArgs ? command.buildArgs(rest, flags) : [];
|
|
201
|
+
const args = command.buildArgs ? command.buildArgs(rest, flags, context) : [];
|
|
166
202
|
const body = {
|
|
167
|
-
...buildRoutingBody(
|
|
203
|
+
...buildRoutingBody(context),
|
|
168
204
|
...(command.destructive ? { allowDestructive: true } : {}),
|
|
169
205
|
payload: {
|
|
170
|
-
projectKey,
|
|
171
|
-
...(
|
|
206
|
+
projectKey: projectKeyFrom(context),
|
|
207
|
+
...(context.repo ? { repo: context.repo } : {}),
|
|
172
208
|
args,
|
|
173
209
|
},
|
|
174
210
|
};
|
package/src/client.js
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
/** A gateway call failure carrying the server's error `code` (when present). */
|
|
6
6
|
export class GatewayError extends Error {
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} message
|
|
9
|
+
* @param {string} [code]
|
|
10
|
+
* @param {unknown} [result] the envelope's `result`, which some failures still
|
|
11
|
+
* carry — `/context` reports its ambiguity candidates there.
|
|
12
|
+
*/
|
|
13
|
+
constructor(message, code, result) {
|
|
9
14
|
super(message);
|
|
10
15
|
this.name = "GatewayError";
|
|
11
16
|
this.code = code;
|
|
17
|
+
this.result = result;
|
|
12
18
|
}
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -73,5 +79,9 @@ export async function gatewayRequest(config, method, path, body) {
|
|
|
73
79
|
|
|
74
80
|
const error = envelope?.error ?? {};
|
|
75
81
|
|
|
76
|
-
throw new GatewayError(
|
|
82
|
+
throw new GatewayError(
|
|
83
|
+
error.message ?? `Gateway error (HTTP ${response.status}).`,
|
|
84
|
+
error.code,
|
|
85
|
+
envelope?.result,
|
|
86
|
+
);
|
|
77
87
|
}
|
package/src/commands.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
// discovery route or POSTs an action whose `payload.args` mirrors the positional
|
|
3
3
|
// signature of the matching Repo store action (see extension gatewayActions.ts).
|
|
4
4
|
//
|
|
5
|
-
// Routing (projectKey / issueKey / profile) is attached by cli.js from the
|
|
6
|
-
// flags
|
|
5
|
+
// Routing (projectKey / issueKey / profile) is attached by cli.js from the
|
|
6
|
+
// resolved context — flags where given, inferred from the checkout otherwise; a
|
|
7
|
+
// command only builds its `args`. `buildArgs` receives that context as its third
|
|
8
|
+
// parameter so a command whose own positional IS an issue key can fall back to it.
|
|
9
|
+
//
|
|
10
|
+
// Usage strings show routing in `[…]`: it is optional inside a configured repo.
|
|
7
11
|
|
|
8
12
|
/** Parse a positional as an integer, or fail with a clear message. */
|
|
9
13
|
function toInt(name, value) {
|
|
@@ -45,11 +49,11 @@ export class UsageError extends Error {
|
|
|
45
49
|
|
|
46
50
|
/**
|
|
47
51
|
* @typedef {Object} Command
|
|
48
|
-
* @property {"GET" | "POST"} method
|
|
52
|
+
* @property {"GET" | "POST" | "LOCAL"} method `LOCAL` runs in-process, no action call.
|
|
49
53
|
* @property {string} [path] For GET commands.
|
|
50
54
|
* @property {string} [action] Gateway action name for POST commands.
|
|
51
55
|
* @property {boolean} [destructive] Requires --force/--yes (gated by cli.js).
|
|
52
|
-
* @property {(pos: string[], flags: Record<string, unknown
|
|
56
|
+
* @property {(pos: string[], flags: Record<string, unknown>, context?: object) => unknown[]} [buildArgs]
|
|
53
57
|
* @property {string} usage One-line usage string.
|
|
54
58
|
*/
|
|
55
59
|
|
|
@@ -65,11 +69,17 @@ const COMMANDS = {
|
|
|
65
69
|
path: "/profiles",
|
|
66
70
|
usage: "list-profiles",
|
|
67
71
|
},
|
|
72
|
+
// Reports the resolved routing context rather than acting on it, so it stays
|
|
73
|
+
// useful precisely when a command failed to route.
|
|
74
|
+
where: {
|
|
75
|
+
method: "LOCAL",
|
|
76
|
+
usage: "where [--json]",
|
|
77
|
+
},
|
|
68
78
|
|
|
69
79
|
"branch create": {
|
|
70
80
|
method: "POST",
|
|
71
81
|
action: "createBranch",
|
|
72
|
-
usage: "branch create <branchName>
|
|
82
|
+
usage: "branch create <branchName> [--issue KEY] [--project KEY]",
|
|
73
83
|
buildArgs: ([branchName]) => {
|
|
74
84
|
if (!branchName) throw new UsageError("branch create requires <branchName>.");
|
|
75
85
|
|
|
@@ -80,7 +90,7 @@ const COMMANDS = {
|
|
|
80
90
|
method: "POST",
|
|
81
91
|
action: "deleteBranch",
|
|
82
92
|
destructive: true,
|
|
83
|
-
usage: "branch delete <branchName>
|
|
93
|
+
usage: "branch delete <branchName> [--issue KEY] [--project KEY] --force",
|
|
84
94
|
buildArgs: ([branchName]) => {
|
|
85
95
|
if (!branchName) throw new UsageError("branch delete requires <branchName>.");
|
|
86
96
|
|
|
@@ -92,7 +102,7 @@ const COMMANDS = {
|
|
|
92
102
|
method: "POST",
|
|
93
103
|
action: "commitByTemplate",
|
|
94
104
|
usage:
|
|
95
|
-
'commit template <worktreePath> [--values \'{"key":"val"}\'] [--no-push]
|
|
105
|
+
'commit template <worktreePath> [--values \'{"key":"val"}\'] [--no-push] [--issue KEY] [--project KEY]',
|
|
96
106
|
buildArgs: ([worktreePath], flags) => {
|
|
97
107
|
if (!worktreePath) throw new UsageError("commit template requires <worktreePath>.");
|
|
98
108
|
|
|
@@ -106,7 +116,7 @@ const COMMANDS = {
|
|
|
106
116
|
"mr create": {
|
|
107
117
|
method: "POST",
|
|
108
118
|
action: "createPullRequest",
|
|
109
|
-
usage: "mr create <title> <headBranch> [--body <text>]
|
|
119
|
+
usage: "mr create <title> <headBranch> [--body <text>] [--issue KEY] [--project KEY]",
|
|
110
120
|
buildArgs: ([title, headBranch], flags) => {
|
|
111
121
|
if (!title || !headBranch) {
|
|
112
122
|
throw new UsageError("mr create requires <title> and <headBranch>.");
|
|
@@ -118,26 +128,26 @@ const COMMANDS = {
|
|
|
118
128
|
"mr ready": {
|
|
119
129
|
method: "POST",
|
|
120
130
|
action: "markPrReadyForReview",
|
|
121
|
-
usage: "mr ready <prId>
|
|
131
|
+
usage: "mr ready <prId> [--issue KEY] [--project KEY]",
|
|
122
132
|
buildArgs: ([prId]) => [toInt("prId", prId)],
|
|
123
133
|
},
|
|
124
134
|
"mr draft": {
|
|
125
135
|
method: "POST",
|
|
126
136
|
action: "markPrAsDraft",
|
|
127
|
-
usage: "mr draft <prId>
|
|
137
|
+
usage: "mr draft <prId> [--issue KEY] [--project KEY]",
|
|
128
138
|
buildArgs: ([prId]) => [toInt("prId", prId)],
|
|
129
139
|
},
|
|
130
140
|
"mr close": {
|
|
131
141
|
method: "POST",
|
|
132
142
|
action: "closePullRequest",
|
|
133
143
|
destructive: true,
|
|
134
|
-
usage: "mr close <prId>
|
|
144
|
+
usage: "mr close <prId> [--issue KEY] [--project KEY] --force",
|
|
135
145
|
buildArgs: ([prId]) => [toInt("prId", prId)],
|
|
136
146
|
},
|
|
137
147
|
"mr assign": {
|
|
138
148
|
method: "POST",
|
|
139
149
|
action: "assignPr",
|
|
140
|
-
usage: "mr assign <prId> <assignee>
|
|
150
|
+
usage: "mr assign <prId> <assignee> [--issue KEY] [--project KEY]",
|
|
141
151
|
buildArgs: ([prId, assignee]) => {
|
|
142
152
|
if (!assignee) throw new UsageError("mr assign requires <prId> and <assignee>.");
|
|
143
153
|
|
|
@@ -147,7 +157,7 @@ const COMMANDS = {
|
|
|
147
157
|
"mr labels": {
|
|
148
158
|
method: "POST",
|
|
149
159
|
action: "addPrLabels",
|
|
150
|
-
usage: "mr labels <prId> <label...>
|
|
160
|
+
usage: "mr labels <prId> <label...> [--issue KEY] [--project KEY]",
|
|
151
161
|
buildArgs: ([prId, ...labels]) => {
|
|
152
162
|
if (labels.length === 0) {
|
|
153
163
|
throw new UsageError("mr labels requires <prId> and at least one <label>.");
|
|
@@ -159,7 +169,7 @@ const COMMANDS = {
|
|
|
159
169
|
"mr reviewers": {
|
|
160
170
|
method: "POST",
|
|
161
171
|
action: "requestPrReviewers",
|
|
162
|
-
usage: "mr reviewers <prId> <reviewer...>
|
|
172
|
+
usage: "mr reviewers <prId> <reviewer...> [--issue KEY] [--project KEY]",
|
|
163
173
|
buildArgs: ([prId, ...reviewers]) => {
|
|
164
174
|
if (reviewers.length === 0) {
|
|
165
175
|
throw new UsageError("mr reviewers requires <prId> and at least one <reviewer>.");
|
|
@@ -171,11 +181,18 @@ const COMMANDS = {
|
|
|
171
181
|
"mr find": {
|
|
172
182
|
method: "POST",
|
|
173
183
|
action: "findPrByIssueKey",
|
|
174
|
-
usage: "mr find
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
usage: "mr find [issueKey] [--issue KEY] [--project KEY]",
|
|
185
|
+
// The positional wins, then --issue, then the issue key inferred from the
|
|
186
|
+
// branch — so `devflow mr find` on `ABC-123-login` needs no argument at all.
|
|
187
|
+
buildArgs: ([issueKey], _flags, context = {}) => {
|
|
188
|
+
const key = issueKey ?? context.issueKey;
|
|
189
|
+
|
|
190
|
+
if (!key) {
|
|
191
|
+
throw new UsageError(
|
|
192
|
+
"mr find needs an issue key: pass it positionally or with --issue " +
|
|
193
|
+
"(none could be inferred from the current branch or directory).",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
179
196
|
|
|
180
197
|
return [key];
|
|
181
198
|
},
|
|
@@ -183,18 +200,17 @@ const COMMANDS = {
|
|
|
183
200
|
"mr fix-prompt": {
|
|
184
201
|
method: "POST",
|
|
185
202
|
action: "getFixCommentsPrompt",
|
|
186
|
-
usage: "mr fix-prompt [prId] [--issue KEY]
|
|
187
|
-
buildArgs: ([prId],
|
|
203
|
+
usage: "mr fix-prompt [prId] [--issue KEY] [--project KEY]",
|
|
204
|
+
buildArgs: ([prId], _flags, context = {}) => {
|
|
188
205
|
const parsedPrId = prId === undefined ? undefined : toInt("prId", prId);
|
|
189
|
-
const issueKey = flags.issue ? String(flags.issue) : undefined;
|
|
190
206
|
|
|
191
|
-
return [parsedPrId, issueKey];
|
|
207
|
+
return [parsedPrId, context.issueKey];
|
|
192
208
|
},
|
|
193
209
|
},
|
|
194
210
|
"pipeline status": {
|
|
195
211
|
method: "POST",
|
|
196
212
|
action: "getPipelineStatus",
|
|
197
|
-
usage: "pipeline status <prId> <headSha>
|
|
213
|
+
usage: "pipeline status <prId> <headSha> [--issue KEY] [--project KEY]",
|
|
198
214
|
buildArgs: ([prId, headSha]) => {
|
|
199
215
|
if (!headSha) throw new UsageError("pipeline status requires <prId> and <headSha>.");
|
|
200
216
|
|
|
@@ -204,7 +220,7 @@ const COMMANDS = {
|
|
|
204
220
|
"pipeline jobs": {
|
|
205
221
|
method: "POST",
|
|
206
222
|
action: "listPipelineJobs",
|
|
207
|
-
usage: "pipeline jobs <prId> <headSha>
|
|
223
|
+
usage: "pipeline jobs <prId> <headSha> [--issue KEY] [--project KEY]",
|
|
208
224
|
buildArgs: ([prId, headSha]) => {
|
|
209
225
|
if (!headSha) throw new UsageError("pipeline jobs requires <prId> and <headSha>.");
|
|
210
226
|
|
|
@@ -214,7 +230,7 @@ const COMMANDS = {
|
|
|
214
230
|
"pipeline retry": {
|
|
215
231
|
method: "POST",
|
|
216
232
|
action: "restartFailedJobs",
|
|
217
|
-
usage: "pipeline retry <pipelineOrRunId> [--all] [--job <jobId>]
|
|
233
|
+
usage: "pipeline retry <pipelineOrRunId> [--all] [--job <jobId>] [--issue KEY] [--project KEY]",
|
|
218
234
|
buildArgs: ([id], flags) => {
|
|
219
235
|
if (flags.job !== undefined) return [toInt("job", String(flags.job))];
|
|
220
236
|
|
|
@@ -234,7 +250,8 @@ const COMMANDS = {
|
|
|
234
250
|
"slack notify": {
|
|
235
251
|
method: "POST",
|
|
236
252
|
action: "notifySlack",
|
|
237
|
-
usage:
|
|
253
|
+
usage:
|
|
254
|
+
'slack notify <message> [--channel ID] [--values \'{"key":"val"}\'] [--issue KEY] [--project KEY]',
|
|
238
255
|
buildArgs: ([message], flags) => {
|
|
239
256
|
if (!message) throw new UsageError("slack notify requires <message>.");
|
|
240
257
|
|
package/src/context.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Wires the shared resolver into the CLI: builds the `GET /context` caller, turns
|
|
2
|
+
// a resolved context into routing fields, and formats the two user-facing
|
|
3
|
+
// outcomes — `devflow where` and the "inference didn't get there" errors.
|
|
4
|
+
//
|
|
5
|
+
// The resolver itself (contextResolver.js) is generated from shared/ and must not
|
|
6
|
+
// be edited here; everything CLI-specific lives in this file.
|
|
7
|
+
|
|
8
|
+
import { resolveContext } from "./contextResolver.js";
|
|
9
|
+
import { createGitRunner } from "./gitFacts.js";
|
|
10
|
+
import { GatewayError } from "./client.js";
|
|
11
|
+
import { UsageError } from "./commands.js";
|
|
12
|
+
|
|
13
|
+
/** Flag that overrides each resolved field, for "pass --x" hints. */
|
|
14
|
+
const FLAG_FOR_FIELD = {
|
|
15
|
+
projectKey: "--project",
|
|
16
|
+
issueKey: "--issue",
|
|
17
|
+
repo: "--repo",
|
|
18
|
+
profile: "--profile",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Human label per precedence step, for `devflow where`. */
|
|
22
|
+
const ORIGIN_LABEL = {
|
|
23
|
+
explicit: "flag",
|
|
24
|
+
env: "environment",
|
|
25
|
+
gitRemote: "git remote",
|
|
26
|
+
localPath: "working directory",
|
|
27
|
+
branch: "branch name",
|
|
28
|
+
path: "directory name",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the `queryContext` the resolver calls.
|
|
33
|
+
*
|
|
34
|
+
* The daemon answers ambiguity with HTTP 409 `{ ok: false }`, which `gatewayRequest`
|
|
35
|
+
* raises as a GatewayError — and the envelope's `result` (holding the candidates)
|
|
36
|
+
* is dropped on that path. So AMBIGUOUS_PROFILE is translated back into the
|
|
37
|
+
* `{ ambiguous, candidates, message }` shape the resolver expects, and every other
|
|
38
|
+
* failure is re-thrown for the resolver to record as a warning.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ baseUrl: string, token: string }} config
|
|
41
|
+
* @param {typeof import("./client.js").gatewayRequest} request
|
|
42
|
+
*/
|
|
43
|
+
export function createContextQuery(config, request) {
|
|
44
|
+
return async ({ cwd = "", remote = "", branch = "" }) => {
|
|
45
|
+
const query = new URLSearchParams({ cwd, remote, branch });
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return await request(config, "GET", `/context?${query}`);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err instanceof GatewayError && err.code === "AMBIGUOUS_PROFILE") {
|
|
51
|
+
return { ambiguous: true, candidates: err.result?.candidates ?? [], message: err.message };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the routing context for this invocation: explicit flags first, then
|
|
61
|
+
* everything the environment can tell us.
|
|
62
|
+
* @param {Record<string, unknown>} flags
|
|
63
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, queryContext?: Function }} [deps]
|
|
64
|
+
*/
|
|
65
|
+
export function resolveCliContext(
|
|
66
|
+
flags,
|
|
67
|
+
{ cwd = process.cwd(), env = process.env, queryContext } = {},
|
|
68
|
+
) {
|
|
69
|
+
return resolveContext({
|
|
70
|
+
explicit: {
|
|
71
|
+
projectKey: flags.project === undefined ? undefined : String(flags.project),
|
|
72
|
+
issueKey: flags.issue === undefined ? undefined : String(flags.issue),
|
|
73
|
+
repo: flags.repo === undefined ? undefined : String(flags.repo),
|
|
74
|
+
profile: flags.profile === undefined ? undefined : String(flags.profile),
|
|
75
|
+
},
|
|
76
|
+
env,
|
|
77
|
+
cwd,
|
|
78
|
+
runGit: createGitRunner(cwd),
|
|
79
|
+
queryContext,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The project key a payload carries: explicit key, else derived from the issue key. */
|
|
84
|
+
export function projectKeyFrom(context) {
|
|
85
|
+
if (context.projectKey) return context.projectKey.toUpperCase();
|
|
86
|
+
|
|
87
|
+
if (context.issueKey) {
|
|
88
|
+
const lastDash = context.issueKey.lastIndexOf("-");
|
|
89
|
+
|
|
90
|
+
return (lastDash === -1 ? context.issueKey : context.issueKey.slice(0, lastDash)).toUpperCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Describe one ambiguity candidate as `PROJECT/repo (profile)`. */
|
|
97
|
+
function describeCandidate(candidate) {
|
|
98
|
+
const repo = candidate.repo?.name ?? candidate.repo?.id ?? candidate.repo?.remoteUrl;
|
|
99
|
+
const where = [repo, candidate.profileId].filter(Boolean).join(" via ");
|
|
100
|
+
|
|
101
|
+
return where ? `${candidate.projectKey} (${where})` : String(candidate.projectKey);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Summarise what inference DID find, so a failure says why it failed rather than
|
|
106
|
+
* only what is missing.
|
|
107
|
+
*/
|
|
108
|
+
function describeFindings(context) {
|
|
109
|
+
const found = Object.keys(FLAG_FOR_FIELD)
|
|
110
|
+
.filter((field) => context[field])
|
|
111
|
+
.map(
|
|
112
|
+
(field) =>
|
|
113
|
+
`${field}=${context[field]} (from ${ORIGIN_LABEL[context.matchedBy[field]] ?? context.matchedBy[field]})`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return found.length ? `Inferred ${found.join(", ")}.` : "Nothing could be inferred here.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A usable flag value, or undefined for anything blank/absent. */
|
|
120
|
+
function flagValue(raw) {
|
|
121
|
+
const value = raw === undefined || raw === true ? "" : String(raw).trim();
|
|
122
|
+
|
|
123
|
+
return value === "" ? undefined : value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The project key the caller named outright, via `--project` or implied by
|
|
128
|
+
* `--issue`. Mirrors `resolveProjectKey` in mcp/src/routing.ts: only the explicit
|
|
129
|
+
* inputs count, so a branch-inferred key never silently settles an ambiguity.
|
|
130
|
+
* @param {Record<string, unknown>} flags
|
|
131
|
+
* @returns {string | undefined}
|
|
132
|
+
*/
|
|
133
|
+
function explicitProjectKey(flags) {
|
|
134
|
+
const project = flagValue(flags.project);
|
|
135
|
+
|
|
136
|
+
if (project) return project;
|
|
137
|
+
|
|
138
|
+
const issue = flagValue(flags.issue);
|
|
139
|
+
|
|
140
|
+
if (!issue) return undefined;
|
|
141
|
+
|
|
142
|
+
const lastDash = issue.lastIndexOf("-");
|
|
143
|
+
|
|
144
|
+
return lastDash === -1 ? issue : issue.slice(0, lastDash);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Throw when the resolved context can't route the call. Ambiguity names every
|
|
149
|
+
* candidate; an empty result states what was inferred and which flag fixes it.
|
|
150
|
+
* @param {string} key command key, for the message
|
|
151
|
+
* @param {object} command
|
|
152
|
+
* @param {object} context resolved context
|
|
153
|
+
* @param {Record<string, unknown>} [flags] raw flags, to spot an explicit choice
|
|
154
|
+
*/
|
|
155
|
+
export function assertRoutable(key, command, context, flags = {}) {
|
|
156
|
+
// Ambiguity is never resolved by guessing — but a caller who already named a
|
|
157
|
+
// project (via --project, or --issue implying it) HAS made that choice, and the
|
|
158
|
+
// refusal below tells them to do exactly that; refusing anyway is a dead end.
|
|
159
|
+
// Same gate as `ambiguous && !explicitKey` in mcp/src/context.ts.
|
|
160
|
+
const explicitKey = explicitProjectKey(flags);
|
|
161
|
+
|
|
162
|
+
if (context.ambiguous && !explicitKey) {
|
|
163
|
+
const named = (context.candidates ?? []).map(describeCandidate).join(", ");
|
|
164
|
+
|
|
165
|
+
throw new UsageError(
|
|
166
|
+
`"${key}" could not be routed — this checkout matches several configured projects` +
|
|
167
|
+
`${named ? ` (${named})` : ""}. Pass --project (or --repo) to choose one. Usage: ${command.usage}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!projectKeyFrom(context) && !context.profile) {
|
|
172
|
+
// `--repo` narrows a project's repos; it cannot name the project itself, so
|
|
173
|
+
// on an ambiguous checkout it still needs --project alongside it. Say so,
|
|
174
|
+
// rather than reporting the generic "nothing was inferred".
|
|
175
|
+
const why = flagValue(flags.repo)
|
|
176
|
+
? `--repo only picks a repo within a project, and this checkout matches several projects` +
|
|
177
|
+
`${context.candidates?.length ? ` (${context.candidates.map(describeCandidate).join(", ")})` : ""}.`
|
|
178
|
+
: [describeFindings(context), ...context.warnings].join(" ");
|
|
179
|
+
|
|
180
|
+
throw new UsageError(
|
|
181
|
+
`"${key}" could not be routed. ${why} ` +
|
|
182
|
+
`Pass --issue ABC-123, --project ABC, or --profile <id>. Usage: ${command.usage}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The Option A offline message: when the owning profile is known but not
|
|
189
|
+
* connected, name it and say what to do, instead of a bare NO_PROFILE.
|
|
190
|
+
* Returns the error to throw, or null when the profile looks usable.
|
|
191
|
+
*/
|
|
192
|
+
export function offlineProfileError(context) {
|
|
193
|
+
if (context.profileAlive !== false || !context.profile) return null;
|
|
194
|
+
|
|
195
|
+
const owns = context.projectKey ? ` owns ${context.projectKey} but` : "";
|
|
196
|
+
|
|
197
|
+
return new GatewayError(
|
|
198
|
+
`Chrome profile "${context.profile}"${owns} is not connected, so this action cannot run. ` +
|
|
199
|
+
`Open that Chrome profile (the DevFlow extension executes the action) and retry.`,
|
|
200
|
+
"NO_PROFILE",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* How the resolved profile looks to the daemon. Shown inline on the profile row
|
|
206
|
+
* so `devflow where` answers "would a call work?" without a second lookup.
|
|
207
|
+
*/
|
|
208
|
+
function describeLiveness(context) {
|
|
209
|
+
if (context.profileAlive === true) return "connected";
|
|
210
|
+
|
|
211
|
+
if (context.profileAlive === false) {
|
|
212
|
+
return "NOT connected; open this Chrome profile before running actions";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return "liveness unknown (the daemon did not say)";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Render the `devflow where` report. */
|
|
219
|
+
export function formatWhere(context) {
|
|
220
|
+
const lines = [];
|
|
221
|
+
|
|
222
|
+
for (const [field, flag] of Object.entries(FLAG_FOR_FIELD)) {
|
|
223
|
+
const value = context[field];
|
|
224
|
+
const origin = ORIGIN_LABEL[context.matchedBy[field]] ?? context.matchedBy[field];
|
|
225
|
+
const suffix = field === "profile" && value ? ` — ${describeLiveness(context)}` : "";
|
|
226
|
+
|
|
227
|
+
lines.push(
|
|
228
|
+
value
|
|
229
|
+
? `${field.padEnd(11)} ${value} (${origin})${suffix}`
|
|
230
|
+
: `${field.padEnd(11)} — (not resolved; pass ${flag})`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (context.ambiguous) {
|
|
235
|
+
lines.push("", "Ambiguous — this checkout matches several configured projects:");
|
|
236
|
+
|
|
237
|
+
for (const candidate of context.candidates ?? []) {
|
|
238
|
+
lines.push(` - ${describeCandidate(candidate)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
lines.push("Pass --project (or --repo) to choose one.");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (context.warnings.length) {
|
|
245
|
+
lines.push("", ...context.warnings.map((w) => `note: ${w}`));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit.
|
|
2
|
+
// Source of truth: shared/contextResolver.js. Regenerate with:
|
|
3
|
+
// node scripts/sync-context-resolver.mjs
|
|
4
|
+
|
|
5
|
+
// The one place routing context is inferred from the environment, consumed by
|
|
6
|
+
// both the CLI and the MCP server (each ships a generated copy inside its own
|
|
7
|
+
// package root — see scripts/sync-context-resolver.mjs; edit THIS file only).
|
|
8
|
+
//
|
|
9
|
+
// Division of labour: this module gathers environment FACTS (cwd, git remote,
|
|
10
|
+
// branch) and asks the daemon's `GET /context` to match them against the routing
|
|
11
|
+
// mirror. Matching itself — remote beats cwd, deepest nested repo wins, ties are
|
|
12
|
+
// ambiguous — lives in desktop/src/gateway/{routing,server}.js and is never
|
|
13
|
+
// re-derived here.
|
|
14
|
+
//
|
|
15
|
+
// Precedence, highest first:
|
|
16
|
+
// 1. explicit input (flag / tool argument)
|
|
17
|
+
// 2. DEVFLOW_PROJECT / DEVFLOW_ISSUE / DEVFLOW_REPO / DEVFLOW_PROFILE
|
|
18
|
+
// 3. git remote `origin`, matched against the mirror
|
|
19
|
+
// 4. cwd (walked to the git root), matched against `localPath`
|
|
20
|
+
// 5. issue key parsed from the current branch
|
|
21
|
+
// 6. Jira-key-shaped ancestor directory name
|
|
22
|
+
// A lower step never overwrites a value a higher one already supplied.
|
|
23
|
+
|
|
24
|
+
/** Env var per resolved field. */
|
|
25
|
+
const ENV_KEYS = {
|
|
26
|
+
projectKey: "DEVFLOW_PROJECT",
|
|
27
|
+
issueKey: "DEVFLOW_ISSUE",
|
|
28
|
+
repo: "DEVFLOW_REPO",
|
|
29
|
+
profile: "DEVFLOW_PROFILE",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Jira-key shape for a whole directory name: `ABC-123`. */
|
|
33
|
+
const DIR_ISSUE_KEY = /^([A-Z][A-Z0-9]+-\d+)$/;
|
|
34
|
+
|
|
35
|
+
/** Jira-key shape inside a branch name: `feature/ABC-123-thing` → `ABC-123`. */
|
|
36
|
+
const BRANCH_ISSUE_KEY = /(?:^|[^A-Z0-9])([A-Z][A-Z0-9]+-\d+)(?:[^0-9]|$)/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract the issue key a branch name refers to, if any. Mirrors
|
|
40
|
+
* `issueKeyFromBranch` in desktop/src/gateway/routing.js — the daemon normally
|
|
41
|
+
* supplies this, but it is needed locally too so an offline daemon still yields
|
|
42
|
+
* a branch-derived issue key.
|
|
43
|
+
* @param {unknown} branch
|
|
44
|
+
* @returns {string | undefined}
|
|
45
|
+
*/
|
|
46
|
+
export function issueKeyFromBranch(branch) {
|
|
47
|
+
if (typeof branch !== "string" || branch.trim() === "") return undefined;
|
|
48
|
+
|
|
49
|
+
return BRANCH_ISSUE_KEY.exec(branch.trim().toUpperCase())?.[1] ?? undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Trimmed string, or `undefined` for anything unusable. */
|
|
53
|
+
function clean(value) {
|
|
54
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The project a key or issue key belongs to: split on the LAST `-` and uppercase
|
|
59
|
+
* (`abc-123` → `ABC`). Mirrors `issueKeyToProjectKey` in the daemon's routing.js.
|
|
60
|
+
* @param {string | undefined} value
|
|
61
|
+
* @returns {string | undefined}
|
|
62
|
+
*/
|
|
63
|
+
function projectOf(value) {
|
|
64
|
+
const trimmed = clean(value);
|
|
65
|
+
|
|
66
|
+
if (!trimmed) return undefined;
|
|
67
|
+
|
|
68
|
+
const lastDash = trimmed.lastIndexOf("-");
|
|
69
|
+
|
|
70
|
+
return (lastDash === -1 ? trimmed : trimmed.slice(0, lastDash)).toUpperCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The issue key an ancestor directory names, nearest first: a checkout parked in
|
|
75
|
+
* `~/work/ABC-123/repo` implies the issue even with no git metadata at all.
|
|
76
|
+
* @param {string} cwd
|
|
77
|
+
* @returns {string | undefined}
|
|
78
|
+
*/
|
|
79
|
+
export function issueKeyFromPath(cwd) {
|
|
80
|
+
const path = clean(cwd);
|
|
81
|
+
|
|
82
|
+
if (!path) return undefined;
|
|
83
|
+
|
|
84
|
+
const segments = path.replace(/\\/g, "/").split("/").filter(Boolean).reverse();
|
|
85
|
+
|
|
86
|
+
for (const segment of segments) {
|
|
87
|
+
const match = DIR_ISSUE_KEY.exec(segment.toUpperCase());
|
|
88
|
+
|
|
89
|
+
if (match) return match[1];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the four routing env vars.
|
|
97
|
+
* @param {Record<string, string | undefined>} env
|
|
98
|
+
* @returns {{ projectKey?: string, issueKey?: string, repo?: string, profile?: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function readEnvContext(env = {}) {
|
|
101
|
+
const out = {};
|
|
102
|
+
|
|
103
|
+
for (const [field, key] of Object.entries(ENV_KEYS)) {
|
|
104
|
+
const value = clean(env[key]);
|
|
105
|
+
|
|
106
|
+
if (value) out[field] = value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collect what git knows about `cwd`, tolerating a directory that is not a repo.
|
|
114
|
+
* Every command is read-only and non-interactive: `--no-pager` plus a terminal
|
|
115
|
+
* prompt of "" so a credential helper can never block on stdin.
|
|
116
|
+
* @param {(args: string[]) => string | null} runGit runner returning stdout or null
|
|
117
|
+
* @returns {{ root?: string, remote?: string, branch?: string }}
|
|
118
|
+
*/
|
|
119
|
+
export function readGitFacts(runGit) {
|
|
120
|
+
const root = clean(runGit(["rev-parse", "--show-toplevel"]));
|
|
121
|
+
|
|
122
|
+
// Not a repo — the remaining reads would fail the same way, so stop here.
|
|
123
|
+
if (!root) return {};
|
|
124
|
+
|
|
125
|
+
const branch = clean(runGit(["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
root,
|
|
129
|
+
remote: clean(runGit(["remote", "get-url", "origin"])),
|
|
130
|
+
// Detached HEAD reports "HEAD", which names no branch.
|
|
131
|
+
branch: branch && branch !== "HEAD" ? branch : undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Merge a lower-precedence source in, recording which step supplied each field.
|
|
137
|
+
* @param {object} resolved accumulator, mutated
|
|
138
|
+
* @param {Record<string, string | undefined>} source
|
|
139
|
+
* @param {string} origin precedence-step label
|
|
140
|
+
*/
|
|
141
|
+
function fill(resolved, source, origin) {
|
|
142
|
+
for (const [field, value] of Object.entries(source)) {
|
|
143
|
+
if (value === undefined || resolved[field] !== undefined) continue;
|
|
144
|
+
|
|
145
|
+
resolved[field] = value;
|
|
146
|
+
resolved.matchedBy[field] = origin;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the routing context for a call.
|
|
152
|
+
*
|
|
153
|
+
* `queryContext` is injected so this stays pure and unit-testable; the CLI and
|
|
154
|
+
* MCP both pass a `GET /context` caller. It may return `null` when the daemon is
|
|
155
|
+
* unreachable — inference then degrades to env + branch/path parsing rather than
|
|
156
|
+
* failing, because a fully explicit call needs no daemon at all.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} options
|
|
159
|
+
* @param {{ projectKey?: string, issueKey?: string, repo?: string, profile?: string }} [options.explicit]
|
|
160
|
+
* @param {Record<string, string | undefined>} [options.env]
|
|
161
|
+
* @param {string} [options.cwd]
|
|
162
|
+
* @param {(args: string[]) => string | null} [options.runGit]
|
|
163
|
+
* @param {(q: { cwd?: string, remote?: string, branch?: string }) => Promise<object | null>} [options.queryContext]
|
|
164
|
+
* @returns {Promise<{
|
|
165
|
+
* projectKey?: string, issueKey?: string, repo?: string, profile?: string,
|
|
166
|
+
* profileAlive?: boolean | null, matchedBy: Record<string, string>,
|
|
167
|
+
* candidates?: object[], ambiguous?: boolean, warnings: string[]
|
|
168
|
+
* }>}
|
|
169
|
+
*/
|
|
170
|
+
export async function resolveContext({
|
|
171
|
+
explicit = {},
|
|
172
|
+
env = {},
|
|
173
|
+
cwd = "",
|
|
174
|
+
runGit = () => null,
|
|
175
|
+
queryContext = async () => null,
|
|
176
|
+
} = {}) {
|
|
177
|
+
const resolved = { matchedBy: {}, warnings: [] };
|
|
178
|
+
|
|
179
|
+
fill(
|
|
180
|
+
resolved,
|
|
181
|
+
{
|
|
182
|
+
projectKey: clean(explicit.projectKey),
|
|
183
|
+
issueKey: clean(explicit.issueKey),
|
|
184
|
+
repo: clean(explicit.repo),
|
|
185
|
+
profile: clean(explicit.profile),
|
|
186
|
+
},
|
|
187
|
+
"explicit",
|
|
188
|
+
);
|
|
189
|
+
fill(resolved, readEnvContext(env), "env");
|
|
190
|
+
|
|
191
|
+
// An explicit issue key implies its project, but only if nothing better said so.
|
|
192
|
+
const git = readGitFacts(runGit);
|
|
193
|
+
|
|
194
|
+
if (!git.root && clean(cwd)) {
|
|
195
|
+
resolved.warnings.push(`${cwd} is not inside a git repository; inferring from the path only.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The git root is the meaningful path to match: a nested subdirectory of a
|
|
199
|
+
// checkout should resolve to the same repo as its root.
|
|
200
|
+
const matchPath = git.root ?? clean(cwd);
|
|
201
|
+
let context = null;
|
|
202
|
+
|
|
203
|
+
if (git.remote || matchPath) {
|
|
204
|
+
try {
|
|
205
|
+
context = await queryContext({
|
|
206
|
+
cwd: matchPath ?? "",
|
|
207
|
+
remote: git.remote ?? "",
|
|
208
|
+
branch: git.branch ?? "",
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
resolved.warnings.push(
|
|
212
|
+
`Could not ask the DevFlow daemon to resolve this checkout: ${err?.message ?? err}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (context?.ambiguous) {
|
|
218
|
+
resolved.ambiguous = true;
|
|
219
|
+
resolved.candidates = context.candidates ?? [];
|
|
220
|
+
resolved.warnings.push(
|
|
221
|
+
context.message ??
|
|
222
|
+
"Several configured repositories match this checkout; pass --project (or --repo) to choose one.",
|
|
223
|
+
);
|
|
224
|
+
} else if (context) {
|
|
225
|
+
// `matchedBy` from the daemon says WHICH fact matched — remote or localPath —
|
|
226
|
+
// so the reported precedence step reflects the real reason, not a guess.
|
|
227
|
+
const origin = context.matchedBy === "remote" ? "gitRemote" : "localPath";
|
|
228
|
+
|
|
229
|
+
// The daemon answered "this CHECKOUT belongs to project P". Its repo, profile,
|
|
230
|
+
// liveness and issue key all describe P — they are only meaningful together.
|
|
231
|
+
// If the caller already pinned a different project, adopting them field-by-field
|
|
232
|
+
// would aim the action at P's repository under the caller's key: silent
|
|
233
|
+
// mis-targeting, and worse than a failure because nothing looks wrong. So the
|
|
234
|
+
// whole group is discarded on disagreement. Note this compares the KEYS, not
|
|
235
|
+
// merely where they came from — a caller who names the project the checkout
|
|
236
|
+
// already matches is agreeing, and must still get its repo/profile.
|
|
237
|
+
const pinned = projectOf(resolved.projectKey) ?? projectOf(resolved.issueKey);
|
|
238
|
+
const matched = projectOf(context.projectKey);
|
|
239
|
+
const conflict = Boolean(pinned && matched && pinned !== matched);
|
|
240
|
+
|
|
241
|
+
if (conflict) {
|
|
242
|
+
const named = resolved.projectKey ? `${resolved.projectKey}` : `${resolved.issueKey}`;
|
|
243
|
+
|
|
244
|
+
resolved.warnings.push(
|
|
245
|
+
`This checkout belongs to ${matched}, but ${named} was given explicitly; ` +
|
|
246
|
+
`ignoring the repository and profile inferred for ${matched}. ` +
|
|
247
|
+
`Pass --repo / --profile to target ${pinned} exactly.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fill(
|
|
252
|
+
resolved,
|
|
253
|
+
{
|
|
254
|
+
projectKey: clean(context.projectKey),
|
|
255
|
+
...(conflict
|
|
256
|
+
? {}
|
|
257
|
+
: {
|
|
258
|
+
repo:
|
|
259
|
+
clean(context.repo?.id) ??
|
|
260
|
+
clean(context.repo?.name) ??
|
|
261
|
+
clean(context.repo?.remoteUrl),
|
|
262
|
+
profile: clean(context.profileId),
|
|
263
|
+
}),
|
|
264
|
+
},
|
|
265
|
+
origin,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (context.candidates?.length) resolved.candidates = context.candidates;
|
|
269
|
+
|
|
270
|
+
// Liveness is only meaningful for the profile we actually resolved to; it lets
|
|
271
|
+
// consumers name an offline profile instead of printing a bare NO_PROFILE.
|
|
272
|
+
if (!conflict && resolved.profile && resolved.profile === clean(context.profileId)) {
|
|
273
|
+
resolved.profileAlive = context.profileAlive ?? null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The daemon's issue key describes its matched project too, so it is dropped on
|
|
277
|
+
// conflict — but the branch/path fallbacks below still apply, since those are
|
|
278
|
+
// read from the local checkout rather than from the project match.
|
|
279
|
+
if (!conflict) fill(resolved, { issueKey: clean(context.issueKey) }, "branch");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Branch-derived issue key ranks below every repo match but above the path.
|
|
283
|
+
// Parsed locally when the daemon didn't answer, so this survives it being down.
|
|
284
|
+
fill(resolved, { issueKey: issueKeyFromBranch(git.branch) }, "branch");
|
|
285
|
+
fill(resolved, { issueKey: issueKeyFromPath(matchPath) }, "path");
|
|
286
|
+
|
|
287
|
+
return resolved;
|
|
288
|
+
}
|
package/src/gitFacts.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit.
|
|
2
|
+
// Source of truth: shared/gitFacts.js. Regenerate with:
|
|
3
|
+
// node scripts/sync-context-resolver.mjs
|
|
4
|
+
|
|
5
|
+
// The only place this repo shells out to git for routing inference. Kept apart
|
|
6
|
+
// from contextResolver.js so the resolver itself stays pure and testable with a
|
|
7
|
+
// fake runner. Generated copies ship inside cli/ and mcp/ — edit THIS file only.
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
/** Hard ceiling so a wedged git can never hang a CLI invocation. */
|
|
12
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a read-only, non-interactive git runner rooted at `cwd`.
|
|
16
|
+
*
|
|
17
|
+
* Every invocation is incapable of blocking on input: `--no-pager` rules out a
|
|
18
|
+
* pager, `GIT_TERMINAL_PROMPT=0` and an empty `GIT_ASKPASS`/`SSH_ASKPASS` stop a
|
|
19
|
+
* credential helper prompting, and stdin is closed. A non-repo directory or any
|
|
20
|
+
* other failure yields `null` rather than throwing.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} cwd
|
|
23
|
+
* @returns {(args: string[]) => string | null} stdout (trimmed), or null on failure
|
|
24
|
+
*/
|
|
25
|
+
export function createGitRunner(cwd) {
|
|
26
|
+
return (args) => {
|
|
27
|
+
try {
|
|
28
|
+
const stdout = execFileSync("git", ["--no-pager", ...args], {
|
|
29
|
+
cwd: cwd || process.cwd(),
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
timeout: GIT_TIMEOUT_MS,
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
36
|
+
GIT_ASKPASS: "",
|
|
37
|
+
SSH_ASKPASS: "",
|
|
38
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return typeof stdout === "string" ? stdout.trim() : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|