-
Notifications
You must be signed in to change notification settings - Fork 84
Add context around file adds/moves in BaselineCheckoutFailedError bails
#1391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c04a7d8
167007e
add5df3
2695c43
0a39c19
c81905c
39bfec3
a43751b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { getChangedFilesWithStatus as getChangedFilesWithStatusDep } from '../../git/git'; | ||
| import TestLogger from '../testLogger'; | ||
| import { classifyTagsFromError } from './classifyBailRootCause'; | ||
| import { BaselineCheckoutFailedError } from './errors'; | ||
|
|
||
| vi.mock('../../git/git'); | ||
| const getChangedFilesWithStatus = vi.mocked(getChangedFilesWithStatusDep); | ||
|
|
||
| const deps = { log: new TestLogger(), options: {} } as any; | ||
|
|
||
| describe('classifyTagsFromError', () => { | ||
| it('returns undefined for an error that is not classified yet', async () => { | ||
| const result = await classifyTagsFromError( | ||
| deps, | ||
| new Error("some random error which won't have special handling") | ||
| ); | ||
|
|
||
| expect(result).toBeUndefined(); | ||
| expect(getChangedFilesWithStatus).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| describe('BaselineCheckoutFailedError', () => { | ||
| it('classifies a file rename as "baselineManifestMoved"', async () => { | ||
| getChangedFilesWithStatus.mockResolvedValue([ | ||
| { status: 'renamed', fromPath: 'libs/old/package.json', path: 'libs/app/package.json' }, | ||
| ]); | ||
|
|
||
| const result = await classifyTagsFromError( | ||
| deps, | ||
| new BaselineCheckoutFailedError('abc123:libs/app/package.json') | ||
| ); | ||
|
|
||
| expect(result).toEqual({ baseline_failure_kind: 'baselineManifestMoved' }); | ||
| }); | ||
|
|
||
| it('classifies a file add as "baselineManifestAdded"', async () => { | ||
| getChangedFilesWithStatus.mockResolvedValue([ | ||
| { status: 'added', path: 'libs/app/package.json' }, | ||
| ]); | ||
|
|
||
| const result = await classifyTagsFromError( | ||
| deps, | ||
| new BaselineCheckoutFailedError('abc123:libs/app/package.json') | ||
| ); | ||
|
|
||
| expect(result).toEqual({ baseline_failure_kind: 'baselineManifestAdded' }); | ||
| }); | ||
|
|
||
| it('classifies a file rename as "baselineManifestMoved" over "baselineManifestAdded" when the diff has both rows', async () => { | ||
| getChangedFilesWithStatus.mockResolvedValue([ | ||
| { status: 'added', path: 'libs/app/package.json' }, | ||
| { status: 'renamed', fromPath: 'libs/old/package.json', path: 'libs/app/package.json' }, | ||
| ]); | ||
|
|
||
| const result = await classifyTagsFromError( | ||
| deps, | ||
| new BaselineCheckoutFailedError('abc123:libs/app/package.json') | ||
| ); | ||
|
|
||
| expect(result).toEqual({ baseline_failure_kind: 'baselineManifestMoved' }); | ||
| }); | ||
|
|
||
| it('returns "unknownBaselineCheckoutFailure" when we cannot classify the change', async () => { | ||
| getChangedFilesWithStatus.mockResolvedValue([ | ||
| { status: 'modified', path: 'some/other/package.json' }, | ||
| ]); | ||
|
|
||
| const result = await classifyTagsFromError( | ||
| deps, | ||
| new BaselineCheckoutFailedError('abc123:libs/app/package.json') | ||
| ); | ||
|
|
||
| expect(result).toEqual({ baseline_failure_kind: 'unknownBaselineCheckoutFailure' }); | ||
| }); | ||
|
|
||
| it('returns "unknownBaselineCheckoutFailure" when a git command throws unexpectedly', async () => { | ||
| getChangedFilesWithStatus.mockRejectedValue(new Error('git diff blew up')); | ||
|
|
||
| const result = await classifyTagsFromError( | ||
| deps, | ||
| new BaselineCheckoutFailedError('abc123:libs/app/package.json') | ||
| ); | ||
|
|
||
| expect(result).toEqual({ baseline_failure_kind: 'unknownBaselineCheckoutFailure' }); | ||
| }); | ||
|
|
||
| it('splits the pathspec on the first colon so a filename containing colons still matches', async () => { | ||
| getChangedFilesWithStatus.mockResolvedValue([ | ||
| { status: 'renamed', fromPath: 'libs/old/a:b.json', path: 'libs/app/a:b.json' }, | ||
| ]); | ||
|
|
||
| const result = await classifyTagsFromError( | ||
| deps, | ||
| new BaselineCheckoutFailedError('abc123:libs/app/a:b.json') | ||
| ); | ||
|
|
||
| expect(result).toEqual({ baseline_failure_kind: 'baselineManifestMoved' }); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import path from 'path'; | ||
|
|
||
| import { GitDeps } from '../../git/execGit'; | ||
| import { ChangedFileWithStatus, getChangedFilesWithStatus } from '../../git/git'; | ||
| import { BaselineCheckoutFailedError } from './errors'; | ||
|
|
||
| /** | ||
| * Classify an error captured at the TurboSnap bail site into additional Sentry tags describing its | ||
| * root cause. | ||
| * | ||
| * @param deps Function dependencies. | ||
| * @param error The error captured at the bail site. | ||
| * | ||
| * @returns A map of Sentry tags, or `undefined` when the error type is not recognized. | ||
| */ | ||
| export async function classifyTagsFromError( | ||
| deps: GitDeps, | ||
| error: unknown | ||
| ): Promise<Record<string, string> | undefined> { | ||
| if (error instanceof BaselineCheckoutFailedError) { | ||
| return classifyBaselineCheckoutFailureTags(deps, error); | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Refined root-cause kinds for a BaselineCheckoutFailedError. | ||
| * | ||
| * These exist for Sentry ONLY and are not sent to the backend. | ||
| */ | ||
| export type BaselineCheckoutFailureKind = | ||
| | 'baselineManifestMoved' | ||
| | 'baselineManifestAdded' | ||
| | 'unknownBaselineCheckoutFailure'; | ||
|
|
||
| /** | ||
| * Check git to determine the root cause of a BaselineCheckoutFailedError. | ||
| * | ||
| * @param deps Function dependencies. | ||
| * @param error The error captured at the bail site. | ||
| * | ||
| * @returns The refined failure kind. | ||
| */ | ||
| async function classifyBaselineCheckoutFailureTags( | ||
| deps: GitDeps, | ||
| error: BaselineCheckoutFailedError | ||
| ): Promise<Record<'baseline_failure_kind', BaselineCheckoutFailureKind>> { | ||
| const { reference, fileName } = parsePathspec(error.pathspec); | ||
|
|
||
| try { | ||
| // Determine how the file changed between the baseline and HEAD (such as if it was added, moved, | ||
| // renamed, etc). | ||
| const basename = path.basename(fileName); | ||
| const changes = await getChangedFilesWithStatus( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this will fail if we have a shallow clone because the I know there's been talk around shallow clones lately, so they might be handled elsewhere, making this a moot point. But I can't recall for sure, so figured I'd raise it. |
||
| deps, | ||
| reference, | ||
| 'HEAD', | ||
| // See https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-pathspec for details | ||
| // | ||
| // `glob` allows us to pass in a glob pattern | ||
| // `top` anchors the response to the root of the repository (so we can catch cross-directory changes) | ||
| `:(glob,top)**/${basename}` | ||
| ); | ||
| return { baseline_failure_kind: classifyManifestChange(changes, fileName) }; | ||
| } catch (err) { | ||
| // We capture the exception higher in the stack so we can simply attach the error context here. | ||
| Sentry.addBreadcrumb({ | ||
| level: 'error', | ||
| category: 'classifyBaselineCheckoutFailure', | ||
| message: 'Error classifying baseline checkout failure', | ||
| data: { | ||
| error: err, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any concern about serialization of the error here? Would |
||
| }, | ||
| }); | ||
|
|
||
| return { baseline_failure_kind: 'unknownBaselineCheckoutFailure' }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Determine if the file was moved or added or something else. | ||
| * | ||
| * @param changes The parsed changed files from the diff. | ||
| * @param fileName The failed file path to match against. | ||
| * | ||
| * @returns The matching manifest failure kind. | ||
| */ | ||
| function classifyManifestChange( | ||
| changes: ChangedFileWithStatus[], | ||
| fileName: string | ||
| ): BaselineCheckoutFailureKind { | ||
| // Check renames first so a moved manifest is never mislabeled as added. | ||
| if (changes.some((change) => change.status === 'renamed' && change.path === fileName)) { | ||
| return 'baselineManifestMoved'; | ||
| } | ||
| if (changes.some((change) => change.status === 'added' && change.path === fileName)) { | ||
| return 'baselineManifestAdded'; | ||
| } | ||
| return 'unknownBaselineCheckoutFailure'; | ||
| } | ||
|
|
||
| /** | ||
| * Split a pathspec of the form `<reference>:<fileName>` into its parts. | ||
| * | ||
| * @param pathspec The pathspec carried by a `BaselineCheckoutFailedError`. | ||
| * | ||
| * @returns The baseline reference (SHA) and the file path. | ||
| */ | ||
| function parsePathspec(pathspec: string): { reference: string; fileName: string } { | ||
| // Find the first colon which separates the reference from the file path. We can't just split on | ||
| // colon and return the first and second parts since files can contain colons. | ||
| const separatorIndex = pathspec.indexOf(':'); | ||
| return { | ||
| reference: pathspec.slice(0, separatorIndex), | ||
| fileName: pathspec.slice(separatorIndex + 1), | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getChangedFilesuses--no-relativein itsgit diffcommand. I think we need that here or running from a subdirectory would have bizarre behavior. Probably use--no-pagertoo, for consistency.