Skip to content

Commit

Permalink
[MWPW-147001] Stage initiative automation (Stage-Main sync PRs) (#2224)
Browse files Browse the repository at this point in the history
* [MWPW-147001] Stage initiative automation

* Add high prio PRs & fail on RCP cutoff

* Rename constants

* Add exclamation mark

* Add the alert emoji

* Add a high impact slack webhook

* Re-add overlap check

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
mokimo and github-actions[bot] authored May 8, 2024
1 parent 9c1339b commit 60a89ce
Show file tree
Hide file tree
Showing 6 changed files with 429 additions and 40 deletions.
86 changes: 86 additions & 0 deletions .github/workflows/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Those env variables are set by an github action automatically
// For local testing, you should test on your fork.
const owner = process.env.REPO_OWNER || ''; // example owner: adobecom
const repo = process.env.REPO_NAME || ''; // example repo name: milo
const auth = process.env.GH_TOKEN || ''; // https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens

const getLocalConfigs = () => {
if (!owner || !repo || !auth) {
throw new Error(`Create a .env file on the root of the project with credentials.
Then run: node --env-file=.env .github/workflows/update-ims.js`);
}

const { Octokit } = require('@octokit/rest');
return {
github: { rest: new Octokit({ auth }) },
context: {
repo: {
owner,
repo,
},
},
};
};

const slackNotification = (text, webhook) => {
console.log(text);
return fetch(webhook || process.env.MILO_RELEASE_SLACK_WH, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
});
};

const addLabels = ({ pr, github, owner, repo }) =>
github.rest.issues
.listLabelsOnIssue({ owner, repo, issue_number: pr.number })
.then(({ data }) => {
pr.labels = data.map(({ name }) => name);
return pr;
});

const addFiles = ({ pr, github, owner, repo }) =>
github.rest.pulls
.listFiles({ owner, repo, pull_number: pr.number })
.then(({ data }) => {
pr.files = data.map(({ filename }) => filename);
return pr;
});

const getChecks = ({ pr, github, owner, repo }) =>
github.rest.checks
.listForRef({ owner, repo, ref: pr.head.sha })
.then(({ data }) => {
const checksByName = data.check_runs.reduce((map, check) => {
if (
!map.has(check.name) ||
new Date(map.get(check.name).completed_at) <
new Date(check.completed_at)
) {
map.set(check.name, check);
}
return map;
}, new Map());
pr.checks = Array.from(checksByName.values());
return pr;
});

const getReviews = ({ pr, github, owner, repo }) =>
github.rest.pulls
.listReviews({
owner,
repo,
pull_number: pr.number,
})
.then(({ data }) => {
pr.reviews = data;
return pr;
});

module.exports = {
getLocalConfigs,
slackNotification,
pulls: { addLabels, addFiles, getChecks, getReviews },
};
25 changes: 0 additions & 25 deletions .github/workflows/localWorkflowConfigs.js

This file was deleted.

252 changes: 252 additions & 0 deletions .github/workflows/merge-to-stage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
const {
slackNotification,
getLocalConfigs,
pulls: { addLabels, addFiles, getChecks, getReviews },
} = require('./helpers.js');

// Run from the root of the project for local testing: node --env-file=.env .github/workflows/merge-to-stage.js
const PR_TITLE = '[Release] Stage to Main';
const SEEN = {};
const REQUIRED_APPROVALS = process.env.REQUIRED_APPROVALS || 2;
const STAGE = 'stage';
const PROD = 'main';
const LABELS = {
highPriority: 'high priority',
readyForStage: 'Ready for Stage',
SOTPrefix: 'SOT',
highImpact: 'high-impact',
};

const SLACK = {
fileOverlap: ({ html_url, number, title }) =>
`:fast_forward: Skipping <${html_url}|${number}: ${title}> due to overlap in files.`,
merge: ({ html_url, number, title, highImpact }) =>
`:merged:${highImpact} Stage merge PR <${html_url}|${number}: ${title}>.`,
failingChecks: ({ html_url, number, title }) =>
`:x: Skipping <${html_url}|${number}: ${title}> due to failing checks`,
requireApprovals: ({ html_url, number, title }) =>
`:x: Skipping <${html_url}|${number}: ${title}> due to insufficient approvals`,
openedSyncPr: ({ html_url, number }) =>
`:fast_forward: Created <${html_url}|Stage to Main PR ${number}>`,
};

let github, owner, repo, currPrNumber, core;

let body = `
## common base root URLs
**Homepage :** https://www.stage.adobe.com/
**BACOM:** https://business.stage.adobe.com/fr/
**CC:** https://www.stage.adobe.com/creativecloud.html
**Blog:** https://blog.stage.adobe.com/
**Acrobat:** https://www.stage.adobe.com/acrobat/online/sign-pdf.html
**Milo:**
- Before: https://main--milo--adobecom.hlx.live/?martech=off
- After: https://stage--milo--adobecom.hlx.live/?martech=off
`;

const RCPDates = [
{
start: new Date('2024-05-26T00:00:00-07:00'),
end: new Date('2024-06-01T00:00:00-07:00'),
},
{
start: new Date('2024-06-13T11:00:00-07:00'),
end: new Date('2024-06-13T14:00:00-07:00'),
},
{
start: new Date('2024-06-30T00:00:00-07:00'),
end: new Date('2024-07-06T00:00:00-07:00'),
},
{
start: new Date('2024-08-25T00:00:00-07:00'),
end: new Date('2024-08-31T00:00:00-07:00'),
},
{
start: new Date('2024-09-12T11:00:00-07:00'),
end: new Date('2024-09-12T14:00:00-07:00'),
},
{
start: new Date('2024-10-14T00:00:00-07:00'),
end: new Date('2024-11-18T17:00:00-08:00'),
},
{
start: new Date('2024-11-17T00:00:00-08:00'),
end: new Date('2024-11-30T00:00:00-08:00'),
},
{
start: new Date('2024-12-12T11:00:00-08:00'),
end: new Date('2024-12-12T14:00:00-08:00'),
},
{
start: new Date('2024-12-15T00:00:00-08:00'),
end: new Date('2025-01-02T00:00:00-08:00'),
},
];

const isHighPrio = (labels) => labels.includes(LABELS.highPriority);

const hasFailingChecks = (checks) =>
checks.some(
({ conclusion, name }) =>
name !== 'merge-to-stage' && conclusion === 'failure'
);

const getPRs = async () => {
let prs = await github.rest.pulls
.list({ owner, repo, state: 'open', per_page: 100, base: STAGE })
.then(({ data }) => data);
await Promise.all(prs.map((pr) => addLabels({ pr, github, owner, repo })));
prs = prs.filter((pr) => pr.labels.includes(LABELS.readyForStage));
await Promise.all([
...prs.map((pr) => addFiles({ pr, github, owner, repo })),
...prs.map((pr) => getChecks({ pr, github, owner, repo })),
...prs.map((pr) => getReviews({ pr, github, owner, repo })),
]);

prs = prs.filter(({ checks, reviews, html_url, number, title }) => {
if (hasFailingChecks(checks)) {
slackNotification(SLACK.failingChecks({ html_url, number, title }));
if (number === currPrNumber)
core.setFailed(`Failing checks on the current PR ${number}`);
return false;
}

const approvals = reviews.filter(({ state }) => state === 'APPROVED');
if (approvals.length < REQUIRED_APPROVALS) {
slackNotification(SLACK.requireApprovals({ html_url, number, title }));
if (number === currPrNumber)
core.setFailed(`Insufficient approvals on the current PR ${number}`);
return false;
}

return true;
});

return prs.reverse(); // OLD PRs first
};

const merge = async ({ prs }) => {
console.log(`Merging ${prs.length || 0} PRs that are ready... `);
for await (const { number, files, html_url, title, labels } of prs) {
if (files.some((file) => SEEN[file])) {
await slackNotification(SLACK.fileOverlap({ html_url, number, title }));
continue;
}
files.forEach((file) => (SEEN[file] = true));
if (!process.env.LOCAL_RUN)
await github.rest.pulls.merge({ owner, repo, pull_number: number });
body = `- [${title}](${html_url})\n${body}`;
const isHighImpact = labels.includes(LABELS.highImpact);
if (isHighImpact && process.env.SLACK_HIGH_IMPACT_PR_WEBHOOK) {
await slackNotification(
SLACK.merge({ html_url, number, title, highImpact: ' :alert:' }),
process.env.SLACK_HIGH_IMPACT_PR_WEBHOOK
);
}
await slackNotification(
SLACK.merge({
html_url,
number,
title,
highImpact: isHighImpact ? ' :alert:' : '',
})
);
}
};

const getStageToMainPR = () =>
github.rest.pulls
.list({ owner, repo, state: 'open', base: PROD })
.then(({ data } = {}) => data.find(({ title } = {}) => title === PR_TITLE))
.then((pr) => pr && addLabels({ pr, github, owner, repo }))
.then((pr) => pr && addFiles({ pr, github, owner, repo }))
.then((pr) => {
pr?.files.forEach((file) => (SEEN[file] = true));
return pr;
});

const openStageToMainPR = async () => {
const { data: comparisonData } = await github.rest.repos.compareCommits({
owner,
repo,
base: PROD,
head: STAGE,
});

for (const commit of comparisonData.commits) {
const { data: pullRequestData } =
await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: commit.sha,
});

for (const pr of pullRequestData) {
if (!body.includes(pr.html_url))
body = `- [${pr.title}](${pr.html_url})\n${body}`;
}
}

try {
const {
data: { html_url, number },
} = await github.rest.pulls.create({
owner,
repo,
title: PR_TITLE,
head: STAGE,
base: PROD,
body,
});
await slackNotification(SLACK.openedSyncPr({ html_url, number }));
} catch (error) {
if (error.message.includes('No commits between main and stage'))
return console.log('No new commits, no stage->main PR opened');
throw error;
}
};

const main = async (params) => {
github = params.github;
owner = params.context.repo.owner;
repo = params.context.repo.repo;
currPrNumber = params.context.issue?.number;
core = params.core;

const now = new Date();
// We need to revisit this every year
if (now.getFullYear() !== 2024) {
throw new Error('ADD NEW RCPs');
}
for (const { start, end } of RCPDates) {
if (start <= now && now <= end) {
console.log('Current date is within a RCP. Stopping execution.');
return;
}
}
try {
const stageToMainPR = await getStageToMainPR();
console.log('has Stage to Main PR:', !!stageToMainPR);
if (stageToMainPR?.labels.some((label) => label.includes(LABELS.SOTPrefix)))
return console.log('PR exists & testing started. Stopping execution.');
const prs = await getPRs();
await merge({ prs: prs.filter(({ labels }) => isHighPrio(labels)) });
await merge({ prs: prs.filter(({ labels }) => !isHighPrio(labels)) });
if (!stageToMainPR) await openStageToMainPR();
console.log('Process successfully executed.');
} catch (error) {
console.error(error);
}
};

if (process.env.LOCAL_RUN) {
const { github, context } = getLocalConfigs();
main({
github,
context,
core: { setFailed: console.error },
});
}

module.exports = main;
Loading

0 comments on commit 60a89ce

Please sign in to comment.