maxsimcli 4.5.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/CHANGELOG.md +38 -0
- package/dist/backend-server.cjs +2739 -41
- package/dist/backend-server.cjs.map +1 -1
- package/dist/cli.cjs +3 -3
- package/dist/{lifecycle-DxCru7rk.cjs → lifecycle-D8mcsEjy.cjs} +2 -2
- package/dist/{lifecycle-DxCru7rk.cjs.map → lifecycle-D8mcsEjy.cjs.map} +1 -1
- package/dist/mcp-server.cjs +2715 -16
- package/dist/mcp-server.cjs.map +1 -1
- package/dist/{server-By0TN-nC.cjs → server-BAHfh_vw.cjs} +2716 -17
- package/dist/server-BAHfh_vw.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/.tsbuildinfo +0 -1
- package/dist/backend/index.d.ts +0 -4
- package/dist/backend/index.d.ts.map +0 -1
- package/dist/backend/index.js +0 -12
- package/dist/backend/index.js.map +0 -1
- package/dist/backend/lifecycle.d.ts +0 -13
- package/dist/backend/lifecycle.d.ts.map +0 -1
- package/dist/backend/lifecycle.js +0 -168
- package/dist/backend/lifecycle.js.map +0 -1
- package/dist/backend/server.d.ts +0 -13
- package/dist/backend/server.d.ts.map +0 -1
- package/dist/backend/server.js +0 -1013
- package/dist/backend/server.js.map +0 -1
- package/dist/backend/terminal.d.ts +0 -49
- package/dist/backend/terminal.d.ts.map +0 -1
- package/dist/backend/terminal.js +0 -209
- package/dist/backend/terminal.js.map +0 -1
- package/dist/backend/types.d.ts +0 -77
- package/dist/backend/types.d.ts.map +0 -1
- package/dist/backend/types.js +0 -6
- package/dist/backend/types.js.map +0 -1
- package/dist/backend-server.d.cts +0 -2
- package/dist/backend-server.d.ts +0 -11
- package/dist/backend-server.d.ts.map +0 -1
- package/dist/backend-server.js +0 -43
- package/dist/backend-server.js.map +0 -1
- package/dist/cli.d.cts +0 -2
- package/dist/cli.d.ts +0 -7
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -510
- package/dist/cli.js.map +0 -1
- package/dist/core/artefakte.d.ts +0 -12
- package/dist/core/artefakte.d.ts.map +0 -1
- package/dist/core/artefakte.js +0 -152
- package/dist/core/artefakte.js.map +0 -1
- package/dist/core/commands.d.ts +0 -26
- package/dist/core/commands.d.ts.map +0 -1
- package/dist/core/commands.js +0 -550
- package/dist/core/commands.js.map +0 -1
- package/dist/core/config.d.ts +0 -10
- package/dist/core/config.d.ts.map +0 -1
- package/dist/core/config.js +0 -143
- package/dist/core/config.js.map +0 -1
- package/dist/core/context-loader.d.ts +0 -21
- package/dist/core/context-loader.d.ts.map +0 -1
- package/dist/core/context-loader.js +0 -212
- package/dist/core/context-loader.js.map +0 -1
- package/dist/core/core.d.ts +0 -91
- package/dist/core/core.d.ts.map +0 -1
- package/dist/core/core.js +0 -823
- package/dist/core/core.js.map +0 -1
- package/dist/core/dashboard-launcher.d.ts +0 -56
- package/dist/core/dashboard-launcher.d.ts.map +0 -1
- package/dist/core/dashboard-launcher.js +0 -246
- package/dist/core/dashboard-launcher.js.map +0 -1
- package/dist/core/drift.d.ts +0 -37
- package/dist/core/drift.d.ts.map +0 -1
- package/dist/core/drift.js +0 -213
- package/dist/core/drift.js.map +0 -1
- package/dist/core/frontmatter.d.ts +0 -33
- package/dist/core/frontmatter.d.ts.map +0 -1
- package/dist/core/frontmatter.js +0 -193
- package/dist/core/frontmatter.js.map +0 -1
- package/dist/core/index.d.ts +0 -28
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/index.js +0 -189
- package/dist/core/index.js.map +0 -1
- package/dist/core/init.d.ts +0 -287
- package/dist/core/init.d.ts.map +0 -1
- package/dist/core/init.js +0 -816
- package/dist/core/init.js.map +0 -1
- package/dist/core/milestone.d.ts +0 -9
- package/dist/core/milestone.d.ts.map +0 -1
- package/dist/core/milestone.js +0 -230
- package/dist/core/milestone.js.map +0 -1
- package/dist/core/phase.d.ts +0 -53
- package/dist/core/phase.d.ts.map +0 -1
- package/dist/core/phase.js +0 -891
- package/dist/core/phase.js.map +0 -1
- package/dist/core/roadmap.d.ts +0 -10
- package/dist/core/roadmap.d.ts.map +0 -1
- package/dist/core/roadmap.js +0 -165
- package/dist/core/roadmap.js.map +0 -1
- package/dist/core/skills.d.ts +0 -20
- package/dist/core/skills.d.ts.map +0 -1
- package/dist/core/skills.js +0 -144
- package/dist/core/skills.js.map +0 -1
- package/dist/core/start.d.ts +0 -15
- package/dist/core/start.d.ts.map +0 -1
- package/dist/core/start.js +0 -80
- package/dist/core/start.js.map +0 -1
- package/dist/core/state.d.ts +0 -32
- package/dist/core/state.d.ts.map +0 -1
- package/dist/core/state.js +0 -582
- package/dist/core/state.js.map +0 -1
- package/dist/core/template.d.ts +0 -30
- package/dist/core/template.d.ts.map +0 -1
- package/dist/core/template.js +0 -223
- package/dist/core/template.js.map +0 -1
- package/dist/core/types.d.ts +0 -519
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js +0 -60
- package/dist/core/types.js.map +0 -1
- package/dist/core/verify.d.ts +0 -128
- package/dist/core/verify.d.ts.map +0 -1
- package/dist/core/verify.js +0 -754
- package/dist/core/verify.js.map +0 -1
- package/dist/hooks/index.d.ts +0 -11
- package/dist/hooks/index.d.ts.map +0 -1
- package/dist/hooks/index.js +0 -18
- package/dist/hooks/index.js.map +0 -1
- package/dist/hooks/maxsim-check-update.d.ts +0 -17
- package/dist/hooks/maxsim-check-update.d.ts.map +0 -1
- package/dist/hooks/maxsim-check-update.js +0 -101
- package/dist/hooks/maxsim-check-update.js.map +0 -1
- package/dist/hooks/maxsim-context-monitor.d.ts +0 -21
- package/dist/hooks/maxsim-context-monitor.d.ts.map +0 -1
- package/dist/hooks/maxsim-context-monitor.js +0 -131
- package/dist/hooks/maxsim-context-monitor.js.map +0 -1
- package/dist/hooks/maxsim-statusline.d.ts +0 -19
- package/dist/hooks/maxsim-statusline.d.ts.map +0 -1
- package/dist/hooks/maxsim-statusline.js +0 -146
- package/dist/hooks/maxsim-statusline.js.map +0 -1
- package/dist/hooks/shared.d.ts +0 -11
- package/dist/hooks/shared.d.ts.map +0 -1
- package/dist/hooks/shared.js +0 -29
- package/dist/hooks/shared.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +0 -1
- package/dist/install/adapters.d.ts +0 -6
- package/dist/install/adapters.d.ts.map +0 -1
- package/dist/install/adapters.js +0 -65
- package/dist/install/adapters.js.map +0 -1
- package/dist/install/copy.d.ts +0 -6
- package/dist/install/copy.d.ts.map +0 -1
- package/dist/install/copy.js +0 -71
- package/dist/install/copy.js.map +0 -1
- package/dist/install/dashboard.d.ts +0 -16
- package/dist/install/dashboard.d.ts.map +0 -1
- package/dist/install/dashboard.js +0 -273
- package/dist/install/dashboard.js.map +0 -1
- package/dist/install/hooks.d.ts +0 -31
- package/dist/install/hooks.d.ts.map +0 -1
- package/dist/install/hooks.js +0 -260
- package/dist/install/hooks.js.map +0 -1
- package/dist/install/index.d.ts +0 -2
- package/dist/install/index.d.ts.map +0 -1
- package/dist/install/index.js +0 -534
- package/dist/install/index.js.map +0 -1
- package/dist/install/manifest.d.ts +0 -23
- package/dist/install/manifest.d.ts.map +0 -1
- package/dist/install/manifest.js +0 -133
- package/dist/install/manifest.js.map +0 -1
- package/dist/install/patches.d.ts +0 -10
- package/dist/install/patches.d.ts.map +0 -1
- package/dist/install/patches.js +0 -124
- package/dist/install/patches.js.map +0 -1
- package/dist/install/shared.d.ts +0 -56
- package/dist/install/shared.d.ts.map +0 -1
- package/dist/install/shared.js +0 -181
- package/dist/install/shared.js.map +0 -1
- package/dist/install/uninstall.d.ts +0 -5
- package/dist/install/uninstall.d.ts.map +0 -1
- package/dist/install/uninstall.js +0 -222
- package/dist/install/uninstall.js.map +0 -1
- package/dist/install/utils.d.ts +0 -27
- package/dist/install/utils.d.ts.map +0 -1
- package/dist/install/utils.js +0 -99
- package/dist/install/utils.js.map +0 -1
- package/dist/install.d.cts +0 -2
- package/dist/mcp/config-tools.d.ts +0 -13
- package/dist/mcp/config-tools.d.ts.map +0 -1
- package/dist/mcp/config-tools.js +0 -66
- package/dist/mcp/config-tools.js.map +0 -1
- package/dist/mcp/context-tools.d.ts +0 -13
- package/dist/mcp/context-tools.d.ts.map +0 -1
- package/dist/mcp/context-tools.js +0 -176
- package/dist/mcp/context-tools.js.map +0 -1
- package/dist/mcp/index.d.ts +0 -11
- package/dist/mcp/index.d.ts.map +0 -1
- package/dist/mcp/index.js +0 -26
- package/dist/mcp/index.js.map +0 -1
- package/dist/mcp/phase-tools.d.ts +0 -13
- package/dist/mcp/phase-tools.d.ts.map +0 -1
- package/dist/mcp/phase-tools.js +0 -177
- package/dist/mcp/phase-tools.js.map +0 -1
- package/dist/mcp/roadmap-tools.d.ts +0 -13
- package/dist/mcp/roadmap-tools.d.ts.map +0 -1
- package/dist/mcp/roadmap-tools.js +0 -79
- package/dist/mcp/roadmap-tools.js.map +0 -1
- package/dist/mcp/state-tools.d.ts +0 -13
- package/dist/mcp/state-tools.d.ts.map +0 -1
- package/dist/mcp/state-tools.js +0 -185
- package/dist/mcp/state-tools.js.map +0 -1
- package/dist/mcp/todo-tools.d.ts +0 -13
- package/dist/mcp/todo-tools.d.ts.map +0 -1
- package/dist/mcp/todo-tools.js +0 -143
- package/dist/mcp/todo-tools.js.map +0 -1
- package/dist/mcp/utils.d.ts +0 -27
- package/dist/mcp/utils.d.ts.map +0 -1
- package/dist/mcp/utils.js +0 -82
- package/dist/mcp/utils.js.map +0 -1
- package/dist/mcp-server.d.cts +0 -2
- package/dist/mcp-server.d.ts +0 -12
- package/dist/mcp-server.d.ts.map +0 -1
- package/dist/mcp-server.js +0 -31
- package/dist/mcp-server.js.map +0 -1
- package/dist/server-By0TN-nC.cjs.map +0 -1
package/dist/mcp-server.cjs
CHANGED
|
@@ -37,6 +37,8 @@ require("node:os");
|
|
|
37
37
|
let node_buffer = require("node:buffer");
|
|
38
38
|
let child_process = require("child_process");
|
|
39
39
|
require("node:events");
|
|
40
|
+
let node_child_process = require("node:child_process");
|
|
41
|
+
let node_util = require("node:util");
|
|
40
42
|
|
|
41
43
|
//#region ../../node_modules/zod/v3/helpers/util.js
|
|
42
44
|
var util$2;
|
|
@@ -33980,6 +33982,1462 @@ async function phaseCompleteCore(cwd, phaseNum) {
|
|
|
33980
33982
|
};
|
|
33981
33983
|
}
|
|
33982
33984
|
|
|
33985
|
+
//#endregion
|
|
33986
|
+
//#region src/github/gh.ts
|
|
33987
|
+
/**
|
|
33988
|
+
* GitHub CLI Wrapper — Core gh CLI interaction layer
|
|
33989
|
+
*
|
|
33990
|
+
* Wraps the `gh` CLI using `child_process.execFile` (never `exec`) for security.
|
|
33991
|
+
* Provides typed results via GhResult<T> discriminated union.
|
|
33992
|
+
* Supports graceful degradation: detectGitHubMode() returns 'local-only'
|
|
33993
|
+
* when gh is not installed or not authenticated with required scopes.
|
|
33994
|
+
*
|
|
33995
|
+
* CRITICAL: Never import octokit or any npm GitHub SDK.
|
|
33996
|
+
* CRITICAL: Never call process.exit() — return GhResult instead.
|
|
33997
|
+
*/
|
|
33998
|
+
const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
|
|
33999
|
+
/**
|
|
34000
|
+
* Check if the `gh` CLI is installed and authenticated with required scopes.
|
|
34001
|
+
*
|
|
34002
|
+
* Parses the output of `gh auth status` (which writes to stderr, not stdout).
|
|
34003
|
+
* Returns structured AuthStatus with scope detection for 'project' scope.
|
|
34004
|
+
* Timeout: 10 seconds.
|
|
34005
|
+
*/
|
|
34006
|
+
async function checkGhAuth() {
|
|
34007
|
+
try {
|
|
34008
|
+
const { stdout, stderr } = await execFileAsync("gh", ["auth", "status"], { timeout: 1e4 });
|
|
34009
|
+
const output = stderr || stdout;
|
|
34010
|
+
const authenticated = !output.includes("not logged in");
|
|
34011
|
+
const scopeMatch = output.match(/Token scopes?:\s*'([^']+(?:',\s*'[^']+)*)'/);
|
|
34012
|
+
const scopes = [];
|
|
34013
|
+
if (scopeMatch) {
|
|
34014
|
+
const allScopes = scopeMatch[0].matchAll(/'([^']+)'/g);
|
|
34015
|
+
for (const m of allScopes) scopes.push(m[1]);
|
|
34016
|
+
}
|
|
34017
|
+
const userMatch = output.match(/Logged in to [^\s]+ as ([^\s(]+)/);
|
|
34018
|
+
return {
|
|
34019
|
+
installed: true,
|
|
34020
|
+
authenticated,
|
|
34021
|
+
scopes,
|
|
34022
|
+
hasProjectScope: scopes.includes("project") || scopes.includes("read:project"),
|
|
34023
|
+
username: userMatch ? userMatch[1] : null
|
|
34024
|
+
};
|
|
34025
|
+
} catch (e) {
|
|
34026
|
+
const error = e;
|
|
34027
|
+
if (error.code === "ENOENT") return {
|
|
34028
|
+
installed: false,
|
|
34029
|
+
authenticated: false,
|
|
34030
|
+
scopes: [],
|
|
34031
|
+
hasProjectScope: false,
|
|
34032
|
+
username: null
|
|
34033
|
+
};
|
|
34034
|
+
error.stderr || error.message;
|
|
34035
|
+
return {
|
|
34036
|
+
installed: true,
|
|
34037
|
+
authenticated: false,
|
|
34038
|
+
scopes: [],
|
|
34039
|
+
hasProjectScope: false,
|
|
34040
|
+
username: null
|
|
34041
|
+
};
|
|
34042
|
+
}
|
|
34043
|
+
}
|
|
34044
|
+
/**
|
|
34045
|
+
* Detect the GitHub integration mode based on auth status.
|
|
34046
|
+
*
|
|
34047
|
+
* Returns 'full' only when gh is installed, authenticated, and has the
|
|
34048
|
+
* 'project' scope. Otherwise returns 'local-only' for graceful degradation.
|
|
34049
|
+
*/
|
|
34050
|
+
async function detectGitHubMode() {
|
|
34051
|
+
const auth = await checkGhAuth();
|
|
34052
|
+
if (!auth.installed) return "local-only";
|
|
34053
|
+
if (!auth.authenticated) return "local-only";
|
|
34054
|
+
if (!auth.hasProjectScope) {
|
|
34055
|
+
console.error("[maxsim] GitHub Projects requires 'project' scope. Run: gh auth refresh -s project");
|
|
34056
|
+
return "local-only";
|
|
34057
|
+
}
|
|
34058
|
+
return "full";
|
|
34059
|
+
}
|
|
34060
|
+
/**
|
|
34061
|
+
* Execute a `gh` CLI command and return a typed GhResult.
|
|
34062
|
+
*
|
|
34063
|
+
* - Uses `execFile` (not `exec`) for security
|
|
34064
|
+
* - Default timeout: 30 seconds
|
|
34065
|
+
* - Auto-detects JSON output when args contain `--json` or `--format`
|
|
34066
|
+
* - Maps exit codes and stderr patterns to GhErrorCode
|
|
34067
|
+
* - For `gh issue create`: does NOT try to parse JSON (it returns a URL string)
|
|
34068
|
+
* - Always includes raw stderr in error messages for AI consumption
|
|
34069
|
+
*/
|
|
34070
|
+
async function ghExec(args, options) {
|
|
34071
|
+
const timeout = options?.timeout ?? 3e4;
|
|
34072
|
+
const isIssueCreate = args[0] === "issue" && args[1] === "create";
|
|
34073
|
+
const hasJsonFlag = args.includes("--json") || args.some((a) => a.startsWith("--format"));
|
|
34074
|
+
const shouldParseJson = options?.parseJson ?? (hasJsonFlag && !isIssueCreate);
|
|
34075
|
+
try {
|
|
34076
|
+
const { stdout, stderr } = await execFileAsync("gh", args, {
|
|
34077
|
+
cwd: options?.cwd,
|
|
34078
|
+
timeout,
|
|
34079
|
+
maxBuffer: 10 * 1024 * 1024
|
|
34080
|
+
});
|
|
34081
|
+
if (shouldParseJson) try {
|
|
34082
|
+
return {
|
|
34083
|
+
ok: true,
|
|
34084
|
+
data: JSON.parse(stdout)
|
|
34085
|
+
};
|
|
34086
|
+
} catch {
|
|
34087
|
+
return {
|
|
34088
|
+
ok: false,
|
|
34089
|
+
error: `Failed to parse gh output as JSON: ${stdout.slice(0, 500)}`,
|
|
34090
|
+
code: "UNKNOWN"
|
|
34091
|
+
};
|
|
34092
|
+
}
|
|
34093
|
+
return {
|
|
34094
|
+
ok: true,
|
|
34095
|
+
data: stdout.trim()
|
|
34096
|
+
};
|
|
34097
|
+
} catch (e) {
|
|
34098
|
+
return mapExecError(e);
|
|
34099
|
+
}
|
|
34100
|
+
}
|
|
34101
|
+
/**
|
|
34102
|
+
* Execute a GraphQL query via `gh api graphql`.
|
|
34103
|
+
*
|
|
34104
|
+
* - String variables use `-f key=value`
|
|
34105
|
+
* - Non-string variables (numbers, booleans) use `-F key=value`
|
|
34106
|
+
* - Parses JSON response and checks for GraphQL `errors` array
|
|
34107
|
+
*/
|
|
34108
|
+
async function ghGraphQL(query, variables) {
|
|
34109
|
+
const args = [
|
|
34110
|
+
"api",
|
|
34111
|
+
"graphql",
|
|
34112
|
+
"-f",
|
|
34113
|
+
`query=${query}`
|
|
34114
|
+
];
|
|
34115
|
+
if (variables) for (const [key, value] of Object.entries(variables)) if (typeof value === "string") args.push("-f", `${key}=${value}`);
|
|
34116
|
+
else args.push("-F", `${key}=${String(value)}`);
|
|
34117
|
+
const result = await ghExec(args, { parseJson: true });
|
|
34118
|
+
if (!result.ok) return result;
|
|
34119
|
+
if (result.data.errors && result.data.errors.length > 0) {
|
|
34120
|
+
const messages = result.data.errors.map((e) => e.message).join("; ");
|
|
34121
|
+
const code = mapGraphQLErrorCode(messages);
|
|
34122
|
+
return {
|
|
34123
|
+
ok: false,
|
|
34124
|
+
error: `GraphQL error: ${messages}`,
|
|
34125
|
+
code
|
|
34126
|
+
};
|
|
34127
|
+
}
|
|
34128
|
+
if (result.data.data === void 0) return {
|
|
34129
|
+
ok: false,
|
|
34130
|
+
error: "GraphQL response missing data field",
|
|
34131
|
+
code: "UNKNOWN"
|
|
34132
|
+
};
|
|
34133
|
+
return {
|
|
34134
|
+
ok: true,
|
|
34135
|
+
data: result.data.data
|
|
34136
|
+
};
|
|
34137
|
+
}
|
|
34138
|
+
/**
|
|
34139
|
+
* Map an execFile error to a GhResult with appropriate GhErrorCode.
|
|
34140
|
+
*/
|
|
34141
|
+
function mapExecError(e) {
|
|
34142
|
+
const error = e;
|
|
34143
|
+
if (error.code === "ENOENT") return {
|
|
34144
|
+
ok: false,
|
|
34145
|
+
error: "gh CLI is not installed. Install from https://cli.github.com/",
|
|
34146
|
+
code: "NOT_INSTALLED"
|
|
34147
|
+
};
|
|
34148
|
+
const stderr = error.stderr || error.message || "";
|
|
34149
|
+
if (error.status === 4) return {
|
|
34150
|
+
ok: false,
|
|
34151
|
+
error: `Not found: ${stderr}`,
|
|
34152
|
+
code: "NOT_FOUND"
|
|
34153
|
+
};
|
|
34154
|
+
if (stderr.includes("not logged in") || stderr.includes("authentication") || stderr.includes("auth login") || stderr.includes("401")) return {
|
|
34155
|
+
ok: false,
|
|
34156
|
+
error: `Authentication required: ${stderr}`,
|
|
34157
|
+
code: "NOT_AUTHENTICATED"
|
|
34158
|
+
};
|
|
34159
|
+
if (stderr.includes("403") || stderr.includes("permission") || stderr.includes("denied")) return {
|
|
34160
|
+
ok: false,
|
|
34161
|
+
error: `Permission denied: ${stderr}`,
|
|
34162
|
+
code: "PERMISSION_DENIED"
|
|
34163
|
+
};
|
|
34164
|
+
if (stderr.includes("rate limit") || stderr.includes("429") || stderr.includes("API rate")) return {
|
|
34165
|
+
ok: false,
|
|
34166
|
+
error: `Rate limited: ${stderr}`,
|
|
34167
|
+
code: "RATE_LIMITED"
|
|
34168
|
+
};
|
|
34169
|
+
if (stderr.includes("scope") || stderr.includes("insufficient")) return {
|
|
34170
|
+
ok: false,
|
|
34171
|
+
error: `Missing scope: ${stderr}`,
|
|
34172
|
+
code: "SCOPE_MISSING"
|
|
34173
|
+
};
|
|
34174
|
+
if (stderr.includes("not found") || stderr.includes("404") || stderr.includes("Could not resolve")) return {
|
|
34175
|
+
ok: false,
|
|
34176
|
+
error: `Not found: ${stderr}`,
|
|
34177
|
+
code: "NOT_FOUND"
|
|
34178
|
+
};
|
|
34179
|
+
return {
|
|
34180
|
+
ok: false,
|
|
34181
|
+
error: `gh command failed: ${stderr}`,
|
|
34182
|
+
code: "UNKNOWN"
|
|
34183
|
+
};
|
|
34184
|
+
}
|
|
34185
|
+
/**
|
|
34186
|
+
* Map GraphQL error messages to GhErrorCode.
|
|
34187
|
+
*/
|
|
34188
|
+
function mapGraphQLErrorCode(message) {
|
|
34189
|
+
const lower = message.toLowerCase();
|
|
34190
|
+
if (lower.includes("not found") || lower.includes("could not resolve")) return "NOT_FOUND";
|
|
34191
|
+
if (lower.includes("insufficient") || lower.includes("scope")) return "SCOPE_MISSING";
|
|
34192
|
+
if (lower.includes("forbidden") || lower.includes("permission")) return "PERMISSION_DENIED";
|
|
34193
|
+
if (lower.includes("rate") || lower.includes("throttl")) return "RATE_LIMITED";
|
|
34194
|
+
return "UNKNOWN";
|
|
34195
|
+
}
|
|
34196
|
+
|
|
34197
|
+
//#endregion
|
|
34198
|
+
//#region src/github/mapping.ts
|
|
34199
|
+
/**
|
|
34200
|
+
* GitHub Issues Mapping — Persistence layer for github-issues.json
|
|
34201
|
+
*
|
|
34202
|
+
* Manages the `.planning/github-issues.json` file that maps MAXSIM tasks/todos
|
|
34203
|
+
* to their corresponding GitHub issue numbers, node IDs, and project item IDs.
|
|
34204
|
+
*
|
|
34205
|
+
* All file operations use synchronous fs (matching the pattern in existing core modules).
|
|
34206
|
+
* Uses planningPath() from core to construct file paths.
|
|
34207
|
+
*
|
|
34208
|
+
* CRITICAL: Never call process.exit() — throw or return null instead.
|
|
34209
|
+
*/
|
|
34210
|
+
const MAPPING_FILENAME = "github-issues.json";
|
|
34211
|
+
/**
|
|
34212
|
+
* Get the absolute path to `.planning/github-issues.json` for a given cwd.
|
|
34213
|
+
*/
|
|
34214
|
+
function mappingFilePath(cwd) {
|
|
34215
|
+
return planningPath(cwd, MAPPING_FILENAME);
|
|
34216
|
+
}
|
|
34217
|
+
/**
|
|
34218
|
+
* Load and parse the mapping file.
|
|
34219
|
+
*
|
|
34220
|
+
* Returns null if the file does not exist.
|
|
34221
|
+
* Throws on malformed JSON or invalid structure (missing required fields).
|
|
34222
|
+
*/
|
|
34223
|
+
function loadMapping(cwd) {
|
|
34224
|
+
const filePath = mappingFilePath(cwd);
|
|
34225
|
+
try {
|
|
34226
|
+
node_fs.default.statSync(filePath);
|
|
34227
|
+
} catch {
|
|
34228
|
+
return null;
|
|
34229
|
+
}
|
|
34230
|
+
const raw = node_fs.default.readFileSync(filePath, "utf-8");
|
|
34231
|
+
const parsed = JSON.parse(raw);
|
|
34232
|
+
if (typeof parsed.project_number !== "number" || typeof parsed.repo !== "string") throw new Error(`Invalid github-issues.json: missing required fields 'project_number' (number) and 'repo' (string)`);
|
|
34233
|
+
return parsed;
|
|
34234
|
+
}
|
|
34235
|
+
/**
|
|
34236
|
+
* Write the mapping file to `.planning/github-issues.json`.
|
|
34237
|
+
*
|
|
34238
|
+
* Creates the `.planning/` directory if it does not exist.
|
|
34239
|
+
* Writes with 2-space indent for readability and diff-friendliness.
|
|
34240
|
+
*/
|
|
34241
|
+
function saveMapping(cwd, mapping) {
|
|
34242
|
+
const filePath = mappingFilePath(cwd);
|
|
34243
|
+
const dir = node_path.default.dirname(filePath);
|
|
34244
|
+
node_fs.default.mkdirSync(dir, { recursive: true });
|
|
34245
|
+
node_fs.default.writeFileSync(filePath, JSON.stringify(mapping, null, 2) + "\n", "utf-8");
|
|
34246
|
+
}
|
|
34247
|
+
/**
|
|
34248
|
+
* Update a specific task's issue mapping within a phase.
|
|
34249
|
+
*
|
|
34250
|
+
* Load-modify-save pattern. Creates phase entry if it does not exist.
|
|
34251
|
+
* Merges partial data with existing entry (if any).
|
|
34252
|
+
*
|
|
34253
|
+
* @throws If mapping file does not exist (must be initialized first via saveMapping)
|
|
34254
|
+
*/
|
|
34255
|
+
function updateTaskMapping(cwd, phaseNum, taskId, data) {
|
|
34256
|
+
const mapping = loadMapping(cwd);
|
|
34257
|
+
if (!mapping) throw new Error("github-issues.json does not exist. Run project setup first.");
|
|
34258
|
+
if (!mapping.phases[phaseNum]) mapping.phases[phaseNum] = {
|
|
34259
|
+
tracking_issue: {
|
|
34260
|
+
number: 0,
|
|
34261
|
+
node_id: "",
|
|
34262
|
+
item_id: "",
|
|
34263
|
+
status: "To Do"
|
|
34264
|
+
},
|
|
34265
|
+
plan: "",
|
|
34266
|
+
tasks: {}
|
|
34267
|
+
};
|
|
34268
|
+
const existing = mapping.phases[phaseNum].tasks[taskId];
|
|
34269
|
+
const defaults = {
|
|
34270
|
+
number: 0,
|
|
34271
|
+
node_id: "",
|
|
34272
|
+
item_id: "",
|
|
34273
|
+
status: "To Do"
|
|
34274
|
+
};
|
|
34275
|
+
mapping.phases[phaseNum].tasks[taskId] = Object.assign(defaults, existing, data);
|
|
34276
|
+
saveMapping(cwd, mapping);
|
|
34277
|
+
}
|
|
34278
|
+
/**
|
|
34279
|
+
* Update a specific todo's issue mapping.
|
|
34280
|
+
*
|
|
34281
|
+
* Load-modify-save pattern. Creates `todos` section if missing.
|
|
34282
|
+
* Merges partial data with existing entry (if any).
|
|
34283
|
+
*
|
|
34284
|
+
* @throws If mapping file does not exist (must be initialized first via saveMapping)
|
|
34285
|
+
*/
|
|
34286
|
+
function updateTodoMapping(cwd, todoId, data) {
|
|
34287
|
+
const mapping = loadMapping(cwd);
|
|
34288
|
+
if (!mapping) throw new Error("github-issues.json does not exist. Run project setup first.");
|
|
34289
|
+
if (!mapping.todos) mapping.todos = {};
|
|
34290
|
+
const existing = mapping.todos[todoId];
|
|
34291
|
+
const defaults = {
|
|
34292
|
+
number: 0,
|
|
34293
|
+
node_id: "",
|
|
34294
|
+
item_id: "",
|
|
34295
|
+
status: "To Do"
|
|
34296
|
+
};
|
|
34297
|
+
mapping.todos[todoId] = Object.assign(defaults, existing, data);
|
|
34298
|
+
saveMapping(cwd, mapping);
|
|
34299
|
+
}
|
|
34300
|
+
/**
|
|
34301
|
+
* Create a properly typed empty mapping object with sensible defaults.
|
|
34302
|
+
*
|
|
34303
|
+
* Used during initial project setup to create the mapping file.
|
|
34304
|
+
*/
|
|
34305
|
+
function createEmptyMapping(repo) {
|
|
34306
|
+
return {
|
|
34307
|
+
project_number: 0,
|
|
34308
|
+
project_id: "",
|
|
34309
|
+
repo,
|
|
34310
|
+
status_field_id: "",
|
|
34311
|
+
status_options: {},
|
|
34312
|
+
estimate_field_id: "",
|
|
34313
|
+
milestone_id: 0,
|
|
34314
|
+
milestone_title: "",
|
|
34315
|
+
labels: {},
|
|
34316
|
+
phases: {},
|
|
34317
|
+
todos: {}
|
|
34318
|
+
};
|
|
34319
|
+
}
|
|
34320
|
+
|
|
34321
|
+
//#endregion
|
|
34322
|
+
//#region src/github/issues.ts
|
|
34323
|
+
/**
|
|
34324
|
+
* Parse an issue number from a `gh issue create` stdout URL.
|
|
34325
|
+
*
|
|
34326
|
+
* `gh issue create` outputs a URL like:
|
|
34327
|
+
* https://github.com/owner/repo/issues/42\n
|
|
34328
|
+
*
|
|
34329
|
+
* We trim whitespace and extract the last path segment as the issue number.
|
|
34330
|
+
*/
|
|
34331
|
+
function parseIssueNumberFromUrl(stdout) {
|
|
34332
|
+
const lastSegment = stdout.trim().split("/").pop();
|
|
34333
|
+
if (!lastSegment) return null;
|
|
34334
|
+
const num = parseInt(lastSegment, 10);
|
|
34335
|
+
return Number.isNaN(num) ? null : num;
|
|
34336
|
+
}
|
|
34337
|
+
/**
|
|
34338
|
+
* After creating an issue (parsed number from URL), fetch its node_id
|
|
34339
|
+
* via `gh issue view {number} --json nodeId,number,url`.
|
|
34340
|
+
*/
|
|
34341
|
+
async function fetchIssueDetails(issueNumber) {
|
|
34342
|
+
const result = await ghExec([
|
|
34343
|
+
"issue",
|
|
34344
|
+
"view",
|
|
34345
|
+
String(issueNumber),
|
|
34346
|
+
"--json",
|
|
34347
|
+
"nodeId,number,url"
|
|
34348
|
+
], { parseJson: true });
|
|
34349
|
+
if (!result.ok) return {
|
|
34350
|
+
ok: false,
|
|
34351
|
+
error: result.error,
|
|
34352
|
+
code: result.code
|
|
34353
|
+
};
|
|
34354
|
+
return {
|
|
34355
|
+
ok: true,
|
|
34356
|
+
data: {
|
|
34357
|
+
number: result.data.number,
|
|
34358
|
+
url: result.data.url,
|
|
34359
|
+
node_id: result.data.nodeId
|
|
34360
|
+
}
|
|
34361
|
+
};
|
|
34362
|
+
}
|
|
34363
|
+
/**
|
|
34364
|
+
* Create a task issue with full specification body in collapsible details section.
|
|
34365
|
+
*
|
|
34366
|
+
* Title format: `[P{phaseNum}] {title}`
|
|
34367
|
+
* Body includes summary, actions, acceptance criteria, dependencies in `<details>`.
|
|
34368
|
+
* Labels: maxsim, phase-task.
|
|
34369
|
+
*
|
|
34370
|
+
* Returns the issue number, URL, and node_id.
|
|
34371
|
+
*/
|
|
34372
|
+
async function createTaskIssue(opts) {
|
|
34373
|
+
const issueTitle = `[P${opts.phaseNum}] ${opts.title}`;
|
|
34374
|
+
const depsSection = opts.dependencies && opts.dependencies.length > 0 ? `\n### Dependencies\nDepends on: ${opts.dependencies.map((d) => `#${d}`).join(", ")}\n` : "";
|
|
34375
|
+
const estimateSection = opts.estimate !== void 0 ? `\n### Estimate\n${opts.estimate} points\n` : "";
|
|
34376
|
+
const args = [
|
|
34377
|
+
"issue",
|
|
34378
|
+
"create",
|
|
34379
|
+
"--title",
|
|
34380
|
+
issueTitle,
|
|
34381
|
+
"--body",
|
|
34382
|
+
`## Summary
|
|
34383
|
+
${opts.summary}
|
|
34384
|
+
|
|
34385
|
+
<details>
|
|
34386
|
+
<summary>Full Specification</summary>
|
|
34387
|
+
|
|
34388
|
+
### Actions
|
|
34389
|
+
${opts.actions.map((a) => `- ${a}`).join("\n")}
|
|
34390
|
+
|
|
34391
|
+
### Acceptance Criteria
|
|
34392
|
+
${opts.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n")}
|
|
34393
|
+
${depsSection}${estimateSection}
|
|
34394
|
+
</details>
|
|
34395
|
+
|
|
34396
|
+
---
|
|
34397
|
+
*Phase: ${opts.phaseNum} | Plan: ${opts.planNum} | Task: ${opts.taskId}*
|
|
34398
|
+
*Generated by MAXSIM*`
|
|
34399
|
+
];
|
|
34400
|
+
args.push("--label", "maxsim");
|
|
34401
|
+
args.push("--label", "phase-task");
|
|
34402
|
+
if (opts.labels) for (const label of opts.labels) args.push("--label", label);
|
|
34403
|
+
if (opts.milestone) args.push("--milestone", opts.milestone);
|
|
34404
|
+
if (opts.projectTitle) args.push("--project", opts.projectTitle);
|
|
34405
|
+
const createResult = await ghExec(args);
|
|
34406
|
+
if (!createResult.ok) return {
|
|
34407
|
+
ok: false,
|
|
34408
|
+
error: createResult.error,
|
|
34409
|
+
code: createResult.code
|
|
34410
|
+
};
|
|
34411
|
+
const issueNumber = parseIssueNumberFromUrl(createResult.data);
|
|
34412
|
+
if (issueNumber === null) return {
|
|
34413
|
+
ok: false,
|
|
34414
|
+
error: `Failed to parse issue number from gh output: ${createResult.data}`,
|
|
34415
|
+
code: "UNKNOWN"
|
|
34416
|
+
};
|
|
34417
|
+
return fetchIssueDetails(issueNumber);
|
|
34418
|
+
}
|
|
34419
|
+
/**
|
|
34420
|
+
* Create a parent tracking issue for a phase with a live checkbox task list.
|
|
34421
|
+
*
|
|
34422
|
+
* Title format: `[Phase {phaseNum}] {phaseName}`
|
|
34423
|
+
* Body includes task list with checkbox links: `- [ ] #{childNumber}`
|
|
34424
|
+
* Labels: maxsim, phase-task.
|
|
34425
|
+
*/
|
|
34426
|
+
async function createParentTrackingIssue(opts) {
|
|
34427
|
+
const issueTitle = `[Phase ${opts.phaseNum}] ${opts.phaseName}`;
|
|
34428
|
+
const taskList = opts.childIssueNumbers.map((n) => `- [ ] #${n}`).join("\n");
|
|
34429
|
+
const args = [
|
|
34430
|
+
"issue",
|
|
34431
|
+
"create",
|
|
34432
|
+
"--title",
|
|
34433
|
+
issueTitle,
|
|
34434
|
+
"--body",
|
|
34435
|
+
`## Phase ${opts.phaseNum}: ${opts.phaseName}
|
|
34436
|
+
|
|
34437
|
+
### Tasks
|
|
34438
|
+
${taskList}
|
|
34439
|
+
|
|
34440
|
+
---
|
|
34441
|
+
*Phase tracking issue -- Generated by MAXSIM*`
|
|
34442
|
+
];
|
|
34443
|
+
args.push("--label", "maxsim");
|
|
34444
|
+
args.push("--label", "phase-task");
|
|
34445
|
+
if (opts.milestone) args.push("--milestone", opts.milestone);
|
|
34446
|
+
if (opts.projectTitle) args.push("--project", opts.projectTitle);
|
|
34447
|
+
const createResult = await ghExec(args);
|
|
34448
|
+
if (!createResult.ok) return {
|
|
34449
|
+
ok: false,
|
|
34450
|
+
error: createResult.error,
|
|
34451
|
+
code: createResult.code
|
|
34452
|
+
};
|
|
34453
|
+
const issueNumber = parseIssueNumberFromUrl(createResult.data);
|
|
34454
|
+
if (issueNumber === null) return {
|
|
34455
|
+
ok: false,
|
|
34456
|
+
error: `Failed to parse issue number from gh output: ${createResult.data}`,
|
|
34457
|
+
code: "UNKNOWN"
|
|
34458
|
+
};
|
|
34459
|
+
return fetchIssueDetails(issueNumber);
|
|
34460
|
+
}
|
|
34461
|
+
/**
|
|
34462
|
+
* Create a todo issue with a lighter body (no collapsible details section).
|
|
34463
|
+
*
|
|
34464
|
+
* Labels: maxsim, todo.
|
|
34465
|
+
*/
|
|
34466
|
+
async function createTodoIssue(opts) {
|
|
34467
|
+
let body = "";
|
|
34468
|
+
if (opts.description) body += `${opts.description}\n`;
|
|
34469
|
+
if (opts.acceptanceCriteria && opts.acceptanceCriteria.length > 0) {
|
|
34470
|
+
body += `\n### Acceptance Criteria\n`;
|
|
34471
|
+
body += opts.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n");
|
|
34472
|
+
body += "\n";
|
|
34473
|
+
}
|
|
34474
|
+
body += `\n---\n*Generated by MAXSIM*`;
|
|
34475
|
+
const args = [
|
|
34476
|
+
"issue",
|
|
34477
|
+
"create",
|
|
34478
|
+
"--title",
|
|
34479
|
+
opts.title,
|
|
34480
|
+
"--body",
|
|
34481
|
+
body
|
|
34482
|
+
];
|
|
34483
|
+
args.push("--label", "maxsim");
|
|
34484
|
+
args.push("--label", "todo");
|
|
34485
|
+
if (opts.milestone) args.push("--milestone", opts.milestone);
|
|
34486
|
+
if (opts.projectTitle) args.push("--project", opts.projectTitle);
|
|
34487
|
+
const createResult = await ghExec(args);
|
|
34488
|
+
if (!createResult.ok) return {
|
|
34489
|
+
ok: false,
|
|
34490
|
+
error: createResult.error,
|
|
34491
|
+
code: createResult.code
|
|
34492
|
+
};
|
|
34493
|
+
const issueNumber = parseIssueNumberFromUrl(createResult.data);
|
|
34494
|
+
if (issueNumber === null) return {
|
|
34495
|
+
ok: false,
|
|
34496
|
+
error: `Failed to parse issue number from gh output: ${createResult.data}`,
|
|
34497
|
+
code: "UNKNOWN"
|
|
34498
|
+
};
|
|
34499
|
+
return fetchIssueDetails(issueNumber);
|
|
34500
|
+
}
|
|
34501
|
+
/**
|
|
34502
|
+
* Build a PR description body with `Closes #{N}` lines for auto-close on merge (AC-08).
|
|
34503
|
+
*
|
|
34504
|
+
* This function is called by `mcp_create_pr` in Plan 04's github-tools.ts.
|
|
34505
|
+
*/
|
|
34506
|
+
function buildPrBody(closesIssues, additionalContent) {
|
|
34507
|
+
return `${closesIssues.map((n) => `Closes #${n}`).join("\n")}${additionalContent ? `\n\n${additionalContent}` : ""}`;
|
|
34508
|
+
}
|
|
34509
|
+
/**
|
|
34510
|
+
* Close an issue with an optional reason.
|
|
34511
|
+
*
|
|
34512
|
+
* Reason defaults to 'completed' if not specified.
|
|
34513
|
+
*/
|
|
34514
|
+
async function closeIssue(issueNumber, reason) {
|
|
34515
|
+
const args = [
|
|
34516
|
+
"issue",
|
|
34517
|
+
"close",
|
|
34518
|
+
String(issueNumber)
|
|
34519
|
+
];
|
|
34520
|
+
if (reason) args.push("--reason", reason);
|
|
34521
|
+
const result = await ghExec(args);
|
|
34522
|
+
if (!result.ok) return {
|
|
34523
|
+
ok: false,
|
|
34524
|
+
error: result.error,
|
|
34525
|
+
code: result.code
|
|
34526
|
+
};
|
|
34527
|
+
return {
|
|
34528
|
+
ok: true,
|
|
34529
|
+
data: void 0
|
|
34530
|
+
};
|
|
34531
|
+
}
|
|
34532
|
+
/**
|
|
34533
|
+
* Close an issue as superseded by a newer issue.
|
|
34534
|
+
*
|
|
34535
|
+
* 1. Posts "Superseded by #{newIssueNumber}" comment on old issue
|
|
34536
|
+
* 2. Adds 'superseded' label to old issue
|
|
34537
|
+
* 3. Closes old issue as completed
|
|
34538
|
+
* 4. Posts "Replaces #{oldIssueNumber}" comment on new issue
|
|
34539
|
+
*
|
|
34540
|
+
* Creates bidirectional cross-references.
|
|
34541
|
+
*/
|
|
34542
|
+
async function closeIssueAsSuperseded(oldIssueNumber, newIssueNumber) {
|
|
34543
|
+
const commentResult = await postComment(oldIssueNumber, `Superseded by #${newIssueNumber}`);
|
|
34544
|
+
if (!commentResult.ok) return {
|
|
34545
|
+
ok: false,
|
|
34546
|
+
error: commentResult.error,
|
|
34547
|
+
code: commentResult.code
|
|
34548
|
+
};
|
|
34549
|
+
const labelResult = await ghExec([
|
|
34550
|
+
"issue",
|
|
34551
|
+
"edit",
|
|
34552
|
+
String(oldIssueNumber),
|
|
34553
|
+
"--add-label",
|
|
34554
|
+
"superseded"
|
|
34555
|
+
]);
|
|
34556
|
+
if (!labelResult.ok) return {
|
|
34557
|
+
ok: false,
|
|
34558
|
+
error: labelResult.error,
|
|
34559
|
+
code: labelResult.code
|
|
34560
|
+
};
|
|
34561
|
+
const closeResult = await closeIssue(oldIssueNumber, "completed");
|
|
34562
|
+
if (!closeResult.ok) return closeResult;
|
|
34563
|
+
const replaceCommentResult = await postComment(newIssueNumber, `Replaces #${oldIssueNumber}`);
|
|
34564
|
+
if (!replaceCommentResult.ok) return {
|
|
34565
|
+
ok: false,
|
|
34566
|
+
error: replaceCommentResult.error,
|
|
34567
|
+
code: replaceCommentResult.code
|
|
34568
|
+
};
|
|
34569
|
+
return {
|
|
34570
|
+
ok: true,
|
|
34571
|
+
data: void 0
|
|
34572
|
+
};
|
|
34573
|
+
}
|
|
34574
|
+
/**
|
|
34575
|
+
* Post a comment on an issue.
|
|
34576
|
+
*/
|
|
34577
|
+
async function postComment(issueNumber, body) {
|
|
34578
|
+
const result = await ghExec([
|
|
34579
|
+
"issue",
|
|
34580
|
+
"comment",
|
|
34581
|
+
String(issueNumber),
|
|
34582
|
+
"--body",
|
|
34583
|
+
body
|
|
34584
|
+
]);
|
|
34585
|
+
if (!result.ok) return {
|
|
34586
|
+
ok: false,
|
|
34587
|
+
error: result.error,
|
|
34588
|
+
code: result.code
|
|
34589
|
+
};
|
|
34590
|
+
return {
|
|
34591
|
+
ok: true,
|
|
34592
|
+
data: void 0
|
|
34593
|
+
};
|
|
34594
|
+
}
|
|
34595
|
+
/**
|
|
34596
|
+
* Import an existing external GitHub issue into MAXSIM tracking.
|
|
34597
|
+
*
|
|
34598
|
+
* Reads the issue details and adds 'maxsim' and 'imported' labels.
|
|
34599
|
+
* Returns the issue details for the AI to decide placement.
|
|
34600
|
+
*/
|
|
34601
|
+
async function importExternalIssue(issueNumber) {
|
|
34602
|
+
const viewResult = await ghExec([
|
|
34603
|
+
"issue",
|
|
34604
|
+
"view",
|
|
34605
|
+
String(issueNumber),
|
|
34606
|
+
"--json",
|
|
34607
|
+
"title,labels,body,state"
|
|
34608
|
+
], { parseJson: true });
|
|
34609
|
+
if (!viewResult.ok) return {
|
|
34610
|
+
ok: false,
|
|
34611
|
+
error: viewResult.error,
|
|
34612
|
+
code: viewResult.code
|
|
34613
|
+
};
|
|
34614
|
+
const labelResult = await ghExec([
|
|
34615
|
+
"issue",
|
|
34616
|
+
"edit",
|
|
34617
|
+
String(issueNumber),
|
|
34618
|
+
"--add-label",
|
|
34619
|
+
"maxsim,imported"
|
|
34620
|
+
]);
|
|
34621
|
+
if (!labelResult.ok) return {
|
|
34622
|
+
ok: false,
|
|
34623
|
+
error: labelResult.error,
|
|
34624
|
+
code: labelResult.code
|
|
34625
|
+
};
|
|
34626
|
+
const existingLabels = viewResult.data.labels.map((l) => l.name);
|
|
34627
|
+
const allLabels = Array.from(new Set([
|
|
34628
|
+
...existingLabels,
|
|
34629
|
+
"maxsim",
|
|
34630
|
+
"imported"
|
|
34631
|
+
]));
|
|
34632
|
+
return {
|
|
34633
|
+
ok: true,
|
|
34634
|
+
data: {
|
|
34635
|
+
number: issueNumber,
|
|
34636
|
+
title: viewResult.data.title,
|
|
34637
|
+
labels: allLabels
|
|
34638
|
+
}
|
|
34639
|
+
};
|
|
34640
|
+
}
|
|
34641
|
+
/**
|
|
34642
|
+
* Update the parent tracking issue's task list checkbox.
|
|
34643
|
+
*
|
|
34644
|
+
* Reads the parent issue body, finds `- [ ] #{childNumber}` or `- [x] #{childNumber}`,
|
|
34645
|
+
* toggles the checkbox, and updates the issue body via `gh issue edit`.
|
|
34646
|
+
*/
|
|
34647
|
+
async function updateParentTaskList(parentIssueNumber, childIssueNumber, checked) {
|
|
34648
|
+
const viewResult = await ghExec([
|
|
34649
|
+
"issue",
|
|
34650
|
+
"view",
|
|
34651
|
+
String(parentIssueNumber),
|
|
34652
|
+
"--json",
|
|
34653
|
+
"body"
|
|
34654
|
+
], { parseJson: true });
|
|
34655
|
+
if (!viewResult.ok) return {
|
|
34656
|
+
ok: false,
|
|
34657
|
+
error: viewResult.error,
|
|
34658
|
+
code: viewResult.code
|
|
34659
|
+
};
|
|
34660
|
+
const currentBody = viewResult.data.body;
|
|
34661
|
+
const checkboxPattern = new RegExp(`- \\[([ x])\\] #${childIssueNumber}\\b`, "g");
|
|
34662
|
+
const newCheckState = checked ? "x" : " ";
|
|
34663
|
+
const updatedBody = currentBody.replace(checkboxPattern, `- [${newCheckState}] #${childIssueNumber}`);
|
|
34664
|
+
if (updatedBody === currentBody) return {
|
|
34665
|
+
ok: true,
|
|
34666
|
+
data: void 0
|
|
34667
|
+
};
|
|
34668
|
+
const editResult = await ghExec([
|
|
34669
|
+
"issue",
|
|
34670
|
+
"edit",
|
|
34671
|
+
String(parentIssueNumber),
|
|
34672
|
+
"--body",
|
|
34673
|
+
updatedBody
|
|
34674
|
+
]);
|
|
34675
|
+
if (!editResult.ok) return {
|
|
34676
|
+
ok: false,
|
|
34677
|
+
error: editResult.error,
|
|
34678
|
+
code: editResult.code
|
|
34679
|
+
};
|
|
34680
|
+
return {
|
|
34681
|
+
ok: true,
|
|
34682
|
+
data: void 0
|
|
34683
|
+
};
|
|
34684
|
+
}
|
|
34685
|
+
/**
|
|
34686
|
+
* Create all issues for a plan at once (eager creation on plan finalization).
|
|
34687
|
+
*
|
|
34688
|
+
* 1. Creates all task issues with concurrency limit of 5 (rate limit safety).
|
|
34689
|
+
* 2. After all task issues created, creates parent tracking issue.
|
|
34690
|
+
* 3. Updates mapping file for all created issues.
|
|
34691
|
+
* 4. Returns parent issue number and all task issue numbers.
|
|
34692
|
+
*
|
|
34693
|
+
* Handles partial failures: continues batch, reports which failed.
|
|
34694
|
+
*/
|
|
34695
|
+
async function createAllPlanIssues(opts) {
|
|
34696
|
+
const BATCH_SIZE = 5;
|
|
34697
|
+
const results = [];
|
|
34698
|
+
const failures = [];
|
|
34699
|
+
for (let i = 0; i < opts.tasks.length; i += BATCH_SIZE) {
|
|
34700
|
+
const batchPromises = opts.tasks.slice(i, i + BATCH_SIZE).map(async (task) => {
|
|
34701
|
+
let depIssueNumbers;
|
|
34702
|
+
if (task.dependencies && task.dependencies.length > 0) depIssueNumbers = task.dependencies.map((depId) => {
|
|
34703
|
+
const found = results.find((r) => r.taskId === depId);
|
|
34704
|
+
return found ? found.issueNumber : 0;
|
|
34705
|
+
}).filter((n) => n > 0);
|
|
34706
|
+
const result = await createTaskIssue({
|
|
34707
|
+
title: task.title,
|
|
34708
|
+
phaseNum: opts.phaseNum,
|
|
34709
|
+
planNum: opts.planNum,
|
|
34710
|
+
taskId: task.taskId,
|
|
34711
|
+
summary: task.summary,
|
|
34712
|
+
actions: task.actions,
|
|
34713
|
+
acceptanceCriteria: task.acceptanceCriteria,
|
|
34714
|
+
dependencies: depIssueNumbers,
|
|
34715
|
+
milestone: opts.milestone,
|
|
34716
|
+
projectTitle: opts.projectTitle,
|
|
34717
|
+
estimate: task.estimate
|
|
34718
|
+
});
|
|
34719
|
+
return {
|
|
34720
|
+
taskId: task.taskId,
|
|
34721
|
+
result
|
|
34722
|
+
};
|
|
34723
|
+
});
|
|
34724
|
+
const batchResults = await Promise.all(batchPromises);
|
|
34725
|
+
for (const { taskId, result } of batchResults) if (result.ok) results.push({
|
|
34726
|
+
taskId,
|
|
34727
|
+
issueNumber: result.data.number,
|
|
34728
|
+
nodeId: result.data.node_id
|
|
34729
|
+
});
|
|
34730
|
+
else failures.push({
|
|
34731
|
+
taskId,
|
|
34732
|
+
error: result.error
|
|
34733
|
+
});
|
|
34734
|
+
}
|
|
34735
|
+
if (results.length === 0) return {
|
|
34736
|
+
ok: false,
|
|
34737
|
+
error: `All task issue creations failed: ${failures.map((f) => `${f.taskId}: ${f.error}`).join("; ")}`,
|
|
34738
|
+
code: "UNKNOWN"
|
|
34739
|
+
};
|
|
34740
|
+
const childNumbers = results.map((r) => r.issueNumber);
|
|
34741
|
+
const parentResult = await createParentTrackingIssue({
|
|
34742
|
+
phaseNum: opts.phaseNum,
|
|
34743
|
+
phaseName: opts.phaseName,
|
|
34744
|
+
childIssueNumbers: childNumbers,
|
|
34745
|
+
milestone: opts.milestone,
|
|
34746
|
+
projectTitle: opts.projectTitle
|
|
34747
|
+
});
|
|
34748
|
+
if (!parentResult.ok) return {
|
|
34749
|
+
ok: false,
|
|
34750
|
+
error: `Task issues created but parent tracking issue failed: ${parentResult.error}`,
|
|
34751
|
+
code: parentResult.code
|
|
34752
|
+
};
|
|
34753
|
+
const mapping = loadMapping(opts.cwd);
|
|
34754
|
+
if (mapping) {
|
|
34755
|
+
if (!mapping.phases[opts.phaseNum]) mapping.phases[opts.phaseNum] = {
|
|
34756
|
+
tracking_issue: {
|
|
34757
|
+
number: 0,
|
|
34758
|
+
node_id: "",
|
|
34759
|
+
item_id: "",
|
|
34760
|
+
status: "To Do"
|
|
34761
|
+
},
|
|
34762
|
+
plan: "",
|
|
34763
|
+
tasks: {}
|
|
34764
|
+
};
|
|
34765
|
+
mapping.phases[opts.phaseNum].tracking_issue = {
|
|
34766
|
+
number: parentResult.data.number,
|
|
34767
|
+
node_id: parentResult.data.node_id,
|
|
34768
|
+
item_id: "",
|
|
34769
|
+
status: "To Do"
|
|
34770
|
+
};
|
|
34771
|
+
mapping.phases[opts.phaseNum].plan = `${opts.phaseNum}-${opts.planNum}`;
|
|
34772
|
+
for (const r of results) mapping.phases[opts.phaseNum].tasks[r.taskId] = {
|
|
34773
|
+
number: r.issueNumber,
|
|
34774
|
+
node_id: r.nodeId,
|
|
34775
|
+
item_id: "",
|
|
34776
|
+
status: "To Do"
|
|
34777
|
+
};
|
|
34778
|
+
saveMapping(opts.cwd, mapping);
|
|
34779
|
+
}
|
|
34780
|
+
const taskIssues = results.map((r) => ({
|
|
34781
|
+
taskId: r.taskId,
|
|
34782
|
+
issueNumber: r.issueNumber
|
|
34783
|
+
}));
|
|
34784
|
+
return {
|
|
34785
|
+
ok: true,
|
|
34786
|
+
data: {
|
|
34787
|
+
parentIssue: parentResult.data.number,
|
|
34788
|
+
taskIssues
|
|
34789
|
+
}
|
|
34790
|
+
};
|
|
34791
|
+
}
|
|
34792
|
+
/**
|
|
34793
|
+
* Supersede old plan's issues when a plan is re-planned (fresh issues per plan).
|
|
34794
|
+
*
|
|
34795
|
+
* 1. Load mapping to find old plan's issue numbers.
|
|
34796
|
+
* 2. For each old issue, close as superseded with cross-reference to new issue.
|
|
34797
|
+
* 3. Close old parent tracking issue as superseded.
|
|
34798
|
+
* 4. Update mapping: mark old entries, add new entries.
|
|
34799
|
+
*/
|
|
34800
|
+
async function supersedePlanIssues(opts) {
|
|
34801
|
+
const mapping = loadMapping(opts.cwd);
|
|
34802
|
+
if (!mapping) return {
|
|
34803
|
+
ok: false,
|
|
34804
|
+
error: "github-issues.json does not exist. Run project setup first.",
|
|
34805
|
+
code: "NOT_FOUND"
|
|
34806
|
+
};
|
|
34807
|
+
const phase = mapping.phases[opts.phaseNum];
|
|
34808
|
+
if (!phase) return {
|
|
34809
|
+
ok: false,
|
|
34810
|
+
error: `No phase ${opts.phaseNum} found in mapping file`,
|
|
34811
|
+
code: "NOT_FOUND"
|
|
34812
|
+
};
|
|
34813
|
+
const currentPlan = phase.plan;
|
|
34814
|
+
const expectedOldPlan = `${opts.phaseNum}-${opts.oldPlanNum}`;
|
|
34815
|
+
if (currentPlan !== expectedOldPlan) return {
|
|
34816
|
+
ok: false,
|
|
34817
|
+
error: `Phase ${opts.phaseNum} is on plan '${currentPlan}', expected '${expectedOldPlan}'`,
|
|
34818
|
+
code: "UNKNOWN"
|
|
34819
|
+
};
|
|
34820
|
+
const failures = [];
|
|
34821
|
+
const oldTasks = Object.entries(phase.tasks);
|
|
34822
|
+
for (const [taskId, oldTask] of oldTasks) {
|
|
34823
|
+
const newIssue = opts.newIssueNumbers.find((n) => n.taskId === taskId);
|
|
34824
|
+
if (!newIssue) {
|
|
34825
|
+
const closeResult = await closeIssue(oldTask.number, "completed");
|
|
34826
|
+
if (!closeResult.ok) failures.push(`close task ${taskId} (#${oldTask.number}): ${closeResult.error}`);
|
|
34827
|
+
continue;
|
|
34828
|
+
}
|
|
34829
|
+
const supersedeResult = await closeIssueAsSuperseded(oldTask.number, newIssue.issueNumber);
|
|
34830
|
+
if (!supersedeResult.ok) failures.push(`supersede task ${taskId} (#${oldTask.number} -> #${newIssue.issueNumber}): ${supersedeResult.error}`);
|
|
34831
|
+
}
|
|
34832
|
+
if (phase.tracking_issue.number > 0) {
|
|
34833
|
+
const closeResult = await closeIssue(phase.tracking_issue.number, "completed");
|
|
34834
|
+
if (!closeResult.ok) failures.push(`close parent tracking issue #${phase.tracking_issue.number}: ${closeResult.error}`);
|
|
34835
|
+
}
|
|
34836
|
+
phase.plan = `${opts.phaseNum}-${opts.newPlanNum}`;
|
|
34837
|
+
phase.tasks = {};
|
|
34838
|
+
for (const newIssue of opts.newIssueNumbers) phase.tasks[newIssue.taskId] = {
|
|
34839
|
+
number: newIssue.issueNumber,
|
|
34840
|
+
node_id: "",
|
|
34841
|
+
item_id: "",
|
|
34842
|
+
status: "To Do"
|
|
34843
|
+
};
|
|
34844
|
+
saveMapping(opts.cwd, mapping);
|
|
34845
|
+
if (failures.length > 0) return {
|
|
34846
|
+
ok: false,
|
|
34847
|
+
error: `Partial failure during supersession: ${failures.join("; ")}`,
|
|
34848
|
+
code: "UNKNOWN"
|
|
34849
|
+
};
|
|
34850
|
+
return {
|
|
34851
|
+
ok: true,
|
|
34852
|
+
data: void 0
|
|
34853
|
+
};
|
|
34854
|
+
}
|
|
34855
|
+
|
|
34856
|
+
//#endregion
|
|
34857
|
+
//#region src/github/projects.ts
|
|
34858
|
+
/**
|
|
34859
|
+
* Extract error info from a failed GhResult and re-wrap it for a different
|
|
34860
|
+
* generic type. This avoids TypeScript narrowing issues with discriminated
|
|
34861
|
+
* union property access.
|
|
34862
|
+
*/
|
|
34863
|
+
function fail$2(result) {
|
|
34864
|
+
return {
|
|
34865
|
+
ok: false,
|
|
34866
|
+
error: result.error,
|
|
34867
|
+
code: result.code
|
|
34868
|
+
};
|
|
34869
|
+
}
|
|
34870
|
+
/**
|
|
34871
|
+
* Create a new GitHub Projects v2 board.
|
|
34872
|
+
*
|
|
34873
|
+
* Runs `gh project create --owner @me --title "{title}" --format json`.
|
|
34874
|
+
* Returns the project number and node ID.
|
|
34875
|
+
*/
|
|
34876
|
+
async function createProjectBoard(title) {
|
|
34877
|
+
const result = await ghExec([
|
|
34878
|
+
"project",
|
|
34879
|
+
"create",
|
|
34880
|
+
"--owner",
|
|
34881
|
+
"@me",
|
|
34882
|
+
"--title",
|
|
34883
|
+
title,
|
|
34884
|
+
"--format",
|
|
34885
|
+
"json"
|
|
34886
|
+
], { parseJson: true });
|
|
34887
|
+
if (!result.ok) return result;
|
|
34888
|
+
return {
|
|
34889
|
+
ok: true,
|
|
34890
|
+
data: {
|
|
34891
|
+
number: result.data.number,
|
|
34892
|
+
id: result.data.id
|
|
34893
|
+
}
|
|
34894
|
+
};
|
|
34895
|
+
}
|
|
34896
|
+
/**
|
|
34897
|
+
* Ensure a project board exists, creating it if needed.
|
|
34898
|
+
*
|
|
34899
|
+
* Checks the mapping file for an existing project. If found, verifies it
|
|
34900
|
+
* still exists via `gh project view`. If not found, creates a new board
|
|
34901
|
+
* and sets up fields and status options.
|
|
34902
|
+
*
|
|
34903
|
+
* Returns the project number, ID, and whether it was newly created.
|
|
34904
|
+
*/
|
|
34905
|
+
async function ensureProjectBoard(title, cwd) {
|
|
34906
|
+
const mapping = loadMapping(cwd);
|
|
34907
|
+
if (mapping && mapping.project_number > 0 && mapping.project_id) {
|
|
34908
|
+
if ((await ghExec([
|
|
34909
|
+
"project",
|
|
34910
|
+
"view",
|
|
34911
|
+
String(mapping.project_number),
|
|
34912
|
+
"--owner",
|
|
34913
|
+
"@me",
|
|
34914
|
+
"--format",
|
|
34915
|
+
"json"
|
|
34916
|
+
], { parseJson: true })).ok) return {
|
|
34917
|
+
ok: true,
|
|
34918
|
+
data: {
|
|
34919
|
+
number: mapping.project_number,
|
|
34920
|
+
id: mapping.project_id,
|
|
34921
|
+
created: false
|
|
34922
|
+
}
|
|
34923
|
+
};
|
|
34924
|
+
}
|
|
34925
|
+
const createResult = await createProjectBoard(title);
|
|
34926
|
+
if (!createResult.ok) return fail$2(createResult);
|
|
34927
|
+
const { number, id } = createResult.data;
|
|
34928
|
+
const setupResult = await setupProjectFields(number, id, cwd);
|
|
34929
|
+
if (!setupResult.ok) return fail$2(setupResult);
|
|
34930
|
+
return {
|
|
34931
|
+
ok: true,
|
|
34932
|
+
data: {
|
|
34933
|
+
number,
|
|
34934
|
+
id,
|
|
34935
|
+
created: true
|
|
34936
|
+
}
|
|
34937
|
+
};
|
|
34938
|
+
}
|
|
34939
|
+
/**
|
|
34940
|
+
* Get all fields for a project board.
|
|
34941
|
+
*
|
|
34942
|
+
* Runs `gh project field-list {num} --owner @me --format json`.
|
|
34943
|
+
* Returns field list with IDs, names, types, and options (for single-select).
|
|
34944
|
+
*/
|
|
34945
|
+
async function getProjectFields(projectNum) {
|
|
34946
|
+
const result = await ghExec([
|
|
34947
|
+
"project",
|
|
34948
|
+
"field-list",
|
|
34949
|
+
String(projectNum),
|
|
34950
|
+
"--owner",
|
|
34951
|
+
"@me",
|
|
34952
|
+
"--format",
|
|
34953
|
+
"json"
|
|
34954
|
+
], { parseJson: true });
|
|
34955
|
+
if (!result.ok) return fail$2(result);
|
|
34956
|
+
return {
|
|
34957
|
+
ok: true,
|
|
34958
|
+
data: result.data.fields ?? result.data
|
|
34959
|
+
};
|
|
34960
|
+
}
|
|
34961
|
+
/**
|
|
34962
|
+
* Add a new single-select option to a project field via GraphQL.
|
|
34963
|
+
*
|
|
34964
|
+
* The `updateProjectV2Field` mutation REPLACES all options, so all existing
|
|
34965
|
+
* options must be included alongside the new one.
|
|
34966
|
+
*
|
|
34967
|
+
* Returns the new option's ID.
|
|
34968
|
+
*/
|
|
34969
|
+
async function addStatusOption(projectId, statusFieldId, optionName, existingOptions) {
|
|
34970
|
+
const result = await ghGraphQL(`
|
|
34971
|
+
mutation {
|
|
34972
|
+
updateProjectV2Field(input: {
|
|
34973
|
+
projectId: "${projectId}"
|
|
34974
|
+
fieldId: "${statusFieldId}"
|
|
34975
|
+
singleSelectOptions: [${[...existingOptions.map((o) => `{name: "${o.name}", description: "", color: GRAY}`), `{name: "${optionName}", description: "", color: BLUE}`].join(", ")}]
|
|
34976
|
+
}) {
|
|
34977
|
+
projectV2Field {
|
|
34978
|
+
... on ProjectV2SingleSelectField {
|
|
34979
|
+
options { id name }
|
|
34980
|
+
}
|
|
34981
|
+
}
|
|
34982
|
+
}
|
|
34983
|
+
}
|
|
34984
|
+
`);
|
|
34985
|
+
if (!result.ok) return fail$2(result);
|
|
34986
|
+
const newOption = result.data.updateProjectV2Field.projectV2Field.options.find((o) => o.name === optionName);
|
|
34987
|
+
if (!newOption) return {
|
|
34988
|
+
ok: false,
|
|
34989
|
+
error: `Option "${optionName}" was not found after mutation — it may have been renamed or rejected`,
|
|
34990
|
+
code: "UNKNOWN"
|
|
34991
|
+
};
|
|
34992
|
+
return {
|
|
34993
|
+
ok: true,
|
|
34994
|
+
data: newOption.id
|
|
34995
|
+
};
|
|
34996
|
+
}
|
|
34997
|
+
/**
|
|
34998
|
+
* Set up project fields: Status options and Estimate number field.
|
|
34999
|
+
*
|
|
35000
|
+
* Orchestrates the full field setup:
|
|
35001
|
+
* (a) Get existing fields
|
|
35002
|
+
* (b) Find Status field, verify "In Review" option exists or add it
|
|
35003
|
+
* (c) Create Estimate NUMBER field via `gh project field-create`
|
|
35004
|
+
* (d) Store all field/option IDs in the mapping file
|
|
35005
|
+
*/
|
|
35006
|
+
async function setupProjectFields(projectNum, projectId, cwd) {
|
|
35007
|
+
const fieldsResult = await getProjectFields(projectNum);
|
|
35008
|
+
if (!fieldsResult.ok) return fail$2(fieldsResult);
|
|
35009
|
+
const fields = fieldsResult.data;
|
|
35010
|
+
const statusField = fields.find((f) => f.name === "Status" && (f.type === "SINGLE_SELECT" || f.type === "ProjectV2SingleSelectField"));
|
|
35011
|
+
if (!statusField) return {
|
|
35012
|
+
ok: false,
|
|
35013
|
+
error: "Status field not found on project board. This is unexpected for a Projects v2 board.",
|
|
35014
|
+
code: "NOT_FOUND"
|
|
35015
|
+
};
|
|
35016
|
+
const statusOptions = statusField.options ?? [];
|
|
35017
|
+
const statusOptionsMap = {};
|
|
35018
|
+
for (const opt of statusOptions) statusOptionsMap[opt.name] = opt.id;
|
|
35019
|
+
if (!statusOptionsMap["In Review"]) {
|
|
35020
|
+
const addResult = await addStatusOption(projectId, statusField.id, "In Review", statusOptions);
|
|
35021
|
+
if (!addResult.ok) return fail$2(addResult);
|
|
35022
|
+
statusOptionsMap["In Review"] = addResult.data;
|
|
35023
|
+
}
|
|
35024
|
+
if (statusOptionsMap["Todo"] && !statusOptionsMap["To Do"]) statusOptionsMap["To Do"] = statusOptionsMap["Todo"];
|
|
35025
|
+
const estimateField = fields.find((f) => f.name === "Estimate");
|
|
35026
|
+
let estimateFieldId = estimateField?.id ?? "";
|
|
35027
|
+
if (!estimateField) {
|
|
35028
|
+
const createFieldResult = await ghExec([
|
|
35029
|
+
"project",
|
|
35030
|
+
"field-create",
|
|
35031
|
+
String(projectNum),
|
|
35032
|
+
"--owner",
|
|
35033
|
+
"@me",
|
|
35034
|
+
"--name",
|
|
35035
|
+
"Estimate",
|
|
35036
|
+
"--data-type",
|
|
35037
|
+
"NUMBER"
|
|
35038
|
+
]);
|
|
35039
|
+
if (!createFieldResult.ok) return fail$2(createFieldResult);
|
|
35040
|
+
const refetch = await getProjectFields(projectNum);
|
|
35041
|
+
if (refetch.ok) {
|
|
35042
|
+
const est = refetch.data.find((f) => f.name === "Estimate");
|
|
35043
|
+
if (est) estimateFieldId = est.id;
|
|
35044
|
+
}
|
|
35045
|
+
}
|
|
35046
|
+
const repoResult = await ghExec([
|
|
35047
|
+
"repo",
|
|
35048
|
+
"view",
|
|
35049
|
+
"--json",
|
|
35050
|
+
"nameWithOwner",
|
|
35051
|
+
"--jq",
|
|
35052
|
+
".nameWithOwner"
|
|
35053
|
+
]);
|
|
35054
|
+
const repo = repoResult.ok ? repoResult.data.trim() : "";
|
|
35055
|
+
const mapping = loadMapping(cwd) ?? createEmptyMapping(repo);
|
|
35056
|
+
mapping.project_number = projectNum;
|
|
35057
|
+
mapping.project_id = projectId;
|
|
35058
|
+
mapping.status_field_id = statusField.id;
|
|
35059
|
+
mapping.status_options = statusOptionsMap;
|
|
35060
|
+
mapping.estimate_field_id = estimateFieldId;
|
|
35061
|
+
if (repo && !mapping.repo) mapping.repo = repo;
|
|
35062
|
+
saveMapping(cwd, mapping);
|
|
35063
|
+
return {
|
|
35064
|
+
ok: true,
|
|
35065
|
+
data: void 0
|
|
35066
|
+
};
|
|
35067
|
+
}
|
|
35068
|
+
/**
|
|
35069
|
+
* Add an issue to a project board.
|
|
35070
|
+
*
|
|
35071
|
+
* Runs `gh project item-add {num} --owner @me --url {issueUrl} --format json`.
|
|
35072
|
+
* Returns the project item ID (different from the issue ID).
|
|
35073
|
+
*/
|
|
35074
|
+
async function addItemToProject(projectNum, issueUrl) {
|
|
35075
|
+
const result = await ghExec([
|
|
35076
|
+
"project",
|
|
35077
|
+
"item-add",
|
|
35078
|
+
String(projectNum),
|
|
35079
|
+
"--owner",
|
|
35080
|
+
"@me",
|
|
35081
|
+
"--url",
|
|
35082
|
+
issueUrl,
|
|
35083
|
+
"--format",
|
|
35084
|
+
"json"
|
|
35085
|
+
], { parseJson: true });
|
|
35086
|
+
if (!result.ok) return fail$2(result);
|
|
35087
|
+
return {
|
|
35088
|
+
ok: true,
|
|
35089
|
+
data: { item_id: result.data.id }
|
|
35090
|
+
};
|
|
35091
|
+
}
|
|
35092
|
+
/**
|
|
35093
|
+
* Move a project item to a specific status column.
|
|
35094
|
+
*
|
|
35095
|
+
* Runs `gh project item-edit` with single-select-option-id for the
|
|
35096
|
+
* Status field.
|
|
35097
|
+
*/
|
|
35098
|
+
async function moveItemToStatus(projectId, itemId, statusFieldId, statusOptionId) {
|
|
35099
|
+
const result = await ghExec([
|
|
35100
|
+
"project",
|
|
35101
|
+
"item-edit",
|
|
35102
|
+
"--project-id",
|
|
35103
|
+
projectId,
|
|
35104
|
+
"--id",
|
|
35105
|
+
itemId,
|
|
35106
|
+
"--field-id",
|
|
35107
|
+
statusFieldId,
|
|
35108
|
+
"--single-select-option-id",
|
|
35109
|
+
statusOptionId
|
|
35110
|
+
]);
|
|
35111
|
+
if (!result.ok) return fail$2(result);
|
|
35112
|
+
return {
|
|
35113
|
+
ok: true,
|
|
35114
|
+
data: void 0
|
|
35115
|
+
};
|
|
35116
|
+
}
|
|
35117
|
+
/**
|
|
35118
|
+
* Set the Estimate (story points) field on a project item.
|
|
35119
|
+
*
|
|
35120
|
+
* Runs `gh project item-edit` with --number flag for the Estimate field.
|
|
35121
|
+
*/
|
|
35122
|
+
async function setEstimate(projectId, itemId, estimateFieldId, points) {
|
|
35123
|
+
const result = await ghExec([
|
|
35124
|
+
"project",
|
|
35125
|
+
"item-edit",
|
|
35126
|
+
"--project-id",
|
|
35127
|
+
projectId,
|
|
35128
|
+
"--id",
|
|
35129
|
+
itemId,
|
|
35130
|
+
"--field-id",
|
|
35131
|
+
estimateFieldId,
|
|
35132
|
+
"--number",
|
|
35133
|
+
String(points)
|
|
35134
|
+
]);
|
|
35135
|
+
if (!result.ok) return fail$2(result);
|
|
35136
|
+
return {
|
|
35137
|
+
ok: true,
|
|
35138
|
+
data: void 0
|
|
35139
|
+
};
|
|
35140
|
+
}
|
|
35141
|
+
|
|
35142
|
+
//#endregion
|
|
35143
|
+
//#region src/github/milestones.ts
|
|
35144
|
+
/**
|
|
35145
|
+
* Re-wrap a failed GhResult for a different generic type.
|
|
35146
|
+
*/
|
|
35147
|
+
function fail$1(result) {
|
|
35148
|
+
return {
|
|
35149
|
+
ok: false,
|
|
35150
|
+
error: result.error,
|
|
35151
|
+
code: result.code
|
|
35152
|
+
};
|
|
35153
|
+
}
|
|
35154
|
+
/**
|
|
35155
|
+
* Create a new GitHub milestone.
|
|
35156
|
+
*
|
|
35157
|
+
* Uses the REST API: `POST /repos/{owner}/{repo}/milestones`.
|
|
35158
|
+
* The `{owner}` and `{repo}` placeholders are auto-resolved by `gh api`.
|
|
35159
|
+
*
|
|
35160
|
+
* Returns the milestone number and internal ID.
|
|
35161
|
+
*/
|
|
35162
|
+
async function createMilestone(title, description) {
|
|
35163
|
+
const args = [
|
|
35164
|
+
"api",
|
|
35165
|
+
"repos/{owner}/{repo}/milestones",
|
|
35166
|
+
"-X",
|
|
35167
|
+
"POST",
|
|
35168
|
+
"-f",
|
|
35169
|
+
`title=${title}`,
|
|
35170
|
+
"-f",
|
|
35171
|
+
"state=open"
|
|
35172
|
+
];
|
|
35173
|
+
if (description) args.push("-f", `description=${description}`);
|
|
35174
|
+
const result = await ghExec(args, { parseJson: true });
|
|
35175
|
+
if (!result.ok) return result;
|
|
35176
|
+
return {
|
|
35177
|
+
ok: true,
|
|
35178
|
+
data: {
|
|
35179
|
+
number: result.data.number,
|
|
35180
|
+
id: result.data.id
|
|
35181
|
+
}
|
|
35182
|
+
};
|
|
35183
|
+
}
|
|
35184
|
+
/**
|
|
35185
|
+
* Find an existing milestone by title.
|
|
35186
|
+
*
|
|
35187
|
+
* Uses the REST API: `GET /repos/{owner}/{repo}/milestones`.
|
|
35188
|
+
* Fetches all open milestones and filters by exact title match.
|
|
35189
|
+
*
|
|
35190
|
+
* Returns null if no milestone with the given title exists.
|
|
35191
|
+
*/
|
|
35192
|
+
async function findMilestone(title) {
|
|
35193
|
+
const result = await ghExec([
|
|
35194
|
+
"api",
|
|
35195
|
+
"repos/{owner}/{repo}/milestones",
|
|
35196
|
+
"--paginate"
|
|
35197
|
+
], { parseJson: true });
|
|
35198
|
+
if (!result.ok) return fail$1(result);
|
|
35199
|
+
const match = result.data.find((m) => m.title === title);
|
|
35200
|
+
if (!match) return {
|
|
35201
|
+
ok: true,
|
|
35202
|
+
data: null
|
|
35203
|
+
};
|
|
35204
|
+
return {
|
|
35205
|
+
ok: true,
|
|
35206
|
+
data: {
|
|
35207
|
+
number: match.number,
|
|
35208
|
+
id: match.id
|
|
35209
|
+
}
|
|
35210
|
+
};
|
|
35211
|
+
}
|
|
35212
|
+
/**
|
|
35213
|
+
* Ensure a milestone exists, creating it if needed. Idempotent.
|
|
35214
|
+
*
|
|
35215
|
+
* First attempts to find an existing milestone with the given title.
|
|
35216
|
+
* If not found, creates a new one. Returns whether it was newly created.
|
|
35217
|
+
*/
|
|
35218
|
+
async function ensureMilestone(title, description) {
|
|
35219
|
+
const findResult = await findMilestone(title);
|
|
35220
|
+
if (!findResult.ok) return fail$1(findResult);
|
|
35221
|
+
if (findResult.data) return {
|
|
35222
|
+
ok: true,
|
|
35223
|
+
data: {
|
|
35224
|
+
...findResult.data,
|
|
35225
|
+
created: false
|
|
35226
|
+
}
|
|
35227
|
+
};
|
|
35228
|
+
const createResult = await createMilestone(title, description);
|
|
35229
|
+
if (!createResult.ok) return fail$1(createResult);
|
|
35230
|
+
return {
|
|
35231
|
+
ok: true,
|
|
35232
|
+
data: {
|
|
35233
|
+
...createResult.data,
|
|
35234
|
+
created: true
|
|
35235
|
+
}
|
|
35236
|
+
};
|
|
35237
|
+
}
|
|
35238
|
+
/**
|
|
35239
|
+
* Close a milestone if all its issues are closed.
|
|
35240
|
+
*
|
|
35241
|
+
* Fetches milestone details via REST API to check `open_issues` count.
|
|
35242
|
+
* If open_issues === 0, patches the milestone state to "closed".
|
|
35243
|
+
*
|
|
35244
|
+
* This implements AC-12: milestones auto-close when all issues are closed.
|
|
35245
|
+
*/
|
|
35246
|
+
async function closeMilestoneIfComplete(milestoneNumber) {
|
|
35247
|
+
const detailResult = await ghExec(["api", `repos/{owner}/{repo}/milestones/${milestoneNumber}`], { parseJson: true });
|
|
35248
|
+
if (!detailResult.ok) return fail$1(detailResult);
|
|
35249
|
+
const milestone = detailResult.data;
|
|
35250
|
+
if (milestone.state === "closed") return {
|
|
35251
|
+
ok: true,
|
|
35252
|
+
data: { closed: true }
|
|
35253
|
+
};
|
|
35254
|
+
if (milestone.open_issues > 0) return {
|
|
35255
|
+
ok: true,
|
|
35256
|
+
data: { closed: false }
|
|
35257
|
+
};
|
|
35258
|
+
const closeResult = await ghExec([
|
|
35259
|
+
"api",
|
|
35260
|
+
`repos/{owner}/{repo}/milestones/${milestoneNumber}`,
|
|
35261
|
+
"-X",
|
|
35262
|
+
"PATCH",
|
|
35263
|
+
"-f",
|
|
35264
|
+
"state=closed"
|
|
35265
|
+
]);
|
|
35266
|
+
if (!closeResult.ok) return fail$1(closeResult);
|
|
35267
|
+
return {
|
|
35268
|
+
ok: true,
|
|
35269
|
+
data: { closed: true }
|
|
35270
|
+
};
|
|
35271
|
+
}
|
|
35272
|
+
|
|
35273
|
+
//#endregion
|
|
35274
|
+
//#region src/github/sync.ts
|
|
35275
|
+
/**
|
|
35276
|
+
* Re-wrap a failed GhResult for a different generic type.
|
|
35277
|
+
*/
|
|
35278
|
+
function fail(result) {
|
|
35279
|
+
return {
|
|
35280
|
+
ok: false,
|
|
35281
|
+
error: result.error,
|
|
35282
|
+
code: result.code
|
|
35283
|
+
};
|
|
35284
|
+
}
|
|
35285
|
+
/**
|
|
35286
|
+
* Batch-fetch issue details via a single GraphQL query.
|
|
35287
|
+
*
|
|
35288
|
+
* Fetches up to 100 issues per query using node ID lookups.
|
|
35289
|
+
* Falls back to sequential `gh issue view` if GraphQL fails.
|
|
35290
|
+
*/
|
|
35291
|
+
async function batchFetchIssues(repo, issueNumbers) {
|
|
35292
|
+
if (issueNumbers.length === 0) return {
|
|
35293
|
+
ok: true,
|
|
35294
|
+
data: /* @__PURE__ */ new Map()
|
|
35295
|
+
};
|
|
35296
|
+
const [owner, name] = repo.split("/");
|
|
35297
|
+
if (!owner || !name) return {
|
|
35298
|
+
ok: false,
|
|
35299
|
+
error: `Invalid repo format: ${repo}. Expected "owner/repo".`,
|
|
35300
|
+
code: "UNKNOWN"
|
|
35301
|
+
};
|
|
35302
|
+
const BATCH_SIZE = 100;
|
|
35303
|
+
const resultMap = /* @__PURE__ */ new Map();
|
|
35304
|
+
for (let i = 0; i < issueNumbers.length; i += BATCH_SIZE) {
|
|
35305
|
+
const batch = issueNumbers.slice(i, i + BATCH_SIZE);
|
|
35306
|
+
const result = await ghGraphQL(`
|
|
35307
|
+
query {
|
|
35308
|
+
repository(owner: "${owner}", name: "${name}") {
|
|
35309
|
+
${batch.map((num, idx) => `issue_${idx}: issue(number: ${num}) { number state title labels(first: 20) { nodes { name } } }`).join("\n ")}
|
|
35310
|
+
}
|
|
35311
|
+
}
|
|
35312
|
+
`);
|
|
35313
|
+
if (!result.ok) return batchFetchIssuesSequential(issueNumbers);
|
|
35314
|
+
const repoData = result.data.repository;
|
|
35315
|
+
for (let idx = 0; idx < batch.length; idx++) {
|
|
35316
|
+
const issueData = repoData[`issue_${idx}`];
|
|
35317
|
+
if (issueData) resultMap.set(issueData.number, {
|
|
35318
|
+
state: issueData.state.toLowerCase(),
|
|
35319
|
+
title: issueData.title,
|
|
35320
|
+
labels: issueData.labels.nodes.map((l) => l.name)
|
|
35321
|
+
});
|
|
35322
|
+
}
|
|
35323
|
+
}
|
|
35324
|
+
return {
|
|
35325
|
+
ok: true,
|
|
35326
|
+
data: resultMap
|
|
35327
|
+
};
|
|
35328
|
+
}
|
|
35329
|
+
/**
|
|
35330
|
+
* Sequential fallback: fetch issues one at a time via `gh issue view`.
|
|
35331
|
+
*/
|
|
35332
|
+
async function batchFetchIssuesSequential(issueNumbers) {
|
|
35333
|
+
const resultMap = /* @__PURE__ */ new Map();
|
|
35334
|
+
for (const num of issueNumbers) {
|
|
35335
|
+
const result = await ghExec([
|
|
35336
|
+
"issue",
|
|
35337
|
+
"view",
|
|
35338
|
+
String(num),
|
|
35339
|
+
"--json",
|
|
35340
|
+
"state,title,labels"
|
|
35341
|
+
], { parseJson: true });
|
|
35342
|
+
if (result.ok) resultMap.set(num, {
|
|
35343
|
+
state: result.data.state.toLowerCase(),
|
|
35344
|
+
title: result.data.title,
|
|
35345
|
+
labels: result.data.labels.map((l) => l.name)
|
|
35346
|
+
});
|
|
35347
|
+
}
|
|
35348
|
+
return {
|
|
35349
|
+
ok: true,
|
|
35350
|
+
data: resultMap
|
|
35351
|
+
};
|
|
35352
|
+
}
|
|
35353
|
+
/**
|
|
35354
|
+
* Compare local mapping file against GitHub reality.
|
|
35355
|
+
*
|
|
35356
|
+
* For each tracked issue (phases + todos), fetches current GitHub state
|
|
35357
|
+
* and compares against the local mapping. Reports discrepancies in
|
|
35358
|
+
* state, title, and labels.
|
|
35359
|
+
*
|
|
35360
|
+
* Uses batched GraphQL for efficiency (single query for up to 100 issues).
|
|
35361
|
+
*/
|
|
35362
|
+
async function syncCheck(cwd) {
|
|
35363
|
+
const mapping = loadMapping(cwd);
|
|
35364
|
+
if (!mapping) return {
|
|
35365
|
+
ok: false,
|
|
35366
|
+
error: "github-issues.json does not exist. Run project setup first.",
|
|
35367
|
+
code: "NOT_FOUND"
|
|
35368
|
+
};
|
|
35369
|
+
if (!mapping.repo) return {
|
|
35370
|
+
ok: false,
|
|
35371
|
+
error: "No repo configured in github-issues.json.",
|
|
35372
|
+
code: "NOT_FOUND"
|
|
35373
|
+
};
|
|
35374
|
+
const trackedIssues = [];
|
|
35375
|
+
for (const [phaseNum, phase] of Object.entries(mapping.phases)) {
|
|
35376
|
+
if (phase.tracking_issue.number > 0) trackedIssues.push({
|
|
35377
|
+
issueNumber: phase.tracking_issue.number,
|
|
35378
|
+
localStatus: phase.tracking_issue.status,
|
|
35379
|
+
source: `phase ${phaseNum} tracking`
|
|
35380
|
+
});
|
|
35381
|
+
for (const [taskId, task] of Object.entries(phase.tasks)) if (task.number > 0) trackedIssues.push({
|
|
35382
|
+
issueNumber: task.number,
|
|
35383
|
+
localStatus: task.status,
|
|
35384
|
+
source: `phase ${phaseNum}, task ${taskId}`
|
|
35385
|
+
});
|
|
35386
|
+
}
|
|
35387
|
+
if (mapping.todos) {
|
|
35388
|
+
for (const [todoId, todo] of Object.entries(mapping.todos)) if (todo.number > 0) trackedIssues.push({
|
|
35389
|
+
issueNumber: todo.number,
|
|
35390
|
+
localStatus: todo.status,
|
|
35391
|
+
source: `todo ${todoId}`
|
|
35392
|
+
});
|
|
35393
|
+
}
|
|
35394
|
+
if (trackedIssues.length === 0) return {
|
|
35395
|
+
ok: true,
|
|
35396
|
+
data: {
|
|
35397
|
+
inSync: true,
|
|
35398
|
+
changes: []
|
|
35399
|
+
}
|
|
35400
|
+
};
|
|
35401
|
+
const issueNumbers = trackedIssues.map((t) => t.issueNumber);
|
|
35402
|
+
const fetchResult = await batchFetchIssues(mapping.repo, issueNumbers);
|
|
35403
|
+
if (!fetchResult.ok) return fail(fetchResult);
|
|
35404
|
+
const remoteStates = fetchResult.data;
|
|
35405
|
+
const changes = [];
|
|
35406
|
+
for (const tracked of trackedIssues) {
|
|
35407
|
+
const remote = remoteStates.get(tracked.issueNumber);
|
|
35408
|
+
if (!remote) {
|
|
35409
|
+
changes.push({
|
|
35410
|
+
issueNumber: tracked.issueNumber,
|
|
35411
|
+
field: "existence",
|
|
35412
|
+
localValue: "exists",
|
|
35413
|
+
remoteValue: "not found"
|
|
35414
|
+
});
|
|
35415
|
+
continue;
|
|
35416
|
+
}
|
|
35417
|
+
const isRemoteClosed = remote.state === "closed";
|
|
35418
|
+
const isLocalDone = tracked.localStatus === "Done";
|
|
35419
|
+
if (isRemoteClosed && !isLocalDone) changes.push({
|
|
35420
|
+
issueNumber: tracked.issueNumber,
|
|
35421
|
+
field: "state",
|
|
35422
|
+
localValue: tracked.localStatus,
|
|
35423
|
+
remoteValue: "closed (Done)"
|
|
35424
|
+
});
|
|
35425
|
+
else if (!isRemoteClosed && isLocalDone) changes.push({
|
|
35426
|
+
issueNumber: tracked.issueNumber,
|
|
35427
|
+
field: "state",
|
|
35428
|
+
localValue: "Done",
|
|
35429
|
+
remoteValue: `open (${remote.state})`
|
|
35430
|
+
});
|
|
35431
|
+
}
|
|
35432
|
+
return {
|
|
35433
|
+
ok: true,
|
|
35434
|
+
data: {
|
|
35435
|
+
inSync: changes.length === 0,
|
|
35436
|
+
changes
|
|
35437
|
+
}
|
|
35438
|
+
};
|
|
35439
|
+
}
|
|
35440
|
+
|
|
33983
35441
|
//#endregion
|
|
33984
35442
|
//#region src/mcp/utils.ts
|
|
33985
35443
|
/**
|
|
@@ -34049,6 +35507,10 @@ function mcpError(error, summary) {
|
|
|
34049
35507
|
/**
|
|
34050
35508
|
* Phase CRUD MCP Tools — Phase operations exposed as MCP tools
|
|
34051
35509
|
*
|
|
35510
|
+
* Integrates with GitHub: phase completion triggers sync check, issue close,
|
|
35511
|
+
* board move to Done, and milestone completion check. Find/list enrich
|
|
35512
|
+
* responses with GitHub issue data when available.
|
|
35513
|
+
*
|
|
34052
35514
|
* CRITICAL: Never import output() or error() from core — they call process.exit().
|
|
34053
35515
|
* CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
|
|
34054
35516
|
* CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
|
|
@@ -34063,6 +35525,31 @@ function registerPhaseTools(server) {
|
|
|
34063
35525
|
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
34064
35526
|
const result = findPhaseInternal(cwd, phase);
|
|
34065
35527
|
if (!result) return mcpError(`Phase ${phase} not found`, "Phase not found");
|
|
35528
|
+
let githubTracking = null;
|
|
35529
|
+
let githubTaskIssues = null;
|
|
35530
|
+
let githubWarning;
|
|
35531
|
+
try {
|
|
35532
|
+
const mapping = loadMapping(cwd);
|
|
35533
|
+
if (mapping && result.phase_number) {
|
|
35534
|
+
const phaseMapping = mapping.phases[result.phase_number];
|
|
35535
|
+
if (phaseMapping) {
|
|
35536
|
+
if (phaseMapping.tracking_issue.number > 0) githubTracking = {
|
|
35537
|
+
number: phaseMapping.tracking_issue.number,
|
|
35538
|
+
status: phaseMapping.tracking_issue.status
|
|
35539
|
+
};
|
|
35540
|
+
const taskEntries = Object.entries(phaseMapping.tasks);
|
|
35541
|
+
if (taskEntries.length > 0) {
|
|
35542
|
+
githubTaskIssues = {};
|
|
35543
|
+
for (const [taskId, task] of taskEntries) if (task.number > 0) githubTaskIssues[taskId] = {
|
|
35544
|
+
number: task.number,
|
|
35545
|
+
status: task.status
|
|
35546
|
+
};
|
|
35547
|
+
}
|
|
35548
|
+
}
|
|
35549
|
+
}
|
|
35550
|
+
} catch (e) {
|
|
35551
|
+
githubWarning = `GitHub data enrichment failed: ${e.message}`;
|
|
35552
|
+
}
|
|
34066
35553
|
return mcpSuccess({
|
|
34067
35554
|
found: result.found,
|
|
34068
35555
|
directory: result.directory,
|
|
@@ -34075,7 +35562,10 @@ function registerPhaseTools(server) {
|
|
|
34075
35562
|
has_research: result.has_research,
|
|
34076
35563
|
has_context: result.has_context,
|
|
34077
35564
|
has_verification: result.has_verification,
|
|
34078
|
-
archived: result.archived ?? null
|
|
35565
|
+
archived: result.archived ?? null,
|
|
35566
|
+
github_tracking_issue: githubTracking,
|
|
35567
|
+
github_task_issues: githubTaskIssues,
|
|
35568
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
34079
35569
|
}, `Found phase ${result.phase_number}: ${result.phase_name ?? "unnamed"}`);
|
|
34080
35570
|
} catch (e) {
|
|
34081
35571
|
return mcpError(e.message, "Operation failed");
|
|
@@ -34107,13 +35597,37 @@ function registerPhaseTools(server) {
|
|
|
34107
35597
|
const total_count = dirs.length;
|
|
34108
35598
|
const paginated = dirs.slice(offset, offset + limit);
|
|
34109
35599
|
const has_more = offset + limit < total_count;
|
|
35600
|
+
let githubIssueCounts = null;
|
|
35601
|
+
let githubWarning;
|
|
35602
|
+
try {
|
|
35603
|
+
if (await detectGitHubMode() === "full") {
|
|
35604
|
+
const mapping = loadMapping(cwd);
|
|
35605
|
+
if (mapping && Object.keys(mapping.phases).length > 0) {
|
|
35606
|
+
githubIssueCounts = {};
|
|
35607
|
+
for (const [phaseNum, phaseData] of Object.entries(mapping.phases)) {
|
|
35608
|
+
let open = 0;
|
|
35609
|
+
let closed = 0;
|
|
35610
|
+
for (const task of Object.values(phaseData.tasks)) if (task.number > 0) if (task.status === "Done") closed++;
|
|
35611
|
+
else open++;
|
|
35612
|
+
githubIssueCounts[phaseNum] = {
|
|
35613
|
+
open,
|
|
35614
|
+
closed
|
|
35615
|
+
};
|
|
35616
|
+
}
|
|
35617
|
+
}
|
|
35618
|
+
}
|
|
35619
|
+
} catch (e) {
|
|
35620
|
+
githubWarning = `GitHub enrichment failed: ${e.message}`;
|
|
35621
|
+
}
|
|
34110
35622
|
return mcpSuccess({
|
|
34111
35623
|
directories: paginated,
|
|
34112
35624
|
count: paginated.length,
|
|
34113
35625
|
total_count,
|
|
34114
35626
|
offset,
|
|
34115
35627
|
limit,
|
|
34116
|
-
has_more
|
|
35628
|
+
has_more,
|
|
35629
|
+
github_issue_counts: githubIssueCounts,
|
|
35630
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
34117
35631
|
}, `Showing ${paginated.length} of ${total_count} phase(s)`);
|
|
34118
35632
|
} catch (e) {
|
|
34119
35633
|
return mcpError(e.message, "Operation failed");
|
|
@@ -34160,7 +35674,50 @@ function registerPhaseTools(server) {
|
|
|
34160
35674
|
try {
|
|
34161
35675
|
const cwd = detectProjectRoot();
|
|
34162
35676
|
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
35677
|
+
let syncDiscrepancies = [];
|
|
35678
|
+
let githubWarning;
|
|
35679
|
+
try {
|
|
35680
|
+
if (await detectGitHubMode() === "full") {
|
|
35681
|
+
const syncResult = await syncCheck(cwd);
|
|
35682
|
+
if (syncResult.ok && !syncResult.data.inSync) syncDiscrepancies = syncResult.data.changes;
|
|
35683
|
+
}
|
|
35684
|
+
} catch (e) {
|
|
35685
|
+
githubWarning = `Sync check failed: ${e.message}`;
|
|
35686
|
+
}
|
|
34163
35687
|
const result = await phaseCompleteCore(cwd, phase);
|
|
35688
|
+
let githubClosed = false;
|
|
35689
|
+
let milestoneClosed = false;
|
|
35690
|
+
try {
|
|
35691
|
+
if (await detectGitHubMode() === "full") {
|
|
35692
|
+
const mapping = loadMapping(cwd);
|
|
35693
|
+
if (mapping) {
|
|
35694
|
+
const phaseMapping = mapping.phases[phase];
|
|
35695
|
+
if (phaseMapping) {
|
|
35696
|
+
if (phaseMapping.tracking_issue.number > 0) {
|
|
35697
|
+
if ((await closeIssue(phaseMapping.tracking_issue.number, "completed")).ok) {
|
|
35698
|
+
githubClosed = true;
|
|
35699
|
+
phaseMapping.tracking_issue.status = "Done";
|
|
35700
|
+
if (phaseMapping.tracking_issue.item_id && mapping.status_field_id && mapping.status_options["Done"]) await moveItemToStatus(mapping.project_id, phaseMapping.tracking_issue.item_id, mapping.status_field_id, mapping.status_options["Done"]);
|
|
35701
|
+
}
|
|
35702
|
+
}
|
|
35703
|
+
for (const [_taskId, task] of Object.entries(phaseMapping.tasks)) if (task.number > 0 && task.status !== "Done") {
|
|
35704
|
+
if ((await closeIssue(task.number, "completed")).ok) {
|
|
35705
|
+
task.status = "Done";
|
|
35706
|
+
if (task.item_id && mapping.status_field_id && mapping.status_options["Done"]) await moveItemToStatus(mapping.project_id, task.item_id, mapping.status_field_id, mapping.status_options["Done"]);
|
|
35707
|
+
if (phaseMapping.tracking_issue.number > 0) await updateParentTaskList(phaseMapping.tracking_issue.number, task.number, true);
|
|
35708
|
+
}
|
|
35709
|
+
}
|
|
35710
|
+
saveMapping(cwd, mapping);
|
|
35711
|
+
if (mapping.milestone_id > 0) {
|
|
35712
|
+
const msResult = await closeMilestoneIfComplete(mapping.milestone_id);
|
|
35713
|
+
if (msResult.ok) milestoneClosed = msResult.data.closed;
|
|
35714
|
+
}
|
|
35715
|
+
}
|
|
35716
|
+
}
|
|
35717
|
+
}
|
|
35718
|
+
} catch (e) {
|
|
35719
|
+
githubWarning = (githubWarning ? githubWarning + "; " : "") + `GitHub completion operations failed: ${e.message}`;
|
|
35720
|
+
}
|
|
34164
35721
|
return mcpSuccess({
|
|
34165
35722
|
completed_phase: result.completed_phase,
|
|
34166
35723
|
phase_name: result.phase_name,
|
|
@@ -34170,12 +35727,118 @@ function registerPhaseTools(server) {
|
|
|
34170
35727
|
is_last_phase: result.is_last_phase,
|
|
34171
35728
|
date: result.date,
|
|
34172
35729
|
roadmap_updated: result.roadmap_updated,
|
|
34173
|
-
state_updated: result.state_updated
|
|
35730
|
+
state_updated: result.state_updated,
|
|
35731
|
+
sync_discrepancies: syncDiscrepancies.length > 0 ? syncDiscrepancies : null,
|
|
35732
|
+
github_closed: githubClosed,
|
|
35733
|
+
milestone_closed: milestoneClosed,
|
|
35734
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
34174
35735
|
}, `Phase ${phase} marked as complete${result.next_phase ? `, next: Phase ${result.next_phase}` : ""}`);
|
|
34175
35736
|
} catch (e) {
|
|
34176
35737
|
return mcpError(e.message, "Operation failed");
|
|
34177
35738
|
}
|
|
34178
35739
|
});
|
|
35740
|
+
server.tool("mcp_bounce_issue", "Bounce a task back from In Review to In Progress with a detailed comment explaining what failed. Implements reviewer feedback loop (AC-05).", {
|
|
35741
|
+
issue_number: numberType().describe("GitHub issue number to bounce back"),
|
|
35742
|
+
reason: stringType().describe("Detailed reason why the task is being bounced back (reviewer feedback)")
|
|
35743
|
+
}, async ({ issue_number, reason }) => {
|
|
35744
|
+
try {
|
|
35745
|
+
const cwd = detectProjectRoot();
|
|
35746
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
35747
|
+
if (await detectGitHubMode() === "local-only") {
|
|
35748
|
+
const mapping = loadMapping(cwd);
|
|
35749
|
+
if (mapping) {
|
|
35750
|
+
if (updateLocalMappingStatus$1(mapping, issue_number, "In Progress")) {
|
|
35751
|
+
saveMapping(cwd, mapping);
|
|
35752
|
+
return mcpSuccess({
|
|
35753
|
+
mode: "local-only",
|
|
35754
|
+
issue_number,
|
|
35755
|
+
status: "In Progress",
|
|
35756
|
+
local_updated: true,
|
|
35757
|
+
reason
|
|
35758
|
+
}, `Local-only: issue #${issue_number} bounced to In Progress (reason recorded locally)`);
|
|
35759
|
+
}
|
|
35760
|
+
}
|
|
35761
|
+
return mcpSuccess({
|
|
35762
|
+
mode: "local-only",
|
|
35763
|
+
issue_number,
|
|
35764
|
+
reason,
|
|
35765
|
+
note: "Bounce recorded locally. GitHub operations skipped."
|
|
35766
|
+
}, `Local-only: bounce for issue #${issue_number} recorded`);
|
|
35767
|
+
}
|
|
35768
|
+
const mapping = loadMapping(cwd);
|
|
35769
|
+
let githubWarning;
|
|
35770
|
+
let moved = false;
|
|
35771
|
+
let commented = false;
|
|
35772
|
+
try {
|
|
35773
|
+
const commentResult = await postComment(issue_number, `## Bounced Back to In Progress\n\n**Reason:** ${reason}\n\n---\n*Review feedback posted by MAXSIM*`);
|
|
35774
|
+
commented = commentResult.ok;
|
|
35775
|
+
if (!commentResult.ok) githubWarning = `Comment failed: ${commentResult.error}`;
|
|
35776
|
+
} catch (e) {
|
|
35777
|
+
githubWarning = `Comment failed: ${e.message}`;
|
|
35778
|
+
}
|
|
35779
|
+
try {
|
|
35780
|
+
if (mapping) {
|
|
35781
|
+
const issueEntry = findIssueInMapping$2(mapping, issue_number);
|
|
35782
|
+
if (issueEntry?.item_id && mapping.status_field_id && mapping.status_options["In Progress"]) {
|
|
35783
|
+
const moveResult = await moveItemToStatus(mapping.project_id, issueEntry.item_id, mapping.status_field_id, mapping.status_options["In Progress"]);
|
|
35784
|
+
moved = moveResult.ok;
|
|
35785
|
+
if (!moveResult.ok) githubWarning = (githubWarning ? githubWarning + "; " : "") + `Board move failed: ${moveResult.error}`;
|
|
35786
|
+
}
|
|
35787
|
+
updateLocalMappingStatus$1(mapping, issue_number, "In Progress");
|
|
35788
|
+
saveMapping(cwd, mapping);
|
|
35789
|
+
}
|
|
35790
|
+
} catch (e) {
|
|
35791
|
+
githubWarning = (githubWarning ? githubWarning + "; " : "") + `Board move failed: ${e.message}`;
|
|
35792
|
+
}
|
|
35793
|
+
return mcpSuccess({
|
|
35794
|
+
mode: "full",
|
|
35795
|
+
issue_number,
|
|
35796
|
+
status: "In Progress",
|
|
35797
|
+
commented,
|
|
35798
|
+
moved,
|
|
35799
|
+
reason,
|
|
35800
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
35801
|
+
}, `Issue #${issue_number} bounced to In Progress${commented ? " with feedback comment" : ""}`);
|
|
35802
|
+
} catch (e) {
|
|
35803
|
+
return mcpError(e.message, "Operation failed");
|
|
35804
|
+
}
|
|
35805
|
+
});
|
|
35806
|
+
}
|
|
35807
|
+
/**
|
|
35808
|
+
* Find an issue entry in the mapping file (searches phases and todos).
|
|
35809
|
+
*/
|
|
35810
|
+
function findIssueInMapping$2(mapping, issueNumber) {
|
|
35811
|
+
for (const phase of Object.values(mapping.phases)) {
|
|
35812
|
+
if (phase.tracking_issue.number === issueNumber) return phase.tracking_issue;
|
|
35813
|
+
for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) return task;
|
|
35814
|
+
}
|
|
35815
|
+
if (mapping.todos) {
|
|
35816
|
+
for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) return todo;
|
|
35817
|
+
}
|
|
35818
|
+
return null;
|
|
35819
|
+
}
|
|
35820
|
+
/**
|
|
35821
|
+
* Update local mapping status for an issue (mutates mapping in-place).
|
|
35822
|
+
* Returns true if the issue was found and updated.
|
|
35823
|
+
*/
|
|
35824
|
+
function updateLocalMappingStatus$1(mapping, issueNumber, status) {
|
|
35825
|
+
for (const phase of Object.values(mapping.phases)) {
|
|
35826
|
+
if (phase.tracking_issue.number === issueNumber) {
|
|
35827
|
+
phase.tracking_issue.status = status;
|
|
35828
|
+
return true;
|
|
35829
|
+
}
|
|
35830
|
+
for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) {
|
|
35831
|
+
task.status = status;
|
|
35832
|
+
return true;
|
|
35833
|
+
}
|
|
35834
|
+
}
|
|
35835
|
+
if (mapping.todos) {
|
|
35836
|
+
for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) {
|
|
35837
|
+
todo.status = status;
|
|
35838
|
+
return true;
|
|
35839
|
+
}
|
|
35840
|
+
}
|
|
35841
|
+
return false;
|
|
34179
35842
|
}
|
|
34180
35843
|
|
|
34181
35844
|
//#endregion
|
|
@@ -34203,6 +35866,10 @@ function parseTodoFrontmatter(content) {
|
|
|
34203
35866
|
/**
|
|
34204
35867
|
* Todo CRUD MCP Tools — Todo operations exposed as MCP tools
|
|
34205
35868
|
*
|
|
35869
|
+
* Integrates with GitHub: todo add creates GitHub issue in 'full' mode,
|
|
35870
|
+
* todo complete closes GitHub issue and moves to Done on board,
|
|
35871
|
+
* todo list enriches with GitHub issue data when available.
|
|
35872
|
+
*
|
|
34206
35873
|
* CRITICAL: Never import output() or error() from core — they call process.exit().
|
|
34207
35874
|
* CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
|
|
34208
35875
|
* CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
|
|
@@ -34228,12 +35895,55 @@ function registerTodoTools(server) {
|
|
|
34228
35895
|
const filePath = node_path.default.join(pendingDir, filename);
|
|
34229
35896
|
const content = `---\ncreated: ${today}\ntitle: ${title}\narea: ${area || "general"}\nphase: ${phase || "unassigned"}\n---\n${description || ""}\n`;
|
|
34230
35897
|
node_fs.default.writeFileSync(filePath, content, "utf-8");
|
|
35898
|
+
let githubIssueNumber = null;
|
|
35899
|
+
let githubIssueUrl = null;
|
|
35900
|
+
let githubWarning;
|
|
35901
|
+
try {
|
|
35902
|
+
if (await detectGitHubMode() === "full") {
|
|
35903
|
+
const mapping = loadMapping(cwd);
|
|
35904
|
+
const issueResult = await createTodoIssue({
|
|
35905
|
+
title,
|
|
35906
|
+
description: description || void 0,
|
|
35907
|
+
milestone: mapping?.milestone_title || void 0
|
|
35908
|
+
});
|
|
35909
|
+
if (issueResult.ok) {
|
|
35910
|
+
githubIssueNumber = issueResult.data.number;
|
|
35911
|
+
githubIssueUrl = issueResult.data.url;
|
|
35912
|
+
if (mapping && mapping.project_number > 0) {
|
|
35913
|
+
const issueUrl = `https://github.com/${mapping.repo}/issues/${issueResult.data.number}`;
|
|
35914
|
+
const addResult = await addItemToProject(mapping.project_number, issueUrl);
|
|
35915
|
+
if (addResult.ok) {
|
|
35916
|
+
updateTodoMapping(cwd, filename, {
|
|
35917
|
+
number: issueResult.data.number,
|
|
35918
|
+
node_id: issueResult.data.node_id,
|
|
35919
|
+
item_id: addResult.data.item_id,
|
|
35920
|
+
status: "To Do"
|
|
35921
|
+
});
|
|
35922
|
+
if (mapping.status_field_id && mapping.status_options["To Do"]) await moveItemToStatus(mapping.project_id, addResult.data.item_id, mapping.status_field_id, mapping.status_options["To Do"]);
|
|
35923
|
+
} else {
|
|
35924
|
+
updateTodoMapping(cwd, filename, {
|
|
35925
|
+
number: issueResult.data.number,
|
|
35926
|
+
node_id: issueResult.data.node_id,
|
|
35927
|
+
item_id: "",
|
|
35928
|
+
status: "To Do"
|
|
35929
|
+
});
|
|
35930
|
+
githubWarning = `Issue created but board add failed: ${addResult.error}`;
|
|
35931
|
+
}
|
|
35932
|
+
} else githubWarning = "Issue created but no project board configured for board tracking.";
|
|
35933
|
+
} else githubWarning = `GitHub issue creation failed: ${issueResult.error}`;
|
|
35934
|
+
}
|
|
35935
|
+
} catch (e) {
|
|
35936
|
+
githubWarning = `GitHub operation failed: ${e.message}`;
|
|
35937
|
+
}
|
|
34231
35938
|
return mcpSuccess({
|
|
34232
35939
|
file: filename,
|
|
34233
35940
|
path: `.planning/todos/pending/${filename}`,
|
|
34234
35941
|
title,
|
|
34235
|
-
area: area || "general"
|
|
34236
|
-
|
|
35942
|
+
area: area || "general",
|
|
35943
|
+
github_issue_number: githubIssueNumber,
|
|
35944
|
+
github_issue_url: githubIssueUrl,
|
|
35945
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
35946
|
+
}, `Todo created: ${title}${githubIssueNumber ? ` (GitHub #${githubIssueNumber})` : ""}`);
|
|
34237
35947
|
} catch (e) {
|
|
34238
35948
|
return mcpError(e.message, "Operation failed");
|
|
34239
35949
|
}
|
|
@@ -34252,11 +35962,33 @@ function registerTodoTools(server) {
|
|
|
34252
35962
|
content = `completed: ${today}\n` + content;
|
|
34253
35963
|
node_fs.default.writeFileSync(node_path.default.join(completedDir, todo_id), content, "utf-8");
|
|
34254
35964
|
node_fs.default.unlinkSync(sourcePath);
|
|
35965
|
+
let githubClosed = false;
|
|
35966
|
+
let githubWarning;
|
|
35967
|
+
try {
|
|
35968
|
+
if (await detectGitHubMode() === "full") {
|
|
35969
|
+
const mapping = loadMapping(cwd);
|
|
35970
|
+
if (mapping?.todos?.[todo_id]) {
|
|
35971
|
+
const todoMapping = mapping.todos[todo_id];
|
|
35972
|
+
if (todoMapping.number > 0) {
|
|
35973
|
+
const closeResult = await closeIssue(todoMapping.number, "completed");
|
|
35974
|
+
githubClosed = closeResult.ok;
|
|
35975
|
+
if (!closeResult.ok) githubWarning = `GitHub issue close failed: ${closeResult.error}`;
|
|
35976
|
+
if (todoMapping.item_id && mapping.status_field_id && mapping.status_options["Done"]) await moveItemToStatus(mapping.project_id, todoMapping.item_id, mapping.status_field_id, mapping.status_options["Done"]);
|
|
35977
|
+
todoMapping.status = "Done";
|
|
35978
|
+
saveMapping(cwd, mapping);
|
|
35979
|
+
}
|
|
35980
|
+
}
|
|
35981
|
+
}
|
|
35982
|
+
} catch (e) {
|
|
35983
|
+
githubWarning = `GitHub operation failed: ${e.message}`;
|
|
35984
|
+
}
|
|
34255
35985
|
return mcpSuccess({
|
|
34256
35986
|
completed: true,
|
|
34257
35987
|
file: todo_id,
|
|
34258
|
-
date: today
|
|
34259
|
-
|
|
35988
|
+
date: today,
|
|
35989
|
+
github_closed: githubClosed,
|
|
35990
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
35991
|
+
}, `Todo completed: ${todo_id}${githubClosed ? " (GitHub issue closed)" : ""}`);
|
|
34260
35992
|
} catch (e) {
|
|
34261
35993
|
return mcpError(e.message, "Operation failed");
|
|
34262
35994
|
}
|
|
@@ -34277,6 +36009,22 @@ function registerTodoTools(server) {
|
|
|
34277
36009
|
if (status === "pending" || status === "all") dirs.push(node_path.default.join(todosBase, "pending"));
|
|
34278
36010
|
if (status === "completed" || status === "all") dirs.push(node_path.default.join(todosBase, "completed"));
|
|
34279
36011
|
const todos = [];
|
|
36012
|
+
let todoMappings = null;
|
|
36013
|
+
let githubWarning;
|
|
36014
|
+
try {
|
|
36015
|
+
if (await detectGitHubMode() === "full") {
|
|
36016
|
+
const mapping = loadMapping(cwd);
|
|
36017
|
+
if (mapping?.todos) {
|
|
36018
|
+
todoMappings = {};
|
|
36019
|
+
for (const [todoId, data] of Object.entries(mapping.todos)) if (data.number > 0) todoMappings[todoId] = {
|
|
36020
|
+
number: data.number,
|
|
36021
|
+
status: data.status
|
|
36022
|
+
};
|
|
36023
|
+
}
|
|
36024
|
+
}
|
|
36025
|
+
} catch (e) {
|
|
36026
|
+
githubWarning = `GitHub enrichment failed: ${e.message}`;
|
|
36027
|
+
}
|
|
34280
36028
|
for (const dir of dirs) {
|
|
34281
36029
|
const dirStatus = dir.endsWith("pending") ? "pending" : "completed";
|
|
34282
36030
|
let files = [];
|
|
@@ -34288,19 +36036,25 @@ function registerTodoTools(server) {
|
|
|
34288
36036
|
for (const file of files) try {
|
|
34289
36037
|
const fm = parseTodoFrontmatter(node_fs.default.readFileSync(node_path.default.join(dir, file), "utf-8"));
|
|
34290
36038
|
if (area && fm.area !== area) continue;
|
|
34291
|
-
|
|
36039
|
+
const todoEntry = {
|
|
34292
36040
|
file,
|
|
34293
36041
|
created: fm.created,
|
|
34294
36042
|
title: fm.title,
|
|
34295
36043
|
area: fm.area,
|
|
34296
36044
|
status: dirStatus,
|
|
34297
36045
|
path: `.planning/todos/${dirStatus}/${file}`
|
|
34298
|
-
}
|
|
36046
|
+
};
|
|
36047
|
+
if (todoMappings?.[file]) {
|
|
36048
|
+
todoEntry.github_issue_number = todoMappings[file].number;
|
|
36049
|
+
todoEntry.github_status = todoMappings[file].status;
|
|
36050
|
+
}
|
|
36051
|
+
todos.push(todoEntry);
|
|
34299
36052
|
} catch {}
|
|
34300
36053
|
}
|
|
34301
36054
|
return mcpSuccess({
|
|
34302
36055
|
count: todos.length,
|
|
34303
|
-
todos
|
|
36056
|
+
todos,
|
|
36057
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
34304
36058
|
}, `${todos.length} todos found`);
|
|
34305
36059
|
} catch (e) {
|
|
34306
36060
|
return mcpError(e.message, "Operation failed");
|
|
@@ -34362,11 +36116,29 @@ function appendToStateSection(content, sectionPattern, entry, placeholderPattern
|
|
|
34362
36116
|
/**
|
|
34363
36117
|
* State Management MCP Tools — STATE.md operations exposed as MCP tools
|
|
34364
36118
|
*
|
|
36119
|
+
* Integrates with GitHub: blocker add/resolve uses best-effort GitHub
|
|
36120
|
+
* issue linking when blocker text references issue numbers.
|
|
36121
|
+
*
|
|
34365
36122
|
* CRITICAL: Never import output() or error() from core — they call process.exit().
|
|
34366
36123
|
* CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
|
|
34367
36124
|
* CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
|
|
34368
36125
|
*/
|
|
34369
36126
|
/**
|
|
36127
|
+
* Extract GitHub issue numbers from text.
|
|
36128
|
+
*
|
|
36129
|
+
* Matches patterns like "#42", "issue 42", "issue #42", "blocked by #42".
|
|
36130
|
+
* Returns unique issue numbers found.
|
|
36131
|
+
*/
|
|
36132
|
+
function extractIssueNumbers(text) {
|
|
36133
|
+
const matches = text.matchAll(/#(\d+)|issue\s+#?(\d+)/gi);
|
|
36134
|
+
const numbers = /* @__PURE__ */ new Set();
|
|
36135
|
+
for (const match of matches) {
|
|
36136
|
+
const num = parseInt(match[1] || match[2], 10);
|
|
36137
|
+
if (!Number.isNaN(num) && num > 0) numbers.add(num);
|
|
36138
|
+
}
|
|
36139
|
+
return Array.from(numbers);
|
|
36140
|
+
}
|
|
36141
|
+
/**
|
|
34370
36142
|
* Register all state management tools on the MCP server.
|
|
34371
36143
|
*/
|
|
34372
36144
|
function registerStateTools(server) {
|
|
@@ -34448,10 +36220,24 @@ function registerStateTools(server) {
|
|
|
34448
36220
|
const updated = appendToStateSection(node_fs.default.readFileSync(stPath, "utf-8"), /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i, `- ${text}`, [/None\.?\s*\n?/gi, /None yet\.?\s*\n?/gi]);
|
|
34449
36221
|
if (!updated) return mcpError("Blockers section not found in STATE.md", "Section not found");
|
|
34450
36222
|
node_fs.default.writeFileSync(stPath, updated, "utf-8");
|
|
36223
|
+
let githubLinked = [];
|
|
36224
|
+
let githubWarning;
|
|
36225
|
+
try {
|
|
36226
|
+
if (await detectGitHubMode() === "full") {
|
|
36227
|
+
const issueNumbers = extractIssueNumbers(text);
|
|
36228
|
+
if (issueNumbers.length > 0) {
|
|
36229
|
+
for (const issueNum of issueNumbers) if ((await postComment(issueNum, `**Blocker added in MAXSIM:**\n\n${text}\n\n---\n*Posted by MAXSIM blocker tracking*`)).ok) githubLinked.push(issueNum);
|
|
36230
|
+
}
|
|
36231
|
+
}
|
|
36232
|
+
} catch (e) {
|
|
36233
|
+
githubWarning = `GitHub linking failed: ${e.message}`;
|
|
36234
|
+
}
|
|
34451
36235
|
return mcpSuccess({
|
|
34452
36236
|
added: true,
|
|
34453
|
-
blocker: text
|
|
34454
|
-
|
|
36237
|
+
blocker: text,
|
|
36238
|
+
github_linked_issues: githubLinked.length > 0 ? githubLinked : null,
|
|
36239
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
36240
|
+
}, `Blocker added${githubLinked.length > 0 ? ` (linked to ${githubLinked.map((n) => `#${n}`).join(", ")})` : ""}`);
|
|
34455
36241
|
} catch (e) {
|
|
34456
36242
|
return mcpError(e.message, "Operation failed");
|
|
34457
36243
|
}
|
|
@@ -34466,17 +36252,37 @@ function registerStateTools(server) {
|
|
|
34466
36252
|
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
|
|
34467
36253
|
const match = content.match(sectionPattern);
|
|
34468
36254
|
if (!match) return mcpError("Blockers section not found in STATE.md", "Section not found");
|
|
34469
|
-
|
|
36255
|
+
const lines = match[2].split("\n");
|
|
36256
|
+
const matchingLines = [];
|
|
36257
|
+
let newBody = lines.filter((line) => {
|
|
34470
36258
|
if (!line.startsWith("- ")) return true;
|
|
34471
|
-
|
|
36259
|
+
if (line.toLowerCase().includes(text.toLowerCase())) {
|
|
36260
|
+
matchingLines.push(line);
|
|
36261
|
+
return false;
|
|
36262
|
+
}
|
|
36263
|
+
return true;
|
|
34472
36264
|
}).join("\n");
|
|
34473
36265
|
if (!newBody.trim() || !newBody.includes("- ")) newBody = "None\n";
|
|
34474
36266
|
content = content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
|
|
34475
36267
|
node_fs.default.writeFileSync(stPath, content, "utf-8");
|
|
36268
|
+
let githubCommented = [];
|
|
36269
|
+
let githubWarning;
|
|
36270
|
+
try {
|
|
36271
|
+
if (await detectGitHubMode() === "full") {
|
|
36272
|
+
const issueNumbers = extractIssueNumbers(matchingLines.join(" ") + " " + text);
|
|
36273
|
+
if (issueNumbers.length > 0) {
|
|
36274
|
+
for (const issueNum of issueNumbers) if ((await postComment(issueNum, `**Blocker resolved in MAXSIM:**\n\nResolved blocker matching: "${text}"\n\n---\n*Posted by MAXSIM blocker tracking*`)).ok) githubCommented.push(issueNum);
|
|
36275
|
+
}
|
|
36276
|
+
}
|
|
36277
|
+
} catch (e) {
|
|
36278
|
+
githubWarning = `GitHub comment failed: ${e.message}`;
|
|
36279
|
+
}
|
|
34476
36280
|
return mcpSuccess({
|
|
34477
36281
|
resolved: true,
|
|
34478
|
-
blocker: text
|
|
34479
|
-
|
|
36282
|
+
blocker: text,
|
|
36283
|
+
github_commented_issues: githubCommented.length > 0 ? githubCommented : null,
|
|
36284
|
+
...githubWarning ? { github_warning: githubWarning } : {}
|
|
36285
|
+
}, `Blocker resolved${githubCommented.length > 0 ? ` (commented on ${githubCommented.map((n) => `#${n}`).join(", ")})` : ""}`);
|
|
34480
36286
|
} catch (e) {
|
|
34481
36287
|
return mcpError(e.message, "Operation failed");
|
|
34482
36288
|
}
|
|
@@ -35031,6 +36837,897 @@ function registerConfigTools(server) {
|
|
|
35031
36837
|
});
|
|
35032
36838
|
}
|
|
35033
36839
|
|
|
36840
|
+
//#endregion
|
|
36841
|
+
//#region src/github/types.ts
|
|
36842
|
+
const MAXSIM_LABELS = [
|
|
36843
|
+
{
|
|
36844
|
+
name: "maxsim",
|
|
36845
|
+
color: "6f42c1",
|
|
36846
|
+
description: "MAXSIM managed issue"
|
|
36847
|
+
},
|
|
36848
|
+
{
|
|
36849
|
+
name: "phase-task",
|
|
36850
|
+
color: "0075ca",
|
|
36851
|
+
description: "MAXSIM phase task"
|
|
36852
|
+
},
|
|
36853
|
+
{
|
|
36854
|
+
name: "todo",
|
|
36855
|
+
color: "fbca04",
|
|
36856
|
+
description: "MAXSIM todo item"
|
|
36857
|
+
},
|
|
36858
|
+
{
|
|
36859
|
+
name: "imported",
|
|
36860
|
+
color: "e4e669",
|
|
36861
|
+
description: "Imported into MAXSIM tracking"
|
|
36862
|
+
},
|
|
36863
|
+
{
|
|
36864
|
+
name: "superseded",
|
|
36865
|
+
color: "d73a4a",
|
|
36866
|
+
description: "Superseded by newer plan"
|
|
36867
|
+
}
|
|
36868
|
+
];
|
|
36869
|
+
const FIBONACCI_POINTS = [
|
|
36870
|
+
1,
|
|
36871
|
+
2,
|
|
36872
|
+
3,
|
|
36873
|
+
5,
|
|
36874
|
+
8,
|
|
36875
|
+
13,
|
|
36876
|
+
21,
|
|
36877
|
+
34
|
|
36878
|
+
];
|
|
36879
|
+
|
|
36880
|
+
//#endregion
|
|
36881
|
+
//#region src/github/labels.ts
|
|
36882
|
+
/**
|
|
36883
|
+
* Ensure all MAXSIM labels exist on the repository.
|
|
36884
|
+
*
|
|
36885
|
+
* Iterates over MAXSIM_LABELS and runs `gh label create` with `--force`
|
|
36886
|
+
* for each label. The `--force` flag updates existing labels with the
|
|
36887
|
+
* specified color and description.
|
|
36888
|
+
*
|
|
36889
|
+
* Continues on individual label failures (logs to stderr).
|
|
36890
|
+
* Only fails if ALL labels fail to create.
|
|
36891
|
+
*/
|
|
36892
|
+
async function ensureLabels() {
|
|
36893
|
+
let successCount = 0;
|
|
36894
|
+
const errors = [];
|
|
36895
|
+
for (const label of MAXSIM_LABELS) {
|
|
36896
|
+
const result = await ghExec([
|
|
36897
|
+
"label",
|
|
36898
|
+
"create",
|
|
36899
|
+
label.name,
|
|
36900
|
+
"--color",
|
|
36901
|
+
label.color,
|
|
36902
|
+
"--description",
|
|
36903
|
+
label.description,
|
|
36904
|
+
"--force"
|
|
36905
|
+
]);
|
|
36906
|
+
if (result.ok) successCount++;
|
|
36907
|
+
else {
|
|
36908
|
+
const errMsg = result.error;
|
|
36909
|
+
console.error(`[maxsim] Failed to create label "${label.name}": ${errMsg}`);
|
|
36910
|
+
errors.push(`${label.name}: ${errMsg}`);
|
|
36911
|
+
}
|
|
36912
|
+
}
|
|
36913
|
+
if (successCount === 0 && errors.length > 0) return {
|
|
36914
|
+
ok: false,
|
|
36915
|
+
error: `All labels failed to create: ${errors.join("; ")}`,
|
|
36916
|
+
code: "UNKNOWN"
|
|
36917
|
+
};
|
|
36918
|
+
return {
|
|
36919
|
+
ok: true,
|
|
36920
|
+
data: void 0
|
|
36921
|
+
};
|
|
36922
|
+
}
|
|
36923
|
+
|
|
36924
|
+
//#endregion
|
|
36925
|
+
//#region src/github/templates.ts
|
|
36926
|
+
/**
|
|
36927
|
+
* GitHub Issue Templates — Template file generation
|
|
36928
|
+
*
|
|
36929
|
+
* Installs GitHub Issue Form YAML templates into `.github/ISSUE_TEMPLATE/`
|
|
36930
|
+
* for the MAXSIM-managed issue types: phase tasks and todos.
|
|
36931
|
+
*
|
|
36932
|
+
* These are file-system operations only (no gh CLI needed).
|
|
36933
|
+
* Uses synchronous fs to match existing core module patterns.
|
|
36934
|
+
*
|
|
36935
|
+
* CRITICAL: Never call process.exit().
|
|
36936
|
+
*/
|
|
36937
|
+
/**
|
|
36938
|
+
* Phase task issue template (GitHub Issue Forms YAML format).
|
|
36939
|
+
*
|
|
36940
|
+
* Used for issues created from MAXSIM phase plans.
|
|
36941
|
+
* Labels: maxsim, phase-task
|
|
36942
|
+
*/
|
|
36943
|
+
const PHASE_TASK_TEMPLATE = `name: "MAXSIM Phase Task"
|
|
36944
|
+
description: "Task generated by MAXSIM phase planning"
|
|
36945
|
+
labels: ["maxsim", "phase-task"]
|
|
36946
|
+
body:
|
|
36947
|
+
- type: markdown
|
|
36948
|
+
attributes:
|
|
36949
|
+
value: |
|
|
36950
|
+
This issue was auto-generated by MAXSIM.
|
|
36951
|
+
|
|
36952
|
+
- type: textarea
|
|
36953
|
+
id: summary
|
|
36954
|
+
attributes:
|
|
36955
|
+
label: Summary
|
|
36956
|
+
description: Task summary
|
|
36957
|
+
validations:
|
|
36958
|
+
required: true
|
|
36959
|
+
|
|
36960
|
+
- type: textarea
|
|
36961
|
+
id: spec
|
|
36962
|
+
attributes:
|
|
36963
|
+
label: Full Specification
|
|
36964
|
+
description: Detailed task specification including actions, criteria, and dependencies
|
|
36965
|
+
`;
|
|
36966
|
+
/**
|
|
36967
|
+
* Todo issue template (GitHub Issue Forms YAML format).
|
|
36968
|
+
*
|
|
36969
|
+
* Used for issues created from MAXSIM todo items.
|
|
36970
|
+
* Labels: maxsim, todo
|
|
36971
|
+
*/
|
|
36972
|
+
const TODO_TEMPLATE = `name: "MAXSIM Todo"
|
|
36973
|
+
description: "Todo item tracked by MAXSIM"
|
|
36974
|
+
labels: ["maxsim", "todo"]
|
|
36975
|
+
body:
|
|
36976
|
+
- type: textarea
|
|
36977
|
+
id: description
|
|
36978
|
+
attributes:
|
|
36979
|
+
label: Description
|
|
36980
|
+
description: Brief description of the todo item
|
|
36981
|
+
validations:
|
|
36982
|
+
required: true
|
|
36983
|
+
|
|
36984
|
+
- type: textarea
|
|
36985
|
+
id: acceptance
|
|
36986
|
+
attributes:
|
|
36987
|
+
label: Acceptance Criteria
|
|
36988
|
+
description: What defines "done" for this todo?
|
|
36989
|
+
`;
|
|
36990
|
+
/**
|
|
36991
|
+
* Install MAXSIM issue templates into the project's `.github/ISSUE_TEMPLATE/` directory.
|
|
36992
|
+
*
|
|
36993
|
+
* Creates the directory recursively if it does not exist.
|
|
36994
|
+
* Writes two YAML files:
|
|
36995
|
+
* - phase-task.yml (for phase plan tasks)
|
|
36996
|
+
* - todo.yml (for todo items)
|
|
36997
|
+
*
|
|
36998
|
+
* Overwrites existing templates if present (to ensure latest version).
|
|
36999
|
+
* This is a synchronous file write operation (no gh CLI needed).
|
|
37000
|
+
*/
|
|
37001
|
+
function installIssueTemplates(cwd) {
|
|
37002
|
+
const templateDir = node_path.default.join(cwd, ".github", "ISSUE_TEMPLATE");
|
|
37003
|
+
node_fs.default.mkdirSync(templateDir, { recursive: true });
|
|
37004
|
+
node_fs.default.writeFileSync(node_path.default.join(templateDir, "phase-task.yml"), PHASE_TASK_TEMPLATE, "utf-8");
|
|
37005
|
+
node_fs.default.writeFileSync(node_path.default.join(templateDir, "todo.yml"), TODO_TEMPLATE, "utf-8");
|
|
37006
|
+
}
|
|
37007
|
+
|
|
37008
|
+
//#endregion
|
|
37009
|
+
//#region src/mcp/github-tools.ts
|
|
37010
|
+
/**
|
|
37011
|
+
* GitHub Issue Lifecycle MCP Tools — GitHub operations exposed as MCP tools
|
|
37012
|
+
*
|
|
37013
|
+
* Provides MCP tools for issue CRUD, PR creation with auto-close linking (AC-08),
|
|
37014
|
+
* sync checking (AC-09), and issue import. Every tool checks detectGitHubMode()
|
|
37015
|
+
* and degrades gracefully to local-only behavior when GitHub is not configured.
|
|
37016
|
+
*
|
|
37017
|
+
* CRITICAL: Never import output() or error() from core — they call process.exit().
|
|
37018
|
+
* CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
|
|
37019
|
+
* CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
|
|
37020
|
+
*/
|
|
37021
|
+
/**
|
|
37022
|
+
* Register all GitHub issue lifecycle tools on the MCP server.
|
|
37023
|
+
*/
|
|
37024
|
+
function registerGitHubTools(server) {
|
|
37025
|
+
server.tool("mcp_github_setup", "Set up GitHub integration: create project board, labels, milestone, and issue templates.", { milestone_title: stringType().optional().describe("Milestone title (defaults to current milestone from STATE.md)") }, async ({ milestone_title }) => {
|
|
37026
|
+
try {
|
|
37027
|
+
const cwd = detectProjectRoot();
|
|
37028
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37029
|
+
if (await detectGitHubMode() === "local-only") {
|
|
37030
|
+
installIssueTemplates(cwd);
|
|
37031
|
+
return mcpSuccess({
|
|
37032
|
+
mode: "local-only",
|
|
37033
|
+
templates_installed: true,
|
|
37034
|
+
board_created: false,
|
|
37035
|
+
labels_created: false,
|
|
37036
|
+
milestone_created: false
|
|
37037
|
+
}, "Local-only mode: installed issue templates only. Run `gh auth login` with project scope for full GitHub integration.");
|
|
37038
|
+
}
|
|
37039
|
+
const boardResult = await ensureProjectBoard("MAXSIM Task Board", cwd);
|
|
37040
|
+
if (!boardResult.ok) return mcpError(`Board setup failed: ${boardResult.error}`, "Setup failed");
|
|
37041
|
+
const labelsResult = await ensureLabels();
|
|
37042
|
+
if (!labelsResult.ok) return mcpError(`Label setup failed: ${labelsResult.error}`, "Setup failed");
|
|
37043
|
+
let milestoneData = null;
|
|
37044
|
+
if (milestone_title) {
|
|
37045
|
+
const msResult = await ensureMilestone(milestone_title);
|
|
37046
|
+
if (msResult.ok) {
|
|
37047
|
+
milestoneData = msResult.data;
|
|
37048
|
+
const mapping = loadMapping(cwd);
|
|
37049
|
+
if (mapping) {
|
|
37050
|
+
mapping.milestone_id = msResult.data.number;
|
|
37051
|
+
mapping.milestone_title = milestone_title;
|
|
37052
|
+
saveMapping(cwd, mapping);
|
|
37053
|
+
}
|
|
37054
|
+
}
|
|
37055
|
+
}
|
|
37056
|
+
installIssueTemplates(cwd);
|
|
37057
|
+
return mcpSuccess({
|
|
37058
|
+
mode: "full",
|
|
37059
|
+
board: {
|
|
37060
|
+
number: boardResult.data.number,
|
|
37061
|
+
created: boardResult.data.created
|
|
37062
|
+
},
|
|
37063
|
+
labels_created: true,
|
|
37064
|
+
milestone: milestoneData ? {
|
|
37065
|
+
number: milestoneData.number,
|
|
37066
|
+
title: milestone_title,
|
|
37067
|
+
created: milestoneData.created
|
|
37068
|
+
} : null,
|
|
37069
|
+
templates_installed: true
|
|
37070
|
+
}, `GitHub integration set up: board #${boardResult.data.number}, labels, ${milestoneData ? `milestone "${milestone_title}"` : "no milestone"}, templates`);
|
|
37071
|
+
} catch (e) {
|
|
37072
|
+
return mcpError(e.message, "Operation failed");
|
|
37073
|
+
}
|
|
37074
|
+
});
|
|
37075
|
+
server.tool("mcp_create_plan_issues", "Create GitHub issues for all tasks in a finalized plan. Creates task issues and parent tracking issue.", {
|
|
37076
|
+
phase: stringType().describe("Phase number (e.g. \"01\")"),
|
|
37077
|
+
plan: stringType().describe("Plan number (e.g. \"01\")"),
|
|
37078
|
+
phase_name: stringType().describe("Phase description for the tracking issue title"),
|
|
37079
|
+
tasks: arrayType(objectType({
|
|
37080
|
+
taskId: stringType(),
|
|
37081
|
+
title: stringType(),
|
|
37082
|
+
summary: stringType(),
|
|
37083
|
+
actions: arrayType(stringType()),
|
|
37084
|
+
acceptanceCriteria: arrayType(stringType()),
|
|
37085
|
+
dependencies: arrayType(stringType()).optional(),
|
|
37086
|
+
estimate: numberType().optional()
|
|
37087
|
+
})).describe("Array of task objects to create issues for"),
|
|
37088
|
+
milestone: stringType().optional().describe("Milestone title to assign")
|
|
37089
|
+
}, async ({ phase, plan, phase_name, tasks, milestone }) => {
|
|
37090
|
+
try {
|
|
37091
|
+
const cwd = detectProjectRoot();
|
|
37092
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37093
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37094
|
+
mode: "local-only",
|
|
37095
|
+
warning: "GitHub not configured, issues not created",
|
|
37096
|
+
tasks_count: tasks.length
|
|
37097
|
+
}, "Local-only mode: GitHub issues not created. Run `gh auth login` for full integration.");
|
|
37098
|
+
const mapping = loadMapping(cwd);
|
|
37099
|
+
const result = await createAllPlanIssues({
|
|
37100
|
+
phaseNum: phase,
|
|
37101
|
+
planNum: plan,
|
|
37102
|
+
phaseName: phase_name,
|
|
37103
|
+
tasks,
|
|
37104
|
+
milestone,
|
|
37105
|
+
projectTitle: mapping?.project_number ? void 0 : void 0,
|
|
37106
|
+
cwd
|
|
37107
|
+
});
|
|
37108
|
+
if (!result.ok) return mcpError(`Issue creation failed: ${result.error}`, "Creation failed");
|
|
37109
|
+
if (mapping && mapping.project_number > 0) {
|
|
37110
|
+
const repo = mapping.repo;
|
|
37111
|
+
const allIssueNumbers = [result.data.parentIssue, ...result.data.taskIssues.map((t) => t.issueNumber)];
|
|
37112
|
+
for (const issueNum of allIssueNumbers) {
|
|
37113
|
+
const issueUrl = `https://github.com/${repo}/issues/${issueNum}`;
|
|
37114
|
+
const addResult = await addItemToProject(mapping.project_number, issueUrl);
|
|
37115
|
+
if (addResult.ok) {
|
|
37116
|
+
const taskEntry = result.data.taskIssues.find((t) => t.issueNumber === issueNum);
|
|
37117
|
+
if (taskEntry) updateTaskMapping(cwd, phase, taskEntry.taskId, { item_id: addResult.data.item_id });
|
|
37118
|
+
if (mapping.status_options["To Do"] && mapping.status_field_id) await moveItemToStatus(mapping.project_id, addResult.data.item_id, mapping.status_field_id, mapping.status_options["To Do"]);
|
|
37119
|
+
if (taskEntry && mapping.estimate_field_id) {
|
|
37120
|
+
const taskDef = tasks.find((t) => t.taskId === taskEntry.taskId);
|
|
37121
|
+
if (taskDef?.estimate) await setEstimate(mapping.project_id, addResult.data.item_id, mapping.estimate_field_id, taskDef.estimate);
|
|
37122
|
+
}
|
|
37123
|
+
}
|
|
37124
|
+
}
|
|
37125
|
+
}
|
|
37126
|
+
return mcpSuccess({
|
|
37127
|
+
mode: "full",
|
|
37128
|
+
parent_issue: result.data.parentIssue,
|
|
37129
|
+
task_issues: result.data.taskIssues,
|
|
37130
|
+
total_created: result.data.taskIssues.length + 1
|
|
37131
|
+
}, `Created ${result.data.taskIssues.length} task issues + parent tracking issue #${result.data.parentIssue}`);
|
|
37132
|
+
} catch (e) {
|
|
37133
|
+
return mcpError(e.message, "Operation failed");
|
|
37134
|
+
}
|
|
37135
|
+
});
|
|
37136
|
+
server.tool("mcp_create_todo_issue", "Create a GitHub issue for a todo item.", {
|
|
37137
|
+
title: stringType().describe("Todo title"),
|
|
37138
|
+
description: stringType().optional().describe("Todo description"),
|
|
37139
|
+
acceptance_criteria: arrayType(stringType()).optional().describe("Acceptance criteria list")
|
|
37140
|
+
}, async ({ title, description, acceptance_criteria }) => {
|
|
37141
|
+
try {
|
|
37142
|
+
const cwd = detectProjectRoot();
|
|
37143
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37144
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37145
|
+
mode: "local-only",
|
|
37146
|
+
warning: "GitHub not configured. Use mcp_add_todo for local todo tracking.",
|
|
37147
|
+
title
|
|
37148
|
+
}, "Local-only mode: GitHub todo issue not created.");
|
|
37149
|
+
const mapping = loadMapping(cwd);
|
|
37150
|
+
const result = await createTodoIssue({
|
|
37151
|
+
title,
|
|
37152
|
+
description,
|
|
37153
|
+
acceptanceCriteria: acceptance_criteria,
|
|
37154
|
+
milestone: mapping?.milestone_title || void 0
|
|
37155
|
+
});
|
|
37156
|
+
if (!result.ok) return mcpError(`Todo issue creation failed: ${result.error}`, "Creation failed");
|
|
37157
|
+
if (mapping && mapping.project_number > 0) {
|
|
37158
|
+
const issueUrl = `https://github.com/${mapping.repo}/issues/${result.data.number}`;
|
|
37159
|
+
const addResult = await addItemToProject(mapping.project_number, issueUrl);
|
|
37160
|
+
if (addResult.ok && mapping) {
|
|
37161
|
+
if (!mapping.todos) mapping.todos = {};
|
|
37162
|
+
mapping.todos[`todo-${result.data.number}`] = {
|
|
37163
|
+
number: result.data.number,
|
|
37164
|
+
node_id: result.data.node_id,
|
|
37165
|
+
item_id: addResult.data.item_id,
|
|
37166
|
+
status: "To Do"
|
|
37167
|
+
};
|
|
37168
|
+
saveMapping(cwd, mapping);
|
|
37169
|
+
}
|
|
37170
|
+
}
|
|
37171
|
+
return mcpSuccess({
|
|
37172
|
+
mode: "full",
|
|
37173
|
+
issue_number: result.data.number,
|
|
37174
|
+
url: result.data.url
|
|
37175
|
+
}, `Created todo issue #${result.data.number}: ${title}`);
|
|
37176
|
+
} catch (e) {
|
|
37177
|
+
return mcpError(e.message, "Operation failed");
|
|
37178
|
+
}
|
|
37179
|
+
});
|
|
37180
|
+
server.tool("mcp_move_issue", "Move a GitHub issue to a new status column (To Do, In Progress, In Review, Done).", {
|
|
37181
|
+
issue_number: numberType().describe("GitHub issue number"),
|
|
37182
|
+
status: enumType([
|
|
37183
|
+
"To Do",
|
|
37184
|
+
"In Progress",
|
|
37185
|
+
"In Review",
|
|
37186
|
+
"Done"
|
|
37187
|
+
]).describe("Target status column")
|
|
37188
|
+
}, async ({ issue_number, status }) => {
|
|
37189
|
+
try {
|
|
37190
|
+
const cwd = detectProjectRoot();
|
|
37191
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37192
|
+
const mode = await detectGitHubMode();
|
|
37193
|
+
const mapping = loadMapping(cwd);
|
|
37194
|
+
if (mode === "local-only") {
|
|
37195
|
+
if (mapping) {
|
|
37196
|
+
if (updateLocalMappingStatus(mapping, issue_number, status)) {
|
|
37197
|
+
saveMapping(cwd, mapping);
|
|
37198
|
+
return mcpSuccess({
|
|
37199
|
+
mode: "local-only",
|
|
37200
|
+
issue_number,
|
|
37201
|
+
status,
|
|
37202
|
+
local_updated: true
|
|
37203
|
+
}, `Local mapping updated: issue #${issue_number} -> ${status}`);
|
|
37204
|
+
}
|
|
37205
|
+
}
|
|
37206
|
+
return mcpError(`Issue #${issue_number} not found in local mapping`, "Issue not tracked");
|
|
37207
|
+
}
|
|
37208
|
+
if (!mapping) return mcpError("github-issues.json not found. Run mcp_github_setup first.", "Setup required");
|
|
37209
|
+
const issueEntry = findIssueInMapping$1(mapping, issue_number);
|
|
37210
|
+
if (!issueEntry) return mcpError(`Issue #${issue_number} not found in local mapping`, "Issue not tracked");
|
|
37211
|
+
if (!issueEntry.item_id) return mcpError(`Issue #${issue_number} has no project item_id. It may not have been added to the board.`, "Not on board");
|
|
37212
|
+
const statusOptionId = mapping.status_options[status];
|
|
37213
|
+
if (!statusOptionId) return mcpError(`Status "${status}" not found in project board options`, "Invalid status");
|
|
37214
|
+
const moveResult = await moveItemToStatus(mapping.project_id, issueEntry.item_id, mapping.status_field_id, statusOptionId);
|
|
37215
|
+
if (!moveResult.ok) return mcpError(`Move failed: ${moveResult.error}`, "Move failed");
|
|
37216
|
+
updateLocalMappingStatus(mapping, issue_number, status);
|
|
37217
|
+
saveMapping(cwd, mapping);
|
|
37218
|
+
return mcpSuccess({
|
|
37219
|
+
mode: "full",
|
|
37220
|
+
issue_number,
|
|
37221
|
+
status,
|
|
37222
|
+
moved: true
|
|
37223
|
+
}, `Issue #${issue_number} moved to "${status}"`);
|
|
37224
|
+
} catch (e) {
|
|
37225
|
+
return mcpError(e.message, "Operation failed");
|
|
37226
|
+
}
|
|
37227
|
+
});
|
|
37228
|
+
server.tool("mcp_close_issue", "Close a GitHub issue as completed or not planned.", {
|
|
37229
|
+
issue_number: numberType().describe("GitHub issue number"),
|
|
37230
|
+
reason: enumType(["completed", "not_planned"]).optional().default("completed").describe("Close reason")
|
|
37231
|
+
}, async ({ issue_number, reason }) => {
|
|
37232
|
+
try {
|
|
37233
|
+
const cwd = detectProjectRoot();
|
|
37234
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37235
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37236
|
+
mode: "local-only",
|
|
37237
|
+
warning: "GitHub not configured. Cannot close remote issue.",
|
|
37238
|
+
issue_number
|
|
37239
|
+
}, "Local-only mode: cannot close GitHub issue.");
|
|
37240
|
+
const result = await closeIssue(issue_number, reason);
|
|
37241
|
+
if (!result.ok) return mcpError(`Close failed: ${result.error}`, "Close failed");
|
|
37242
|
+
const mapping = loadMapping(cwd);
|
|
37243
|
+
if (mapping) {
|
|
37244
|
+
const issueEntry = findIssueInMapping$1(mapping, issue_number);
|
|
37245
|
+
if (issueEntry?.item_id && mapping.status_options["Done"] && mapping.status_field_id) await moveItemToStatus(mapping.project_id, issueEntry.item_id, mapping.status_field_id, mapping.status_options["Done"]);
|
|
37246
|
+
updateLocalMappingStatus(mapping, issue_number, "Done");
|
|
37247
|
+
saveMapping(cwd, mapping);
|
|
37248
|
+
}
|
|
37249
|
+
return mcpSuccess({
|
|
37250
|
+
mode: "full",
|
|
37251
|
+
issue_number,
|
|
37252
|
+
reason,
|
|
37253
|
+
closed: true
|
|
37254
|
+
}, `Issue #${issue_number} closed (${reason})`);
|
|
37255
|
+
} catch (e) {
|
|
37256
|
+
return mcpError(e.message, "Operation failed");
|
|
37257
|
+
}
|
|
37258
|
+
});
|
|
37259
|
+
server.tool("mcp_post_comment", "Post a progress comment on a GitHub issue.", {
|
|
37260
|
+
issue_number: numberType().describe("GitHub issue number"),
|
|
37261
|
+
body: stringType().describe("Comment body (markdown supported)")
|
|
37262
|
+
}, async ({ issue_number, body }) => {
|
|
37263
|
+
try {
|
|
37264
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37265
|
+
mode: "local-only",
|
|
37266
|
+
warning: "GitHub not configured. Cannot post comment.",
|
|
37267
|
+
issue_number
|
|
37268
|
+
}, "Local-only mode: cannot post comment on GitHub issue.");
|
|
37269
|
+
const result = await postComment(issue_number, body);
|
|
37270
|
+
if (!result.ok) return mcpError(`Comment failed: ${result.error}`, "Comment failed");
|
|
37271
|
+
return mcpSuccess({
|
|
37272
|
+
mode: "full",
|
|
37273
|
+
issue_number,
|
|
37274
|
+
commented: true
|
|
37275
|
+
}, `Comment posted on issue #${issue_number}`);
|
|
37276
|
+
} catch (e) {
|
|
37277
|
+
return mcpError(e.message, "Operation failed");
|
|
37278
|
+
}
|
|
37279
|
+
});
|
|
37280
|
+
server.tool("mcp_import_issue", "Import an external GitHub issue into MAXSIM tracking.", { issue_number: numberType().describe("GitHub issue number to import") }, async ({ issue_number }) => {
|
|
37281
|
+
try {
|
|
37282
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37283
|
+
mode: "local-only",
|
|
37284
|
+
warning: "GitHub not configured. Cannot import issue.",
|
|
37285
|
+
issue_number
|
|
37286
|
+
}, "Local-only mode: cannot import GitHub issue.");
|
|
37287
|
+
const result = await importExternalIssue(issue_number);
|
|
37288
|
+
if (!result.ok) return mcpError(`Import failed: ${result.error}`, "Import failed");
|
|
37289
|
+
return mcpSuccess({
|
|
37290
|
+
mode: "full",
|
|
37291
|
+
issue_number: result.data.number,
|
|
37292
|
+
title: result.data.title,
|
|
37293
|
+
labels: result.data.labels,
|
|
37294
|
+
imported: true
|
|
37295
|
+
}, `Imported issue #${result.data.number}: "${result.data.title}". Assign to a phase or todo for tracking.`);
|
|
37296
|
+
} catch (e) {
|
|
37297
|
+
return mcpError(e.message, "Operation failed");
|
|
37298
|
+
}
|
|
37299
|
+
});
|
|
37300
|
+
server.tool("mcp_sync_check", "Check for external changes to tracked GitHub issues.", {}, async () => {
|
|
37301
|
+
try {
|
|
37302
|
+
const cwd = detectProjectRoot();
|
|
37303
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37304
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37305
|
+
mode: "local-only",
|
|
37306
|
+
warning: "GitHub not configured. Sync check not available.",
|
|
37307
|
+
in_sync: true,
|
|
37308
|
+
changes: []
|
|
37309
|
+
}, "Local-only mode: sync check skipped.");
|
|
37310
|
+
const result = await syncCheck(cwd);
|
|
37311
|
+
if (!result.ok) return mcpError(`Sync check failed: ${result.error}`, "Sync failed");
|
|
37312
|
+
return mcpSuccess({
|
|
37313
|
+
mode: "full",
|
|
37314
|
+
in_sync: result.data.inSync,
|
|
37315
|
+
changes: result.data.changes,
|
|
37316
|
+
change_count: result.data.changes.length
|
|
37317
|
+
}, result.data.inSync ? "All tracked issues are in sync with GitHub." : `${result.data.changes.length} discrepancies found between local mapping and GitHub.`);
|
|
37318
|
+
} catch (e) {
|
|
37319
|
+
return mcpError(e.message, "Operation failed");
|
|
37320
|
+
}
|
|
37321
|
+
});
|
|
37322
|
+
server.tool("mcp_supersede_plan", "Close old plan issues and link to new plan issues.", {
|
|
37323
|
+
phase: stringType().describe("Phase number"),
|
|
37324
|
+
old_plan: stringType().describe("Old plan number to supersede"),
|
|
37325
|
+
new_plan: stringType().describe("New plan number that replaces it")
|
|
37326
|
+
}, async ({ phase, old_plan, new_plan }) => {
|
|
37327
|
+
try {
|
|
37328
|
+
const cwd = detectProjectRoot();
|
|
37329
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37330
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37331
|
+
mode: "local-only",
|
|
37332
|
+
warning: "GitHub not configured. Cannot supersede plan issues.",
|
|
37333
|
+
phase,
|
|
37334
|
+
old_plan,
|
|
37335
|
+
new_plan
|
|
37336
|
+
}, "Local-only mode: plan supersession skipped.");
|
|
37337
|
+
const mapping = loadMapping(cwd);
|
|
37338
|
+
if (!mapping) return mcpError("github-issues.json not found. Run mcp_github_setup first.", "Setup required");
|
|
37339
|
+
const newPhaseMapping = mapping.phases[phase];
|
|
37340
|
+
if (!newPhaseMapping) return mcpError(`Phase ${phase} not found in mapping. Create new plan issues first.`, "Phase not found");
|
|
37341
|
+
const result = await supersedePlanIssues({
|
|
37342
|
+
phaseNum: phase,
|
|
37343
|
+
oldPlanNum: old_plan,
|
|
37344
|
+
newPlanNum: new_plan,
|
|
37345
|
+
newIssueNumbers: Object.entries(newPhaseMapping.tasks).map(([taskId, task]) => ({
|
|
37346
|
+
taskId,
|
|
37347
|
+
issueNumber: task.number
|
|
37348
|
+
})),
|
|
37349
|
+
cwd
|
|
37350
|
+
});
|
|
37351
|
+
if (!result.ok) return mcpError(`Supersession failed: ${result.error}`, "Supersession failed");
|
|
37352
|
+
return mcpSuccess({
|
|
37353
|
+
mode: "full",
|
|
37354
|
+
phase,
|
|
37355
|
+
old_plan,
|
|
37356
|
+
new_plan,
|
|
37357
|
+
superseded: true
|
|
37358
|
+
}, `Plan ${phase}-${old_plan} superseded by ${phase}-${new_plan}`);
|
|
37359
|
+
} catch (e) {
|
|
37360
|
+
return mcpError(e.message, "Operation failed");
|
|
37361
|
+
}
|
|
37362
|
+
});
|
|
37363
|
+
server.tool("mcp_create_pr", "Create a pull request with auto-close linking for tracked GitHub issues. Generates PR description with Closes #N lines (AC-08).", {
|
|
37364
|
+
issue_numbers: arrayType(numberType()).describe("Issue numbers to auto-close when PR merges"),
|
|
37365
|
+
branch: stringType().describe("Source branch name for the PR"),
|
|
37366
|
+
title: stringType().describe("PR title"),
|
|
37367
|
+
base: stringType().optional().default("main").describe("Base branch (default: main)"),
|
|
37368
|
+
additional_context: stringType().optional().describe("Additional context to include in PR body"),
|
|
37369
|
+
draft: booleanType().optional().default(false).describe("Create as draft PR")
|
|
37370
|
+
}, async ({ issue_numbers, branch, title, base, additional_context, draft }) => {
|
|
37371
|
+
try {
|
|
37372
|
+
const mode = await detectGitHubMode();
|
|
37373
|
+
const prBody = buildPrBody(issue_numbers, additional_context);
|
|
37374
|
+
if (mode === "local-only") return mcpSuccess({
|
|
37375
|
+
mode: "local-only",
|
|
37376
|
+
warning: "GitHub not configured. PR not created. Use the body below to create manually.",
|
|
37377
|
+
pr_body: prBody,
|
|
37378
|
+
issues_linked: issue_numbers
|
|
37379
|
+
}, "Local-only mode: PR body generated but PR not created.");
|
|
37380
|
+
const args = [
|
|
37381
|
+
"pr",
|
|
37382
|
+
"create",
|
|
37383
|
+
"--title",
|
|
37384
|
+
title,
|
|
37385
|
+
"--body",
|
|
37386
|
+
prBody,
|
|
37387
|
+
"--head",
|
|
37388
|
+
branch
|
|
37389
|
+
];
|
|
37390
|
+
if (base) args.push("--base", base);
|
|
37391
|
+
if (draft) args.push("--draft");
|
|
37392
|
+
const createResult = await ghExec(args);
|
|
37393
|
+
if (!createResult.ok) return mcpError(`PR creation failed: ${createResult.error}`, "PR creation failed");
|
|
37394
|
+
const prUrl = createResult.data.trim();
|
|
37395
|
+
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
|
|
37396
|
+
return mcpSuccess({
|
|
37397
|
+
mode: "full",
|
|
37398
|
+
pr_number: prNumberMatch ? parseInt(prNumberMatch[1], 10) : null,
|
|
37399
|
+
pr_url: prUrl,
|
|
37400
|
+
issues_linked: issue_numbers,
|
|
37401
|
+
draft
|
|
37402
|
+
}, `PR${draft ? " (draft)" : ""} created: ${prUrl} — auto-closes ${issue_numbers.map((n) => `#${n}`).join(", ")}`);
|
|
37403
|
+
} catch (e) {
|
|
37404
|
+
return mcpError(e.message, "Operation failed");
|
|
37405
|
+
}
|
|
37406
|
+
});
|
|
37407
|
+
}
|
|
37408
|
+
/**
|
|
37409
|
+
* Find an issue entry in the mapping file (searches phases and todos).
|
|
37410
|
+
*/
|
|
37411
|
+
function findIssueInMapping$1(mapping, issueNumber) {
|
|
37412
|
+
for (const phase of Object.values(mapping.phases)) {
|
|
37413
|
+
if (phase.tracking_issue.number === issueNumber) return phase.tracking_issue;
|
|
37414
|
+
for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) return task;
|
|
37415
|
+
}
|
|
37416
|
+
if (mapping.todos) {
|
|
37417
|
+
for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) return todo;
|
|
37418
|
+
}
|
|
37419
|
+
return null;
|
|
37420
|
+
}
|
|
37421
|
+
/**
|
|
37422
|
+
* Update local mapping status for an issue (mutates mapping in-place).
|
|
37423
|
+
* Returns true if the issue was found and updated.
|
|
37424
|
+
*/
|
|
37425
|
+
function updateLocalMappingStatus(mapping, issueNumber, status) {
|
|
37426
|
+
for (const phase of Object.values(mapping.phases)) {
|
|
37427
|
+
if (phase.tracking_issue.number === issueNumber) {
|
|
37428
|
+
phase.tracking_issue.status = status;
|
|
37429
|
+
return true;
|
|
37430
|
+
}
|
|
37431
|
+
for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) {
|
|
37432
|
+
task.status = status;
|
|
37433
|
+
return true;
|
|
37434
|
+
}
|
|
37435
|
+
}
|
|
37436
|
+
if (mapping.todos) {
|
|
37437
|
+
for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) {
|
|
37438
|
+
todo.status = status;
|
|
37439
|
+
return true;
|
|
37440
|
+
}
|
|
37441
|
+
}
|
|
37442
|
+
return false;
|
|
37443
|
+
}
|
|
37444
|
+
|
|
37445
|
+
//#endregion
|
|
37446
|
+
//#region src/mcp/board-tools.ts
|
|
37447
|
+
/**
|
|
37448
|
+
* Board Query MCP Tools — Project board operations exposed as MCP tools
|
|
37449
|
+
*
|
|
37450
|
+
* Provides MCP tools for querying the GitHub project board, searching issues,
|
|
37451
|
+
* getting issue details, and setting estimates. Every tool checks detectGitHubMode()
|
|
37452
|
+
* and degrades gracefully to local-only behavior when GitHub is not configured.
|
|
37453
|
+
*
|
|
37454
|
+
* CRITICAL: Never import output() or error() from core — they call process.exit().
|
|
37455
|
+
* CRITICAL: Never write to stdout — it is reserved for MCP JSON-RPC protocol.
|
|
37456
|
+
* CRITICAL: Never call process.exit() — the server must stay alive after every tool call.
|
|
37457
|
+
*/
|
|
37458
|
+
/**
|
|
37459
|
+
* Register all board query tools on the MCP server.
|
|
37460
|
+
*/
|
|
37461
|
+
function registerBoardTools(server) {
|
|
37462
|
+
server.tool("mcp_query_board", "Query the GitHub project board. Returns all items with their status, estimates, and issue details.", {
|
|
37463
|
+
status: enumType([
|
|
37464
|
+
"To Do",
|
|
37465
|
+
"In Progress",
|
|
37466
|
+
"In Review",
|
|
37467
|
+
"Done"
|
|
37468
|
+
]).optional().describe("Filter by status column"),
|
|
37469
|
+
phase: stringType().optional().describe("Filter by phase number (matches issue title prefix)")
|
|
37470
|
+
}, async ({ status, phase }) => {
|
|
37471
|
+
try {
|
|
37472
|
+
const cwd = detectProjectRoot();
|
|
37473
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37474
|
+
const mode = await detectGitHubMode();
|
|
37475
|
+
const mapping = loadMapping(cwd);
|
|
37476
|
+
if (mode === "local-only") {
|
|
37477
|
+
if (!mapping) return mcpSuccess({
|
|
37478
|
+
mode: "local-only",
|
|
37479
|
+
items: [],
|
|
37480
|
+
count: 0
|
|
37481
|
+
}, "Local-only mode: no mapping file found.");
|
|
37482
|
+
const items = buildLocalBoardItems(mapping, status, phase);
|
|
37483
|
+
return mcpSuccess({
|
|
37484
|
+
mode: "local-only",
|
|
37485
|
+
items,
|
|
37486
|
+
count: items.length
|
|
37487
|
+
}, `Local-only mode: ${items.length} items from local mapping.`);
|
|
37488
|
+
}
|
|
37489
|
+
if (!mapping || !mapping.project_number) return mcpError("No project board configured. Run mcp_github_setup first.", "Setup required");
|
|
37490
|
+
const result = await ghExec([
|
|
37491
|
+
"project",
|
|
37492
|
+
"item-list",
|
|
37493
|
+
String(mapping.project_number),
|
|
37494
|
+
"--owner",
|
|
37495
|
+
"@me",
|
|
37496
|
+
"--format",
|
|
37497
|
+
"json"
|
|
37498
|
+
], { parseJson: true });
|
|
37499
|
+
if (!result.ok) return mcpError(`Board query failed: ${result.error}`, "Query failed");
|
|
37500
|
+
let items = result.data.items || [];
|
|
37501
|
+
if (status) items = items.filter((item) => item.status === status);
|
|
37502
|
+
if (phase) {
|
|
37503
|
+
const phasePrefix = `[P${phase}]`;
|
|
37504
|
+
const phasePrefixAlt = `[Phase ${phase}]`;
|
|
37505
|
+
items = items.filter((item) => item.title?.includes(phasePrefix) || item.title?.includes(phasePrefixAlt) || item.content?.title?.includes(phasePrefix) || item.content?.title?.includes(phasePrefixAlt));
|
|
37506
|
+
}
|
|
37507
|
+
const formatted = items.map((item) => ({
|
|
37508
|
+
item_id: item.id,
|
|
37509
|
+
title: item.content?.title ?? item.title,
|
|
37510
|
+
issue_number: item.content?.number ?? null,
|
|
37511
|
+
status: item.status ?? "No Status",
|
|
37512
|
+
url: item.content?.url ?? null
|
|
37513
|
+
}));
|
|
37514
|
+
return mcpSuccess({
|
|
37515
|
+
mode: "full",
|
|
37516
|
+
items: formatted,
|
|
37517
|
+
count: formatted.length
|
|
37518
|
+
}, `Board query: ${formatted.length} items${status ? ` in "${status}"` : ""}${phase ? ` for phase ${phase}` : ""}`);
|
|
37519
|
+
} catch (e) {
|
|
37520
|
+
return mcpError(e.message, "Operation failed");
|
|
37521
|
+
}
|
|
37522
|
+
});
|
|
37523
|
+
server.tool("mcp_search_issues", "Search GitHub issues by label, milestone, state, or text query.", {
|
|
37524
|
+
labels: arrayType(stringType()).optional().describe("Filter by label names"),
|
|
37525
|
+
milestone: stringType().optional().describe("Filter by milestone title"),
|
|
37526
|
+
state: enumType([
|
|
37527
|
+
"open",
|
|
37528
|
+
"closed",
|
|
37529
|
+
"all"
|
|
37530
|
+
]).optional().default("open").describe("Filter by issue state"),
|
|
37531
|
+
query: stringType().optional().describe("Text search query")
|
|
37532
|
+
}, async ({ labels, milestone, state, query }) => {
|
|
37533
|
+
try {
|
|
37534
|
+
const cwd = detectProjectRoot();
|
|
37535
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37536
|
+
if (await detectGitHubMode() === "local-only") {
|
|
37537
|
+
const mapping = loadMapping(cwd);
|
|
37538
|
+
if (!mapping) return mcpSuccess({
|
|
37539
|
+
mode: "local-only",
|
|
37540
|
+
issues: [],
|
|
37541
|
+
count: 0
|
|
37542
|
+
}, "Local-only mode: no mapping file found.");
|
|
37543
|
+
const items = buildLocalSearchResults(mapping, state);
|
|
37544
|
+
return mcpSuccess({
|
|
37545
|
+
mode: "local-only",
|
|
37546
|
+
issues: items,
|
|
37547
|
+
count: items.length
|
|
37548
|
+
}, `Local-only mode: ${items.length} items from local mapping.`);
|
|
37549
|
+
}
|
|
37550
|
+
const args = [
|
|
37551
|
+
"issue",
|
|
37552
|
+
"list",
|
|
37553
|
+
"--json",
|
|
37554
|
+
"number,title,state,labels,milestone",
|
|
37555
|
+
"--limit",
|
|
37556
|
+
"100"
|
|
37557
|
+
];
|
|
37558
|
+
if (state && state !== "all") args.push("--state", state);
|
|
37559
|
+
else if (state === "all") args.push("--state", "all");
|
|
37560
|
+
if (labels && labels.length > 0) for (const label of labels) args.push("--label", label);
|
|
37561
|
+
if (milestone) args.push("--milestone", milestone);
|
|
37562
|
+
if (query) args.push("--search", query);
|
|
37563
|
+
const result = await ghExec(args, { parseJson: true });
|
|
37564
|
+
if (!result.ok) return mcpError(`Search failed: ${result.error}`, "Search failed");
|
|
37565
|
+
const issues = result.data.map((issue) => ({
|
|
37566
|
+
number: issue.number,
|
|
37567
|
+
title: issue.title,
|
|
37568
|
+
state: issue.state,
|
|
37569
|
+
labels: issue.labels.map((l) => l.name),
|
|
37570
|
+
milestone: issue.milestone?.title ?? null
|
|
37571
|
+
}));
|
|
37572
|
+
return mcpSuccess({
|
|
37573
|
+
mode: "full",
|
|
37574
|
+
issues,
|
|
37575
|
+
count: issues.length
|
|
37576
|
+
}, `Found ${issues.length} issues`);
|
|
37577
|
+
} catch (e) {
|
|
37578
|
+
return mcpError(e.message, "Operation failed");
|
|
37579
|
+
}
|
|
37580
|
+
});
|
|
37581
|
+
server.tool("mcp_get_issue_detail", "Get full details of a specific GitHub issue including comments.", { issue_number: numberType().describe("GitHub issue number") }, async ({ issue_number }) => {
|
|
37582
|
+
try {
|
|
37583
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37584
|
+
mode: "local-only",
|
|
37585
|
+
warning: "GitHub not configured. Cannot fetch issue details.",
|
|
37586
|
+
issue_number
|
|
37587
|
+
}, "Local-only mode: cannot fetch issue details.");
|
|
37588
|
+
const result = await ghExec([
|
|
37589
|
+
"issue",
|
|
37590
|
+
"view",
|
|
37591
|
+
String(issue_number),
|
|
37592
|
+
"--json",
|
|
37593
|
+
"number,title,body,state,labels,comments,assignees"
|
|
37594
|
+
], { parseJson: true });
|
|
37595
|
+
if (!result.ok) return mcpError(`Fetch failed: ${result.error}`, "Fetch failed");
|
|
37596
|
+
const issue = result.data;
|
|
37597
|
+
return mcpSuccess({
|
|
37598
|
+
mode: "full",
|
|
37599
|
+
number: issue.number,
|
|
37600
|
+
title: issue.title,
|
|
37601
|
+
body: issue.body,
|
|
37602
|
+
state: issue.state,
|
|
37603
|
+
labels: issue.labels.map((l) => l.name),
|
|
37604
|
+
assignees: issue.assignees.map((a) => a.login),
|
|
37605
|
+
comments: issue.comments.map((c) => ({
|
|
37606
|
+
author: c.author.login,
|
|
37607
|
+
body: c.body,
|
|
37608
|
+
created_at: c.createdAt
|
|
37609
|
+
})),
|
|
37610
|
+
comment_count: issue.comments.length
|
|
37611
|
+
}, `Issue #${issue.number}: ${issue.title} (${issue.state})`);
|
|
37612
|
+
} catch (e) {
|
|
37613
|
+
return mcpError(e.message, "Operation failed");
|
|
37614
|
+
}
|
|
37615
|
+
});
|
|
37616
|
+
server.tool("mcp_set_estimate", "Set Fibonacci story points on a GitHub issue.", {
|
|
37617
|
+
issue_number: numberType().describe("GitHub issue number"),
|
|
37618
|
+
points: numberType().describe("Fibonacci story points (1, 2, 3, 5, 8, 13, 21, 34)")
|
|
37619
|
+
}, async ({ issue_number, points }) => {
|
|
37620
|
+
try {
|
|
37621
|
+
const cwd = detectProjectRoot();
|
|
37622
|
+
if (!cwd) return mcpError("No .planning/ directory found", "Project not detected");
|
|
37623
|
+
if (!FIBONACCI_POINTS.includes(points)) return mcpError(`Invalid points: ${points}. Must be one of: ${FIBONACCI_POINTS.join(", ")}`, "Validation failed");
|
|
37624
|
+
if (await detectGitHubMode() === "local-only") return mcpSuccess({
|
|
37625
|
+
mode: "local-only",
|
|
37626
|
+
warning: "GitHub not configured. Cannot set estimate.",
|
|
37627
|
+
issue_number,
|
|
37628
|
+
points
|
|
37629
|
+
}, "Local-only mode: cannot set estimate on GitHub project.");
|
|
37630
|
+
const mapping = loadMapping(cwd);
|
|
37631
|
+
if (!mapping) return mcpError("github-issues.json not found. Run mcp_github_setup first.", "Setup required");
|
|
37632
|
+
if (!mapping.estimate_field_id) return mcpError("Estimate field not configured. Re-run mcp_github_setup.", "Setup required");
|
|
37633
|
+
const issueEntry = findIssueInMapping(mapping, issue_number);
|
|
37634
|
+
if (!issueEntry) return mcpError(`Issue #${issue_number} not found in local mapping`, "Issue not tracked");
|
|
37635
|
+
if (!issueEntry.item_id) return mcpError(`Issue #${issue_number} has no project item_id. It may not have been added to the board.`, "Not on board");
|
|
37636
|
+
const result = await setEstimate(mapping.project_id, issueEntry.item_id, mapping.estimate_field_id, points);
|
|
37637
|
+
if (!result.ok) return mcpError(`Set estimate failed: ${result.error}`, "Estimate failed");
|
|
37638
|
+
return mcpSuccess({
|
|
37639
|
+
mode: "full",
|
|
37640
|
+
issue_number,
|
|
37641
|
+
points,
|
|
37642
|
+
set: true
|
|
37643
|
+
}, `Estimate set: issue #${issue_number} = ${points} points`);
|
|
37644
|
+
} catch (e) {
|
|
37645
|
+
return mcpError(e.message, "Operation failed");
|
|
37646
|
+
}
|
|
37647
|
+
});
|
|
37648
|
+
}
|
|
37649
|
+
/**
|
|
37650
|
+
* Find an issue entry in the mapping file (searches phases and todos).
|
|
37651
|
+
*/
|
|
37652
|
+
function findIssueInMapping(mapping, issueNumber) {
|
|
37653
|
+
for (const phase of Object.values(mapping.phases)) {
|
|
37654
|
+
if (phase.tracking_issue.number === issueNumber) return phase.tracking_issue;
|
|
37655
|
+
for (const task of Object.values(phase.tasks)) if (task.number === issueNumber) return task;
|
|
37656
|
+
}
|
|
37657
|
+
if (mapping.todos) {
|
|
37658
|
+
for (const todo of Object.values(mapping.todos)) if (todo.number === issueNumber) return todo;
|
|
37659
|
+
}
|
|
37660
|
+
return null;
|
|
37661
|
+
}
|
|
37662
|
+
/**
|
|
37663
|
+
* Build local board items from the mapping file (for local-only mode).
|
|
37664
|
+
*/
|
|
37665
|
+
function buildLocalBoardItems(mapping, statusFilter, phaseFilter) {
|
|
37666
|
+
const items = [];
|
|
37667
|
+
for (const [phaseNum, phase] of Object.entries(mapping.phases)) {
|
|
37668
|
+
if (phaseFilter && phaseNum !== phaseFilter) continue;
|
|
37669
|
+
if (phase.tracking_issue.number > 0) {
|
|
37670
|
+
const entry = {
|
|
37671
|
+
issue_number: phase.tracking_issue.number,
|
|
37672
|
+
title: `[Phase ${phaseNum}] Tracking`,
|
|
37673
|
+
status: phase.tracking_issue.status,
|
|
37674
|
+
source: `phase ${phaseNum}`
|
|
37675
|
+
};
|
|
37676
|
+
if (!statusFilter || entry.status === statusFilter) items.push(entry);
|
|
37677
|
+
}
|
|
37678
|
+
for (const [taskId, task] of Object.entries(phase.tasks)) if (task.number > 0) {
|
|
37679
|
+
const entry = {
|
|
37680
|
+
issue_number: task.number,
|
|
37681
|
+
title: `[P${phaseNum}] Task ${taskId}`,
|
|
37682
|
+
status: task.status,
|
|
37683
|
+
source: `phase ${phaseNum}, task ${taskId}`
|
|
37684
|
+
};
|
|
37685
|
+
if (!statusFilter || entry.status === statusFilter) items.push(entry);
|
|
37686
|
+
}
|
|
37687
|
+
}
|
|
37688
|
+
if (!phaseFilter && mapping.todos) {
|
|
37689
|
+
for (const [todoId, todo] of Object.entries(mapping.todos)) if (todo.number > 0) {
|
|
37690
|
+
const entry = {
|
|
37691
|
+
issue_number: todo.number,
|
|
37692
|
+
title: `Todo: ${todoId}`,
|
|
37693
|
+
status: todo.status,
|
|
37694
|
+
source: `todo ${todoId}`
|
|
37695
|
+
};
|
|
37696
|
+
if (!statusFilter || entry.status === statusFilter) items.push(entry);
|
|
37697
|
+
}
|
|
37698
|
+
}
|
|
37699
|
+
return items;
|
|
37700
|
+
}
|
|
37701
|
+
/**
|
|
37702
|
+
* Build local search results from the mapping file (for local-only mode).
|
|
37703
|
+
*/
|
|
37704
|
+
function buildLocalSearchResults(mapping, stateFilter) {
|
|
37705
|
+
const items = [];
|
|
37706
|
+
for (const [phaseNum, phase] of Object.entries(mapping.phases)) for (const [taskId, task] of Object.entries(phase.tasks)) if (task.number > 0) {
|
|
37707
|
+
const state = task.status === "Done" ? "closed" : "open";
|
|
37708
|
+
if (stateFilter && stateFilter !== "all" && state !== stateFilter) continue;
|
|
37709
|
+
items.push({
|
|
37710
|
+
issue_number: task.number,
|
|
37711
|
+
title: `[P${phaseNum}] Task ${taskId}`,
|
|
37712
|
+
state,
|
|
37713
|
+
source: `phase ${phaseNum}`
|
|
37714
|
+
});
|
|
37715
|
+
}
|
|
37716
|
+
if (mapping.todos) {
|
|
37717
|
+
for (const [todoId, todo] of Object.entries(mapping.todos)) if (todo.number > 0) {
|
|
37718
|
+
const state = todo.status === "Done" ? "closed" : "open";
|
|
37719
|
+
if (stateFilter && stateFilter !== "all" && state !== stateFilter) continue;
|
|
37720
|
+
items.push({
|
|
37721
|
+
issue_number: todo.number,
|
|
37722
|
+
title: `Todo: ${todoId}`,
|
|
37723
|
+
state,
|
|
37724
|
+
source: "todo"
|
|
37725
|
+
});
|
|
37726
|
+
}
|
|
37727
|
+
}
|
|
37728
|
+
return items;
|
|
37729
|
+
}
|
|
37730
|
+
|
|
35034
37731
|
//#endregion
|
|
35035
37732
|
//#region src/mcp/index.ts
|
|
35036
37733
|
/**
|
|
@@ -35043,6 +37740,8 @@ function registerAllTools(server) {
|
|
|
35043
37740
|
registerContextTools(server);
|
|
35044
37741
|
registerRoadmapTools(server);
|
|
35045
37742
|
registerConfigTools(server);
|
|
37743
|
+
registerGitHubTools(server);
|
|
37744
|
+
registerBoardTools(server);
|
|
35046
37745
|
}
|
|
35047
37746
|
|
|
35048
37747
|
//#endregion
|