ralph-hero-mcp-server 2.5.2 → 2.5.3
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/index.js +14 -0
- package/dist/lib/dashboard.js +17 -0
- package/dist/lib/helpers.js +12 -0
- package/dist/lib/registry-loader.js +100 -0
- package/dist/lib/repo-registry.js +256 -0
- package/dist/tools/dashboard-tools.js +33 -1
- package/dist/tools/decompose-tools.js +321 -0
- package/dist/tools/issue-tools.js +33 -6
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { registerBatchTools } from "./tools/batch-tools.js";
|
|
|
21
21
|
import { registerProjectManagementTools } from "./tools/project-management-tools.js";
|
|
22
22
|
import { registerHygieneTools } from "./tools/hygiene-tools.js";
|
|
23
23
|
import { registerDebugTools } from "./tools/debug-tools.js";
|
|
24
|
+
import { registerDecomposeTools } from "./tools/decompose-tools.js";
|
|
24
25
|
/**
|
|
25
26
|
* Initialize the GitHub client from environment variables.
|
|
26
27
|
*/
|
|
@@ -244,6 +245,17 @@ async function main() {
|
|
|
244
245
|
console.error("[ralph-hero] Debug logging enabled (RALPH_DEBUG=true)");
|
|
245
246
|
}
|
|
246
247
|
const client = initGitHubClient(debugLogger);
|
|
248
|
+
// Load repo registry (.ralph-repos.yml) before repo inference (non-fatal)
|
|
249
|
+
try {
|
|
250
|
+
const { loadRepoRegistry } = await import("./lib/registry-loader.js");
|
|
251
|
+
const registry = await loadRepoRegistry(client);
|
|
252
|
+
if (registry) {
|
|
253
|
+
client.config.repoRegistry = registry;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
console.error(`[ralph-hero] Repo registry load skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
258
|
+
}
|
|
247
259
|
// Attempt lazy repo inference from project (non-fatal)
|
|
248
260
|
try {
|
|
249
261
|
await resolveRepoFromProject(client);
|
|
@@ -287,6 +299,8 @@ async function main() {
|
|
|
287
299
|
registerProjectManagementTools(server, client, fieldCache);
|
|
288
300
|
// Hygiene reporting tools
|
|
289
301
|
registerHygieneTools(server, client, fieldCache);
|
|
302
|
+
// Decompose feature tool (cross-repo decomposition via .ralph-repos.yml)
|
|
303
|
+
registerDecomposeTools(server, client, fieldCache);
|
|
290
304
|
// Debug tools (only when RALPH_DEBUG=true)
|
|
291
305
|
if (process.env.RALPH_DEBUG === 'true') {
|
|
292
306
|
registerDebugTools(server, client);
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -618,6 +618,23 @@ export function formatMarkdown(data, issuesPerPhase = 10) {
|
|
|
618
618
|
}
|
|
619
619
|
return lines.join("\n");
|
|
620
620
|
}
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// groupDashboardItemsByRepo
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
/**
|
|
625
|
+
* Group dashboard items by repository (nameWithOwner).
|
|
626
|
+
* Items without a repository are grouped under "(unknown)".
|
|
627
|
+
*/
|
|
628
|
+
export function groupDashboardItemsByRepo(items) {
|
|
629
|
+
const groups = {};
|
|
630
|
+
for (const item of items) {
|
|
631
|
+
const key = item.repository || "(unknown)";
|
|
632
|
+
if (!groups[key])
|
|
633
|
+
groups[key] = [];
|
|
634
|
+
groups[key].push(item);
|
|
635
|
+
}
|
|
636
|
+
return groups;
|
|
637
|
+
}
|
|
621
638
|
/**
|
|
622
639
|
* Render dashboard data as an ASCII bar chart.
|
|
623
640
|
*/
|
package/dist/lib/helpers.js
CHANGED
|
@@ -249,6 +249,18 @@ export async function resolveRepoFromProject(client) {
|
|
|
249
249
|
}
|
|
250
250
|
return inferred.repo;
|
|
251
251
|
}
|
|
252
|
+
// When a registry is loaded, use its first repo entry as the default
|
|
253
|
+
const registry = client.config.repoRegistry;
|
|
254
|
+
if (registry) {
|
|
255
|
+
const firstRepoName = Object.keys(registry.repos)[0];
|
|
256
|
+
const firstRepoEntry = registry.repos[firstRepoName];
|
|
257
|
+
client.config.repo = firstRepoName;
|
|
258
|
+
if (!client.config.owner && firstRepoEntry.owner) {
|
|
259
|
+
client.config.owner = firstRepoEntry.owner;
|
|
260
|
+
}
|
|
261
|
+
console.error(`[ralph-hero] Multiple repos linked. Using "${firstRepoName}" as default (from .ralph-repos.yml).`);
|
|
262
|
+
return firstRepoName;
|
|
263
|
+
}
|
|
252
264
|
const repoList = result.repos.map(r => r.nameWithOwner).join(", ");
|
|
253
265
|
console.error(`[ralph-hero] Multiple repos linked to project: ${repoList}. ` +
|
|
254
266
|
`Set RALPH_GH_REPO to select the default repo. ` +
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry loader — fetches .ralph-repos.yml from project-linked GitHub repos.
|
|
3
|
+
*
|
|
4
|
+
* Called once at MCP server startup (before resolveRepoFromProject).
|
|
5
|
+
* The registry is optional: if not found in any linked repo, returns null.
|
|
6
|
+
*/
|
|
7
|
+
import { parseRepoRegistry } from "./repo-registry.js";
|
|
8
|
+
import { queryProjectRepositories } from "./helpers.js";
|
|
9
|
+
import { resolveProjectOwner } from "../types.js";
|
|
10
|
+
const REGISTRY_FILENAME = ".ralph-repos.yml";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Internal: fetch file content from a single repo via GraphQL
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
async function fetchFileFromRepo(client, owner, repo, expression) {
|
|
15
|
+
try {
|
|
16
|
+
const result = await client.query(`query($owner: String!, $repo: String!, $expr: String!) {
|
|
17
|
+
repository(owner: $owner, name: $repo) {
|
|
18
|
+
object(expression: $expr) {
|
|
19
|
+
... on Blob { __typename text }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}`, { owner, repo, expr: expression });
|
|
23
|
+
const obj = result.repository?.object;
|
|
24
|
+
if (obj?.__typename === "Blob" && typeof obj.text === "string") {
|
|
25
|
+
return obj.text;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Public: load registry from project-linked repos
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Load .ralph-repos.yml from a GitHub repo linked to the configured project.
|
|
38
|
+
*
|
|
39
|
+
* Strategy:
|
|
40
|
+
* 1. If client.config.repo is set, try that repo first.
|
|
41
|
+
* 2. Otherwise, query linked repos and try each in order until one has the file.
|
|
42
|
+
* 3. If none found or any validation error, log a warning and return null.
|
|
43
|
+
*
|
|
44
|
+
* The registry is optional — consumers must handle null gracefully.
|
|
45
|
+
*/
|
|
46
|
+
export async function loadRepoRegistry(client) {
|
|
47
|
+
const expression = `HEAD:${REGISTRY_FILENAME}`;
|
|
48
|
+
// Try the explicitly configured repo first
|
|
49
|
+
if (client.config.repo && client.config.owner) {
|
|
50
|
+
const text = await fetchFileFromRepo(client, client.config.owner, client.config.repo, expression);
|
|
51
|
+
if (text !== null) {
|
|
52
|
+
return parseAndLog(text, client.config.owner, client.config.repo);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Fall back to querying linked repos (multi-repo case or no explicit repo)
|
|
56
|
+
const projectOwner = resolveProjectOwner(client.config);
|
|
57
|
+
const projectNumber = client.config.projectNumber;
|
|
58
|
+
if (!projectOwner || !projectNumber) {
|
|
59
|
+
// Not enough config to query project repos — skip silently
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const projectRepos = await queryProjectRepositories(client, projectOwner, projectNumber);
|
|
63
|
+
if (!projectRepos || projectRepos.repos.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
for (const linked of projectRepos.repos) {
|
|
67
|
+
// Skip if we already tried this repo above
|
|
68
|
+
if (linked.repo === client.config.repo &&
|
|
69
|
+
linked.owner === client.config.owner) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const text = await fetchFileFromRepo(client, linked.owner, linked.repo, expression);
|
|
73
|
+
if (text !== null) {
|
|
74
|
+
return parseAndLog(text, linked.owner, linked.repo);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Internal: parse and log on success or validation failure
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
function parseAndLog(text, owner, repo) {
|
|
83
|
+
try {
|
|
84
|
+
const registry = parseRepoRegistry(text);
|
|
85
|
+
const repoCount = Object.keys(registry.repos).length;
|
|
86
|
+
const patternCount = registry.patterns
|
|
87
|
+
? Object.keys(registry.patterns).length
|
|
88
|
+
: 0;
|
|
89
|
+
console.error(`[ralph-hero] Repo registry loaded from ${owner}/${repo}: ` +
|
|
90
|
+
`${repoCount} repo${repoCount !== 1 ? "s" : ""}, ` +
|
|
91
|
+
`${patternCount} pattern${patternCount !== 1 ? "s" : ""}`);
|
|
92
|
+
return registry;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
console.error(`[ralph-hero] Warning: ${REGISTRY_FILENAME} found in ${owner}/${repo} ` +
|
|
96
|
+
`but failed to parse: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=registry-loader.js.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo registry schema, types, parser, and lookup helpers.
|
|
3
|
+
*
|
|
4
|
+
* Provides structured configuration for multi-repo portfolio management.
|
|
5
|
+
* The registry YAML file maps short repo names to metadata (domain, tech
|
|
6
|
+
* stack, default labels/assignees/estimate) and defines cross-repo
|
|
7
|
+
* decomposition patterns for feature work.
|
|
8
|
+
*
|
|
9
|
+
* Consumers:
|
|
10
|
+
* - server startup: loads registry once from env-specified path
|
|
11
|
+
* - create_issue tool: merges repo defaults into issue creation args
|
|
12
|
+
* - decompose_feature tool: looks up patterns for cross-repo decomposition
|
|
13
|
+
* - pipeline_dashboard: groups issues by repo domain
|
|
14
|
+
*/
|
|
15
|
+
import { parse as yamlParse } from "yaml";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Schemas
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Default values applied to issues created in a specific repo.
|
|
22
|
+
* All fields are optional — only specified ones are used.
|
|
23
|
+
*
|
|
24
|
+
* Example YAML:
|
|
25
|
+
* defaults:
|
|
26
|
+
* labels: ["backend", "infra"]
|
|
27
|
+
* assignees: ["cdubiel08"]
|
|
28
|
+
* estimate: "S"
|
|
29
|
+
*/
|
|
30
|
+
export const RepoDefaultsSchema = z.object({
|
|
31
|
+
labels: z
|
|
32
|
+
.array(z.string())
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Default labels to apply to issues in this repo"),
|
|
35
|
+
assignees: z
|
|
36
|
+
.array(z.string())
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Default assignees for issues in this repo"),
|
|
39
|
+
estimate: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Default estimate (e.g., 'XS', 'S', 'M', 'L') for this repo"),
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* A single repository entry in the registry.
|
|
46
|
+
*
|
|
47
|
+
* Example YAML:
|
|
48
|
+
* mcp-server:
|
|
49
|
+
* owner: cdubiel08
|
|
50
|
+
* domain: platform
|
|
51
|
+
* tech: [typescript, node]
|
|
52
|
+
* defaults:
|
|
53
|
+
* labels: [backend]
|
|
54
|
+
* paths: [plugin/ralph-hero/mcp-server]
|
|
55
|
+
*/
|
|
56
|
+
export const RepoEntrySchema = z.object({
|
|
57
|
+
owner: z
|
|
58
|
+
.string()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("GitHub owner (user or org); falls back to RALPH_GH_OWNER if omitted"),
|
|
61
|
+
domain: z
|
|
62
|
+
.string()
|
|
63
|
+
.describe("Functional domain this repo belongs to (e.g., 'platform', 'frontend')"),
|
|
64
|
+
tech: z
|
|
65
|
+
.array(z.string())
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Technology stack tags for this repo (e.g., ['typescript', 'react'])"),
|
|
68
|
+
defaults: RepoDefaultsSchema
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Default values applied to issues created in this repo"),
|
|
71
|
+
paths: z
|
|
72
|
+
.array(z.string())
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Monorepo sub-paths owned by this repo (e.g., ['packages/core'])"),
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* One step in a cross-repo decomposition pattern.
|
|
78
|
+
*
|
|
79
|
+
* Example YAML:
|
|
80
|
+
* - repo: mcp-server
|
|
81
|
+
* role: Implement MCP tool endpoint
|
|
82
|
+
*/
|
|
83
|
+
export const DecompositionStepSchema = z.object({
|
|
84
|
+
repo: z
|
|
85
|
+
.string()
|
|
86
|
+
.describe("Registry key of the repo responsible for this step"),
|
|
87
|
+
role: z
|
|
88
|
+
.string()
|
|
89
|
+
.describe("Human-readable description of what this repo does in the decomposition"),
|
|
90
|
+
});
|
|
91
|
+
/**
|
|
92
|
+
* A named cross-repo decomposition pattern for feature work.
|
|
93
|
+
*
|
|
94
|
+
* Example YAML:
|
|
95
|
+
* full-stack-feature:
|
|
96
|
+
* description: "Frontend + backend + infra change"
|
|
97
|
+
* decomposition:
|
|
98
|
+
* - repo: frontend
|
|
99
|
+
* role: Build UI
|
|
100
|
+
* - repo: mcp-server
|
|
101
|
+
* role: Add API endpoint
|
|
102
|
+
* dependency-flow:
|
|
103
|
+
* - mcp-server -> frontend
|
|
104
|
+
*/
|
|
105
|
+
export const PatternSchema = z.object({
|
|
106
|
+
description: z
|
|
107
|
+
.string()
|
|
108
|
+
.describe("Human-readable description of when to use this pattern"),
|
|
109
|
+
decomposition: z
|
|
110
|
+
.array(DecompositionStepSchema)
|
|
111
|
+
.min(1)
|
|
112
|
+
.describe("Ordered list of repo steps for this pattern (at least one required)"),
|
|
113
|
+
"dependency-flow": z
|
|
114
|
+
.array(z.string())
|
|
115
|
+
.optional()
|
|
116
|
+
.describe("Dependency edges between repos (e.g., 'api -> frontend')"),
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* Top-level repo registry configuration.
|
|
120
|
+
*
|
|
121
|
+
* Example YAML:
|
|
122
|
+
* version: 1
|
|
123
|
+
* repos:
|
|
124
|
+
* mcp-server:
|
|
125
|
+
* domain: platform
|
|
126
|
+
* tech: [typescript]
|
|
127
|
+
* patterns:
|
|
128
|
+
* full-stack:
|
|
129
|
+
* description: "Full stack feature"
|
|
130
|
+
* decomposition:
|
|
131
|
+
* - repo: mcp-server
|
|
132
|
+
* role: Backend
|
|
133
|
+
*/
|
|
134
|
+
export const RepoRegistrySchema = z.object({
|
|
135
|
+
version: z
|
|
136
|
+
.literal(1)
|
|
137
|
+
.describe("Schema version for forward compatibility"),
|
|
138
|
+
repos: z
|
|
139
|
+
.record(z.string(), RepoEntrySchema)
|
|
140
|
+
.refine((r) => Object.keys(r).length >= 1, {
|
|
141
|
+
message: "At least one repo entry is required",
|
|
142
|
+
})
|
|
143
|
+
.describe("Map of short repo name to repo metadata"),
|
|
144
|
+
patterns: z
|
|
145
|
+
.record(z.string(), PatternSchema)
|
|
146
|
+
.optional()
|
|
147
|
+
.describe("Named cross-repo decomposition patterns"),
|
|
148
|
+
});
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Parser
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
/**
|
|
153
|
+
* Parse a YAML string into a validated RepoRegistry.
|
|
154
|
+
*
|
|
155
|
+
* Throws a descriptive error if:
|
|
156
|
+
* - the YAML is syntactically invalid
|
|
157
|
+
* - the parsed value does not match RepoRegistrySchema
|
|
158
|
+
*
|
|
159
|
+
* @param yamlContent - Raw YAML string (e.g., from fs.readFileSync)
|
|
160
|
+
* @returns Validated RepoRegistry object
|
|
161
|
+
*/
|
|
162
|
+
export function parseRepoRegistry(yamlContent) {
|
|
163
|
+
let parsed;
|
|
164
|
+
try {
|
|
165
|
+
parsed = yamlParse(yamlContent);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
throw new Error(`Repo registry YAML parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
const result = RepoRegistrySchema.safeParse(parsed);
|
|
171
|
+
if (!result.success) {
|
|
172
|
+
const messages = result.error.issues
|
|
173
|
+
.map((issue) => {
|
|
174
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
175
|
+
return ` ${path}: ${issue.message}`;
|
|
176
|
+
})
|
|
177
|
+
.join("\n");
|
|
178
|
+
throw new Error(`Repo registry schema validation failed:\n${messages}`);
|
|
179
|
+
}
|
|
180
|
+
return result.data;
|
|
181
|
+
}
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Lookup Helpers
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
/**
|
|
186
|
+
* Look up a repo entry by name (case-insensitive).
|
|
187
|
+
*
|
|
188
|
+
* @param registry - Parsed registry
|
|
189
|
+
* @param repoName - Repo key to look up (case-insensitive)
|
|
190
|
+
* @returns { name, entry } if found, undefined otherwise
|
|
191
|
+
*/
|
|
192
|
+
export function lookupRepo(registry, repoName) {
|
|
193
|
+
const lower = repoName.toLowerCase();
|
|
194
|
+
for (const [name, entry] of Object.entries(registry.repos)) {
|
|
195
|
+
if (name.toLowerCase() === lower) {
|
|
196
|
+
return { name, entry };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Look up a decomposition pattern by name (case-insensitive).
|
|
203
|
+
*
|
|
204
|
+
* @param registry - Parsed registry
|
|
205
|
+
* @param patternName - Pattern key to look up (case-insensitive)
|
|
206
|
+
* @returns { name, pattern } if found, undefined otherwise
|
|
207
|
+
*/
|
|
208
|
+
export function lookupPattern(registry, patternName) {
|
|
209
|
+
if (!registry.patterns)
|
|
210
|
+
return undefined;
|
|
211
|
+
const lower = patternName.toLowerCase();
|
|
212
|
+
for (const [name, pattern] of Object.entries(registry.patterns)) {
|
|
213
|
+
if (name.toLowerCase() === lower) {
|
|
214
|
+
return { name, pattern };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Defaults Merging
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
/**
|
|
223
|
+
* Merge repo defaults with caller-supplied args.
|
|
224
|
+
*
|
|
225
|
+
* Merge rules:
|
|
226
|
+
* - **labels**: additive union, deduplicated (args labels + defaults labels)
|
|
227
|
+
* - **assignees**: args win; fall back to defaults if args omit it
|
|
228
|
+
* - **estimate**: args win; fall back to defaults if args omit it
|
|
229
|
+
*
|
|
230
|
+
* @param defaults - Repo-level defaults from registry (may be undefined)
|
|
231
|
+
* @param args - Caller-supplied values (take precedence for non-label fields)
|
|
232
|
+
* @returns Merged object with only the fields that have values
|
|
233
|
+
*/
|
|
234
|
+
export function mergeDefaults(defaults, args) {
|
|
235
|
+
const result = {};
|
|
236
|
+
// Labels: additive union, deduplicated
|
|
237
|
+
const allLabels = [
|
|
238
|
+
...(args.labels ?? []),
|
|
239
|
+
...(defaults?.labels ?? []),
|
|
240
|
+
];
|
|
241
|
+
if (allLabels.length > 0) {
|
|
242
|
+
result.labels = [...new Set(allLabels)];
|
|
243
|
+
}
|
|
244
|
+
// Assignees: args win, fall back to defaults
|
|
245
|
+
const assignees = args.assignees ?? defaults?.assignees;
|
|
246
|
+
if (assignees !== undefined) {
|
|
247
|
+
result.assignees = assignees;
|
|
248
|
+
}
|
|
249
|
+
// Estimate: args win, fall back to defaults
|
|
250
|
+
const estimate = args.estimate ?? defaults?.estimate;
|
|
251
|
+
if (estimate !== undefined) {
|
|
252
|
+
result.estimate = estimate;
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=repo-registry.js.map
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { paginateConnection } from "../lib/pagination.js";
|
|
10
|
-
import { buildDashboard, formatMarkdown, formatAscii, DEFAULT_HEALTH_CONFIG, } from "../lib/dashboard.js";
|
|
10
|
+
import { buildDashboard, formatMarkdown, formatAscii, groupDashboardItemsByRepo, DEFAULT_HEALTH_CONFIG, } from "../lib/dashboard.js";
|
|
11
11
|
import { toolSuccess, toolError, resolveProjectOwner, resolveProjectNumbers } from "../types.js";
|
|
12
12
|
import { detectWorkStreams } from "../lib/work-stream-detection.js";
|
|
13
13
|
import { detectStreamPipelinePositions } from "../lib/pipeline-detection.js";
|
|
@@ -225,6 +225,10 @@ export function registerDashboardTools(server, client, fieldCache) {
|
|
|
225
225
|
}))
|
|
226
226
|
.optional()
|
|
227
227
|
.describe("Pre-computed stream assignments from detect_work_streams. When provided, dashboard includes a Streams section."),
|
|
228
|
+
groupBy: z
|
|
229
|
+
.enum(["repo"])
|
|
230
|
+
.optional()
|
|
231
|
+
.describe("Group dashboard output by dimension. 'repo' groups items by repository within the project."),
|
|
228
232
|
}, async (args) => {
|
|
229
233
|
try {
|
|
230
234
|
const owner = args.owner || resolveProjectOwner(client.config);
|
|
@@ -287,6 +291,34 @@ export function registerDashboardTools(server, client, fieldCache) {
|
|
|
287
291
|
for (const phase of dashboard.phases) {
|
|
288
292
|
phase.issues = phase.issues.slice(0, issuesPerPhase);
|
|
289
293
|
}
|
|
294
|
+
// If groupBy=repo, build per-repo sub-dashboards
|
|
295
|
+
if (args.groupBy === "repo") {
|
|
296
|
+
const repoGroups = groupDashboardItemsByRepo(allItems);
|
|
297
|
+
if (args.format === "markdown") {
|
|
298
|
+
let md = "# Pipeline Dashboard (by repo)\n\n";
|
|
299
|
+
for (const [repoName, repoItems] of Object.entries(repoGroups)) {
|
|
300
|
+
const sub = buildDashboard(repoItems, healthConfig);
|
|
301
|
+
md += `## ${repoName} (${repoItems.length} items)\n\n`;
|
|
302
|
+
md += formatMarkdown(sub) + "\n\n";
|
|
303
|
+
}
|
|
304
|
+
return toolSuccess({ markdown: md });
|
|
305
|
+
}
|
|
306
|
+
if (args.format === "ascii") {
|
|
307
|
+
let ascii = "Pipeline Dashboard (by repo)\n\n";
|
|
308
|
+
for (const [repoName, repoItems] of Object.entries(repoGroups)) {
|
|
309
|
+
const sub = buildDashboard(repoItems, healthConfig);
|
|
310
|
+
ascii += `=== ${repoName} (${repoItems.length} items) ===\n`;
|
|
311
|
+
ascii += formatAscii(sub) + "\n\n";
|
|
312
|
+
}
|
|
313
|
+
return toolSuccess({ ascii });
|
|
314
|
+
}
|
|
315
|
+
// JSON format
|
|
316
|
+
const repoResults = {};
|
|
317
|
+
for (const [repoName, repoItems] of Object.entries(repoGroups)) {
|
|
318
|
+
repoResults[repoName] = buildDashboard(repoItems, healthConfig);
|
|
319
|
+
}
|
|
320
|
+
return toolSuccess({ groupBy: "repo", repos: repoResults });
|
|
321
|
+
}
|
|
290
322
|
// Compute metrics if requested
|
|
291
323
|
let metrics;
|
|
292
324
|
if (args.includeMetrics) {
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools for cross-repo feature decomposition.
|
|
3
|
+
*
|
|
4
|
+
* Uses the .ralph-repos.yml registry to split a feature description into
|
|
5
|
+
* repo-specific issues following a named decomposition pattern.
|
|
6
|
+
*
|
|
7
|
+
* When dryRun=true (default), returns a proposed issue list for review.
|
|
8
|
+
* When dryRun=false, creates the actual issues on GitHub and adds each to
|
|
9
|
+
* the project board.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { toolSuccess, toolError } from "../types.js";
|
|
13
|
+
import { ensureFieldCache, resolveFullConfigOptionalRepo, } from "../lib/helpers.js";
|
|
14
|
+
import { lookupRepo, lookupPattern, mergeDefaults, } from "../lib/repo-registry.js";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Pure function: buildDecomposition
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Build a decomposition proposal from a feature description and registry pattern.
|
|
20
|
+
*
|
|
21
|
+
* Pure function — no side effects, no API calls. Suitable for testing.
|
|
22
|
+
*
|
|
23
|
+
* @param input - { title, description, pattern }
|
|
24
|
+
* @param registry - Validated repo registry
|
|
25
|
+
* @param defaultOwner - Global owner fallback (from client.config.owner)
|
|
26
|
+
* @returns DecompositionResult with proposed issues and dependency chain
|
|
27
|
+
* @throws Error if pattern is not found (lists available patterns)
|
|
28
|
+
* @throws Error if a step references a repo not in the registry
|
|
29
|
+
*/
|
|
30
|
+
export function buildDecomposition(input, registry, defaultOwner) {
|
|
31
|
+
// Look up the pattern (case-insensitive)
|
|
32
|
+
const patternLookup = lookupPattern(registry, input.pattern);
|
|
33
|
+
if (!patternLookup) {
|
|
34
|
+
const available = Object.keys(registry.patterns ?? {});
|
|
35
|
+
const availableList = available.length > 0
|
|
36
|
+
? `Available patterns: ${available.join(", ")}`
|
|
37
|
+
: "No patterns are defined in the registry.";
|
|
38
|
+
throw new Error(`Pattern "${input.pattern}" not found in registry. ${availableList}`);
|
|
39
|
+
}
|
|
40
|
+
const { name: matchedPattern, pattern } = patternLookup;
|
|
41
|
+
// Build a proposed issue for each step
|
|
42
|
+
const proposed_issues = pattern.decomposition.map((step) => {
|
|
43
|
+
const repoLookup = lookupRepo(registry, step.repo);
|
|
44
|
+
if (!repoLookup) {
|
|
45
|
+
const available = Object.keys(registry.repos);
|
|
46
|
+
throw new Error(`Pattern step references unknown repo "${step.repo}". ` +
|
|
47
|
+
`Available repos: ${available.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
const { name: repoKey, entry } = repoLookup;
|
|
50
|
+
// Merge defaults: no caller overrides at decompose time
|
|
51
|
+
const merged = mergeDefaults(entry.defaults, {});
|
|
52
|
+
// Resolve owner: registry entry > global default
|
|
53
|
+
const owner = entry.owner ?? defaultOwner;
|
|
54
|
+
// Generate title
|
|
55
|
+
const title = `[${input.title}] ${step.role}`;
|
|
56
|
+
// Generate body
|
|
57
|
+
const domainLine = `Domain: ${entry.domain}`;
|
|
58
|
+
const techLine = entry.tech && entry.tech.length > 0
|
|
59
|
+
? `Tech: ${entry.tech.join(", ")}`
|
|
60
|
+
: undefined;
|
|
61
|
+
const pathsLine = entry.paths && entry.paths.length > 0
|
|
62
|
+
? `Paths: ${entry.paths.join(", ")}`
|
|
63
|
+
: undefined;
|
|
64
|
+
const repoDomainParts = [domainLine, techLine, pathsLine].filter((p) => p !== undefined);
|
|
65
|
+
const body = [
|
|
66
|
+
`## Context`,
|
|
67
|
+
``,
|
|
68
|
+
input.description,
|
|
69
|
+
``,
|
|
70
|
+
`## Scope`,
|
|
71
|
+
``,
|
|
72
|
+
step.role,
|
|
73
|
+
``,
|
|
74
|
+
`## Repo`,
|
|
75
|
+
``,
|
|
76
|
+
repoDomainParts.join("\n"),
|
|
77
|
+
].join("\n");
|
|
78
|
+
const proposed = { repoKey, owner, title, body };
|
|
79
|
+
if (merged.labels !== undefined)
|
|
80
|
+
proposed.labels = merged.labels;
|
|
81
|
+
if (merged.assignees !== undefined)
|
|
82
|
+
proposed.assignees = merged.assignees;
|
|
83
|
+
if (merged.estimate !== undefined)
|
|
84
|
+
proposed.estimate = merged.estimate;
|
|
85
|
+
return proposed;
|
|
86
|
+
});
|
|
87
|
+
// Extract dependency chain
|
|
88
|
+
const dependency_chain = pattern["dependency-flow"] ?? [];
|
|
89
|
+
return { proposed_issues, dependency_chain, matched_pattern: matchedPattern };
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Register decompose tools
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
export function registerDecomposeTools(server, client, fieldCache) {
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
// ralph_hero__decompose_feature
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
server.tool("ralph_hero__decompose_feature", "Split a feature description into repo-specific issues using a named decomposition pattern from .ralph-repos.yml. " +
|
|
99
|
+
"When no pattern is specified, lists available patterns and repos. " +
|
|
100
|
+
"When dryRun=true (default), returns a proposal without creating anything. " +
|
|
101
|
+
"When dryRun=false, creates the issues on GitHub and adds each to the project board. " +
|
|
102
|
+
"Returns: proposed_issues (with title, body, labels, assignees, estimate per repo), dependency_chain, matched_pattern.", {
|
|
103
|
+
title: z.string().describe("Feature name or title (used as issue title prefix)"),
|
|
104
|
+
description: z
|
|
105
|
+
.string()
|
|
106
|
+
.describe("Feature description (included in each issue body as Context)"),
|
|
107
|
+
pattern: z
|
|
108
|
+
.string()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe("Decomposition pattern name from .ralph-repos.yml. " +
|
|
111
|
+
"Omit to list available patterns and repos."),
|
|
112
|
+
dryRun: z
|
|
113
|
+
.boolean()
|
|
114
|
+
.optional()
|
|
115
|
+
.default(true)
|
|
116
|
+
.describe("When true (default), return the proposal without creating issues. " +
|
|
117
|
+
"When false, create real issues on GitHub and add them to the project."),
|
|
118
|
+
projectNumber: z.coerce
|
|
119
|
+
.number()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("Project number override (defaults to configured project)"),
|
|
122
|
+
}, async (args) => {
|
|
123
|
+
try {
|
|
124
|
+
const registry = client.config.repoRegistry;
|
|
125
|
+
// If no registry is loaded, return a helpful error
|
|
126
|
+
if (!registry) {
|
|
127
|
+
return toolError("No .ralph-repos.yml registry loaded. " +
|
|
128
|
+
"Create a .ralph-repos.yml file in your repo root and restart the MCP server. " +
|
|
129
|
+
"See the ralph-hero documentation for the schema.");
|
|
130
|
+
}
|
|
131
|
+
// If no pattern specified, list available patterns and repos
|
|
132
|
+
if (!args.pattern) {
|
|
133
|
+
const available_patterns = Object.entries(registry.patterns ?? {}).map(([name, p]) => ({
|
|
134
|
+
name,
|
|
135
|
+
description: p.description,
|
|
136
|
+
steps: p.decomposition.map((s) => `${s.repo}: ${s.role}`),
|
|
137
|
+
}));
|
|
138
|
+
const available_repos = Object.entries(registry.repos).map(([name, r]) => ({
|
|
139
|
+
name,
|
|
140
|
+
domain: r.domain,
|
|
141
|
+
tech: r.tech,
|
|
142
|
+
owner: r.owner,
|
|
143
|
+
}));
|
|
144
|
+
return toolSuccess({
|
|
145
|
+
message: "No pattern specified. Provide a `pattern` parameter to decompose the feature.",
|
|
146
|
+
available_patterns,
|
|
147
|
+
available_repos,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Build decomposition (pure — throws on unknown pattern or missing repo)
|
|
151
|
+
let decomposition;
|
|
152
|
+
try {
|
|
153
|
+
decomposition = buildDecomposition({
|
|
154
|
+
title: args.title,
|
|
155
|
+
description: args.description,
|
|
156
|
+
pattern: args.pattern,
|
|
157
|
+
}, registry, client.config.owner);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
161
|
+
return toolError(message);
|
|
162
|
+
}
|
|
163
|
+
// dryRun=true: return proposal without creating anything
|
|
164
|
+
if (args.dryRun !== false) {
|
|
165
|
+
return toolSuccess({
|
|
166
|
+
dryRun: true,
|
|
167
|
+
matched_pattern: decomposition.matched_pattern,
|
|
168
|
+
proposed_issues: decomposition.proposed_issues,
|
|
169
|
+
dependency_chain: decomposition.dependency_chain,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// dryRun=false: create real issues on GitHub
|
|
173
|
+
const { projectNumber, projectOwner } = resolveFullConfigOptionalRepo(client, { projectNumber: args.projectNumber });
|
|
174
|
+
// Ensure field cache is populated for project operations
|
|
175
|
+
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
176
|
+
const projectId = fieldCache.getProjectId(projectNumber);
|
|
177
|
+
if (!projectId) {
|
|
178
|
+
return toolError("Could not resolve project ID for adding issues to project");
|
|
179
|
+
}
|
|
180
|
+
const createdIssues = [];
|
|
181
|
+
// Create each proposed issue
|
|
182
|
+
for (const proposed of decomposition.proposed_issues) {
|
|
183
|
+
const issueOwner = proposed.owner ?? client.config.owner;
|
|
184
|
+
if (!issueOwner) {
|
|
185
|
+
return toolError(`Cannot create issue for repo "${proposed.repoKey}": no owner resolved. ` +
|
|
186
|
+
`Set owner in the registry entry or configure RALPH_GH_OWNER.`);
|
|
187
|
+
}
|
|
188
|
+
// Step 1: Get repository ID
|
|
189
|
+
const repoResult = await client.query(`query($owner: String!, $repo: String!) {
|
|
190
|
+
repository(owner: $owner, name: $repo) { id }
|
|
191
|
+
}`, { owner: issueOwner, repo: proposed.repoKey }, { cache: true, cacheTtlMs: 60 * 60 * 1000 });
|
|
192
|
+
const repoId = repoResult.repository?.id;
|
|
193
|
+
if (!repoId) {
|
|
194
|
+
return toolError(`Repository ${issueOwner}/${proposed.repoKey} not found. ` +
|
|
195
|
+
`Check that the repo key in .ralph-repos.yml matches the GitHub repo name.`);
|
|
196
|
+
}
|
|
197
|
+
// Step 2: Resolve label IDs if provided
|
|
198
|
+
let labelIds;
|
|
199
|
+
if (proposed.labels && proposed.labels.length > 0) {
|
|
200
|
+
const labelResult = await client.query(`query($owner: String!, $repo: String!) {
|
|
201
|
+
repository(owner: $owner, name: $repo) {
|
|
202
|
+
labels(first: 100) {
|
|
203
|
+
nodes { id name }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}`, { owner: issueOwner, repo: proposed.repoKey }, { cache: true, cacheTtlMs: 5 * 60 * 1000 });
|
|
207
|
+
const allLabels = labelResult.repository.labels.nodes;
|
|
208
|
+
labelIds = proposed.labels
|
|
209
|
+
.map((name) => allLabels.find((l) => l.name === name)?.id)
|
|
210
|
+
.filter((id) => id !== undefined);
|
|
211
|
+
}
|
|
212
|
+
// Step 3: Create the issue
|
|
213
|
+
const createResult = await client.mutate(`mutation($repoId: ID!, $title: String!, $body: String, $labelIds: [ID!]) {
|
|
214
|
+
createIssue(input: {
|
|
215
|
+
repositoryId: $repoId,
|
|
216
|
+
title: $title,
|
|
217
|
+
body: $body,
|
|
218
|
+
labelIds: $labelIds
|
|
219
|
+
}) {
|
|
220
|
+
issue {
|
|
221
|
+
id
|
|
222
|
+
number
|
|
223
|
+
title
|
|
224
|
+
url
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}`, {
|
|
228
|
+
repoId,
|
|
229
|
+
title: proposed.title,
|
|
230
|
+
body: proposed.body,
|
|
231
|
+
labelIds: labelIds ?? null,
|
|
232
|
+
});
|
|
233
|
+
const issue = createResult.createIssue.issue;
|
|
234
|
+
// Cache the node ID
|
|
235
|
+
client
|
|
236
|
+
.getCache()
|
|
237
|
+
.set(`issue-node-id:${issueOwner}/${proposed.repoKey}#${issue.number}`, issue.id, 30 * 60 * 1000);
|
|
238
|
+
// Step 4: Add to project
|
|
239
|
+
const addResult = await client.projectMutate(`mutation($projectId: ID!, $contentId: ID!) {
|
|
240
|
+
addProjectV2ItemById(input: {
|
|
241
|
+
projectId: $projectId,
|
|
242
|
+
contentId: $contentId
|
|
243
|
+
}) {
|
|
244
|
+
item { id }
|
|
245
|
+
}
|
|
246
|
+
}`, { projectId, contentId: issue.id });
|
|
247
|
+
const projectItemId = addResult.addProjectV2ItemById.item.id;
|
|
248
|
+
// Cache project item ID
|
|
249
|
+
client
|
|
250
|
+
.getCache()
|
|
251
|
+
.set(`project-item-id:${issueOwner}/${proposed.repoKey}#${issue.number}`, projectItemId, 30 * 60 * 1000);
|
|
252
|
+
createdIssues.push({
|
|
253
|
+
repoKey: proposed.repoKey,
|
|
254
|
+
owner: issueOwner,
|
|
255
|
+
number: issue.number,
|
|
256
|
+
id: issue.id,
|
|
257
|
+
title: issue.title,
|
|
258
|
+
url: issue.url,
|
|
259
|
+
projectItemId,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
// Step 5: Wire dependencies (addSubIssue for dependency edges)
|
|
263
|
+
// Parse "a -> b" edges from dependency_chain; cross-repo sub-issues
|
|
264
|
+
// may not be supported — catch and continue.
|
|
265
|
+
const wiringResults = [];
|
|
266
|
+
for (const edge of decomposition.dependency_chain) {
|
|
267
|
+
const match = edge.match(/^\s*(\S+)\s*->\s*(\S+)\s*$/);
|
|
268
|
+
if (!match) {
|
|
269
|
+
wiringResults.push({
|
|
270
|
+
edge,
|
|
271
|
+
status: "skipped",
|
|
272
|
+
reason: "Unrecognized edge format (expected 'a -> b')",
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const [, fromRepo, toRepo] = match;
|
|
277
|
+
const fromIssue = createdIssues.find((i) => i.repoKey === fromRepo);
|
|
278
|
+
const toIssue = createdIssues.find((i) => i.repoKey === toRepo);
|
|
279
|
+
if (!fromIssue || !toIssue) {
|
|
280
|
+
wiringResults.push({
|
|
281
|
+
edge,
|
|
282
|
+
status: "skipped",
|
|
283
|
+
reason: `Could not find created issue for repo "${fromRepo}" or "${toRepo}"`,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
await client.mutate(`mutation($parentId: ID!, $childId: ID!) {
|
|
289
|
+
addSubIssue(input: {
|
|
290
|
+
issueId: $parentId,
|
|
291
|
+
subIssueId: $childId
|
|
292
|
+
}) {
|
|
293
|
+
issue { id }
|
|
294
|
+
subIssue { id }
|
|
295
|
+
}
|
|
296
|
+
}`, { parentId: fromIssue.id, childId: toIssue.id });
|
|
297
|
+
wiringResults.push({ edge, status: "ok" });
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
301
|
+
wiringResults.push({
|
|
302
|
+
edge,
|
|
303
|
+
status: "skipped",
|
|
304
|
+
reason: `addSubIssue failed (cross-repo sub-issues may not be supported): ${reason}`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return toolSuccess({
|
|
309
|
+
dryRun: false,
|
|
310
|
+
matched_pattern: decomposition.matched_pattern,
|
|
311
|
+
created_issues: createdIssues,
|
|
312
|
+
dependency_wiring: wiringResults,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
317
|
+
return toolError(`Failed to decompose feature: ${message}`);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=decompose-tools.js.map
|
|
@@ -15,6 +15,7 @@ import { parseDateMath } from "../lib/date-math.js";
|
|
|
15
15
|
import { expandProfile } from "../lib/filter-profiles.js";
|
|
16
16
|
import { toolSuccess, toolError } from "../types.js";
|
|
17
17
|
import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, updateProjectItemField, resolveConfig, resolveFullConfig, resolveFullConfigOptionalRepo, syncStatusField, autoAdvanceParent, } from "../lib/helpers.js";
|
|
18
|
+
import { lookupRepo, mergeDefaults } from "../lib/repo-registry.js";
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Register issue tools
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
@@ -630,7 +631,33 @@ export function registerIssueTools(server, client, fieldCache) {
|
|
|
630
631
|
priority: z.string().optional().describe("Priority (P0, P1, P2, P3)"),
|
|
631
632
|
}, async (args) => {
|
|
632
633
|
try {
|
|
633
|
-
|
|
634
|
+
// Resolve owner from registry for repo shorthand
|
|
635
|
+
let resolvedArgs = { ...args };
|
|
636
|
+
const registry = client.config.repoRegistry;
|
|
637
|
+
if (registry && args.repo && !args.owner) {
|
|
638
|
+
const repoLookup = lookupRepo(registry, args.repo);
|
|
639
|
+
if (repoLookup?.entry.owner) {
|
|
640
|
+
resolvedArgs = { ...args, owner: repoLookup.entry.owner };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const { owner, repo, projectNumber, projectOwner } = resolveFullConfig(client, resolvedArgs);
|
|
644
|
+
// Apply registry defaults if available
|
|
645
|
+
let effectiveLabels = args.labels;
|
|
646
|
+
let effectiveAssignees = args.assignees;
|
|
647
|
+
let effectiveEstimate = args.estimate;
|
|
648
|
+
if (registry) {
|
|
649
|
+
const repoLookup = lookupRepo(registry, repo);
|
|
650
|
+
if (repoLookup) {
|
|
651
|
+
const merged = mergeDefaults(repoLookup.entry.defaults, {
|
|
652
|
+
labels: effectiveLabels,
|
|
653
|
+
assignees: effectiveAssignees,
|
|
654
|
+
estimate: effectiveEstimate,
|
|
655
|
+
});
|
|
656
|
+
effectiveLabels = merged.labels;
|
|
657
|
+
effectiveAssignees = merged.assignees;
|
|
658
|
+
effectiveEstimate = merged.estimate;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
634
661
|
// Ensure field cache is populated
|
|
635
662
|
await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
|
|
636
663
|
// Step 1: Get repository ID
|
|
@@ -643,7 +670,7 @@ export function registerIssueTools(server, client, fieldCache) {
|
|
|
643
670
|
}
|
|
644
671
|
// Step 2: Resolve label IDs if provided
|
|
645
672
|
let labelIds;
|
|
646
|
-
if (
|
|
673
|
+
if (effectiveLabels && effectiveLabels.length > 0) {
|
|
647
674
|
const labelResult = await client.query(`query($owner: String!, $repo: String!) {
|
|
648
675
|
repository(owner: $owner, name: $repo) {
|
|
649
676
|
labels(first: 100) {
|
|
@@ -652,7 +679,7 @@ export function registerIssueTools(server, client, fieldCache) {
|
|
|
652
679
|
}
|
|
653
680
|
}`, { owner, repo }, { cache: true, cacheTtlMs: 5 * 60 * 1000 });
|
|
654
681
|
const allLabels = labelResult.repository.labels.nodes;
|
|
655
|
-
labelIds =
|
|
682
|
+
labelIds = effectiveLabels
|
|
656
683
|
.map((name) => allLabels.find((l) => l.name === name)?.id)
|
|
657
684
|
.filter((id) => id !== undefined);
|
|
658
685
|
}
|
|
@@ -707,8 +734,8 @@ export function registerIssueTools(server, client, fieldCache) {
|
|
|
707
734
|
await updateProjectItemField(client, fieldCache, projectItemId, "Workflow State", args.workflowState, projectNumber);
|
|
708
735
|
await syncStatusField(client, fieldCache, projectItemId, args.workflowState, projectNumber);
|
|
709
736
|
}
|
|
710
|
-
if (
|
|
711
|
-
await updateProjectItemField(client, fieldCache, projectItemId, "Estimate",
|
|
737
|
+
if (effectiveEstimate) {
|
|
738
|
+
await updateProjectItemField(client, fieldCache, projectItemId, "Estimate", effectiveEstimate, projectNumber);
|
|
712
739
|
}
|
|
713
740
|
if (args.priority) {
|
|
714
741
|
await updateProjectItemField(client, fieldCache, projectItemId, "Priority", args.priority, projectNumber);
|
|
@@ -721,7 +748,7 @@ export function registerIssueTools(server, client, fieldCache) {
|
|
|
721
748
|
projectItemId,
|
|
722
749
|
fieldsSet: {
|
|
723
750
|
workflowState: args.workflowState || null,
|
|
724
|
-
estimate:
|
|
751
|
+
estimate: effectiveEstimate || null,
|
|
725
752
|
priority: args.priority || null,
|
|
726
753
|
},
|
|
727
754
|
});
|