Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Adds additional Crashlytics tools for debugging/analyzing crashes (#9020)
55 changes: 55 additions & 0 deletions src/crashlytics/addNote.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as chai from "chai";
import * as nock from "nock";
import * as chaiAsPromised from "chai-as-promised";

import { addNote } from "./addNote";
import { FirebaseError } from "../error";
import { crashlyticsApiOrigin } from "../api";

chai.use(chaiAsPromised);
const expect = chai.expect;
describe("addNote", () => {
const appId = "1:1234567890:android:abcdef1234567890";
const requestProjectNumber = "1234567890";
const issueId = "test-issue-id";
const note = "This is a test note.";

afterEach(() => {
nock.cleanAll();
});

it("should resolve with the response body on success", async () => {
const mockResponse = { name: "note1", body: note };

nock(crashlyticsApiOrigin())
.post(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`, {
body: note,
})
.reply(200, mockResponse);

const result = await addNote(appId, issueId, note);

expect(result).to.deep.equal(mockResponse);
expect(nock.isDone()).to.be.true;
});

it("should throw a FirebaseError if the API call fails", async () => {
nock(crashlyticsApiOrigin())
.post(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`)
.reply(500, { error: "Internal Server Error" });

await expect(addNote(appId, issueId, note)).to.be.rejectedWith(
FirebaseError,
`Failed to add note to issue ${issueId} for app ${appId}.`,
);
});

it("should throw a FirebaseError if the appId is invalid", async () => {
const invalidAppId = "invalid-app-id";

await expect(addNote(invalidAppId, issueId, note)).to.be.rejectedWith(
FirebaseError,
"Unable to get the projectId from the AppId.",
);
});
});
33 changes: 33 additions & 0 deletions src/crashlytics/addNote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";

type NoteRequest = {
body: string;
};

export async function addNote(appId: string, issueId: string, note: string): Promise<string> {

Check warning on line 9 in src/crashlytics/addNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
const requestProjectNumber = parseProjectNumber(appId);
logger.debug(
`[mcp][crashlytics] addNote called with appId: ${appId}, issueId: ${issueId}, note: ${note}`,
);
try {
const response = await CRASHLYTICS_API_CLIENT.request<NoteRequest, string>({
method: "POST",
headers: {
"Content-Type": "application/json",
},
path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`,
body: { body: note },
timeout: TIMEOUT,
});

return response.body;
} catch (err: any) {

Check warning on line 26 in src/crashlytics/addNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
logger.debug(err.message);

Check warning on line 27 in src/crashlytics/addNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .message on an `any` value

Check warning on line 27 in src/crashlytics/addNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `Error`
throw new FirebaseError(
`Failed to add note to issue ${issueId} for app ${appId}. Error: ${err}.`,

Check warning on line 29 in src/crashlytics/addNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "any" of template literal expression
{ original: err },

Check warning on line 30 in src/crashlytics/addNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
);
}
}
56 changes: 56 additions & 0 deletions src/crashlytics/deleteNote.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as chai from "chai";
import * as nock from "nock";
import * as chaiAsPromised from "chai-as-promised";

import { deleteNote } from "./deleteNote";
import { FirebaseError } from "../error";
import { crashlyticsApiOrigin } from "../api";

chai.use(chaiAsPromised);
const expect = chai.expect;

describe("deleteNote", () => {
const appId = "1:1234567890:android:abcdef1234567890";
const requestProjectNumber = "1234567890";
const issueId = "test-issue-id";
const noteId = "test-note-id";

afterEach(() => {
nock.cleanAll();
});

it("should resolve on success", async () => {
nock(crashlyticsApiOrigin())
.delete(
`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes/${noteId}`,
)
.reply(200, {});

const result = await deleteNote(appId, issueId, noteId);

expect(result).to.deep.equal(`Successfully deleted note ${noteId} from issue ${issueId}.`);
expect(nock.isDone()).to.be.true;
});

it("should throw a FirebaseError if the API call fails", async () => {
nock(crashlyticsApiOrigin())
.delete(
`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes/${noteId}`,
)
.reply(500, { error: "Internal Server Error" });

await expect(deleteNote(appId, issueId, noteId)).to.be.rejectedWith(
FirebaseError,
`Failed to delete note ${noteId} from issue ${issueId} for app ${appId}.`,
);
});

it("should throw a FirebaseError if the appId is invalid", async () => {
const invalidAppId = "invalid-app-id";

await expect(deleteNote(invalidAppId, issueId, noteId)).to.be.rejectedWith(
FirebaseError,
"Unable to get the projectId from the AppId.",
);
});
});
25 changes: 25 additions & 0 deletions src/crashlytics/deleteNote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";

export async function deleteNote(appId: string, issueId: string, noteId: string): Promise<string> {

Check warning on line 5 in src/crashlytics/deleteNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
const requestProjectNumber = parseProjectNumber(appId);

logger.debug(
`[mcp][crashlytics] deleteNote called with appId: ${appId}, issueId: ${issueId}, noteId: ${noteId}`,
);
try {
await CRASHLYTICS_API_CLIENT.request<void, void>({
method: "DELETE",
path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes/${noteId}`,
timeout: TIMEOUT,
});
return `Successfully deleted note ${noteId} from issue ${issueId}.`;
} catch (err: any) {

Check warning on line 18 in src/crashlytics/deleteNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
logger.debug(err.message);

Check warning on line 19 in src/crashlytics/deleteNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .message on an `any` value

Check warning on line 19 in src/crashlytics/deleteNote.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `Error`
throw new FirebaseError(
`Failed to delete note ${noteId} from issue ${issueId} for app ${appId}. Error: ${err}.`,
{ original: err },
);
}
}
30 changes: 7 additions & 23 deletions src/crashlytics/getIssueDetails.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
import { Client } from "../apiv2";
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { crashlyticsApiOrigin } from "../api";

const TIMEOUT = 10000;

const apiClient = new Client({
urlPrefix: crashlyticsApiOrigin(),
apiVersion: "v1alpha",
});
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";

export async function getIssueDetails(appId: string, issueId: string): Promise<string> {
try {
const requestProjectNumber = parseProjectNumber(appId);
if (requestProjectNumber === undefined) {
throw new FirebaseError("Unable to get the projectId from the AppId.");
}
const requestProjectNumber = parseProjectNumber(appId);

const response = await apiClient.request<void, string>({
logger.debug(
`[mcp][crashlytics] getIssueDetails called with appId: ${appId}, issueId: ${issueId}`,
);
try {
const response = await CRASHLYTICS_API_CLIENT.request<void, string>({
method: "GET",
headers: {
"Content-Type": "application/json",
Expand All @@ -35,11 +27,3 @@ export async function getIssueDetails(appId: string, issueId: string): Promise<s
);
}
}

function parseProjectNumber(appId: string): string | undefined {
const appIdParts = appId.split(":");
if (appIdParts.length > 1) {
return appIdParts[1];
}
return undefined;
}
31 changes: 8 additions & 23 deletions src/crashlytics/getSampleCrash.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import { Client } from "../apiv2";
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { crashlyticsApiOrigin } from "../api";

const TIMEOUT = 10000;

const apiClient = new Client({
urlPrefix: crashlyticsApiOrigin(),
apiVersion: "v1alpha",
});
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";

export async function getSampleCrash(
appId: string,
issueId: string,
sampleCount: number,
variantId?: string,
): Promise<string> {
const requestProjectNumber = parseProjectNumber(appId);

logger.debug(
`[mcp][crashlytics] getSampleCrash called with appId: ${appId}, issueId: ${issueId}, sampleCount: ${sampleCount}, variantId: ${variantId}`,
);
try {
const queryParams = new URLSearchParams();
queryParams.set("filter.issue.id", issueId);
Expand All @@ -24,12 +21,8 @@ export async function getSampleCrash(
queryParams.set("filter.issue.variant_id", variantId);
}

const requestProjectNumber = parseProjectNumber(appId);
if (requestProjectNumber === undefined) {
throw new FirebaseError("Unable to get the projectId from the AppId.");
}

const response = await apiClient.request<void, string>({
logger.debug(`[mcp][crashlytics] getSampleCrash query paramaters: ${queryParams}`);
const response = await CRASHLYTICS_API_CLIENT.request<void, string>({
method: "GET",
headers: {
"Content-Type": "application/json",
Expand All @@ -48,11 +41,3 @@ export async function getSampleCrash(
);
}
}

function parseProjectNumber(appId: string): string | undefined {
const appIdParts = appId.split(":");
if (appIdParts.length > 1) {
return appIdParts[1];
}
return undefined;
}
63 changes: 63 additions & 0 deletions src/crashlytics/listNotes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as chai from "chai";
import * as nock from "nock";
import * as chaiAsPromised from "chai-as-promised";

import { listNotes } from "./listNotes";
import { FirebaseError } from "../error";
import { crashlyticsApiOrigin } from "../api";

chai.use(chaiAsPromised);
const expect = chai.expect;

describe("listNotes", () => {
const appId = "1:1234567890:android:abcdef1234567890";
const requestProjectNumber = "1234567890";
const issueId = "test-issue-id";

afterEach(() => {
nock.cleanAll();
});

it("should resolve with the response body on success", async () => {
const mockResponse = { notes: [{ name: "note1", body: "a note" }] };
const noteCount = 10;

nock(crashlyticsApiOrigin())
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`)
.query({
page_size: `${noteCount}`,
})
.reply(200, mockResponse);

const result = await listNotes(appId, issueId, noteCount);

expect(result).to.deep.equal(mockResponse);
expect(nock.isDone()).to.be.true;
});

it("should throw a FirebaseError if the API call fails", async () => {
const noteCount = 10;

nock(crashlyticsApiOrigin())
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`)
.query({
page_size: `${noteCount}`,
})
.reply(500, { error: "Internal Server Error" });

await expect(listNotes(appId, issueId, noteCount)).to.be.rejectedWith(
FirebaseError,
`Failed to fetch notes for issue ${issueId} for app ${appId}.`,
);
});

it("should throw a FirebaseError if the appId is invalid", async () => {
const invalidAppId = "invalid-app-id";
const noteCount = 10;

await expect(listNotes(invalidAppId, issueId, noteCount)).to.be.rejectedWith(
FirebaseError,
"Unable to get the projectId from the AppId.",
);
});
});
36 changes: 36 additions & 0 deletions src/crashlytics/listNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";

export async function listNotes(
appId: string,
issueId: string,
noteCount: number,
): Promise<string> {
const requestProjectNumber = parseProjectNumber(appId);
try {
const queryParams = new URLSearchParams();
queryParams.set("page_size", `${noteCount}`);

logger.debug(
`[mcp][crashlytics] listNotes called with appId: ${appId}, issueId: ${issueId}, noteCount: ${noteCount}`,
);
const response = await CRASHLYTICS_API_CLIENT.request<void, string>({
method: "GET",
headers: {
"Content-Type": "application/json",
},
path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`,
queryParams: queryParams,
timeout: TIMEOUT,
});

return response.body;
} catch (err: any) {
logger.debug(err.message);
throw new FirebaseError(
`Failed to fetch notes for issue ${issueId} for app ${appId}. Error: ${err}.`,
{ original: err },
);
}
}
Loading
Loading