Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ All notable user-visible changes to CASCADE are documented here. The format is l

### Fixed

- **Linear and JIRA inline checklist updates no longer lose sibling checklist rows during concurrent updates.** Both providers rewrite the whole issue description for checklist mutations, so their read/mutate/write path is now serialized per provider/work item with a stale-safe temp-file lock and provider-only retry semantics. The shared inline checklist parser also keeps scanning through prose, indented detail lines, and bullet detail lines until the next heading, so `ReadWorkItem` reports every visible checkbox row under a checklist heading. See Linear issue [MNG-656](https://linear.app/issue/MNG-656).

- **`ReadWorkItem` examples now render PM IDs as runnable bare CLI values.** Native-tool prompt guidance and `cascade-tools pm read-work-item --help` now show `--workItemId abc123` instead of JSON-string-literal forms like `--workItemId '"abc123"'`. The CLI also strips one accidental outer quote layer for `ReadWorkItem` IDs only, so a copied bad example no longer sends literal quote characters to the PM provider. See Trello card [M5f9T1D7](https://trello.com/c/M5f9T1D7/673-frictionlow-readworkitem-example-quoting-produced-trello-card-id-with-literal-quotes).

- **`cascade-tools scm create-pr-review` now accepts `--body-file <path>` and `--body-file -`.** This matches the generated CreatePRReview guidance and the existing CreatePR / PostPRComment file-input pattern for long Markdown bodies. See Trello card [7kmo42o6](https://trello.com/c/7kmo42o6/691-friction-tooling-low-createprreview-docs-advertise-body-file-but-cli-rejects-it).
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/07-gadgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,11 @@ cascade-tools pm report-friction \
| Gadget | Capability | Purpose |
|--------|-----------|---------|
| `GetAlertingIssue` | `alerting:read` | Fetch Sentry issue details |
| `GetAlertingEventDetail` | `alerting:read` | Fetch specific event with stacktrace |
| `GetAlertingEventDetail` | `alerting:read` | Fetch Sentry issue-event details with stacktrace, tags, breadcrumbs, request data, and context |
| `ListAlertingEvents` | `alerting:read` | List recent events for an issue |

`GetAlertingEventDetail` accepts Sentry's issue-event response shape, including REST aliases from the [Retrieve an Issue Event API](https://docs.sentry.io/api/events/retrieve-an-issue-event/).

## cascade-tools CLI

`src/cli/` — the `cascade-tools` binary
Expand Down
201 changes: 178 additions & 23 deletions src/gadgets/sentry/core/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
SentryEvent,
SentryException,
SentryIssue,
SentryRequest,
SentryStackFrame,
} from '../../../sentry/types.js';

Expand Down Expand Up @@ -51,24 +52,28 @@ export function formatSentryIssue(issue: SentryIssue): string {

function formatStackFrame(frame: SentryStackFrame, index: number): string {
const lines: string[] = [];
const location = [frame.filename ?? frame.abs_path, frame.lineno].filter(Boolean).join(':');
const lineno = frame.lineno ?? frame.lineNo;
const location = [frame.filename ?? frame.abs_path ?? frame.absPath, lineno]
.filter(Boolean)
.join(':');
const fn = frame.function ?? '<anonymous>';
const inApp = frame.in_app ? ' [in_app]' : '';
const inApp = (frame.in_app ?? frame.inApp) ? ' [in_app]' : '';

lines.push(` Frame ${index}: ${fn}${inApp}`);
if (location) lines.push(` at ${location}`);

// Source context
if (frame.pre_context?.length) {
for (const line of frame.pre_context) {
const sourceContext = normalizeFrameSourceContext(frame);
if (sourceContext.pre.length) {
for (const line of sourceContext.pre) {
lines.push(` | ${line}`);
}
}
if (frame.context_line !== undefined) {
lines.push(` > | ${frame.context_line} ← error here`);
if (sourceContext.current !== undefined) {
lines.push(` > | ${sourceContext.current} ← error here`);
}
if (frame.post_context?.length) {
for (const line of frame.post_context) {
if (sourceContext.post.length) {
for (const line of sourceContext.post) {
lines.push(` | ${line}`);
}
}
Expand All @@ -81,6 +86,39 @@ function formatStackFrame(frame: SentryStackFrame, index: number): string {
return lines.join('\n');
}

function normalizeFrameSourceContext(frame: SentryStackFrame): {
pre: string[];
current?: string;
post: string[];
} {
if (frame.pre_context?.length || frame.context_line !== undefined || frame.post_context?.length) {
return {
pre: frame.pre_context ?? [],
current: frame.context_line,
post: frame.post_context ?? [],
};
}

if (!frame.context?.length) {
return { pre: [], post: [] };
}

const lineNo = frame.lineno ?? frame.lineNo;
const currentIndex =
lineNo === undefined
? -1
: frame.context.findIndex(([contextLineNo]) => contextLineNo === lineNo);
if (currentIndex < 0) {
return { pre: frame.context.map(([, line]) => line), post: [] };
}

return {
pre: frame.context.slice(0, currentIndex).map(([, line]) => line),
current: frame.context[currentIndex][1],
post: frame.context.slice(currentIndex + 1).map(([, line]) => line),
};
}

function formatException(exc: SentryException): string {
const lines: string[] = [];
const header = [exc.type, exc.value].filter(Boolean).join(': ');
Expand Down Expand Up @@ -128,32 +166,54 @@ function formatBreadcrumbs(breadcrumbs: SentryBreadcrumb[]): string {
// ============================================================================

function appendEventMeta(lines: string[], event: SentryEvent): void {
if (event.event_id) lines.push(`Event ID: ${event.event_id}`);
if (event.timestamp) lines.push(`Timestamp: ${event.timestamp}`);
const eventId = getEventId(event);
const timestamp = getEventTimestamp(event);
const release = getReleaseValue(event.release);
if (eventId) lines.push(`Event ID: ${eventId}`);
if (timestamp) lines.push(`Timestamp: ${timestamp}`);
if (event.environment) lines.push(`Environment: ${event.environment}`);
if (event.release) lines.push(`Release: ${event.release}`);
if (release) lines.push(`Release: ${release}`);
if (event.platform) lines.push(`Platform: ${event.platform}`);
if (event.transaction) lines.push(`Transaction: ${event.transaction}`);
if (event.level) lines.push(`Level: ${event.level}`);
}

function appendEventTags(lines: string[], event: SentryEvent): void {
const tags = event.tags;
if (!tags) return;
const tagPairs = Array.isArray(tags)
? tags.map(([k, v]) => `${k}=${v}`)
: Object.entries(tags).map(([k, v]) => `${k}=${v}`);
const tagPairs = normalizeTagPairs(event.tags).map(([key, value]) => `${key}=${value}`);
if (tagPairs.length > 0) {
lines.push(`Tags: ${tagPairs.join(', ')}`);
}
}

function normalizeRequestQuery(request: SentryRequest): string | undefined {
// Prefer the already-serialized query-string aliases
const qs = request.query_string ?? request.queryString;
if (qs) return qs;

// REST issue-event shape: `query` can be tuple pairs, a plain string, or a record
const q = request.query;
if (!q) return undefined;
if (typeof q === 'string') return q;
if (Array.isArray(q)) {
const pairs = q.map(([k, v]) => `${k}=${v}`).join('&');
return pairs || undefined;
}
// Record<string, string>
const pairs = Object.entries(q)
.map(([k, v]) => `${k}=${v}`)
.join('&');
return pairs || undefined;
}

function appendEventRequest(lines: string[], event: SentryEvent): void {
if (!event.request?.url) return;
const request = getEventRequest(event);
if (!request?.url) return;
lines.push('');
lines.push('## Request');
lines.push(`${event.request.method ?? 'GET'} ${event.request.url}`);
if (event.request.query_string) lines.push(`Query: ${event.request.query_string}`);
lines.push(`${request.method ?? 'GET'} ${request.url}`);
const query = normalizeRequestQuery(request);
if (query) lines.push(`Query: ${query}`);
if (request.data !== undefined) lines.push(`Data: ${formatCompactValue(request.data)}`);
}

function appendEventUser(lines: string[], event: SentryEvent): void {
Expand All @@ -168,7 +228,7 @@ function appendEventUser(lines: string[], event: SentryEvent): void {
}

function appendEventStacktrace(lines: string[], event: SentryEvent): void {
const exceptions = event.exception?.values;
const exceptions = getEventExceptions(event);
if (exceptions?.length) {
lines.push('');
lines.push('## Exception');
Expand All @@ -188,6 +248,99 @@ function appendEventStacktrace(lines: string[], event: SentryEvent): void {
}
}

function appendEventContext(lines: string[], event: SentryEvent): void {
const contextLines: string[] = [];
for (const [key, value] of Object.entries(event.context ?? {})) {
contextLines.push(`${key}: ${formatCompactValue(value)}`);
}
for (const [key, value] of Object.entries(event.contexts ?? {})) {
if (value === undefined || value === null) continue;
contextLines.push(`${key}: ${formatCompactValue(value)}`);
}
if (contextLines.length === 0) return;

lines.push('');
lines.push('## Context');
for (const line of contextLines.slice(0, 20)) {
lines.push(line);
}
}

function normalizeTagPairs(
tags: SentryEvent['tags'],
): Array<[string, string | number | boolean | null]> {
if (!tags) return [];
if (!Array.isArray(tags)) {
return Object.entries(tags).filter(([key, value]) => key && value !== undefined);
}

const pairs: Array<[string, string | number | boolean | null]> = [];
for (const tag of tags) {
if (Array.isArray(tag)) {
const [key, value] = tag;
if (key && value !== undefined) pairs.push([key, value]);
continue;
}
if (tag && typeof tag === 'object' && tag.key && tag.value !== undefined) {
pairs.push([tag.key, tag.value]);
}
}
return pairs;
}

function getEventId(event: SentryEvent): string | undefined {
return event.event_id ?? event.eventID ?? event.id;
}

function getEventTimestamp(event: SentryEvent): string | undefined {
return event.timestamp ?? event.dateCreated ?? event.dateReceived ?? event.received;
}

function getReleaseValue(release: SentryEvent['release']): string | undefined {
if (!release) return undefined;
if (typeof release === 'string') return release;
if (typeof release === 'object') {
const record = release as Record<string, unknown>;
const value = record.version ?? record.shortVersion ?? record.package;
return typeof value === 'string' ? value : undefined;
}
return undefined;
}

function findEntryData<T>(event: SentryEvent, type: string): T | undefined {
const entry = event.entries?.find((candidate) => candidate.type === type);
return entry?.data as T | undefined;
}

function getEventExceptions(event: SentryEvent): SentryException[] | undefined {
return (
event.exception?.values ??
findEntryData<{ values?: SentryException[] }>(event, 'exception')?.values
);
}

function getEventBreadcrumbs(event: SentryEvent): SentryBreadcrumb[] | undefined {
return (
event.breadcrumbs?.values ??
findEntryData<{ values?: SentryBreadcrumb[] }>(event, 'breadcrumbs')?.values
);
}

function getEventRequest(event: SentryEvent): SentryRequest | undefined {
return event.request ?? findEntryData<SentryRequest>(event, 'request');
}

function formatCompactValue(value: unknown): string {
if (typeof value === 'string') return value.slice(0, 200);
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
return String(value);
try {
return JSON.stringify(value, null, 0).slice(0, 200);
} catch {
return String(value).slice(0, 200);
}
}

export function formatSentryEvent(event: SentryEvent): string {
const lines: string[] = [];

Expand All @@ -201,12 +354,13 @@ export function formatSentryEvent(event: SentryEvent): string {
appendEventUser(lines, event);
appendEventStacktrace(lines, event);

const breadcrumbs = event.breadcrumbs?.values;
const breadcrumbs = getEventBreadcrumbs(event);
if (breadcrumbs?.length) {
lines.push('');
lines.push('## Breadcrumbs');
lines.push(formatBreadcrumbs(breadcrumbs));
}
appendEventContext(lines, event);

if (event.web_url) {
lines.push('');
Expand All @@ -225,8 +379,9 @@ export function formatSentryEventList(events: SentryEvent[]): string {

const lines: string[] = [`${events.length} event(s):`];
for (const e of events) {
const ts = e.timestamp ?? e.received ?? '(unknown time)';
const id = e.event_id ? e.event_id.slice(0, 8) : '(no id)';
const ts = getEventTimestamp(e) ?? '(unknown time)';
const eventId = getEventId(e);
const id = eventId ? eventId.slice(0, 8) : '(no id)';
const tx = e.transaction ? ` — ${e.transaction}` : '';
lines.push(` [${id}] ${ts}${tx}`);
}
Expand Down
2 changes: 2 additions & 0 deletions src/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ Different PM providers have different native concepts of "checklist". The `PMPro

The shared engine that parses, appends, toggles, and removes inline checklist items lives at `src/pm/_shared/inline-checklist.ts` and is consumed by both the Linear and JIRA adapters.

Because Linear and JIRA checklist mutations rewrite the whole description, their adapters serialize the full read/mutate/write operation with `withDescriptionMutationLock(provider, workItemId, fn)` from `src/pm/_shared/description-mutation-lock.ts`. Keep future inline-description mutations inside that guard; otherwise concurrent `cascade-tools pm update-checklist-item` processes can overwrite each other's description snapshots without a provider-side conflict error.

---

## Image delivery contract
Expand Down
Loading
Loading