diff --git a/src/__tests__/Search/fetchRevisionFromLando.test.tsx b/src/__tests__/Search/fetchRevisionFromLando.test.tsx index 1a2b24d7d..e100e97c4 100644 --- a/src/__tests__/Search/fetchRevisionFromLando.test.tsx +++ b/src/__tests__/Search/fetchRevisionFromLando.test.tsx @@ -1,6 +1,7 @@ import fetchMock from '@fetch-mock/jest'; import App, { router } from '../../components/App'; +import { Strings } from '../../resources/Strings'; import { render } from '../utils/test-utils'; describe('Lando to commit validating', () => { @@ -74,7 +75,7 @@ describe('Lando to commit validating', () => { (console.error as jest.Mock).mockClear(); }); - it('should reject', async () => { + it('should explain when Lando is still creating the try push', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); fetchMock.get( 'glob:https://api.lando.services.mozilla.com/*', @@ -83,6 +84,118 @@ describe('Lando to commit validating', () => { ? { commit_id: null, id: 108, + status: 'SUBMITTED', + } + : { + commit_id: '6331cb86f104e2587160208d8e47d8bef8b38ffc', + id: 96, + status: 'LANDED', + }; + }, + ); + await router.navigate( + '/compare-lando-results?baseLando=123&baseRepo=try&newLando=456&newRepo=try&framework=1', + ); + render(); + expect(console.error).toHaveBeenCalledWith( + new Error(Strings.errors.lando.pending('123', 'SUBMITTED')), + ); + expect(console.error).toHaveBeenCalledTimes(1); + (console.error as jest.Mock).mockClear(); + }); + + it('should explain when Lando failed to create the try push', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.get( + 'glob:https://api.lando.services.mozilla.com/*', + ({ url }) => { + return url.includes('123') + ? { + commit_id: '096aa2c25fb2f031021de8c58baf9c46c052ab2e', + id: 108, + status: 'LANDED', + } + : { + commit_id: null, + error: 'Tree is closed', + id: 96, + status: 'FAILED', + }; + }, + ); + await router.navigate( + '/compare-lando-results?baseLando=123&baseRepo=try&newLando=456&newRepo=try&framework=1', + ); + render(); + expect(console.error).toHaveBeenCalledWith( + new Error(Strings.errors.lando.failed('456', 'FAILED', 'Tree is closed')), + ); + expect(console.error).toHaveBeenCalledTimes(1); + (console.error as jest.Mock).mockClear(); + }); + + it('should explain a failed Lando try push that returned an empty commit_id', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.get('glob:https://lando.moz.tools/*', ({ url }) => { + return url.includes('91073') + ? { + commit_id: '', + error: + 'Unexpected error while pushing to try.\nhg error in cmd: hg push -r tip ssh://hg.mozilla.org/try -f: pushing to ssh://hg.mozilla.org/try\n\nremote: Connection closed by 63.245.208.203 port 22\nabort: no suitable response from remote hg', + id: 91073, + status: 'FAILED', + } + : { + commit_id: '8920f830aab97b1912099621e73bd4cd1ee5fa23', + error: '', + id: 91086, + status: 'LANDED', + }; + }); + await router.navigate( + '/compare-lando-results?landoInstance=lando-prod-2025&baseLando=91073&newLando=91086&baseRepo=try&newRepo=try&framework=13', + ); + render(); + expect(console.error).toHaveBeenCalledWith( + new Error( + Strings.errors.lando.failed( + '91073', + 'FAILED', + 'Unexpected error while pushing to try.', + ), + ), + ); + expect(console.error).toHaveBeenCalledTimes(1); + (console.error as jest.Mock).mockClear(); + }); + + it('should explain when Lando landed but still has no revision', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.get('glob:https://api.lando.services.mozilla.com/*', { + commit_id: null, + id: 108, + status: 'LANDED', + }); + await router.navigate( + '/compare-lando-results?baseLando=123&baseRepo=try&newLando=456&newRepo=try&framework=1', + ); + render(); + expect(console.error).toHaveBeenCalledWith( + new Error(Strings.errors.lando.landedWithoutRevision('123', 'LANDED')), + ); + expect(console.error).toHaveBeenCalledTimes(1); + (console.error as jest.Mock).mockClear(); + }); + + it('should explain when Treeherder has not ingested the try push yet', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.get( + 'glob:https://api.lando.services.mozilla.com/*', + ({ url }) => { + return url.includes('123') + ? { + commit_id: '096aa2c25fb2f031021de8c58baf9c46c052ab2e', + id: 108, status: 'LANDED', } : { @@ -92,12 +205,20 @@ describe('Lando to commit validating', () => { }; }, ); + fetchMock.get('glob:https://treeherder.mozilla.org/api/project/*/push/*', { + results: [], + }); await router.navigate( '/compare-lando-results?baseLando=123&baseRepo=try&newLando=456&newRepo=try&framework=1', ); render(); expect(console.error).toHaveBeenCalledWith( - new Error('The parameter baseRev is missing.'), + new Error( + Strings.errors.lando.notInTreeherder( + '123', + '096aa2c25fb2f031021de8c58baf9c46c052ab2e', + ), + ), ); expect(console.error).toHaveBeenCalledTimes(1); (console.error as jest.Mock).mockClear(); diff --git a/src/components/CompareResults/landoToCommitLoader.ts b/src/components/CompareResults/landoToCommitLoader.ts index 3dd29f463..b4bfbee0d 100644 --- a/src/components/CompareResults/landoToCommitLoader.ts +++ b/src/components/CompareResults/landoToCommitLoader.ts @@ -1,6 +1,8 @@ import { checkValues, getComparisonInformation } from './loader'; import { compareView } from '../../common/constants'; import { fetchRevisionFromLandoId, LandoInstance } from '../../logic/lando'; +import { fetchRecentRevisions } from '../../logic/treeherder'; +import { Strings } from '../../resources/Strings'; import { Changeset, CombinedResultsItemType, @@ -8,6 +10,24 @@ import { } from '../../types/state'; import { Framework, TestVersion } from '../../types/types'; +async function ensureTryPushExists({ + landoId, + commitId, + repo, +}: { + landoId: string; + commitId: string; + repo: Repository['name']; +}) { + const pushes = await fetchRecentRevisions({ + repository: repo, + hash: commitId, + }); + if (!pushes.length) { + throw new Error(Strings.errors.lando.notInTreeherder(landoId, commitId)); + } +} + // This function is responsible for fetching the data from the URL. It's called // by React Router DOM when the compare-lando-results route is requested. // This loader is used by ./mach try perf, and due to recent changes in @@ -63,6 +83,16 @@ export async function loader({ request }: { request: Request }) { replicates: replicatesFromUrl, testVersion: testVersionFromUrl, }); + await ensureTryPushExists({ + landoId: baseLandoIDFromUrl, + commitId: baseRev, + repo: baseRepo, + }); + await ensureTryPushExists({ + landoId: newLandoIDFromUrl, + commitId: newRevs[0], + repo: newRepos[0], + }); return await getComparisonInformation( baseRev, baseRepo, diff --git a/src/logic/lando.ts b/src/logic/lando.ts index 8c50617bf..83df3fdb6 100644 --- a/src/logic/lando.ts +++ b/src/logic/lando.ts @@ -1,3 +1,4 @@ +import { Strings } from '../resources/Strings'; import { LandoToCommit } from '../types/state'; const landoInstances = { @@ -9,6 +10,54 @@ const landoInstances = { export type LandoInstance = keyof typeof landoInstances; +export type LandoRevision = LandoToCommit & { commit_id: string }; + +const PENDING_STATUSES = new Set([ + 'submitted', + 'in_progress', + 'deferred', + 'created', + 'unknown', +]); + +const FAILED_STATUSES = new Set(['failed', 'aborted', 'cancelled', 'canceled']); + +function hasCommitId(job: LandoToCommit): job is LandoRevision { + // New Lando returns "" rather than null when a job has not produced a revision. + return typeof job.commit_id === 'string' && job.commit_id.trim().length > 0; +} + +function firstLine(text: string | undefined): string | undefined { + if (!text) { + return undefined; + } + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed) { + return trimmed; + } + } + return undefined; +} + +export function messageForMissingLandoRevision( + landoId: string, + job: LandoToCommit, +) { + const status = job.status?.trim() ? job.status : 'unknown'; + const normalizedStatus = status.toLowerCase(); + + if (FAILED_STATUSES.has(normalizedStatus)) { + return Strings.errors.lando.failed(landoId, status, firstLine(job.error)); + } + + if (PENDING_STATUSES.has(normalizedStatus)) { + return Strings.errors.lando.pending(landoId, status); + } + + return Strings.errors.lando.landedWithoutRevision(landoId, status); +} + async function fetchFromLando(url: string) { const response = await fetch(url); if (!response.ok) { @@ -20,11 +69,17 @@ async function fetchFromLando(url: string) { } export async function fetchRevisionFromLandoId( - landoid: string, + landoId: string, instance: LandoInstance = 'lando-prod', -) { +): Promise { const host = landoInstances[instance] ?? landoInstances['lando-prod']; - const url = `https://${host}/landing_jobs/${landoid}`; + const url = `https://${host}/landing_jobs/${landoId}`; const response = await fetchFromLando(url); - return response.json() as Promise; + const job = (await response.json()) as LandoToCommit; + + if (!hasCommitId(job)) { + throw new Error(messageForMissingLandoRevision(landoId, job)); + } + + return job; } diff --git a/src/resources/Strings.tsx b/src/resources/Strings.tsx index 171e628af..8b8eff6e4 100644 --- a/src/resources/Strings.tsx +++ b/src/resources/Strings.tsx @@ -184,5 +184,17 @@ export const Strings = { }, errors: { warningText: 'The search input must be at least three characters.', + lando: { + pending: (landoId: string, status: string) => + `Lando has not finished creating the try push for job ${landoId} yet (status: ${status}). Please wait a few moments for the push to complete and then refresh the page.`, + failed: (landoId: string, status: string, detail?: string) => + detail + ? `Lando could not create the try push for job ${landoId} (status: ${status}): ${detail}` + : `Lando could not create the try push for job ${landoId} (status: ${status}).`, + landedWithoutRevision: (landoId: string, status: string) => + `Lando reports job ${landoId} as ${status}, but no Treeherder revision is available yet. Please wait a few moments and then refresh the page.`, + notInTreeherder: (landoId: string, revision: string) => + `Lando job ${landoId} has revision ${revision}, but Treeherder does not have this try push yet. If the push exists, it will appear in a few minutes once Treeherder has processed it. If this Lando ID is from lando.moz.tools, add landoInstance=lando-prod-2025 to the URL.`, + }, }, }; diff --git a/src/types/state.ts b/src/types/state.ts index fd0b467b0..9486de2d5 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -222,9 +222,10 @@ export type HashToCommit = { }; export type LandoToCommit = { - commit_id: string; - id: string; + commit_id: string | null; + id: string | number; status: string; + error?: string; }; export type InputType = 'base' | 'new';