Ctrl K
ComunidadGuías…

scripts/createOrUpdateDeployChecklist.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bun
import CONST from '@github/libs/CONST';
import {generateDeployChecklistBodyAndAssignees, getDeployChecklistData} from '@github/libs/DeployChecklistUtils';
import type {ChecklistItem, DeployChecklistData} from '@github/libs/DeployChecklistUtils';
import GithubUtils from '@github/libs/GithubUtils';
import GitUtils from '@github/libs/GitUtils';
import type {MergedPR, SubmoduleUpdate} from '@github/libs/GitUtils';

/* eslint-disable @typescript-eslint/naming-convention */
import * as core from '@actions/core';
import {format} from 'date-fns/format';
import fs from 'fs';

type IssuesCreateResponse = Awaited<ReturnType<typeof GithubUtils.octokit.issues.create>>['data'];

type TimelineEntry = {type: 'pr'; prNumber: number; date: string} | {type: 'submodule'; version: string; date: string; commit: string};

/**
 * Preserve the checked state from a previous checklist when building a new one.
 */
function preserveCheckboxState(items: Array<{number: number; url: string}>, currentList: ChecklistItem[] | undefined): ChecklistItem[] {
    return items.map((item) => {
        const existing = currentList?.find((c) => c.number === item.number);
        return {...item, isChecked: existing?.isChecked ?? false};
    });
}

async function buildChronologicalSection({
    chronologicalPREntries,
    submoduleUpdates,
    mergedMobileExpensifyPREntries,
}: {
    chronologicalPREntries: MergedPR[];
    submoduleUpdates: SubmoduleUpdate[];
    mergedMobileExpensifyPREntries: MergedPR[];
}): Promise<string> {
    if (chronologicalPREntries.length === 0 && submoduleUpdates.length === 0 && mergedMobileExpensifyPREntries.length === 0) {
        return '';
    }

    // Look up workflow run URLs for each submodule update commit
    const submoduleRunURLs = new Map<string, string | undefined>();
    const results = await Promise.allSettled(
        submoduleUpdates.map(async (update) => {
            const runURL = await GithubUtils.getWorkflowRunURLForCommit(update.commit, 'testBuildOnPush.yml');
            return {commit: update.commit, runURL};
        }),
    );
    for (const result of results) {
        if (result.status === 'fulfilled') {
            submoduleRunURLs.set(result.value.commit, result.value.runURL);
        }
    }

    // Group Mobile-Expensify PRs by the submodule update that introduced them.
    // A Mobile-Expensify PR is assigned to the first submodule bump whose date >= PR merge date,
    // because merging to Mobile-Expensify doesn't matter until the submodule is actually bumped in App.
    const sortedSubmoduleUpdates = [...submoduleUpdates].sort((a, b) => a.date.localeCompare(b.date));
    const mobileExpensifyPRsBySubmodule = new Map<string, MergedPR[]>();
    const mobileExpensifyPRsPendingSubmoduleUpdate: MergedPR[] = [];
    for (const mobileExpensifyPR of mergedMobileExpensifyPREntries) {
        const matchingUpdate = sortedSubmoduleUpdates.find((update) => update.date.localeCompare(mobileExpensifyPR.date) >= 0);
        if (matchingUpdate) {
            const existing = mobileExpensifyPRsBySubmodule.get(matchingUpdate.commit) ?? [];
            existing.push(mobileExpensifyPR);
            mobileExpensifyPRsBySubmodule.set(matchingUpdate.commit, existing);
        } else {
            mobileExpensifyPRsPendingSubmoduleUpdate.push(mobileExpensifyPR);
        }
    }

    // Merge PRs and submodule updates into a single chronological timeline
    const timeline: TimelineEntry[] = [
        ...chronologicalPREntries.map((pr): TimelineEntry => ({type: 'pr', prNumber: pr.prNumber, date: pr.date})),
        ...submoduleUpdates.map((update): TimelineEntry => ({type: 'submodule', version: update.version, date: update.date, commit: update.commit})),
    ].sort((a, b) => a.date.localeCompare(b.date));

    let section = '<details>\n<summary><b>Chronologically ordered merged PRs (oldest first)</b></summary>\n\n';
    let prIndex = 0;
    for (const entry of timeline) {
        if (entry.type === 'submodule') {
            prIndex++;
            const runURL = submoduleRunURLs.get(entry.commit);
            const buildLink = runURL ? ` — [Adhoc Build](${runURL})` : ` — ${entry.commit.substring(0, 7)}`;
            section += `${prIndex}. Mobile-Expensify submodule update to \`${entry.version}\`${buildLink}\n`;
            const mobileExpensifyPRs = mobileExpensifyPRsBySubmodule.get(entry.commit);
            if (mobileExpensifyPRs) {
                const sortedMobileExpensifyPRs = [...mobileExpensifyPRs].sort((a, b) => a.date.localeCompare(b.date));
                for (const mobileExpensifyPR of sortedMobileExpensifyPRs) {
                    const mobileExpensifyUrl = GithubUtils.getPullRequestURLFromNumber(mobileExpensifyPR.prNumber, CONST.MOBILE_EXPENSIFY_URL);
                    section += `   ↳ ${mobileExpensifyUrl}\n`;
                }
            }
        } else {
            prIndex++;
            const url = GithubUtils.getPullRequestURLFromNumber(entry.prNumber, CONST.APP_REPO_URL);
            section += `${prIndex}. ${url}\n`;
        }
    }
    if (mobileExpensifyPRsPendingSubmoduleUpdate.length > 0) {
        const sortedPending = [...mobileExpensifyPRsPendingSubmoduleUpdate].sort((a, b) => a.date.localeCompare(b.date));
        section += `\n--- PRs waiting for Mobile-Expensify submodule update\n`;
        for (const mobileExpensifyPR of sortedPending) {
            const mobileExpensifyUrl = GithubUtils.getPullRequestURLFromNumber(mobileExpensifyPR.prNumber, CONST.MOBILE_EXPENSIFY_URL);
            section += `${mobileExpensifyUrl}\n`;
        }
    }
    section += '\n</details>\n';
    return section;
}

/**
 * Build params for updating an existing open deploy checklist, preserving checked state.
 */
async function buildUpdateChecklistParams({
    newVersion,
    newPRNumbers,
    mergedMobileExpensifyPREntries,
    previousChecklistData,
    currentChecklistData,
    chronologicalSection,
}: {
    newVersion: string;
    newPRNumbers: number[];
    mergedMobileExpensifyPREntries: MergedPR[];
    previousChecklistData: DeployChecklistData;
    currentChecklistData: DeployChecklistData;
    chronologicalSection: string;
}) {
    const PRList = preserveCheckboxState(
        newPRNumbers.map((prNum) => ({
            number: prNum,
            url: GithubUtils.getPullRequestURLFromNumber(prNum, CONST.APP_REPO_URL),
        })),
        currentChecklistData.PRList,
    );

    const PRListMobileExpensify = preserveCheckboxState(
        mergedMobileExpensifyPREntries.map((entry) => ({
            number: entry.prNumber,
            url: GithubUtils.getPullRequestURLFromNumber(entry.prNumber, CONST.MOBILE_EXPENSIFY_URL),
        })),
        currentChecklistData.PRListMobileExpensify,
    );

    // Include existing Mobile-Expensify PRs from the current checklist that aren't in the new merged list
    for (const existingPR of currentChecklistData.PRListMobileExpensify) {
        if (!PRListMobileExpensify.some((pr) => pr.number === existingPR.number)) {
            PRListMobileExpensify.push(existingPR);
        }
    }

    const openDeployBlockers = await GithubUtils.paginate(GithubUtils.octokit.issues.listForRepo, {
        log: console,
        owner: CONST.GITHUB_OWNER,
        repo: CONST.APP_REPO,
        labels: CONST.LABELS.DEPLOY_BLOCKER,
    });

    // Merge open blockers (from the API) with previously-tracked blockers (from the current checklist).
    // Open blockers preserve their checked state; closed/demoted blockers are auto-checked.
    const openBlockerNumbers = new Set(openDeployBlockers.map((db) => db.number));
    const allBlockerNumbers = new Set([...openBlockerNumbers, ...currentChecklistData.deployBlockers.map((db) => db.number)]);
    const deployBlockers: ChecklistItem[] = [...allBlockerNumbers].map((number) => {
        const existing = currentChecklistData.deployBlockers.find((db) => db.number === number);
        if (!openBlockerNumbers.has(number)) {
            return {number, url: existing?.url ?? '', isChecked: true};
        }
        const url = existing?.url ?? openDeployBlockers.find((db) => db.number === number)?.html_url ?? '';
        return {number, url, isChecked: existing?.isChecked ?? false};
    });

    const didVersionChange = newVersion !== currentChecklistData.version;

    return {
        tag: newVersion,
        PRList: PRList.map((pr) => pr.number),
        PRListMobileExpensify: PRListMobileExpensify.map((pr) => pr.number),
        verifiedPRList: PRList.filter((pr) => pr.isChecked).map((pr) => pr.number),
        verifiedPRListMobileExpensify: PRListMobileExpensify.filter((pr) => pr.isChecked).map((pr) => pr.number),
        deployBlockers: deployBlockers.map((blocker) => blocker.number),
        resolvedDeployBlockers: deployBlockers.filter((blocker) => blocker.isChecked).map((blocker) => blocker.number),
        resolvedInternalQAPRs: currentChecklistData.internalQAPRList.filter((pr) => pr.isChecked).map((pr) => pr.number),
        isSentryChecked: didVersionChange ? false : currentChecklistData.isSentryChecked,
        isGHStatusChecked: didVersionChange ? false : currentChecklistData.isGHStatusChecked,
        previousTag: previousChecklistData.version,
        chronologicalSection,
    };
}

async function run(): Promise<IssuesCreateResponse | void> {
    // Read version at runtime (not via static import) so tests can mock different versions per test case
    const {version: newVersion} = JSON.parse(fs.readFileSync('package.json', 'utf8')) as {version: string};
    // e.g. '1.2.3-4-staging'
    const newStagingTag = `${newVersion}-staging`;

    try {
        const {data: recentDeployChecklists} = await GithubUtils.octokit.issues.listForRepo({
            log: console,
            owner: CONST.GITHUB_OWNER,
            repo: CONST.APP_REPO,
            labels: CONST.LABELS.STAGING_DEPLOY,
            state: 'all',
        });

        // If the most recent checklist is open we'll update it; otherwise we create a new one
        const mostRecentChecklist = recentDeployChecklists.at(0);
        if (!mostRecentChecklist) {
            throw new Error('Could not find the most recent checklist');
        }

        const shouldCreateNewDeployChecklist = mostRecentChecklist.state !== 'open';
        const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists.at(1);
        if (shouldCreateNewDeployChecklist) {
            console.log('Latest deploy checklist is closed, creating a new one.', mostRecentChecklist);
        } else {
            console.log('Latest deploy checklist is open, updating it instead of creating a new one.', 'Current:', mostRecentChecklist, 'Previous:', previousChecklist);
        }

        if (!previousChecklist) {
            throw new Error('Could not find the previous checklist');
        }

        // Parse checklist data from the previous (and optionally current) deploy checklists
        const previousChecklistData = getDeployChecklistData(previousChecklist);
        const currentChecklistData: DeployChecklistData | undefined = shouldCreateNewDeployChecklist ? undefined : getDeployChecklistData(mostRecentChecklist);

        // Find PRs merged between the previous checklist's tag and the new staging tag
        const {mergedPRs: mergedPREntries, submoduleUpdates} = await GitUtils.getMergedPRsDeployedBetween(previousChecklistData.tag, newStagingTag, CONST.APP_REPO);
        const mergedPRs = mergedPREntries.map((pr) => pr.prNumber).sort((a, b) => a - b);

        // Previous PRs should include PRs and internalQA items
        const previousPRNumbers = new Set([...previousChecklistData.PRList, ...previousChecklistData.internalQAPRList].map((pr) => pr.number));
        const previousMobileExpensifyPRNumbers = new Set(previousChecklistData.PRListMobileExpensify.map((pr) => pr.number));
        core.startGroup('Filtering PRs:');
        core.info('mergedPRs includes cherry-picked PRs that have already been released with previous checklist, so we need to filter these out');
        core.info(`Found ${previousPRNumbers.size} PRs in the previous checklist:`);
        core.info(JSON.stringify(Array.from(previousPRNumbers)));
        const newPRNumbers = mergedPRs.filter((prNum) => !previousPRNumbers.has(prNum));
        core.info(`Found ${newPRNumbers.length} PRs deployed since the previous checklist:`);
        core.info(JSON.stringify(newPRNumbers));

        const removedPRs = mergedPRs.filter((prNum) => previousPRNumbers.has(prNum));
        if (removedPRs.length > 0) {
            core.info(`⚠️⚠️ Filtered out the following cherry-picked PRs that were released with the previous checklist: ${removedPRs.join(', ')} ⚠️⚠️`);
        }
        core.endGroup();
        console.info(`[api] Checklist PRs: ${newPRNumbers.join(', ')}`);

        // Fetch Mobile-Expensify PRs (with dates for chronological grouping by submodule update)
        let mergedMobileExpensifyPREntries: MergedPR[] = [];
        try {
            const {mergedPRs: allMobileExpensifyPREntries} = await GitUtils.getMergedPRsDeployedBetween(previousChecklistData.tag, newStagingTag, CONST.MOBILE_EXPENSIFY_REPO);
            mergedMobileExpensifyPREntries = allMobileExpensifyPREntries.filter((pr) => !previousMobileExpensifyPRNumbers.has(pr.prNumber));

            const allCount = allMobileExpensifyPREntries.length;
            const newCount = mergedMobileExpensifyPREntries.length;
            console.info(`Found ${allCount} total Mobile-Expensify PRs, ${newCount} new ones after filtering:`);
            console.info(`Mobile-Expensify PRs: ${mergedMobileExpensifyPREntries.map((pr) => pr.prNumber).join(', ')}`);

            const removedMobileExpensifyPRs = allMobileExpensifyPREntries.filter((pr) => previousMobileExpensifyPRNumbers.has(pr.prNumber));
            if (removedMobileExpensifyPRs.length > 0) {
                core.info(
                    `⚠️⚠️ Filtered out the following cherry-picked Mobile-Expensify PRs that were released with the previous checklist: ${removedMobileExpensifyPRs.map((pr) => pr.prNumber).join(', ')} ⚠️⚠️`,
                );
            }
        } catch (error) {
            if (process.env.GITHUB_REPOSITORY !== `${CONST.GITHUB_OWNER}/${CONST.APP_REPO}`) {
                console.warn(
                    "⚠️ Unable to fetch Mobile-Expensify PRs because this workflow is running on a forked repository and secrets aren't accessible. This is expected for development/testing on forks.",
                );
            } else {
                console.error('Failed to fetch Mobile-Expensify PRs:', error);
            }
        }

        const chronologicalPREntries = mergedPREntries.filter((pr) => !previousPRNumbers.has(pr.prNumber)).sort((a, b) => a.date.localeCompare(b.date));
        const chronologicalSection = await buildChronologicalSection({
            chronologicalPREntries,
            submoduleUpdates,
            mergedMobileExpensifyPREntries,
        });

        let checklistParams;
        if (shouldCreateNewDeployChecklist) {
            checklistParams = {
                tag: newVersion,
                PRList: newPRNumbers,
                PRListMobileExpensify: mergedMobileExpensifyPREntries.map((pr) => pr.prNumber),
                previousTag: previousChecklistData.version,
                chronologicalSection,
            };
        } else if (currentChecklistData) {
            checklistParams = await buildUpdateChecklistParams({
                newVersion,
                newPRNumbers,
                mergedMobileExpensifyPREntries,
                previousChecklistData,
                currentChecklistData,
                chronologicalSection,
            });
        } else {
            throw new Error('Expected current checklist data for update but it was undefined');
        }

        const {issueBody: checklistBody, issueAssignees: checklistAssignees} = await generateDeployChecklistBodyAndAssignees(checklistParams);

        // Finally, create or update the checklist
        const defaultPayload = {
            owner: CONST.GITHUB_OWNER,
            repo: CONST.APP_REPO,
            body: checklistBody,
        };

        if (shouldCreateNewDeployChecklist) {
            const {data: newChecklist} = await GithubUtils.octokit.issues.create({
                ...defaultPayload,
                title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`,
                labels: [CONST.LABELS.STAGING_DEPLOY, CONST.LABELS.LOCK_DEPLOY, CONST.LABELS.DAILY],
                assignees: [CONST.APPLAUSE_BOT as string].concat(checklistAssignees),
            });
            console.log(`Successfully created new deploy checklist! 🎉 ${newChecklist.html_url}`);
            return newChecklist;
        }

        const {data: updatedChecklist} = await GithubUtils.octokit.issues.update({
            ...defaultPayload,
            issue_number: currentChecklistData?.number ?? 0,
        });
        console.log(`Successfully updated deploy checklist! 🎉 ${updatedChecklist.html_url}`);
        return updatedChecklist;
    } catch (err: unknown) {
        console.error('An unknown error occurred!', err);
        core.setFailed(err as Error);
    }
}

if (import.meta.main) {
    run();
}

export default run;
export {buildChronologicalSection, buildUpdateChecklistParams, preserveCheckboxState};