Skip to content

Commit 0ed673c

Browse files
authored
Merge branch 'Expensify:main' into fix/66381-part-3
2 parents 5176f4b + 42e9c77 commit 0ed673c

456 files changed

Lines changed: 16039 additions & 9139 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/schemas/code-review-output.json

Lines changed: 0 additions & 27 deletions
This file was deleted.

.claude/scripts/addPrReaction.sh

Lines changed: 0 additions & 15 deletions
This file was deleted.

.claude/scripts/createInlineComment.sh

Lines changed: 0 additions & 66 deletions
This file was deleted.

.github/actions/javascript/isDeployChecklistLocked/index.js

Lines changed: 114 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11532,26 +11532,31 @@ var __importStar = (this && this.__importStar) || (function () {
1153211532
return result;
1153311533
};
1153411534
})();
11535+
var __importDefault = (this && this.__importDefault) || function (mod) {
11536+
return (mod && mod.__esModule) ? mod : { "default": mod };
11537+
};
1153511538
Object.defineProperty(exports, "__esModule", ({ value: true }));
1153611539
const core = __importStar(__nccwpck_require__(2186));
11540+
const CONST_1 = __importDefault(__nccwpck_require__(9873));
1153711541
const DeployChecklistUtils_1 = __nccwpck_require__(2141);
1153811542
const run = function () {
1153911543
return (0, DeployChecklistUtils_1.getDeployChecklist)()
1154011544
.then(({ labels, number }) => {
11541-
const labelsNames = labels.map((label) => {
11542-
if (typeof label === 'string') {
11543-
return '';
11544-
}
11545-
return label.name;
11546-
});
11547-
console.log(`Found deploy checklist with labels: ${JSON.stringify(labelsNames)}`);
11548-
core.setOutput('IS_LOCKED', labelsNames.includes('🔐 LockCashDeploys 🔐'));
11545+
const labelNames = (labels ?? []).map((label) => (typeof label === 'string' ? label : (label.name ?? '')));
11546+
const isLocked = labelNames.includes(CONST_1.default.LABELS.LOCK_DEPLOY);
11547+
console.log(`Found deploy checklist #${number} with labels: ${JSON.stringify(labelNames)}`);
11548+
core.setOutput('IS_LOCKED', isLocked);
1154911549
core.setOutput('NUMBER', number);
1155011550
})
11551-
.catch((err) => {
11552-
console.warn('No open deploy checklist found, continuing...', err);
11553-
core.setOutput('IS_LOCKED', false);
11554-
core.setOutput('NUMBER', 0);
11551+
.catch((error) => {
11552+
if (error instanceof DeployChecklistUtils_1.NoOpenDeployChecklistError) {
11553+
console.log(`No open deploy checklist; treating as not locked. ${error.message}`);
11554+
core.setOutput('IS_LOCKED', false);
11555+
core.setOutput('NUMBER', 0);
11556+
return;
11557+
}
11558+
const message = error instanceof Error ? error.message : String(error);
11559+
core.setFailed(`Could not resolve deploy checklist; blocking deploy: ${message}`);
1155511560
});
1155611561
};
1155711562
if (require.main === require.cache[eval('__filename')]) {
@@ -11650,13 +11655,50 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
1165011655
return (mod && mod.__esModule) ? mod : { "default": mod };
1165111656
};
1165211657
Object.defineProperty(exports, "__esModule", ({ value: true }));
11658+
exports.NoOpenDeployChecklistError = void 0;
1165311659
exports.getDeployChecklist = getDeployChecklist;
1165411660
exports.getDeployChecklistData = getDeployChecklistData;
1165511661
exports.generateDeployChecklistBodyAndAssignees = generateDeployChecklistBodyAndAssignees;
1165611662
exports.parseChecklistSection = parseChecklistSection;
1165711663
const dedent_1 = __importDefault(__nccwpck_require__(6762));
1165811664
const CONST_1 = __importDefault(__nccwpck_require__(9873));
1165911665
const GithubUtils_1 = __importDefault(__nccwpck_require__(9296));
11666+
/** Milliseconds to wait before each subsequent `listForRepo` attempt. */
11667+
const LIST_RETRY_DELAYS_MS = [2000, 5000];
11668+
/**
11669+
* HTTP statuses that indicate a definitively-permanent failure of `listForRepo` -
11670+
* retrying cannot help (auth, missing resource, validation). Note that `403` is
11671+
* intentionally absent because GitHub returns secondary-rate-limit and abuse
11672+
* detection responses as `403` and those should be retried with backoff.
11673+
*/
11674+
const NON_RETRYABLE_LIST_STATUSES = new Set([401, 404, 422]);
11675+
/**
11676+
* Duck-type check for "this error is a permanent failure from `listForRepo`".
11677+
* We avoid `instanceof RequestError` because the bundled action ends up with
11678+
* multiple copies of that class (one from `@octokit/request-error` directly,
11679+
* one nested via `@actions/github` -> `@octokit/core`) and `instanceof`
11680+
* compares identity, so it can miss real errors thrown by octokit.
11681+
*/
11682+
function isPermanentListError(error) {
11683+
if (typeof error !== 'object' || error === null || !('status' in error)) {
11684+
return false;
11685+
}
11686+
const status = error.status;
11687+
return typeof status === 'number' && NON_RETRYABLE_LIST_STATUSES.has(status);
11688+
}
11689+
/**
11690+
* Thrown by `getDeployChecklist` when GitHub successfully confirms there is no open
11691+
* StagingDeployCash issue and the most recent checklist is closed - i.e. we're in the
11692+
* legitimate window between deploy cycles. Callers that need to distinguish "benign
11693+
* empty" from "could not resolve" should catch this specific subclass.
11694+
*/
11695+
class NoOpenDeployChecklistError extends Error {
11696+
constructor(message) {
11697+
super(message);
11698+
this.name = 'NoOpenDeployChecklistError';
11699+
}
11700+
}
11701+
exports.NoOpenDeployChecklistError = NoOpenDeployChecklistError;
1166011702
/**
1166111703
* Generic checklist section parser. Extracts a section from the issue body,
1166211704
* parses checkbox items within it, and returns ChecklistItems sorted by number.
@@ -11692,24 +11734,75 @@ function getDeployChecklistDeployBlockers(issue) {
1169211734
function getDeployChecklistInternalQA(issue) {
1169311735
return parseChecklistSection(issue.body, /Internal QA:\*\*\r?\n((?:- \[[ x]].*\r?\n)+)/, new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'), (url) => url.split('-').at(0)?.trim() ?? '');
1169411736
}
11737+
/**
11738+
* Calls `issues.listForRepo` with simple retry-on-throw. Retries 1 + `LIST_RETRY_DELAYS_MS.length`
11739+
* times total, sleeping the corresponding delay between attempts. Empty results are NOT retried -
11740+
* the caller must decide whether an empty list is legitimate. Permanent statuses listed in
11741+
* `NON_RETRYABLE_LIST_STATUSES` short-circuit and re-throw on the first attempt.
11742+
*/
11743+
async function listForRepoWithRetry(params) {
11744+
let lastError;
11745+
const maxAttempts = LIST_RETRY_DELAYS_MS.length + 1;
11746+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
11747+
try {
11748+
const { data } = await GithubUtils_1.default.octokit.issues.listForRepo(params);
11749+
return data;
11750+
}
11751+
catch (error) {
11752+
lastError = error;
11753+
if (isPermanentListError(error)) {
11754+
console.warn(`listForRepo failed with permanent status ${error.status}; not retrying`, error);
11755+
throw error;
11756+
}
11757+
const delay = LIST_RETRY_DELAYS_MS.at(attempt - 1);
11758+
if (delay === undefined) {
11759+
throw error;
11760+
}
11761+
console.warn(`listForRepo attempt ${attempt}/${maxAttempts} failed; retrying in ${delay}ms`, error);
11762+
await new Promise((resolve) => {
11763+
setTimeout(resolve, delay);
11764+
});
11765+
}
11766+
}
11767+
throw lastError;
11768+
}
1169511769
async function getDeployChecklist() {
11696-
const { data } = await GithubUtils_1.default.octokit.issues.listForRepo({
11770+
const openIssues = await listForRepoWithRetry({
1169711771
owner: CONST_1.default.GITHUB_OWNER,
1169811772
repo: CONST_1.default.APP_REPO,
1169911773
labels: CONST_1.default.LABELS.STAGING_DEPLOY,
1170011774
state: 'open',
1170111775
});
11702-
if (!data.length) {
11703-
throw new Error(`Unable to find ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`);
11776+
if (openIssues.length > 1) {
11777+
throw new Error(`Found more than one open ${CONST_1.default.LABELS.STAGING_DEPLOY} issue: #${openIssues.map((issue) => issue.number).join(', #')}.`);
11778+
}
11779+
if (openIssues.length === 1) {
11780+
const issue = openIssues.at(0);
11781+
if (!issue) {
11782+
throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`);
11783+
}
11784+
return getDeployChecklistData(issue);
1170411785
}
11705-
if (data.length > 1) {
11706-
throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`);
11786+
// The filtered open list was empty. Cross-check against state:'all' to tell
11787+
// a legitimate between-cycles window apart from an API inconsistency. Any open
11788+
// issue anywhere in the response - not just the most recent - blocks the deploy,
11789+
// so a stale older issue that got reopened cannot slip through.
11790+
const allIssues = await listForRepoWithRetry({
11791+
owner: CONST_1.default.GITHUB_OWNER,
11792+
repo: CONST_1.default.APP_REPO,
11793+
labels: CONST_1.default.LABELS.STAGING_DEPLOY,
11794+
state: 'all',
11795+
});
11796+
if (allIssues.length === 0) {
11797+
// The label has been in continuous use; an empty state:'all' result is pathological.
11798+
throw new Error(`No ${CONST_1.default.LABELS.STAGING_DEPLOY} issues found at all (state:'all' returned empty). Refusing to deploy.`);
1170711799
}
11708-
const issue = data.at(0);
11709-
if (!issue) {
11710-
throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`);
11800+
const openIssuesInAll = allIssues.filter((issue) => issue.state === 'open');
11801+
if (openIssuesInAll.length > 0) {
11802+
throw new Error(`Inconsistent GitHub response: state:open returned empty but state:all reports open ${CONST_1.default.LABELS.STAGING_DEPLOY} issue(s) #${openIssuesInAll.map((issue) => issue.number).join(', #')}. Refusing to deploy.`);
1171111803
}
11712-
return getDeployChecklistData(issue);
11804+
const mostRecent = allIssues.at(0);
11805+
throw new NoOpenDeployChecklistError(`No open ${CONST_1.default.LABELS.STAGING_DEPLOY} issue (most recent #${mostRecent?.number} is closed).`);
1171311806
}
1171411807
function getDeployChecklistData(issue) {
1171511808
try {

.github/actions/javascript/isDeployChecklistLocked/isDeployChecklistLocked.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
import * as core from '@actions/core';
2-
import {getDeployChecklist} from '@github/libs/DeployChecklistUtils';
2+
import CONST from '@github/libs/CONST';
3+
import {getDeployChecklist, NoOpenDeployChecklistError} from '@github/libs/DeployChecklistUtils';
34

45
const run = function (): Promise<void> {
56
return getDeployChecklist()
67
.then(({labels, number}) => {
7-
const labelsNames = labels.map((label) => {
8-
if (typeof label === 'string') {
9-
return '';
10-
}
11-
return label.name;
12-
});
13-
console.log(`Found deploy checklist with labels: ${JSON.stringify(labelsNames)}`);
14-
core.setOutput('IS_LOCKED', labelsNames.includes('🔐 LockCashDeploys 🔐'));
8+
const labelNames = (labels ?? []).map((label) => (typeof label === 'string' ? label : (label.name ?? '')));
9+
const isLocked = labelNames.includes(CONST.LABELS.LOCK_DEPLOY);
10+
console.log(`Found deploy checklist #${number} with labels: ${JSON.stringify(labelNames)}`);
11+
core.setOutput('IS_LOCKED', isLocked);
1512
core.setOutput('NUMBER', number);
1613
})
17-
.catch((err) => {
18-
console.warn('No open deploy checklist found, continuing...', err);
19-
core.setOutput('IS_LOCKED', false);
20-
core.setOutput('NUMBER', 0);
14+
.catch((error: unknown) => {
15+
if (error instanceof NoOpenDeployChecklistError) {
16+
console.log(`No open deploy checklist; treating as not locked. ${error.message}`);
17+
core.setOutput('IS_LOCKED', false);
18+
core.setOutput('NUMBER', 0);
19+
return;
20+
}
21+
const message = error instanceof Error ? error.message : String(error);
22+
core.setFailed(`Could not resolve deploy checklist; blocking deploy: ${message}`);
2123
});
2224
};
2325

.github/actions/javascript/postTestBuildComment/action.yml renamed to .github/actions/javascript/postOrReplaceComment/action.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
name: "Mark Pull Requests as Deployed"
2-
description: "Mark pull requests as deployed on production or staging"
1+
name: "postOrReplaceComment"
2+
description: "Post a test build or custom pull request comment, hiding the previous matching comment"
33
inputs:
44
REPO:
55
description: "Repository to place a comment. Can be App or Mobile-Expensify"
@@ -31,6 +31,12 @@ inputs:
3131
WEB_LINK:
3232
description: "Link for the web build"
3333
required: false
34+
COMMENT_BODY:
35+
description: "Custom comment body. When provided, posts this comment instead of the test build message"
36+
required: false
37+
COMMENT_PREFIX:
38+
description: "Prefix used to find and hide the previous matching comment"
39+
required: true
3440
runs:
3541
using: "node24"
3642
main: "./index.js"

0 commit comments

Comments
 (0)