codiedev 0.4.0 → 0.5.1
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/cli.js +55 -15
- package/dist/commands/reverseTicket.js +147 -8
- package/dist/mcp.js +255 -32
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -41,8 +41,12 @@ Artifacts:
|
|
|
41
41
|
Fetch an artifact (stdout by default)
|
|
42
42
|
codiedev promote <artifact-id> Promote an auto-extracted artifact
|
|
43
43
|
to an authored one
|
|
44
|
-
codiedev reverse-ticket <pr-url>
|
|
45
|
-
|
|
44
|
+
codiedev reverse-ticket [<pr-url>] Generate a Jira ticket draft.
|
|
45
|
+
No URL = branch mode (uses your current
|
|
46
|
+
local git branch, no PR needed).
|
|
47
|
+
Draft lands in /portal/agentic-ticketing.
|
|
48
|
+
codiedev reverse-ticket --base <ref> Override the base ref in branch mode
|
|
49
|
+
(defaults to origin/main).
|
|
46
50
|
|
|
47
51
|
Messaging:
|
|
48
52
|
codiedev ping <user> "<msg>" [--with <key>]
|
|
@@ -58,15 +62,26 @@ Other:
|
|
|
58
62
|
codiedev docs Print the full usage guide
|
|
59
63
|
codiedev version Show version
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
The 90% workflow:
|
|
66
|
+
1. Name your file 'spec-<topic>.md' and push.
|
|
67
|
+
2. Ping a teammate to pull review.
|
|
68
|
+
3. They edit, push back. Versions stack. Done.
|
|
69
|
+
|
|
70
|
+
Other artifact types (rare — use the matching prefix):
|
|
71
|
+
review-*.md → review decision-*.md → decision
|
|
72
|
+
proposal-*.md → proposal bugfix-*.md → bugfix
|
|
73
|
+
anything else → note
|
|
74
|
+
|
|
75
|
+
Skills (slash commands):
|
|
76
|
+
Add 'name:' + 'description:' YAML frontmatter to any pushed file
|
|
77
|
+
and it shows up as /<name> in Claude Code / Codex. Filename
|
|
78
|
+
prefix doesn't matter — frontmatter is the signal.
|
|
65
79
|
|
|
66
80
|
Examples:
|
|
67
|
-
codiedev push
|
|
81
|
+
codiedev push spec-cart-clear.md # the typical case
|
|
82
|
+
codiedev push portal-design.md # with name:/description: → skill
|
|
68
83
|
codiedev pull spec-cart-clear.md
|
|
69
|
-
codiedev ping maya "thoughts
|
|
84
|
+
codiedev ping maya "thoughts?" --with spec-cart-clear.md
|
|
70
85
|
codiedev inbox --unread
|
|
71
86
|
codiedev note "idempotency key is worth a follow-up"
|
|
72
87
|
|
|
@@ -92,17 +107,23 @@ codiedev connect — link your agent CLI to CodieDev
|
|
|
92
107
|
push: `
|
|
93
108
|
codiedev push — author or update an artifact
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
codiedev push
|
|
110
|
+
Default path:
|
|
111
|
+
codiedev push spec-<topic>.md
|
|
97
112
|
|
|
98
|
-
The file's basename becomes its key (unique per company). Pushing
|
|
99
|
-
same key again
|
|
113
|
+
The file's basename becomes its key (unique per company). Pushing
|
|
114
|
+
the same key again versions it — full history is preserved.
|
|
100
115
|
|
|
101
|
-
|
|
116
|
+
Other types: rename with the matching prefix (review-*, decision-*,
|
|
117
|
+
proposal-*, bugfix-*) or pass --type. Anything else lands as 'note'.
|
|
118
|
+
|
|
119
|
+
Skills (slash commands): add 'name:' + 'description:' YAML
|
|
120
|
+
frontmatter to the top of the file. It shows up as /<name> in
|
|
121
|
+
the Skills tab. Filename prefix doesn't matter for the skill role.
|
|
102
122
|
|
|
103
123
|
Examples:
|
|
104
|
-
codiedev push spec-cart-clear.md
|
|
105
|
-
codiedev push
|
|
124
|
+
codiedev push spec-cart-clear.md # the typical case
|
|
125
|
+
codiedev push portal-design.md # with name:/description: → skill
|
|
126
|
+
codiedev push docs/review-auth.md
|
|
106
127
|
codiedev push notes/random.md --type note
|
|
107
128
|
`.trim(),
|
|
108
129
|
pull: `
|
|
@@ -240,6 +261,25 @@ Artifacts have a status that auto-advances as PRs reference them:
|
|
|
240
261
|
Artifacts stay editable at every stage, including after merge —
|
|
241
262
|
lessons-learned and follow-up notes append as new versions.
|
|
242
263
|
|
|
264
|
+
## Skills
|
|
265
|
+
|
|
266
|
+
Default path:
|
|
267
|
+
|
|
268
|
+
1. Pick a name: e.g. portal-design
|
|
269
|
+
2. Top the file with frontmatter:
|
|
270
|
+
---
|
|
271
|
+
name: portal-design
|
|
272
|
+
description: Use when reviewing or building portal UI.
|
|
273
|
+
---
|
|
274
|
+
# body...
|
|
275
|
+
3. codiedev push portal-design.md
|
|
276
|
+
|
|
277
|
+
It's now /portal-design in Claude Code / Codex.
|
|
278
|
+
|
|
279
|
+
Filename prefix is irrelevant for skills — frontmatter is the only
|
|
280
|
+
signal. The stored artifact type still follows the filename rule
|
|
281
|
+
(so a skill in a spec file is both a 'spec' and a callable skill).
|
|
282
|
+
|
|
243
283
|
## Portal
|
|
244
284
|
|
|
245
285
|
Everything you do in the CLI is visible at:
|
|
@@ -33,6 +33,7 @@ function ghRaw(path) {
|
|
|
33
33
|
}
|
|
34
34
|
function parseArgs(args) {
|
|
35
35
|
let prUrl;
|
|
36
|
+
let base;
|
|
36
37
|
let forcedKey;
|
|
37
38
|
let noTruncate = false;
|
|
38
39
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -43,6 +44,12 @@ function parseArgs(args) {
|
|
|
43
44
|
else if (a.startsWith("--with=")) {
|
|
44
45
|
forcedKey = a.slice("--with=".length);
|
|
45
46
|
}
|
|
47
|
+
else if ((a === "--base" || a === "-b") && i + 1 < args.length) {
|
|
48
|
+
base = args[++i];
|
|
49
|
+
}
|
|
50
|
+
else if (a.startsWith("--base=")) {
|
|
51
|
+
base = a.slice("--base=".length);
|
|
52
|
+
}
|
|
46
53
|
else if (a === "--full") {
|
|
47
54
|
noTruncate = true;
|
|
48
55
|
}
|
|
@@ -50,19 +57,19 @@ function parseArgs(args) {
|
|
|
50
57
|
prUrl = a;
|
|
51
58
|
}
|
|
52
59
|
}
|
|
53
|
-
|
|
54
|
-
console.error("Usage: codiedev reverse-ticket <pr-url> [--with <artifact-key>] [--full]");
|
|
55
|
-
console.error("");
|
|
56
|
-
console.error("Example: codiedev reverse-ticket https://github.com/signalandcode/repo/pull/42");
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
return { prUrl, forcedKey, noTruncate };
|
|
60
|
+
return { prUrl, base, forcedKey, noTruncate };
|
|
60
61
|
}
|
|
61
62
|
async function runReverseTicket(args) {
|
|
62
|
-
const { prUrl, forcedKey, noTruncate } = parseArgs(args);
|
|
63
|
+
const { prUrl, base, forcedKey, noTruncate } = parseArgs(args);
|
|
64
|
+
// No PR URL → branch mode (mid-session, no PR exists yet).
|
|
65
|
+
if (!prUrl) {
|
|
66
|
+
return runBranchMode({ explicitBase: base, forcedKey });
|
|
67
|
+
}
|
|
63
68
|
const parsed = parsePrUrl(prUrl);
|
|
64
69
|
if (!parsed) {
|
|
65
70
|
console.error("Couldn't parse PR URL. Expected format: https://github.com/<owner>/<repo>/pull/<number>");
|
|
71
|
+
console.error("");
|
|
72
|
+
console.error("Run with no arguments for branch mode (uses your current local branch).");
|
|
66
73
|
process.exit(1);
|
|
67
74
|
}
|
|
68
75
|
if (!checkGhCli()) {
|
|
@@ -148,3 +155,135 @@ async function runReverseTicket(args) {
|
|
|
148
155
|
process.exit(1);
|
|
149
156
|
}
|
|
150
157
|
}
|
|
158
|
+
function git(cmd) {
|
|
159
|
+
try {
|
|
160
|
+
return (0, child_process_1.execSync)(`git ${cmd}`, {
|
|
161
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
162
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
163
|
+
})
|
|
164
|
+
.toString("utf8")
|
|
165
|
+
.trim();
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
throw new Error(`git ${cmd} failed: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function safeGit(fn) {
|
|
172
|
+
try {
|
|
173
|
+
return fn() || undefined;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function runBranchMode(opts) {
|
|
180
|
+
// Confirm we're in a git repo.
|
|
181
|
+
try {
|
|
182
|
+
git("rev-parse --is-inside-work-tree");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
console.error("Not inside a git repository. Run this from a checked-out repo.");
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
// Resolve base ref — explicit, then origin/main, then origin/master.
|
|
189
|
+
const candidates = opts.explicitBase
|
|
190
|
+
? [opts.explicitBase]
|
|
191
|
+
: ["origin/main", "origin/master"];
|
|
192
|
+
let baseRef = null;
|
|
193
|
+
for (const c of candidates) {
|
|
194
|
+
try {
|
|
195
|
+
git(`rev-parse --verify ${c}`);
|
|
196
|
+
baseRef = c;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// try next
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!baseRef) {
|
|
204
|
+
console.error(`Couldn't find a base ref (tried ${candidates.join(", ")}). Pass --base <ref>.`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const branch = git("rev-parse --abbrev-ref HEAD");
|
|
208
|
+
if (branch === "HEAD") {
|
|
209
|
+
console.error("Detached HEAD — checkout a branch first.");
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const baseSha = git(`merge-base HEAD ${baseRef}`);
|
|
213
|
+
const headSha = git("rev-parse HEAD");
|
|
214
|
+
if (baseSha === headSha) {
|
|
215
|
+
console.error(`No commits on ${branch} ahead of ${baseRef} — make a commit first.`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
const filesRaw = git(`diff --name-only ${baseSha}...HEAD`);
|
|
219
|
+
const filesChanged = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
|
|
220
|
+
if (filesChanged.length === 0) {
|
|
221
|
+
console.error(`No files changed between ${baseRef} and ${branch} — nothing to write up.`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
let diff = "";
|
|
225
|
+
try {
|
|
226
|
+
diff = git(`diff ${baseSha}...HEAD`).slice(0, 12_000);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// best effort
|
|
230
|
+
}
|
|
231
|
+
let repo;
|
|
232
|
+
try {
|
|
233
|
+
const remoteUrl = git("config --get remote.origin.url");
|
|
234
|
+
const m = remoteUrl.match(/[:/]([^/:]+)\/([^/:]+?)(?:\.git)?$/);
|
|
235
|
+
if (!m) {
|
|
236
|
+
throw new Error(`Couldn't parse owner/repo from remote: ${remoteUrl}`);
|
|
237
|
+
}
|
|
238
|
+
repo = `${m[1]}/${m[2]}`;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
console.error(`Couldn't read git remote.origin.url: ${err.message}`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
const title = safeGit(() => git("log -1 --format=%s")) ?? `WIP: ${branch}`;
|
|
245
|
+
const author = safeGit(() => git("config --get user.name"));
|
|
246
|
+
const authorEmail = safeGit(() => git("config --get user.email"));
|
|
247
|
+
console.log(`Branch: ${branch} (${repo})`);
|
|
248
|
+
console.log(`Base: ${baseRef} @ ${baseSha.slice(0, 12)}`);
|
|
249
|
+
console.log(`Head: ${headSha.slice(0, 12)}`);
|
|
250
|
+
console.log(`Files: ${filesChanged.length}`);
|
|
251
|
+
console.log("");
|
|
252
|
+
console.log("Generating ticket draft…");
|
|
253
|
+
console.log("");
|
|
254
|
+
const config = (0, shared_1.requireConfig)();
|
|
255
|
+
try {
|
|
256
|
+
const res = await (0, shared_1.apiRequest)("POST", "/api/cli/reverseTicketFromBranch", {
|
|
257
|
+
config,
|
|
258
|
+
body: {
|
|
259
|
+
branch: {
|
|
260
|
+
repo,
|
|
261
|
+
branch,
|
|
262
|
+
baseSha,
|
|
263
|
+
headSha,
|
|
264
|
+
title,
|
|
265
|
+
author,
|
|
266
|
+
authorEmail,
|
|
267
|
+
filesChanged,
|
|
268
|
+
diff: diff || undefined,
|
|
269
|
+
},
|
|
270
|
+
forcedArtifactKey: opts.forcedKey,
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
if (!res.ok || !res.artifactId) {
|
|
274
|
+
console.error("Reverse-ticket generation returned no result. The writer may have skipped due to missing context.");
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
const verb = res.action === "overwritten" ? "Updated" : "Created";
|
|
278
|
+
console.log(`✓ ${verb} reverse-ticket draft.`);
|
|
279
|
+
console.log(` artifact: ${res.artifactKey ?? res.artifactId}`);
|
|
280
|
+
console.log(` portal: ${res.portalUrl}`);
|
|
281
|
+
console.log("");
|
|
282
|
+
console.log("Open in your portal to review, edit, and push to Jira.");
|
|
283
|
+
console.log("When the PR opens later, the webhook dedupes against this draft — one ticket, not two.");
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
console.error(`Reverse-ticket failed: ${err.message}`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -52,7 +52,7 @@ const path = __importStar(require("path"));
|
|
|
52
52
|
const utils_1 = require("./utils");
|
|
53
53
|
const shared_1 = require("./commands/shared");
|
|
54
54
|
const PKG_NAME = "codiedev";
|
|
55
|
-
const PKG_VERSION = "0.
|
|
55
|
+
const PKG_VERSION = "0.5.0";
|
|
56
56
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
57
|
// Tool definitions — descriptions tuned so Claude/Codex resolve natural-language
|
|
58
58
|
// requests into the right tool without manual steering.
|
|
@@ -372,6 +372,41 @@ const TOOLS = [
|
|
|
372
372
|
},
|
|
373
373
|
},
|
|
374
374
|
},
|
|
375
|
+
{
|
|
376
|
+
name: "codiedev_reverse_ticket",
|
|
377
|
+
description: "Generate a Jira-ready reverse-ticket draft from the work the user has " +
|
|
378
|
+
"done in their CURRENT git branch — mid-session, before any PR exists. " +
|
|
379
|
+
"Reads local git state (current branch, divergence from main, diff, " +
|
|
380
|
+
"commits, files changed) and runs it through the codiedev reverse-" +
|
|
381
|
+
"ticket writer. The draft lands in the user's portal at " +
|
|
382
|
+
"/portal/agentic-ticketing where they can edit the title, description, " +
|
|
383
|
+
"and scope, then push to Jira. Use when the user asks to 'create a " +
|
|
384
|
+
"reverse ticket', 'draft a Jira ticket from this work', 'write up what " +
|
|
385
|
+
"I've done in this branch', or runs `codiedev reverse-ticket` directly. " +
|
|
386
|
+
"The webhook on PR-open later dedupes against this draft so the user " +
|
|
387
|
+
"never gets a double ticket. The draft is intentionally not auto-pushed " +
|
|
388
|
+
"to Jira — trust comes from the user reviewing in the portal first.",
|
|
389
|
+
inputSchema: {
|
|
390
|
+
type: "object",
|
|
391
|
+
properties: {
|
|
392
|
+
title: {
|
|
393
|
+
type: "string",
|
|
394
|
+
description: "Optional working title for the ticket. Defaults to the most " +
|
|
395
|
+
"recent commit subject on the branch.",
|
|
396
|
+
},
|
|
397
|
+
base: {
|
|
398
|
+
type: "string",
|
|
399
|
+
description: "Optional base ref to diff against. Defaults to 'origin/main', " +
|
|
400
|
+
"falls back to 'origin/master'.",
|
|
401
|
+
},
|
|
402
|
+
forcedArtifactKey: {
|
|
403
|
+
type: "string",
|
|
404
|
+
description: "Optional — force the writer to link to a specific artifact key " +
|
|
405
|
+
"for context (e.g. 'spec-auth.md').",
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
375
410
|
];
|
|
376
411
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
377
412
|
// Server
|
|
@@ -380,6 +415,26 @@ const server = new index_js_1.Server({ name: PKG_NAME, version: PKG_VERSION }, {
|
|
|
380
415
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
381
416
|
tools: TOOLS,
|
|
382
417
|
}));
|
|
418
|
+
/**
|
|
419
|
+
* Fire-and-forget telemetry — POST one event per tool invocation. Never blocks,
|
|
420
|
+
* never throws into the user-facing path. Drop silently on any failure.
|
|
421
|
+
*/
|
|
422
|
+
function emitTelemetry(config, event) {
|
|
423
|
+
if (!config)
|
|
424
|
+
return;
|
|
425
|
+
(0, shared_1.apiRequest)("POST", "/api/telemetry/mcp-event", {
|
|
426
|
+
config,
|
|
427
|
+
body: {
|
|
428
|
+
tool: event.tool,
|
|
429
|
+
ok: event.ok,
|
|
430
|
+
latencyMs: event.latencyMs,
|
|
431
|
+
error: event.error,
|
|
432
|
+
mcpVersion: PKG_VERSION,
|
|
433
|
+
},
|
|
434
|
+
}).catch(() => {
|
|
435
|
+
// Swallow — telemetry should never affect the user's session.
|
|
436
|
+
});
|
|
437
|
+
}
|
|
383
438
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
384
439
|
const config = (0, utils_1.readConfig)();
|
|
385
440
|
if (!config) {
|
|
@@ -397,40 +452,26 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
397
452
|
}
|
|
398
453
|
const { name, arguments: argsRaw } = request.params;
|
|
399
454
|
const args = (argsRaw ?? {});
|
|
455
|
+
const start = Date.now();
|
|
400
456
|
try {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return await handleNote(args, config);
|
|
412
|
-
case "codiedev_promote":
|
|
413
|
-
return await handlePromote(args, config);
|
|
414
|
-
case "codiedev_post_to_feed":
|
|
415
|
-
return await handlePostToFeed(args, config);
|
|
416
|
-
case "codiedev_share_with":
|
|
417
|
-
return await handleShareWith(args, config);
|
|
418
|
-
case "codiedev_send_to":
|
|
419
|
-
return await handleSendTo(args, config);
|
|
420
|
-
case "codiedev_react":
|
|
421
|
-
return await handleReact(args, config);
|
|
422
|
-
case "codiedev_search":
|
|
423
|
-
return await handleSearch(args, config);
|
|
424
|
-
case "codiedev_get_library":
|
|
425
|
-
return await handleGetLibrary(args, config);
|
|
426
|
-
default:
|
|
427
|
-
return {
|
|
428
|
-
isError: true,
|
|
429
|
-
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
430
|
-
};
|
|
431
|
-
}
|
|
457
|
+
const result = await dispatchTool(name, args, config);
|
|
458
|
+
emitTelemetry(config, {
|
|
459
|
+
tool: name,
|
|
460
|
+
ok: !result?.isError,
|
|
461
|
+
latencyMs: Date.now() - start,
|
|
462
|
+
error: result?.isError
|
|
463
|
+
? (result?.content?.[0]?.text ?? "").slice(0, 200) || "unknown error"
|
|
464
|
+
: undefined,
|
|
465
|
+
});
|
|
466
|
+
return result;
|
|
432
467
|
}
|
|
433
468
|
catch (err) {
|
|
469
|
+
emitTelemetry(config, {
|
|
470
|
+
tool: name,
|
|
471
|
+
ok: false,
|
|
472
|
+
latencyMs: Date.now() - start,
|
|
473
|
+
error: (err.message ?? "unknown").slice(0, 200),
|
|
474
|
+
});
|
|
434
475
|
return {
|
|
435
476
|
isError: true,
|
|
436
477
|
content: [
|
|
@@ -442,6 +483,41 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
442
483
|
};
|
|
443
484
|
}
|
|
444
485
|
});
|
|
486
|
+
async function dispatchTool(name, args, config) {
|
|
487
|
+
switch (name) {
|
|
488
|
+
case "codiedev_push":
|
|
489
|
+
return await handlePush(args, config);
|
|
490
|
+
case "codiedev_pull":
|
|
491
|
+
return await handlePull(args, config);
|
|
492
|
+
case "codiedev_ping":
|
|
493
|
+
return await handlePing(args, config);
|
|
494
|
+
case "codiedev_inbox":
|
|
495
|
+
return await handleInbox(args, config);
|
|
496
|
+
case "codiedev_note":
|
|
497
|
+
return await handleNote(args, config);
|
|
498
|
+
case "codiedev_promote":
|
|
499
|
+
return await handlePromote(args, config);
|
|
500
|
+
case "codiedev_post_to_feed":
|
|
501
|
+
return await handlePostToFeed(args, config);
|
|
502
|
+
case "codiedev_share_with":
|
|
503
|
+
return await handleShareWith(args, config);
|
|
504
|
+
case "codiedev_send_to":
|
|
505
|
+
return await handleSendTo(args, config);
|
|
506
|
+
case "codiedev_react":
|
|
507
|
+
return await handleReact(args, config);
|
|
508
|
+
case "codiedev_search":
|
|
509
|
+
return await handleSearch(args, config);
|
|
510
|
+
case "codiedev_get_library":
|
|
511
|
+
return await handleGetLibrary(args, config);
|
|
512
|
+
case "codiedev_reverse_ticket":
|
|
513
|
+
return await handleReverseTicket(args, config);
|
|
514
|
+
default:
|
|
515
|
+
return {
|
|
516
|
+
isError: true,
|
|
517
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
445
521
|
async function handlePush(args, config) {
|
|
446
522
|
const filename = asString(args.filename);
|
|
447
523
|
const markdown = asString(args.markdown);
|
|
@@ -763,6 +839,153 @@ function formatAmbiguousRecipient(err, to) {
|
|
|
763
839
|
}
|
|
764
840
|
throw err;
|
|
765
841
|
}
|
|
842
|
+
async function handleReverseTicket(args, config) {
|
|
843
|
+
const explicitTitle = asStringOrUndefined(args.title);
|
|
844
|
+
const explicitBase = asStringOrUndefined(args.base);
|
|
845
|
+
const forcedKey = asStringOrUndefined(args.forcedArtifactKey);
|
|
846
|
+
// Helper that runs git from cwd with a hard-fail on error.
|
|
847
|
+
const { execSync } = await Promise.resolve().then(() => __importStar(require("child_process")));
|
|
848
|
+
const git = (cmd) => {
|
|
849
|
+
try {
|
|
850
|
+
return execSync(`git ${cmd}`, {
|
|
851
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
852
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
853
|
+
})
|
|
854
|
+
.toString("utf8")
|
|
855
|
+
.trim();
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
throw new Error(`git ${cmd} failed: ${err.message}`);
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
// Confirm we're in a git repo.
|
|
862
|
+
try {
|
|
863
|
+
git("rev-parse --is-inside-work-tree");
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
throw new Error("Not inside a git repository. Run this from a checked-out repo.");
|
|
867
|
+
}
|
|
868
|
+
// Resolve base ref — explicit, then origin/main, then origin/master.
|
|
869
|
+
const candidates = explicitBase
|
|
870
|
+
? [explicitBase]
|
|
871
|
+
: ["origin/main", "origin/master"];
|
|
872
|
+
let baseRef = null;
|
|
873
|
+
for (const c of candidates) {
|
|
874
|
+
try {
|
|
875
|
+
git(`rev-parse --verify ${c}`);
|
|
876
|
+
baseRef = c;
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// try next
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (!baseRef) {
|
|
884
|
+
throw new Error(`Couldn't find a base ref (tried ${candidates.join(", ")}). ` +
|
|
885
|
+
`Pass an explicit base via the 'base' argument.`);
|
|
886
|
+
}
|
|
887
|
+
const branch = git("rev-parse --abbrev-ref HEAD");
|
|
888
|
+
if (branch === "HEAD") {
|
|
889
|
+
throw new Error("Detached HEAD — checkout a branch first.");
|
|
890
|
+
}
|
|
891
|
+
const baseSha = git(`merge-base HEAD ${baseRef}`);
|
|
892
|
+
const headSha = git("rev-parse HEAD");
|
|
893
|
+
if (baseSha === headSha) {
|
|
894
|
+
throw new Error(`No commits on ${branch} ahead of ${baseRef} — make a commit first.`);
|
|
895
|
+
}
|
|
896
|
+
const filesRaw = git(`diff --name-only ${baseSha}...HEAD`);
|
|
897
|
+
const filesChanged = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
|
|
898
|
+
if (filesChanged.length === 0) {
|
|
899
|
+
throw new Error(`No files changed between ${baseRef} and ${branch} — nothing to write up.`);
|
|
900
|
+
}
|
|
901
|
+
// Truncated diff (12k chars) — writer works without it but it sharpens output.
|
|
902
|
+
let diff = "";
|
|
903
|
+
try {
|
|
904
|
+
diff = git(`diff ${baseSha}...HEAD`).slice(0, 12_000);
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
// Best effort.
|
|
908
|
+
}
|
|
909
|
+
// Repo name from origin remote.
|
|
910
|
+
let repo;
|
|
911
|
+
try {
|
|
912
|
+
const remoteUrl = git("config --get remote.origin.url");
|
|
913
|
+
const m = remoteUrl.match(/[:/]([^/:]+)\/([^/:]+?)(?:\.git)?$/);
|
|
914
|
+
if (!m) {
|
|
915
|
+
throw new Error(`Couldn't parse owner/repo from remote: ${remoteUrl}`);
|
|
916
|
+
}
|
|
917
|
+
repo = `${m[1]}/${m[2]}`;
|
|
918
|
+
}
|
|
919
|
+
catch (err) {
|
|
920
|
+
throw new Error(`Couldn't read git remote.origin.url: ${err.message}`);
|
|
921
|
+
}
|
|
922
|
+
const title = explicitTitle ?? safeGit(() => git("log -1 --format=%s")) ?? `WIP: ${branch}`;
|
|
923
|
+
const author = safeGit(() => git("config --get user.name"));
|
|
924
|
+
const authorEmail = safeGit(() => git("config --get user.email"));
|
|
925
|
+
// Post to backend.
|
|
926
|
+
const res = await (0, shared_1.apiRequest)("POST", "/api/cli/reverseTicketFromBranch", {
|
|
927
|
+
config,
|
|
928
|
+
body: {
|
|
929
|
+
branch: {
|
|
930
|
+
repo,
|
|
931
|
+
branch,
|
|
932
|
+
baseSha,
|
|
933
|
+
headSha,
|
|
934
|
+
title,
|
|
935
|
+
author,
|
|
936
|
+
authorEmail,
|
|
937
|
+
filesChanged,
|
|
938
|
+
diff: diff || undefined,
|
|
939
|
+
},
|
|
940
|
+
forcedArtifactKey: forcedKey,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
if (!res.ok || !res.artifactId) {
|
|
944
|
+
return {
|
|
945
|
+
isError: true,
|
|
946
|
+
content: [
|
|
947
|
+
{
|
|
948
|
+
type: "text",
|
|
949
|
+
text: "Reverse-ticket generation returned no result. The writer may have " +
|
|
950
|
+
"skipped due to missing context (no captured sessions, etc.). Try " +
|
|
951
|
+
"again after working on the branch a bit more, or check the " +
|
|
952
|
+
"server logs.",
|
|
953
|
+
},
|
|
954
|
+
],
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
const portalBase = (config.backendUrl || "")
|
|
958
|
+
.replace(/\/$/, "")
|
|
959
|
+
.replace(/\.convex\.cloud$/, ".convex.cloud"); // backendUrl is the Convex site; portal is separate
|
|
960
|
+
// Best effort — surface the portal path; the user can prefix with their portal host.
|
|
961
|
+
const portalPath = res.portalUrl;
|
|
962
|
+
const verb = res.action === "overwritten" ? "Updated" : "Created";
|
|
963
|
+
return {
|
|
964
|
+
content: [
|
|
965
|
+
{
|
|
966
|
+
type: "text",
|
|
967
|
+
text: `✓ ${verb} reverse-ticket draft for branch \`${branch}\` (${repo}).\n` +
|
|
968
|
+
` files changed: ${filesChanged.length}\n` +
|
|
969
|
+
` base: ${baseSha.slice(0, 12)} → head: ${headSha.slice(0, 12)} (${baseRef})\n` +
|
|
970
|
+
` diff size: ${diff.length} chars\n` +
|
|
971
|
+
` artifact: ${res.artifactKey ?? res.artifactId}\n\n` +
|
|
972
|
+
`Open in your portal to review, edit, and push to Jira:\n` +
|
|
973
|
+
` ${portalPath}\n\n` +
|
|
974
|
+
`When the PR opens later, the webhook dedupes against this draft — ` +
|
|
975
|
+
`you'll get one ticket, not two.`,
|
|
976
|
+
},
|
|
977
|
+
],
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
// Run a git invocation; return undefined instead of throwing.
|
|
981
|
+
function safeGit(fn) {
|
|
982
|
+
try {
|
|
983
|
+
return fn();
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return undefined;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
766
989
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
767
990
|
// Helpers
|
|
768
991
|
// ─────────────────────────────────────────────────────────────────────────────
|