Skip to content

Commit c774e74

Browse files
authored
refactor(project create): migrate human output to markdown rendering system (#341)
Migrate `project create` from ad-hoc `stdout.write()` + `writeKeyValue()` formatting to the markdown-first rendering pipeline used by all other detail/view commands. ## Changes - **New `formatProjectCreated()` in `human.ts`** — Builds CommonMark using `mdKvTable()`, `escapeMarkdownInline()`, `safeCodeSpan()`, and `renderMarkdown()`. Notes (slug divergence, auto-created/auto-selected team) use markdown blockquotes. Tip footer uses italic text. - **Simplified `create.ts` output path** — Replaced ~30 lines of multi-write output with a single `formatProjectCreated()` call. JSON output and error handling unchanged. - **Removed `writeKeyValue()` from `output.ts`** — Dead code; `project create` was its only consumer. - **Updated test assertions** — Slug divergence note now uses backtick code spans instead of single quotes. All 32 tests pass. ## Output **TTY** — Unicode box-drawing tables, styled headings, terminal hyperlinks: ``` Created project 'my-app' in acme-corp Note: Using team 'engineering'. See all teams: sentry team list ╭──────────┬──────────────────────────────────────────────╮ │ Project │ my-app │ │ Slug │ my-app │ │ Org │ acme-corp │ │ Team │ engineering │ │ Platform │ python │ │ DSN │ https://abc@o123.ingest.us.sentry.io/999 │ │ URL │ https://sentry.io/settings/acme-corp/… │ ╰──────────┴──────────────────────────────────────────────╯ Tip: Use sentry project view acme-corp/my-app for details ``` **Plain/piped** — Clean CommonMark (same as all other commands).
1 parent feb415c commit c774e74

File tree

4 files changed

+98
-60
lines changed

4 files changed

+98
-60
lines changed

src/commands/project/create.ts

Lines changed: 13 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,7 @@ import {
3030
CliError,
3131
ContextError,
3232
} from "../../lib/errors.js";
33-
import {
34-
writeFooter,
35-
writeJson,
36-
writeKeyValue,
37-
} from "../../lib/formatters/index.js";
33+
import { formatProjectCreated, writeJson } from "../../lib/formatters/index.js";
3834
import { resolveOrg } from "../../lib/resolve-target.js";
3935
import {
4036
buildOrgNotFoundError,
@@ -404,44 +400,20 @@ export const createCommand = buildCommand({
404400

405401
// Human-readable output
406402
const url = buildProjectUrl(orgSlug, project.slug);
407-
const fields: [string, string][] = [
408-
["Project", project.name],
409-
["Slug", project.slug],
410-
["Org", orgSlug],
411-
["Team", team.slug],
412-
["Platform", project.platform || platform],
413-
];
414-
if (dsn) {
415-
fields.push(["DSN", dsn]);
416-
}
417-
fields.push(["URL", url]);
418-
419-
stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n`);
420-
421-
// Sentry may adjust the slug to avoid collisions (e.g., "my-app" → "my-app-0g")
422403
const expectedSlug = slugify(name);
423-
if (project.slug !== expectedSlug) {
424-
stdout.write(
425-
`Note: Slug '${project.slug}' was assigned because '${expectedSlug}' is already taken.\n`
426-
);
427-
}
428-
429-
// Inform user which team was used when not explicitly specified
430-
if (team.source === "auto-created") {
431-
stdout.write(`Note: Created team '${team.slug}' (org had no teams).\n`);
432-
} else if (team.source === "auto-selected") {
433-
stdout.write(
434-
`Note: Using team '${team.slug}'. ` +
435-
"See all teams: sentry team list\n"
436-
);
437-
}
438-
439-
stdout.write("\n");
440-
writeKeyValue(stdout, fields);
441404

442-
writeFooter(
443-
stdout,
444-
`Tip: Use 'sentry project view ${orgSlug}/${project.slug}' for details`
405+
stdout.write(
406+
`${formatProjectCreated({
407+
project,
408+
orgSlug,
409+
teamSlug: team.slug,
410+
teamSource: team.source,
411+
requestedPlatform: platform,
412+
dsn,
413+
url,
414+
slugDiverged: project.slug !== expectedSlug,
415+
expectedSlug,
416+
})}\n`
445417
);
446418
},
447419
});

src/lib/formatters/human.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,89 @@ export function formatProjectDetails(
12341234
return renderMarkdown(lines.join("\n"));
12351235
}
12361236

1237+
// Project Creation Formatting
1238+
1239+
/** Input for the project-created success formatter */
1240+
export type ProjectCreatedResult = {
1241+
/** The created project */
1242+
project: SentryProject;
1243+
/** Organization slug the project was created in */
1244+
orgSlug: string;
1245+
/** Team slug the project was assigned to */
1246+
teamSlug: string;
1247+
/** How the team was resolved */
1248+
teamSource: "explicit" | "auto-selected" | "auto-created";
1249+
/** The platform the user requested via CLI argument (used as fallback display) */
1250+
requestedPlatform: string;
1251+
/** Primary DSN, if fetched successfully */
1252+
dsn: string | null;
1253+
/** Sentry web URL for the project settings page */
1254+
url: string;
1255+
/** Whether Sentry assigned a different slug than expected */
1256+
slugDiverged: boolean;
1257+
/** The slug the user expected (derived from the project name) */
1258+
expectedSlug: string;
1259+
};
1260+
1261+
/**
1262+
* Format a successful project creation as rendered markdown.
1263+
*
1264+
* Includes a heading, contextual notes (slug divergence, team auto-selection),
1265+
* a key-value detail table, and a tip footer.
1266+
*
1267+
* @param result - Project creation context
1268+
* @returns Rendered terminal string
1269+
*/
1270+
export function formatProjectCreated(result: ProjectCreatedResult): string {
1271+
const lines: string[] = [];
1272+
1273+
lines.push(
1274+
`## Created project '${escapeMarkdownInline(result.project.name)}' in ${escapeMarkdownInline(result.orgSlug)}`
1275+
);
1276+
lines.push("");
1277+
1278+
// Slug divergence note
1279+
if (result.slugDiverged) {
1280+
lines.push(
1281+
`> **Note:** Slug \`${result.project.slug}\` was assigned because \`${result.expectedSlug}\` is already taken.`
1282+
);
1283+
lines.push("");
1284+
}
1285+
1286+
// Team source notes
1287+
if (result.teamSource === "auto-created") {
1288+
lines.push(
1289+
`> **Note:** Created team '${escapeMarkdownInline(result.teamSlug)}' (org had no teams).`
1290+
);
1291+
lines.push("");
1292+
} else if (result.teamSource === "auto-selected") {
1293+
lines.push(
1294+
`> **Note:** Using team '${escapeMarkdownInline(result.teamSlug)}'. See all teams: \`sentry team list\``
1295+
);
1296+
lines.push("");
1297+
}
1298+
1299+
const kvRows: [string, string][] = [
1300+
["Project", escapeMarkdownInline(result.project.name)],
1301+
["Slug", safeCodeSpan(result.project.slug)],
1302+
["Org", safeCodeSpan(result.orgSlug)],
1303+
["Team", safeCodeSpan(result.teamSlug)],
1304+
["Platform", result.project.platform || result.requestedPlatform],
1305+
];
1306+
if (result.dsn) {
1307+
kvRows.push(["DSN", safeCodeSpan(result.dsn)]);
1308+
}
1309+
kvRows.push(["URL", result.url]);
1310+
1311+
lines.push(mdKvTable(kvRows));
1312+
lines.push("");
1313+
lines.push(
1314+
`*Tip: Use \`sentry project view ${result.orgSlug}/${result.project.slug}\` for details*`
1315+
);
1316+
1317+
return renderMarkdown(lines.join("\n"));
1318+
}
1319+
12371320
// User Identity Formatting
12381321

12391322
/**

src/lib/formatters/output.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,3 @@ export function writeFooter(stdout: Writer, text: string): void {
5555
stdout.write("\n");
5656
stdout.write(`${muted(text)}\n`);
5757
}
58-
59-
/**
60-
* Write key-value pairs with aligned columns.
61-
* Used for human-readable output after resource creation.
62-
*/
63-
export function writeKeyValue(
64-
stdout: Writer,
65-
pairs: [label: string, value: string][]
66-
): void {
67-
if (pairs.length === 0) {
68-
return;
69-
}
70-
const maxLabel = Math.max(...pairs.map(([l]) => l.length));
71-
for (const [label, value] of pairs) {
72-
stdout.write(` ${label.padEnd(maxLabel + 2)}${value}\n`);
73-
}
74-
}

test/commands/project/create.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,8 @@ describe("project create", () => {
475475
await func.call(context, { json: false }, "my-app", "node");
476476

477477
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
478-
expect(output).toContain("Slug 'my-app-0g' was assigned");
479-
expect(output).toContain("'my-app' is already taken");
478+
expect(output).toContain("Slug `my-app-0g` was assigned");
479+
expect(output).toContain("`my-app` is already taken");
480480
});
481481

482482
test("does not show slug note when slug matches name", async () => {

0 commit comments

Comments
 (0)