@agilemonster/mcp-server 1.0.25 → 1.0.27
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 +43 -8
- package/bin/mcp.js +25 -1
- package/bin/orchestrator.js +1 -0
- package/dist/agent-engine.d.ts +4 -0
- package/dist/agent-engine.js +14 -2
- package/dist/branch-health.d.ts +118 -0
- package/dist/branch-health.js +302 -0
- package/dist/client.d.ts +17 -3
- package/dist/client.js +24 -5
- package/dist/deploy-memory.d.ts +17 -0
- package/dist/deploy-memory.js +20 -0
- package/dist/deploy-runner.d.ts +9 -0
- package/dist/deploy-runner.js +43 -4
- package/dist/machine.d.ts +25 -0
- package/dist/machine.js +32 -0
- package/dist/orchestrator.d.ts +2 -0
- package/dist/orchestrator.js +33 -7
- package/dist/server.d.ts +13 -1
- package/dist/server.js +197 -34
- package/dist/service.d.ts +82 -0
- package/dist/service.js +327 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -14,7 +14,9 @@ Seamlessly connects your Agile Monster boards with your favorite developer AI ag
|
|
|
14
14
|
|
|
15
15
|
## ⚡ Quick Start: Adding to your AI Agent
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
The quickest way is the one command in [Connect a project folder](#-connect-a-project-folder-bootstrap) below: it does everything in this section for you.
|
|
18
|
+
|
|
19
|
+
To set a tool up by hand, generate a **Personal API Key** in Agile Monster ([https://app.agilemonster.com](https://app.agilemonster.com) -> Profile -> AI Agents & API Keys -> Generate API key). Your key will look like `am_live_...`.
|
|
18
20
|
|
|
19
21
|
### 1. Cursor (`.cursor/mcp.json` or Global Settings)
|
|
20
22
|
```json
|
|
@@ -58,14 +60,19 @@ Add to your Antigravity MCP directory (`~/.gemini/antigravity/mcp/agilemonster`
|
|
|
58
60
|
|
|
59
61
|
---
|
|
60
62
|
|
|
61
|
-
## 📁
|
|
63
|
+
## 📁 Connect a project folder (`bootstrap`)
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
Open a terminal in your project folder and run this once (it needs [Node.js](https://nodejs.org) 18.14 or newer):
|
|
64
66
|
|
|
65
67
|
```bash
|
|
66
|
-
npx @agilemonster/mcp-server
|
|
68
|
+
npx -y @agilemonster/mcp-server bootstrap
|
|
67
69
|
```
|
|
68
70
|
|
|
71
|
+
It asks for your API key the first time, lets you pick the board, writes the files below, **installs the
|
|
72
|
+
background runner** (see the next section) and ends by telling you which coding agent on this computer is
|
|
73
|
+
ready. `init` and `setup` are the same command. Add `--board="Board name"` to skip the question, or
|
|
74
|
+
`--no-service` to leave the background runner out.
|
|
75
|
+
|
|
69
76
|
This generates:
|
|
70
77
|
1. `.agilemonster.json` — Binds the local repository to the board UUID, list columns, and default agent provider.
|
|
71
78
|
2. `AGENTS.md` — Codified operational rules for autonomous agents (Cursor, Claude Code, Antigravity, etc.) so they know when to claim tasks, ask clarifying questions, log progress, and submit work for human testing.
|
|
@@ -93,14 +100,37 @@ Agents have access to these purpose-built lifecycle tools:
|
|
|
93
100
|
|
|
94
101
|
## 🧠 Who works a card that is queued from the board
|
|
95
102
|
|
|
96
|
-
A card you assign to an agent in the web app is picked up by the background runner on your machine.
|
|
97
|
-
|
|
103
|
+
A card you assign to an agent in the web app is picked up by the background runner on your machine.
|
|
104
|
+
|
|
105
|
+
### The background runner
|
|
106
|
+
|
|
107
|
+
`bootstrap` installs the runner as a service that starts with the computer, so cards are worked whether or
|
|
108
|
+
not a coding tool is open. Without it, cards are only picked up while an AI coding tool with this MCP server
|
|
109
|
+
is open in the project folder.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npx -y @agilemonster/mcp-server service status # is it installed and running?
|
|
113
|
+
npx -y @agilemonster/mcp-server service install # install or repair it
|
|
114
|
+
npx -y @agilemonster/mcp-server service uninstall # stop and remove it
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **macOS**: a launchd agent (`~/Library/LaunchAgents/com.agilemonster.pulse.plist`). **Linux**: a systemd
|
|
118
|
+
user unit (`agilemonster-runner.service`). **Windows**: not available as a service yet; keep your coding
|
|
119
|
+
tool open, or run `npx -y @agilemonster/mcp-server pulse-daemon` in a terminal.
|
|
120
|
+
- The service runs its own copy of this package from `~/.agilemonster/runner` and keeps it up to date.
|
|
121
|
+
- A runner that was set up by hand is left alone unless you pass `--force`.
|
|
122
|
+
- The board shows the result: next to a waiting card it says whether a computer is connected, whether it is
|
|
123
|
+
there right now, and which agent will do the work.
|
|
124
|
+
|
|
125
|
+
### The agent that does the work
|
|
126
|
+
|
|
127
|
+
The runner hands a card to a **real coding agent running headless in the project folder** — the same agent you use
|
|
98
128
|
in the IDE, with the same ability to search the code, open what it finds, run the build and fix what it broke:
|
|
99
129
|
|
|
100
130
|
| Card's agent | Command line agent used | One-time setup |
|
|
101
131
|
| :--- | :--- | :--- |
|
|
102
132
|
| Cursor | `cursor-agent -p --force` | `curl https://cursor.com/install -fsS \| bash`, then `cursor-agent login` |
|
|
103
|
-
| Claude Code | `claude -p --permission-mode acceptEdits` | `npm install -g @anthropic-ai/claude-code`, then `claude auth login` |
|
|
133
|
+
| Claude Code | `claude -p --permission-mode acceptEdits --allowedTools Bash` | `npm install -g @anthropic-ai/claude-code`, then `claude auth login` |
|
|
104
134
|
| Codex | `codex exec --sandbox workspace-write` | `npm install -g @openai/codex`, then `codex login` |
|
|
105
135
|
|
|
106
136
|
The runner also finds an agent that is not on your `PATH` — for example the Codex CLI that the Codex app and
|
|
@@ -148,12 +178,17 @@ What the runner guarantees:
|
|
|
148
178
|
- **Verifies before claiming "Deployed"** — deploy workflow run for that commit, container state and health, script exit code, and a smoke check of a URL you provided. A guessed URL can never fail a deploy.
|
|
149
179
|
- **Asks once, then remembers** — an unconfirmed deploy script, cloud CLI or manually-dispatched workflow is not run on a guess. Reply on the card with `deploy: <command or method>` to confirm; `retry` re-runs only the deployment, not the code change.
|
|
150
180
|
- **Heals what it can** — starts Docker Desktop/OrbStack if it is installed but stopped, rebases and re-pushes when the remote moved ahead, retries network errors, and falls back to the next route when the chosen one plainly does not apply.
|
|
181
|
+
- **Checks the branch before it starts** — fetches, and fast-forwards a folder that is only behind (Git refuses if that would touch an uncommitted edit of yours). Unpushed commits that fit are replayed on top. If unpushed commits clash with the remote, the card asks first, in plain words, instead of letting an agent work for minutes on something that cannot be pushed.
|
|
182
|
+
- **A refused push comes with ways out** — the card says what clashes (this card, or older commits that never left this computer) and offers **Ship only this change** or **Redo on the latest version**. Anything moved out of the way is kept on an `agilemonster/backup-…` branch; nothing is deleted. If you push the change yourself, `retry` only checks the deployment: no second commit, no second deploy.
|
|
151
183
|
- **Never reports a slow pipeline as failed** — a CI run that outlives the wait is marked pending and re-checked on later pulses; the outcome is posted on the card.
|
|
152
184
|
|
|
153
185
|
State lives outside your repositories: `~/.agilemonster/projects/` (per-project deployment history) and
|
|
154
186
|
`~/.agilemonster/logs/daemon.jsonl` (structured daemon log).
|
|
155
187
|
|
|
156
|
-
|
|
188
|
+
When the runner asks a question, the card shows the answers it accepts as buttons, so nobody has to type
|
|
189
|
+
these. They still work as typed replies: `retry` · `deploy: github_actions` · `deploy: docker_compose` · `deploy: manual` · `deploy: ./deploy/deploy.sh` · `live url: https://example.com` · `deploy: only this change` · `branch: start fresh`
|
|
190
|
+
|
|
191
|
+
Replies that decide what runs or what moves on your computer (`deploy: …`, `branch: start fresh`) are only taken from the person whose account the runner uses.
|
|
157
192
|
|
|
158
193
|
---
|
|
159
194
|
|
package/bin/mcp.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runStdioServer, runBackgroundPulseDaemon } from '../dist/server.js';
|
|
3
|
-
import { MCP_SERVER_VERSION } from '../dist/client.js';
|
|
3
|
+
import { AgileMonsterClient, MCP_SERVER_VERSION } from '../dist/client.js';
|
|
4
4
|
|
|
5
5
|
const cliCommands = new Set([
|
|
6
6
|
'bootstrap',
|
|
@@ -30,7 +30,31 @@ if (firstArg === '--version' || firstArg === '-v' || firstArg === 'version') {
|
|
|
30
30
|
await ensureUserPath();
|
|
31
31
|
forgetEngineReadiness();
|
|
32
32
|
console.log(await describeAgents());
|
|
33
|
+
const { describeService } = await import('../dist/service.js');
|
|
34
|
+
console.log(`\n${await describeService()}`);
|
|
33
35
|
process.exit(0);
|
|
36
|
+
} else if (firstArg === 'service') {
|
|
37
|
+
// The background runner that works cards with no coding tool open: install | status | uninstall
|
|
38
|
+
const { installService, uninstallService, describeService } = await import('../dist/service.js');
|
|
39
|
+
const { ensureUserPath } = await import('../dist/runtime.js');
|
|
40
|
+
await ensureUserPath();
|
|
41
|
+
const sub = process.argv[3] || 'status';
|
|
42
|
+
const force = process.argv.includes('--force');
|
|
43
|
+
if (sub === 'install') {
|
|
44
|
+
const key = new AgileMonsterClient().getApiKey();
|
|
45
|
+
// The service is not started inside a project folder, so it reads the key from the home config
|
|
46
|
+
if (key && !AgileMonsterClient.hasGlobalApiKey()) AgileMonsterClient.saveGlobalConfig({ api_key: key });
|
|
47
|
+
const res = await installService({ force, hasApiKey: Boolean(key) });
|
|
48
|
+
console.log(res.message);
|
|
49
|
+
process.exit(res.ok ? 0 : 1);
|
|
50
|
+
} else if (sub === 'uninstall' || sub === 'remove') {
|
|
51
|
+
const res = await uninstallService({ force });
|
|
52
|
+
console.log(res.message);
|
|
53
|
+
process.exit(res.ok ? 0 : 1);
|
|
54
|
+
} else {
|
|
55
|
+
console.log(await describeService());
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
34
58
|
} else if (firstArg === 'pulse-daemon' || firstArg === '--pulse-daemon') {
|
|
35
59
|
runBackgroundPulseDaemon(20000).catch((err) => {
|
|
36
60
|
console.error('Fatal Pulse Daemon Error:', err);
|
package/bin/orchestrator.js
CHANGED
package/dist/agent-engine.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface EngineStatus {
|
|
|
48
48
|
export declare function engineStatuses(): Promise<EngineStatus[]>;
|
|
49
49
|
/** What `agilemonster agents` prints: which agent works a queued card here, and the one step that would fix the others. */
|
|
50
50
|
export declare function describeAgents(): Promise<string>;
|
|
51
|
+
/** How this machine works cards: with real agents, with the developer's own command, or with agents switched off. */
|
|
52
|
+
export declare function agentMode(): 'agents' | 'builtin' | 'custom';
|
|
51
53
|
export declare function engineLabelFor(provider: string): string | null;
|
|
52
54
|
/**
|
|
53
55
|
* The agent CLIs this machine can run for a card, best first: the card's own agent, then any other one that
|
|
@@ -90,6 +92,8 @@ export interface AgentReport {
|
|
|
90
92
|
summary: string;
|
|
91
93
|
verificationSteps: string[];
|
|
92
94
|
question: string;
|
|
95
|
+
/** Short answers the requester can pick with one tap, when the question has a few clear ones. */
|
|
96
|
+
options: string[];
|
|
93
97
|
}
|
|
94
98
|
/** Read the closing JSON object out of the agent's final message. Tolerates code fences and prose around it. */
|
|
95
99
|
export declare function parseAgentReport(text: string): AgentReport;
|
package/dist/agent-engine.js
CHANGED
|
@@ -243,6 +243,15 @@ async function readiness(spec, bin) {
|
|
|
243
243
|
readyCache.set(bin, { at: Date.now(), reason });
|
|
244
244
|
return reason;
|
|
245
245
|
}
|
|
246
|
+
/** How this machine works cards: with real agents, with the developer's own command, or with agents switched off. */
|
|
247
|
+
export function agentMode() {
|
|
248
|
+
const pinned = (process.env.AGILEMONSTER_AGENT_ENGINE || '').trim().toLowerCase();
|
|
249
|
+
if (['builtin', 'built-in', 'off', 'none', '0', 'false'].includes(pinned))
|
|
250
|
+
return 'builtin';
|
|
251
|
+
if (customCommand() && (!pinned || pinned === 'auto' || pinned === 'custom'))
|
|
252
|
+
return 'custom';
|
|
253
|
+
return 'agents';
|
|
254
|
+
}
|
|
246
255
|
export function engineLabelFor(provider) {
|
|
247
256
|
const spec = ENGINES.find((e) => e.providers.includes((provider || '').toLowerCase()));
|
|
248
257
|
return spec ? spec.label : null;
|
|
@@ -368,7 +377,7 @@ export function buildAgentPrompt(brief) {
|
|
|
368
377
|
lines.push(`- An earlier attempt at this card left uncommitted changes in ${brief.earlierFiles.slice(0, 40).map((f) => `\`${f}\``).join(', ')}. They are yours to keep, fix or replace.` +
|
|
369
378
|
(brief.earlierSummary ? ` It reported: ${brief.earlierSummary.trim().slice(0, 600)}` : ''));
|
|
370
379
|
}
|
|
371
|
-
lines.push('', '## When you are done', 'End your reply with one JSON object on its own, and nothing after it:', '{"status":"done","summary":"2-4 plain sentences for the person reviewing the card: what changed and where they will see it","verification_steps":["a step they can do in the running app","..."]}', 'Only if the work is impossible or unsafe without something that is not in the repository:', '{"status":"blocked","question":"the one specific thing you need, and what you already tried"}');
|
|
380
|
+
lines.push('', '## When you are done', 'End your reply with one JSON object on its own, and nothing after it:', '{"status":"done","summary":"2-4 plain sentences for the person reviewing the card: what changed and where they will see it","verification_steps":["a step they can do in the running app","..."]}', 'Only if the work is impossible or unsafe without something that is not in the repository:', '{"status":"blocked","question":"the one specific thing you need, and what you already tried","options":["up to four short answers the requester can pick with one tap, if the question has clear ones"]}');
|
|
372
381
|
return lines.join('\n');
|
|
373
382
|
}
|
|
374
383
|
/** Read the closing JSON object out of the agent's final message. Tolerates code fences and prose around it. */
|
|
@@ -395,13 +404,16 @@ export function parseAgentReport(text) {
|
|
|
395
404
|
summary: String(obj.summary || '').trim(),
|
|
396
405
|
verificationSteps: steps,
|
|
397
406
|
question: String(obj.question || '').trim(),
|
|
407
|
+
options: Array.isArray(obj.options)
|
|
408
|
+
? obj.options.map((o) => String(o).trim()).filter((o) => o.length > 0 && o.length <= 80).slice(0, 4)
|
|
409
|
+
: [],
|
|
398
410
|
};
|
|
399
411
|
}
|
|
400
412
|
catch { }
|
|
401
413
|
end = clean.lastIndexOf('}', end - 1);
|
|
402
414
|
}
|
|
403
415
|
}
|
|
404
|
-
return { status: 'unknown', summary: '', verificationSteps: [], question: '' };
|
|
416
|
+
return { status: 'unknown', summary: '', verificationSteps: [], question: '', options: [] };
|
|
405
417
|
}
|
|
406
418
|
/** The agent's prose without the closing JSON object: what to show a human when the report is missing a field. */
|
|
407
419
|
export function proseOf(text, maxChars = 900) {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the project folder in a state where work can be built and pushed?
|
|
3
|
+
*
|
|
4
|
+
* A card used to fail only at the very end: the agent worked for minutes on a branch that was behind its remote,
|
|
5
|
+
* and the push then stopped on a conflict that had nothing to do with the card. This module looks BEFORE the
|
|
6
|
+
* agent starts, brings the folder up to date when that is safe, and otherwise says in plain words what is in
|
|
7
|
+
* the way and what the owner can choose.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here ever discards work: commits that are moved out of the way stay on a backup branch.
|
|
10
|
+
*/
|
|
11
|
+
export interface UnpushedCommit {
|
|
12
|
+
sha: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
/** YYYY-MM-DD */
|
|
15
|
+
date: string;
|
|
16
|
+
}
|
|
17
|
+
export interface BranchHealth {
|
|
18
|
+
isRepo: boolean;
|
|
19
|
+
/** Empty when HEAD is detached. */
|
|
20
|
+
branch: string;
|
|
21
|
+
remote: string;
|
|
22
|
+
/** e.g. `origin/main`. Empty when this branch does not exist on the remote (or there is no remote). */
|
|
23
|
+
upstream: string;
|
|
24
|
+
/** Where the remote lives, in words for a card: "GitHub", "GitLab", "Bitbucket" or "the remote". */
|
|
25
|
+
remoteLabel: string;
|
|
26
|
+
/** The remote answered just now, so `ahead` / `behind` are current. */
|
|
27
|
+
fetched: boolean;
|
|
28
|
+
ahead: number;
|
|
29
|
+
behind: number;
|
|
30
|
+
/** Tracked files with uncommitted changes. (Untracked files are no obstacle to updating.) */
|
|
31
|
+
dirty: boolean;
|
|
32
|
+
/** Commits on this computer that were never pushed, newest first. */
|
|
33
|
+
unpushed: UnpushedCommit[];
|
|
34
|
+
/** Only when both sides moved: do the unpushed commits fit on top of the remote? null = could not tell. */
|
|
35
|
+
fitsOnRemote: boolean | null;
|
|
36
|
+
conflictFiles: string[];
|
|
37
|
+
}
|
|
38
|
+
export declare function remoteLabelFor(url: string): string;
|
|
39
|
+
export declare function inspectBranch(cwd: string, options?: {
|
|
40
|
+
fetch?: boolean;
|
|
41
|
+
}): Promise<BranchHealth>;
|
|
42
|
+
export type BranchPreparation = {
|
|
43
|
+
status: 'ready';
|
|
44
|
+
health: BranchHealth;
|
|
45
|
+
} | {
|
|
46
|
+
status: 'updated';
|
|
47
|
+
health: BranchHealth;
|
|
48
|
+
note: string;
|
|
49
|
+
} | {
|
|
50
|
+
status: 'blocked';
|
|
51
|
+
health: BranchHealth;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Bring the folder up to date with its remote when that cannot hurt anything:
|
|
55
|
+
* - only behind: fast-forward (Git itself refuses if that would touch an uncommitted edit);
|
|
56
|
+
* - both sides moved and the unpushed commits fit: replay them on top, but only in a clean tree;
|
|
57
|
+
* - both sides moved and they clash: 'blocked' — a person has to choose, before any work is built on it.
|
|
58
|
+
* When the remote cannot be reached, nothing is decided on stale information.
|
|
59
|
+
*/
|
|
60
|
+
export declare function prepareBranch(cwd: string): Promise<BranchPreparation>;
|
|
61
|
+
/** The reply that picks "start from the remote's version". */
|
|
62
|
+
export declare function parseBranchDirective(text: string): 'fresh' | null;
|
|
63
|
+
export declare const START_FRESH_REPLY = "branch: start fresh";
|
|
64
|
+
/** The question on the card when the folder is blocked before any work started. */
|
|
65
|
+
export declare function describeBlockedBranch(health: BranchHealth): {
|
|
66
|
+
question: string;
|
|
67
|
+
options: Array<{
|
|
68
|
+
label: string;
|
|
69
|
+
reply: string;
|
|
70
|
+
}>;
|
|
71
|
+
};
|
|
72
|
+
export interface SetAsideResult {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
backup: string;
|
|
75
|
+
setAside: number;
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* "Start from the remote's version": keep every unpushed commit on a backup branch, then make the local branch
|
|
80
|
+
* equal to the remote. Refuses when there are uncommitted changes, which a reset would destroy.
|
|
81
|
+
*/
|
|
82
|
+
export declare function startFromRemote(cwd: string): Promise<SetAsideResult>;
|
|
83
|
+
export interface PushConflict {
|
|
84
|
+
branch: string;
|
|
85
|
+
remoteLabel: string;
|
|
86
|
+
behind: number;
|
|
87
|
+
/** Unpushed commits that are NOT this card's (older work that never left this computer). */
|
|
88
|
+
older: UnpushedCommit[];
|
|
89
|
+
/** This card's commits, oldest first. */
|
|
90
|
+
cardCommits: string[];
|
|
91
|
+
conflictFiles: string[];
|
|
92
|
+
/** Does this card's change fit on the remote's version without the older commits? null = could not tell. */
|
|
93
|
+
cardFitsAlone: boolean | null;
|
|
94
|
+
}
|
|
95
|
+
export declare const cardTag: (cardId: string) => string;
|
|
96
|
+
/** Work out, after a refused push, what actually clashes — the card, or older commits that were never pushed. */
|
|
97
|
+
export declare function explainPushConflict(cwd: string, cardId: string, unmergedFiles?: string[]): Promise<PushConflict | null>;
|
|
98
|
+
export declare const ONLY_THIS_REPLY = "deploy: only this change";
|
|
99
|
+
/** Plain words for the card, the way out, and the buttons that take it. */
|
|
100
|
+
export declare function describePushConflict(c: PushConflict): {
|
|
101
|
+
detail: string;
|
|
102
|
+
hint: string;
|
|
103
|
+
options: Array<{
|
|
104
|
+
label: string;
|
|
105
|
+
reply: string;
|
|
106
|
+
}>;
|
|
107
|
+
};
|
|
108
|
+
export interface ShipOnlyResult {
|
|
109
|
+
ok: boolean;
|
|
110
|
+
backup: string;
|
|
111
|
+
setAside: number;
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* "Ship only this change": keep the whole local branch on a backup branch, make the branch equal to the remote,
|
|
116
|
+
* and put only this card's commits back on top. If they do not apply, everything is put back as it was.
|
|
117
|
+
*/
|
|
118
|
+
export declare function shipOnlyCardCommits(cwd: string, cardId: string): Promise<ShipOnlyResult>;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { exec, log } from './runtime.js';
|
|
2
|
+
const GIT_ENV = () => ({ ...process.env, GIT_TERMINAL_PROMPT: '0' });
|
|
3
|
+
const run = (cwd, args, timeoutMs = 45000) => exec('git', args, { cwd, timeoutMs, env: GIT_ENV() });
|
|
4
|
+
export function remoteLabelFor(url) {
|
|
5
|
+
if (/github\.com/i.test(url))
|
|
6
|
+
return 'GitHub';
|
|
7
|
+
if (/gitlab/i.test(url))
|
|
8
|
+
return 'GitLab';
|
|
9
|
+
if (/bitbucket/i.test(url))
|
|
10
|
+
return 'Bitbucket';
|
|
11
|
+
return 'the remote';
|
|
12
|
+
}
|
|
13
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
14
|
+
const fileList = (files) => {
|
|
15
|
+
const shown = files.slice(0, 3).map((f) => `\`${f}\``);
|
|
16
|
+
const more = files.length - shown.length;
|
|
17
|
+
return shown.join(', ') + (more > 0 ? ` and ${plural(more, 'other file')}` : '');
|
|
18
|
+
};
|
|
19
|
+
async function unpushedCommits(cwd, upstream) {
|
|
20
|
+
const out = await run(cwd, ['log', '--max-count=30', '--format=%h%x09%cs%x09%s', `${upstream}..HEAD`]);
|
|
21
|
+
if (!out.ok)
|
|
22
|
+
return [];
|
|
23
|
+
return out.stdout
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((line) => {
|
|
27
|
+
const [sha, date, ...subject] = line.split('\t');
|
|
28
|
+
return { sha, date: date || '', subject: subject.join('\t') };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Would merging `theirs` into `ours` conflict? Asked of Git without touching the working tree
|
|
33
|
+
* (`git merge-tree --write-tree`, Git 2.38+). `base` replays a single range the way a cherry-pick would.
|
|
34
|
+
*/
|
|
35
|
+
async function mergeFits(cwd, ours, theirs, base) {
|
|
36
|
+
const args = ['merge-tree', '--write-tree', '--name-only', '--no-messages'];
|
|
37
|
+
if (base)
|
|
38
|
+
args.push(`--merge-base=${base}`);
|
|
39
|
+
const out = await run(cwd, [...args, ours, theirs]);
|
|
40
|
+
if (out.code === 0)
|
|
41
|
+
return { fits: true, files: [] };
|
|
42
|
+
if (out.code === 1) {
|
|
43
|
+
// First line is the resulting tree; the conflicted paths follow
|
|
44
|
+
const files = out.stdout.split(/\r?\n/).slice(1).map((l) => l.trim()).filter(Boolean);
|
|
45
|
+
return { fits: false, files: Array.from(new Set(files)) };
|
|
46
|
+
}
|
|
47
|
+
return { fits: null, files: [] }; // an older Git: we simply do not know
|
|
48
|
+
}
|
|
49
|
+
export async function inspectBranch(cwd, options = {}) {
|
|
50
|
+
const health = {
|
|
51
|
+
isRepo: false,
|
|
52
|
+
branch: '',
|
|
53
|
+
remote: '',
|
|
54
|
+
upstream: '',
|
|
55
|
+
remoteLabel: 'the remote',
|
|
56
|
+
fetched: false,
|
|
57
|
+
ahead: 0,
|
|
58
|
+
behind: 0,
|
|
59
|
+
dirty: false,
|
|
60
|
+
unpushed: [],
|
|
61
|
+
fitsOnRemote: null,
|
|
62
|
+
conflictFiles: [],
|
|
63
|
+
};
|
|
64
|
+
health.isRepo = (await run(cwd, ['rev-parse', '--is-inside-work-tree'])).stdout.trim() === 'true';
|
|
65
|
+
if (!health.isRepo)
|
|
66
|
+
return health;
|
|
67
|
+
health.branch = (await run(cwd, ['branch', '--show-current'])).stdout.trim();
|
|
68
|
+
health.dirty = (await run(cwd, ['status', '--porcelain', '--untracked-files=no'])).stdout.trim().length > 0;
|
|
69
|
+
if (!health.branch)
|
|
70
|
+
return health;
|
|
71
|
+
const remotes = (await run(cwd, ['remote'])).stdout.split(/\s+/).filter(Boolean);
|
|
72
|
+
if (remotes.length === 0)
|
|
73
|
+
return health;
|
|
74
|
+
const configured = (await run(cwd, ['config', '--get', `branch.${health.branch}.remote`])).stdout.trim();
|
|
75
|
+
health.remote = configured && remotes.includes(configured) ? configured : remotes.includes('origin') ? 'origin' : remotes[0];
|
|
76
|
+
health.remoteLabel = remoteLabelFor((await run(cwd, ['remote', 'get-url', health.remote])).stdout.trim());
|
|
77
|
+
if (options.fetch !== false) {
|
|
78
|
+
health.fetched = (await run(cwd, ['fetch', health.remote, health.branch], 60000)).ok;
|
|
79
|
+
}
|
|
80
|
+
const ref = `${health.remote}/${health.branch}`;
|
|
81
|
+
if (!(await run(cwd, ['rev-parse', '--verify', '--quiet', `refs/remotes/${ref}`])).ok)
|
|
82
|
+
return health;
|
|
83
|
+
health.upstream = ref;
|
|
84
|
+
const counts = (await run(cwd, ['rev-list', '--left-right', '--count', `HEAD...${ref}`])).stdout.trim().split(/\s+/);
|
|
85
|
+
health.ahead = Number(counts[0]) || 0;
|
|
86
|
+
health.behind = Number(counts[1]) || 0;
|
|
87
|
+
if (health.ahead > 0)
|
|
88
|
+
health.unpushed = await unpushedCommits(cwd, ref);
|
|
89
|
+
if (health.ahead > 0 && health.behind > 0) {
|
|
90
|
+
const merge = await mergeFits(cwd, ref, 'HEAD');
|
|
91
|
+
health.fitsOnRemote = merge.fits;
|
|
92
|
+
health.conflictFiles = merge.files;
|
|
93
|
+
}
|
|
94
|
+
return health;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Bring the folder up to date with its remote when that cannot hurt anything:
|
|
98
|
+
* - only behind: fast-forward (Git itself refuses if that would touch an uncommitted edit);
|
|
99
|
+
* - both sides moved and the unpushed commits fit: replay them on top, but only in a clean tree;
|
|
100
|
+
* - both sides moved and they clash: 'blocked' — a person has to choose, before any work is built on it.
|
|
101
|
+
* When the remote cannot be reached, nothing is decided on stale information.
|
|
102
|
+
*/
|
|
103
|
+
export async function prepareBranch(cwd) {
|
|
104
|
+
const health = await inspectBranch(cwd);
|
|
105
|
+
if (!health.isRepo || !health.branch || !health.upstream || !health.fetched || health.behind === 0) {
|
|
106
|
+
return { status: 'ready', health };
|
|
107
|
+
}
|
|
108
|
+
const newer = plural(health.behind, 'newer commit');
|
|
109
|
+
if (health.ahead === 0) {
|
|
110
|
+
const ff = await run(cwd, ['merge', '--ff-only', health.upstream], 120000);
|
|
111
|
+
if (ff.ok) {
|
|
112
|
+
return { status: 'updated', health, note: `Before starting, I brought this project folder up to date with ${health.remoteLabel} (${newer} on \`${health.branch}\`).` };
|
|
113
|
+
}
|
|
114
|
+
// An uncommitted edit of the developer's is in the way of the update: leave their folder exactly as it is
|
|
115
|
+
log.info('branch.fast_forward_skipped', { project: cwd, branch: health.branch, behind: health.behind, reason: ff.tail.slice(0, 200) });
|
|
116
|
+
return { status: 'ready', health };
|
|
117
|
+
}
|
|
118
|
+
if (health.fitsOnRemote === false)
|
|
119
|
+
return { status: 'blocked', health };
|
|
120
|
+
if (health.fitsOnRemote === null || health.dirty)
|
|
121
|
+
return { status: 'ready', health };
|
|
122
|
+
const rebase = await run(cwd, ['rebase', health.upstream], 120000);
|
|
123
|
+
if (rebase.ok) {
|
|
124
|
+
return {
|
|
125
|
+
status: 'updated',
|
|
126
|
+
health,
|
|
127
|
+
note: `Before starting, I brought this project folder up to date with ${health.remoteLabel} (${newer} on \`${health.branch}\`); the ${plural(health.ahead, 'commit')} that had not been pushed from this computer now sit on top.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const unmerged = (await run(cwd, ['diff', '--name-only', '--diff-filter=U'])).stdout.split(/\r?\n/).filter(Boolean);
|
|
131
|
+
await run(cwd, ['rebase', '--abort']);
|
|
132
|
+
return { status: 'blocked', health: { ...health, fitsOnRemote: false, conflictFiles: unmerged.length > 0 ? unmerged : health.conflictFiles } };
|
|
133
|
+
}
|
|
134
|
+
/** The reply that picks "start from the remote's version". */
|
|
135
|
+
export function parseBranchDirective(text) {
|
|
136
|
+
return /^\s*branch\s*[:=]\s*`?(start(ing)? fresh|fresh|(start from|use) (the )?(github|gitlab|bitbucket|remote|latest) version)`?[\s.!]*$/im.test(text || '')
|
|
137
|
+
? 'fresh'
|
|
138
|
+
: null;
|
|
139
|
+
}
|
|
140
|
+
export const START_FRESH_REPLY = 'branch: start fresh';
|
|
141
|
+
function commitLines(commits) {
|
|
142
|
+
const shown = commits.slice(0, 5).map((c) => ` - \`${c.sha}\` ${c.subject.slice(0, 90)}${c.date ? ` (${c.date})` : ''}`);
|
|
143
|
+
if (commits.length > shown.length)
|
|
144
|
+
shown.push(` - and ${plural(commits.length - shown.length, 'more commit')}`);
|
|
145
|
+
return shown;
|
|
146
|
+
}
|
|
147
|
+
/** The question on the card when the folder is blocked before any work started. */
|
|
148
|
+
export function describeBlockedBranch(health) {
|
|
149
|
+
const where = health.remoteLabel;
|
|
150
|
+
const lines = [
|
|
151
|
+
'I have not started on this card yet: the project folder on this computer needs a decision first.',
|
|
152
|
+
'',
|
|
153
|
+
`- ${where === 'the remote' ? 'The remote' : where} has ${plural(health.behind, 'newer commit')} on \`${health.branch}\` that this computer does not have.`,
|
|
154
|
+
`- This computer has ${plural(health.ahead, 'commit')} that ${health.ahead === 1 ? 'was' : 'were'} never pushed:`,
|
|
155
|
+
...commitLines(health.unpushed),
|
|
156
|
+
health.conflictFiles.length > 0
|
|
157
|
+
? `- The two clash in ${fileList(health.conflictFiles)}, so anything I build here could not be pushed.`
|
|
158
|
+
: '- The two do not fit together, so anything I build here could not be pushed.',
|
|
159
|
+
'',
|
|
160
|
+
];
|
|
161
|
+
if (health.dirty) {
|
|
162
|
+
lines.push('There are also uncommitted changes in the folder, so I will not move anything myself.', `Please sort out the branch (commit or put away the changes, then merge or drop the unpushed ${health.ahead === 1 ? 'commit' : 'commits'}) and reply **retry**. I will check again and then do the card.`);
|
|
163
|
+
return { question: lines.join('\n'), options: [{ label: 'I fixed it, check again', reply: 'retry' }] };
|
|
164
|
+
}
|
|
165
|
+
lines.push('You can choose:', `- **Start from the ${where === 'the remote' ? 'remote' : where} version**: I keep ${health.ahead === 1 ? 'that commit' : `those ${health.ahead} commits`} safe on a backup branch (nothing is deleted), bring the folder up to date, and then do this card.`, '- **Sort it out yourself** (merge or drop those commits), then reply **retry** and I will check again.');
|
|
166
|
+
return {
|
|
167
|
+
question: lines.join('\n'),
|
|
168
|
+
options: [
|
|
169
|
+
{ label: `Start from the ${where === 'the remote' ? 'remote' : where} version`, reply: START_FRESH_REPLY },
|
|
170
|
+
{ label: 'I fixed it, check again', reply: 'retry' },
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const backupName = (branch) => `agilemonster/backup-${branch.replace(/[^A-Za-z0-9._-]+/g, '-')}-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-')}`;
|
|
175
|
+
/**
|
|
176
|
+
* "Start from the remote's version": keep every unpushed commit on a backup branch, then make the local branch
|
|
177
|
+
* equal to the remote. Refuses when there are uncommitted changes, which a reset would destroy.
|
|
178
|
+
*/
|
|
179
|
+
export async function startFromRemote(cwd) {
|
|
180
|
+
const health = await inspectBranch(cwd);
|
|
181
|
+
if (!health.upstream || !health.branch)
|
|
182
|
+
return { ok: false, backup: '', setAside: 0, error: 'this branch does not exist on the remote' };
|
|
183
|
+
if (health.dirty)
|
|
184
|
+
return { ok: false, backup: '', setAside: 0, error: 'there are uncommitted changes in the project folder' };
|
|
185
|
+
const backup = backupName(health.branch);
|
|
186
|
+
const saved = await run(cwd, ['branch', backup, 'HEAD']);
|
|
187
|
+
if (!saved.ok)
|
|
188
|
+
return { ok: false, backup: '', setAside: 0, error: `could not create the backup branch: ${saved.tail.slice(0, 200)}` };
|
|
189
|
+
const reset = await run(cwd, ['reset', '--hard', health.upstream], 120000);
|
|
190
|
+
if (!reset.ok)
|
|
191
|
+
return { ok: false, backup, setAside: 0, error: `could not update the folder: ${reset.tail.slice(0, 200)}` };
|
|
192
|
+
log.info('branch.started_fresh', { project: cwd, branch: health.branch, backup, set_aside: health.ahead });
|
|
193
|
+
return { ok: true, backup, setAside: health.ahead };
|
|
194
|
+
}
|
|
195
|
+
export const cardTag = (cardId) => `[AM-${cardId.slice(0, 8)}]`;
|
|
196
|
+
/** This card's commits, when they are the newest ones on the branch (the only shape that can be shipped alone). */
|
|
197
|
+
function cardCommitsOnTop(unpushed, cardId) {
|
|
198
|
+
const tag = cardTag(cardId);
|
|
199
|
+
const mine = [];
|
|
200
|
+
let i = 0;
|
|
201
|
+
while (i < unpushed.length && unpushed[i].subject.includes(tag))
|
|
202
|
+
mine.push(unpushed[i++].sha);
|
|
203
|
+
if (unpushed.slice(i).some((c) => c.subject.includes(tag)))
|
|
204
|
+
return null; // interleaved with other work
|
|
205
|
+
return mine.reverse();
|
|
206
|
+
}
|
|
207
|
+
/** Work out, after a refused push, what actually clashes — the card, or older commits that were never pushed. */
|
|
208
|
+
export async function explainPushConflict(cwd, cardId, unmergedFiles = []) {
|
|
209
|
+
const health = await inspectBranch(cwd, { fetch: false });
|
|
210
|
+
if (!health.upstream)
|
|
211
|
+
return null;
|
|
212
|
+
const mine = cardCommitsOnTop(health.unpushed, cardId);
|
|
213
|
+
const older = health.unpushed.filter((c) => !c.subject.includes(cardTag(cardId)));
|
|
214
|
+
let cardFitsAlone = null;
|
|
215
|
+
let files = unmergedFiles.length > 0 ? unmergedFiles : health.conflictFiles;
|
|
216
|
+
if (mine && mine.length > 0) {
|
|
217
|
+
if (older.length === 0) {
|
|
218
|
+
cardFitsAlone = false; // nothing else is unpushed: the card itself is what clashes
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const alone = await mergeFits(cwd, health.upstream, mine[mine.length - 1], `${mine[0]}^`);
|
|
222
|
+
cardFitsAlone = alone.fits;
|
|
223
|
+
if (alone.fits === false && alone.files.length > 0)
|
|
224
|
+
files = alone.files;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
branch: health.branch,
|
|
229
|
+
remoteLabel: health.remoteLabel,
|
|
230
|
+
behind: health.behind,
|
|
231
|
+
older,
|
|
232
|
+
cardCommits: mine || [],
|
|
233
|
+
conflictFiles: files,
|
|
234
|
+
cardFitsAlone,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
export const ONLY_THIS_REPLY = 'deploy: only this change';
|
|
238
|
+
/** Plain words for the card, the way out, and the buttons that take it. */
|
|
239
|
+
export function describePushConflict(c) {
|
|
240
|
+
const where = c.remoteLabel;
|
|
241
|
+
const newer = `${where === 'the remote' ? 'The remote' : where} has ${plural(c.behind, 'newer commit')} on \`${c.branch}\``;
|
|
242
|
+
const inFiles = c.conflictFiles.length > 0 ? ` in ${fileList(c.conflictFiles)}` : '';
|
|
243
|
+
const fixed = { label: 'I fixed it, try again', reply: 'retry' };
|
|
244
|
+
const pushedYourself = 'If you sort out the branch or push it yourself, reply **retry**: I will not redo the work, only push what is missing and check the deployment.';
|
|
245
|
+
if (c.older.length > 0 && c.cardFitsAlone !== false) {
|
|
246
|
+
const olderText = c.older.length === 1 ? '1 older commit that was never pushed, and it clashes' : `${c.older.length} older commits that were never pushed, and they clash`;
|
|
247
|
+
return {
|
|
248
|
+
detail: `The change is made and committed on this computer, but it could not be pushed. ${newer}. This computer also holds ${olderText} with the newer ones${inFiles}.` +
|
|
249
|
+
(c.cardFitsAlone ? " This card's own change is not the problem: it fits on top of the newer version by itself." : ''),
|
|
250
|
+
hint: [
|
|
251
|
+
`- **Ship only this change**: I put just this card's commit on top of the ${where === 'the remote' ? 'remote' : where} version and deploy it. The ${plural(c.older.length, 'older commit')} ${c.older.length === 1 ? 'stays' : 'stay'} safe on a backup branch, not deployed.`,
|
|
252
|
+
`- ${pushedYourself}`,
|
|
253
|
+
].join('\n'),
|
|
254
|
+
options: [{ label: 'Ship only this change', reply: ONLY_THIS_REPLY }, fixed],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
detail: `The change is made and committed on this computer, but it could not be pushed. ${newer}, and they changed the same lines as this card${inFiles}: my change was built on an older version of the project.`,
|
|
259
|
+
hint: [
|
|
260
|
+
'- **Redo on the latest version**: I keep what I did on a backup branch, bring the folder up to date, and do the card again.',
|
|
261
|
+
`- ${pushedYourself}`,
|
|
262
|
+
].join('\n'),
|
|
263
|
+
options: [{ label: 'Redo on the latest version', reply: START_FRESH_REPLY }, fixed],
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* "Ship only this change": keep the whole local branch on a backup branch, make the branch equal to the remote,
|
|
268
|
+
* and put only this card's commits back on top. If they do not apply, everything is put back as it was.
|
|
269
|
+
*/
|
|
270
|
+
export async function shipOnlyCardCommits(cwd, cardId) {
|
|
271
|
+
const none = { backup: '', setAside: 0 };
|
|
272
|
+
const health = await inspectBranch(cwd);
|
|
273
|
+
if (!health.upstream || !health.branch)
|
|
274
|
+
return { ok: false, ...none, error: 'this branch does not exist on the remote' };
|
|
275
|
+
if (health.dirty)
|
|
276
|
+
return { ok: false, ...none, error: 'there are uncommitted changes in the project folder' };
|
|
277
|
+
const mine = cardCommitsOnTop(health.unpushed, cardId);
|
|
278
|
+
if (!mine || mine.length === 0)
|
|
279
|
+
return { ok: false, ...none, error: "this card's commit is no longer the newest one on the branch" };
|
|
280
|
+
const setAside = health.unpushed.length - mine.length;
|
|
281
|
+
const original = (await run(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
282
|
+
const backup = backupName(health.branch);
|
|
283
|
+
const saved = await run(cwd, ['branch', backup, 'HEAD']);
|
|
284
|
+
if (!saved.ok)
|
|
285
|
+
return { ok: false, ...none, error: `could not create the backup branch: ${saved.tail.slice(0, 200)}` };
|
|
286
|
+
const putBack = async (error) => {
|
|
287
|
+
await run(cwd, ['cherry-pick', '--abort']);
|
|
288
|
+
await run(cwd, ['reset', '--hard', original], 120000);
|
|
289
|
+
await run(cwd, ['branch', '-D', backup]);
|
|
290
|
+
return { ok: false, ...none, error };
|
|
291
|
+
};
|
|
292
|
+
const reset = await run(cwd, ['reset', '--hard', health.upstream], 120000);
|
|
293
|
+
if (!reset.ok)
|
|
294
|
+
return putBack(`could not update the folder: ${reset.tail.slice(0, 200)}`);
|
|
295
|
+
const pick = await run(cwd, ['cherry-pick', ...mine], 120000);
|
|
296
|
+
if (!pick.ok) {
|
|
297
|
+
const unmerged = (await run(cwd, ['diff', '--name-only', '--diff-filter=U'])).stdout.split(/\r?\n/).filter(Boolean);
|
|
298
|
+
return putBack(unmerged.length > 0 ? `this card's change itself clashes with the newer version in ${fileList(unmerged)}` : `this card's change did not apply on the newer version: ${pick.tail.slice(0, 200)}`);
|
|
299
|
+
}
|
|
300
|
+
log.info('branch.shipped_only_card', { project: cwd, branch: health.branch, backup, set_aside: setAside, picked: mine.length });
|
|
301
|
+
return { ok: true, backup, setAside };
|
|
302
|
+
}
|