Skip to content

Commit d99f191

Browse files
committed
fix(commands): support org/project/id as single positional arg (#257)
Parse slash-separated single arguments (e.g., `sentry event view sentry/cli/abc123`) as org/project/id instead of treating the entire string as the ID. Event IDs, trace IDs, and log IDs are hex strings that never contain slashes, so any single arg with slashes is unambiguously a structured format. - 0 slashes: plain ID (existing behavior) - 1 slash: org/project missing ID → ContextError - 2+ slashes: split on last / → target + ID
1 parent ff4195f commit d99f191

File tree

8 files changed

+263
-13
lines changed

8 files changed

+263
-13
lines changed

src/commands/event/view.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,31 @@ export function parsePositionalArgs(args: string[]): {
113113
}
114114

115115
if (args.length === 1) {
116-
// Single arg - must be event ID
117-
return { eventId: first, targetArg: undefined };
116+
const slashIdx = first.indexOf("/");
117+
118+
if (slashIdx === -1) {
119+
// No slashes — plain event ID
120+
return { eventId: first, targetArg: undefined };
121+
}
122+
123+
// Event IDs are hex and never contain "/" — this must be a structured
124+
// "org/project/eventId" or "org/project" (missing event ID)
125+
const lastSlashIdx = first.lastIndexOf("/");
126+
127+
if (slashIdx === lastSlashIdx) {
128+
// Exactly one slash: "org/project" without event ID
129+
throw new ContextError("Event ID", USAGE_HINT);
130+
}
131+
132+
// Two+ slashes: split on last "/" → target + eventId
133+
const targetArg = first.slice(0, lastSlashIdx);
134+
const eventId = first.slice(lastSlashIdx + 1);
135+
136+
if (!eventId) {
137+
throw new ContextError("Event ID", USAGE_HINT);
138+
}
139+
140+
return { eventId, targetArg };
118141
}
119142

120143
const second = args[1];

src/commands/log/view.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,31 @@ export function parsePositionalArgs(args: string[]): {
4848
}
4949

5050
if (args.length === 1) {
51-
// Single arg - must be log ID
52-
return { logId: first, targetArg: undefined };
51+
const slashIdx = first.indexOf("/");
52+
53+
if (slashIdx === -1) {
54+
// No slashes — plain log ID
55+
return { logId: first, targetArg: undefined };
56+
}
57+
58+
// Log IDs are hex and never contain "/" — this must be a structured
59+
// "org/project/logId" or "org/project" (missing log ID)
60+
const lastSlashIdx = first.lastIndexOf("/");
61+
62+
if (slashIdx === lastSlashIdx) {
63+
// Exactly one slash: "org/project" without log ID
64+
throw new ContextError("Log ID", USAGE_HINT);
65+
}
66+
67+
// Two+ slashes: split on last "/" → target + logId
68+
const targetArg = first.slice(0, lastSlashIdx);
69+
const logId = first.slice(lastSlashIdx + 1);
70+
71+
if (!logId) {
72+
throw new ContextError("Log ID", USAGE_HINT);
73+
}
74+
75+
return { logId, targetArg };
5376
}
5477

5578
const second = args[1];

src/commands/trace/view.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,31 @@ export function parsePositionalArgs(args: string[]): {
5555
}
5656

5757
if (args.length === 1) {
58-
// Single arg - must be trace ID
59-
return { traceId: first, targetArg: undefined };
58+
const slashIdx = first.indexOf("/");
59+
60+
if (slashIdx === -1) {
61+
// No slashes — plain trace ID
62+
return { traceId: first, targetArg: undefined };
63+
}
64+
65+
// Trace IDs are hex and never contain "/" — this must be a structured
66+
// "org/project/traceId" or "org/project" (missing trace ID)
67+
const lastSlashIdx = first.lastIndexOf("/");
68+
69+
if (slashIdx === lastSlashIdx) {
70+
// Exactly one slash: "org/project" without trace ID
71+
throw new ContextError("Trace ID", USAGE_HINT);
72+
}
73+
74+
// Two+ slashes: split on last "/" → target + traceId
75+
const targetArg = first.slice(0, lastSlashIdx);
76+
const traceId = first.slice(lastSlashIdx + 1);
77+
78+
if (!traceId) {
79+
throw new ContextError("Trace ID", USAGE_HINT);
80+
}
81+
82+
return { traceId, targetArg };
6083
}
6184

6285
const second = args[1];

test/commands/event/view.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ describe("parsePositionalArgs", () => {
7272
});
7373
});
7474

75+
describe("slash-separated org/project/eventId (single arg)", () => {
76+
test("parses org/project/eventId as target + event ID", () => {
77+
const result = parsePositionalArgs(["sentry/cli/abc123def"]);
78+
expect(result.targetArg).toBe("sentry/cli");
79+
expect(result.eventId).toBe("abc123def");
80+
});
81+
82+
test("parses with long hex event ID", () => {
83+
const result = parsePositionalArgs([
84+
"my-org/frontend/a1b2c3d4e5f67890abcdef1234567890",
85+
]);
86+
expect(result.targetArg).toBe("my-org/frontend");
87+
expect(result.eventId).toBe("a1b2c3d4e5f67890abcdef1234567890");
88+
});
89+
90+
test("handles hyphenated org and project slugs", () => {
91+
const result = parsePositionalArgs(["my-org/my-project/deadbeef"]);
92+
expect(result.targetArg).toBe("my-org/my-project");
93+
expect(result.eventId).toBe("deadbeef");
94+
});
95+
96+
test("one slash (org/project, missing event ID) throws ContextError", () => {
97+
expect(() => parsePositionalArgs(["sentry/cli"])).toThrow(ContextError);
98+
});
99+
100+
test("trailing slash (org/project/) throws ContextError", () => {
101+
expect(() => parsePositionalArgs(["sentry/cli/"])).toThrow(ContextError);
102+
});
103+
104+
test("one-slash ContextError mentions Event ID", () => {
105+
try {
106+
parsePositionalArgs(["sentry/cli"]);
107+
expect.unreachable("Should have thrown");
108+
} catch (error) {
109+
expect(error).toBeInstanceOf(ContextError);
110+
expect((error as ContextError).message).toContain("Event ID");
111+
}
112+
});
113+
});
114+
75115
describe("edge cases", () => {
76116
test("handles more than two args (ignores extras)", () => {
77117
const result = parsePositionalArgs([

test/commands/log/view.property.test.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test";
99
import {
1010
array,
1111
assert as fcAssert,
12+
pre,
1213
property,
1314
string,
1415
stringMatching,
@@ -27,10 +28,13 @@ const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/);
2728
/** Non-empty strings for general args */
2829
const nonEmptyStringArb = string({ minLength: 1, maxLength: 50 });
2930

31+
/** Non-empty strings without slashes (valid plain IDs) */
32+
const plainIdArb = nonEmptyStringArb.filter((s) => !s.includes("/"));
33+
3034
describe("parsePositionalArgs properties", () => {
31-
test("single arg: always returns it as logId with undefined targetArg", async () => {
35+
test("single arg without slashes: returns it as logId with undefined targetArg", async () => {
3236
await fcAssert(
33-
property(nonEmptyStringArb, (input) => {
37+
property(plainIdArb, (input) => {
3438
const result = parsePositionalArgs([input]);
3539
expect(result.logId).toBe(input);
3640
expect(result.targetArg).toBeUndefined();
@@ -39,6 +43,29 @@ describe("parsePositionalArgs properties", () => {
3943
);
4044
});
4145

46+
test("single arg org/project/logId: splits into target and logId", async () => {
47+
await fcAssert(
48+
property(tuple(slugArb, slugArb, logIdArb), ([org, project, logId]) => {
49+
const combined = `${org}/${project}/${logId}`;
50+
const result = parsePositionalArgs([combined]);
51+
expect(result.targetArg).toBe(`${org}/${project}`);
52+
expect(result.logId).toBe(logId);
53+
}),
54+
{ numRuns: DEFAULT_NUM_RUNS }
55+
);
56+
});
57+
58+
test("single arg with one slash: throws ContextError (missing log ID)", async () => {
59+
await fcAssert(
60+
property(tuple(slugArb, slugArb), ([org, project]) => {
61+
expect(() => parsePositionalArgs([`${org}/${project}`])).toThrow(
62+
ContextError
63+
);
64+
}),
65+
{ numRuns: DEFAULT_NUM_RUNS }
66+
);
67+
});
68+
4269
test("two args: first is always targetArg, second is always logId", async () => {
4370
await fcAssert(
4471
property(
@@ -91,6 +118,9 @@ describe("parsePositionalArgs properties", () => {
91118
property(
92119
array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }),
93120
(args) => {
121+
// Skip single-arg with slashes — those throw ContextError (tested separately)
122+
pre(args.length > 1 || !args[0]?.includes("/"));
123+
94124
const result1 = parsePositionalArgs(args);
95125
const result2 = parsePositionalArgs(args);
96126
expect(result1).toEqual(result2);
@@ -109,6 +139,9 @@ describe("parsePositionalArgs properties", () => {
109139
property(
110140
array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }),
111141
(args) => {
142+
// Skip single-arg with slashes — those throw ContextError (tested separately)
143+
pre(args.length > 1 || !args[0]?.includes("/"));
144+
112145
const result = parsePositionalArgs(args);
113146
expect(result.logId).toBeDefined();
114147
expect(typeof result.logId).toBe("string");
@@ -118,10 +151,10 @@ describe("parsePositionalArgs properties", () => {
118151
);
119152
});
120153

121-
test("result targetArg is undefined for single arg, defined for multiple", async () => {
122-
// Single arg case
154+
test("result targetArg is undefined for single slash-free arg, defined for multiple", async () => {
155+
// Single arg case (without slashes)
123156
await fcAssert(
124-
property(nonEmptyStringArb, (input) => {
157+
property(plainIdArb, (input) => {
125158
const result = parsePositionalArgs([input]);
126159
expect(result.targetArg).toBeUndefined();
127160
}),

test/commands/log/view.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,42 @@ describe("parsePositionalArgs", () => {
7070
});
7171
});
7272

73+
describe("slash-separated org/project/logId (single arg)", () => {
74+
test("parses org/project/logId as target + log ID", () => {
75+
const result = parsePositionalArgs([
76+
"sentry/cli/968c763c740cfda8b6728f27fb9e9b01",
77+
]);
78+
expect(result.targetArg).toBe("sentry/cli");
79+
expect(result.logId).toBe("968c763c740cfda8b6728f27fb9e9b01");
80+
});
81+
82+
test("handles hyphenated org and project slugs", () => {
83+
const result = parsePositionalArgs([
84+
"my-org/my-project/deadbeef12345678",
85+
]);
86+
expect(result.targetArg).toBe("my-org/my-project");
87+
expect(result.logId).toBe("deadbeef12345678");
88+
});
89+
90+
test("one slash (org/project, missing log ID) throws ContextError", () => {
91+
expect(() => parsePositionalArgs(["sentry/cli"])).toThrow(ContextError);
92+
});
93+
94+
test("trailing slash (org/project/) throws ContextError", () => {
95+
expect(() => parsePositionalArgs(["sentry/cli/"])).toThrow(ContextError);
96+
});
97+
98+
test("one-slash ContextError mentions Log ID", () => {
99+
try {
100+
parsePositionalArgs(["sentry/cli"]);
101+
expect.unreachable("Should have thrown");
102+
} catch (error) {
103+
expect(error).toBeInstanceOf(ContextError);
104+
expect((error as ContextError).message).toContain("Log ID");
105+
}
106+
});
107+
});
108+
73109
describe("edge cases", () => {
74110
test("handles more than two args (ignores extras)", () => {
75111
const result = parsePositionalArgs([

test/commands/trace/view.property.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test";
99
import {
1010
array,
1111
assert as fcAssert,
12+
pre,
1213
property,
1314
string,
1415
stringMatching,
@@ -27,10 +28,13 @@ const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/);
2728
/** Non-empty strings for general args */
2829
const nonEmptyStringArb = string({ minLength: 1, maxLength: 50 });
2930

31+
/** Non-empty strings without slashes (valid plain IDs) */
32+
const plainIdArb = nonEmptyStringArb.filter((s) => !s.includes("/"));
33+
3034
describe("parsePositionalArgs properties", () => {
31-
test("single arg: always returns it as traceId with undefined targetArg", async () => {
35+
test("single arg without slashes: returns it as traceId with undefined targetArg", async () => {
3236
await fcAssert(
33-
property(nonEmptyStringArb, (input) => {
37+
property(plainIdArb, (input) => {
3438
const result = parsePositionalArgs([input]);
3539
expect(result.traceId).toBe(input);
3640
expect(result.targetArg).toBeUndefined();
@@ -39,6 +43,32 @@ describe("parsePositionalArgs properties", () => {
3943
);
4044
});
4145

46+
test("single arg org/project/traceId: splits into target and traceId", async () => {
47+
await fcAssert(
48+
property(
49+
tuple(slugArb, slugArb, traceIdArb),
50+
([org, project, traceId]) => {
51+
const combined = `${org}/${project}/${traceId}`;
52+
const result = parsePositionalArgs([combined]);
53+
expect(result.targetArg).toBe(`${org}/${project}`);
54+
expect(result.traceId).toBe(traceId);
55+
}
56+
),
57+
{ numRuns: DEFAULT_NUM_RUNS }
58+
);
59+
});
60+
61+
test("single arg with one slash: throws ContextError (missing trace ID)", async () => {
62+
await fcAssert(
63+
property(tuple(slugArb, slugArb), ([org, project]) => {
64+
expect(() => parsePositionalArgs([`${org}/${project}`])).toThrow(
65+
ContextError
66+
);
67+
}),
68+
{ numRuns: DEFAULT_NUM_RUNS }
69+
);
70+
});
71+
4272
test("two args: first is always targetArg, second is always traceId", async () => {
4373
await fcAssert(
4474
property(
@@ -94,6 +124,9 @@ describe("parsePositionalArgs properties", () => {
94124
property(
95125
array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }),
96126
(args) => {
127+
// Skip single-arg with slashes — those throw ContextError (tested separately)
128+
pre(args.length > 1 || !args[0]?.includes("/"));
129+
97130
const result1 = parsePositionalArgs(args);
98131
const result2 = parsePositionalArgs(args);
99132
expect(result1).toEqual(result2);
@@ -112,6 +145,9 @@ describe("parsePositionalArgs properties", () => {
112145
property(
113146
array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }),
114147
(args) => {
148+
// Skip single-arg with slashes — those throw ContextError (tested separately)
149+
pre(args.length > 1 || !args[0]?.includes("/"));
150+
115151
const result = parsePositionalArgs(args);
116152
expect(result.traceId).toBeDefined();
117153
expect(typeof result.traceId).toBe("string");

test/commands/trace/view.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,42 @@ describe("parsePositionalArgs", () => {
5858
});
5959
});
6060

61+
describe("slash-separated org/project/traceId (single arg)", () => {
62+
test("parses org/project/traceId as target + trace ID", () => {
63+
const result = parsePositionalArgs([
64+
"sentry/cli/aaaa1111bbbb2222cccc3333dddd4444",
65+
]);
66+
expect(result.targetArg).toBe("sentry/cli");
67+
expect(result.traceId).toBe("aaaa1111bbbb2222cccc3333dddd4444");
68+
});
69+
70+
test("handles hyphenated org and project slugs", () => {
71+
const result = parsePositionalArgs([
72+
"my-org/my-project/deadbeef12345678",
73+
]);
74+
expect(result.targetArg).toBe("my-org/my-project");
75+
expect(result.traceId).toBe("deadbeef12345678");
76+
});
77+
78+
test("one slash (org/project, missing trace ID) throws ContextError", () => {
79+
expect(() => parsePositionalArgs(["sentry/cli"])).toThrow(ContextError);
80+
});
81+
82+
test("trailing slash (org/project/) throws ContextError", () => {
83+
expect(() => parsePositionalArgs(["sentry/cli/"])).toThrow(ContextError);
84+
});
85+
86+
test("one-slash ContextError mentions Trace ID", () => {
87+
try {
88+
parsePositionalArgs(["sentry/cli"]);
89+
expect.unreachable("Should have thrown");
90+
} catch (error) {
91+
expect(error).toBeInstanceOf(ContextError);
92+
expect((error as ContextError).message).toContain("Trace ID");
93+
}
94+
});
95+
});
96+
6197
describe("edge cases", () => {
6298
test("handles more than two args (ignores extras)", () => {
6399
const result = parsePositionalArgs([

0 commit comments

Comments
 (0)