Skip to content
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

[FEATURE] Pouvoir choisir les fronts qu’on déploit en RA pix #460

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 84 additions & 62 deletions build/controllers/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { logger } from '../../common/services/logger.js';
import ScalingoClient from '../../common/services/scalingo-client.js';
import { config } from '../../config.js';
import * as reviewAppRepo from '../repositories/review-app-repository.js';
import * as reviewAppDeploymentRepo from '../repositories/review-app-deployment-repository.js';
import { MERGE_STATUS, mergeQueue as _mergeQueue } from '../services/merge-queue.js';

const repositoryToScalingoAppsReview = {
Expand All @@ -26,6 +27,10 @@ const repositoryToScalingoAppsReview = {
pix4pix: ['pix-4pix-front-review', 'pix-4pix-api-review'],
};

const repositoryToScalingoOnDemandAppsReview = {
pix: ['pix-front-review'],
};

const __dirname = url.fileURLToPath(new URL('.', import.meta.url));

function getMessageTemplate(repositoryName) {
Expand Down Expand Up @@ -69,6 +74,7 @@ async function _handleRA(
addMessageToPullRequest = _addMessageToPullRequest,
githubService = commonGithubService,
reviewAppRepository = reviewAppRepo,
reviewAppDeploymentRepository = reviewAppDeploymentRepo,
) {
const payload = request.payload;
const prId = payload.number;
Expand All @@ -90,6 +96,7 @@ async function _handleRA(
addMessageToPullRequest,
githubService,
reviewAppRepository,
reviewAppDeploymentRepository,
);

return `Triggered deployment of RA on app ${deployedRA.join(', ')} with pr ${prId}`;
Expand Down Expand Up @@ -144,6 +151,63 @@ async function _handleCloseRA(request, scalingoClient = ScalingoClient) {
return `Closed RA for PR ${prId} : ${result.join(', ')}.`;
}

async function _handleIssueComment(
request,
scalingoClient = ScalingoClient,
githubService = commonGithubService,
reviewAppDeploymentRepository = reviewAppDeploymentRepo,
) {
const payload = request.payload;
const repo = payload.repository.name;
const owner = payload.repository.owner.login;
const reviewApps = repositoryToScalingoOnDemandAppsReview[repo];
const pull_number = payload.issue.number;

if (!reviewApps) {
return `${repo} is not managed by Pix Bot nor on-demand review app.`;
}
let client;

try {
client = await scalingoClient.getInstance('reviewApps');
} catch (error) {
throw new Error(`Scalingo auth APIError: ${error.message}`);
}

const reviewAppName = `${reviewApps[0]}-pr${pull_number}`;

if (reviewAppName !== 'pix-front-review-pr10490') return 'non';

const reviewAppExists = await client.reviewAppExists(reviewAppName);
if (!reviewAppExists) {
return `Review app ${reviewAppName} does not exist.`;
}

const selectedApps = Array.from(payload.comment.body.matchAll(/^- \[[xX]\].+<!-- ([\w-]+) -->$/gm), ([, app]) => app);

if (selectedApps.length > 0) {
await client.bulkUpdateEnvVar(reviewAppName, {
CI_FRONT_TASKS: selectedApps.map((app) => `ci:${app}`).join(' '),
BUILD_FRONT_TASKS: selectedApps.map((app) => `build:${app}`).join(' '),
});
} else {
await client.bulkUpdateEnvVar(reviewAppName, {
CI_FRONT_TASKS: 'ci:none',
BUILD_FRONT_TASKS: 'build:none',
});
}

const branchName = await githubService.getPullRequestBranchName({ repo, owner, pull_number });

await reviewAppDeploymentRepository.save({
appName: reviewAppName,
scmRef: branchName,
after: getDeployAfter(),
});

return 'ok';
}

async function deployPullRequest(
scalingoClient,
reviewApps,
Expand All @@ -153,6 +217,7 @@ async function deployPullRequest(
addMessageToPullRequest,
githubService,
reviewAppRepository,
reviewAppDeploymentRepository,
) {
const deployedRA = [];
let client;
Expand All @@ -166,12 +231,20 @@ async function deployPullRequest(
try {
const reviewAppExists = await client.reviewAppExists(reviewAppName);
if (reviewAppExists) {
await client.deployUsingSCM(reviewAppName, ref);
await reviewAppDeploymentRepository.save({
appName: reviewAppName,
scmRef: ref,
after: getDeployAfter(),
});
} else {
await reviewAppRepository.create({ name: reviewAppName, repository, prNumber: prId, parentApp: appName });
await client.deployReviewApp(appName, prId);
await client.disableAutoDeploy(reviewAppName);
await client.deployUsingSCM(reviewAppName, ref);
await reviewAppDeploymentRepository.save({
appName: reviewAppName,
scmRef: ref,
after: getDeployAfter(),
});
}
deployedRA.push({ name: appName, isCreated: !reviewAppExists });
} catch (error) {
Expand Down Expand Up @@ -249,69 +322,12 @@ async function processWebhook(
handleCloseRA = _handleCloseRA,
mergeQueue = _mergeQueue,
githubService = commonGithubService,
handleIssueComment = _handleIssueComment,
} = {},
) {
const eventName = request.headers['x-github-event'];
if (eventName === 'push') {
return pushOnDefaultBranchWebhook(request);
} else if (eventName === 'pull_request') {
if (['opened', 'reopened', 'synchronize'].includes(request.payload.action)) {
return handleRA(request);
}
if (request.payload.action === 'closed') {
const repositoryName = request.payload.repository.full_name;
const isMerged = request.payload.pull_request.merged;
const status = isMerged ? MERGE_STATUS.MERGED : MERGE_STATUS.ABORTED;
await mergeQueue.unmanagePullRequest({ repositoryName, number: request.payload.number, status });
return handleCloseRA(request);
}
if (request.payload.action === 'labeled' && request.payload.label.name == 'no-review-app') {
await handleCloseRA(request);
}
if (request.payload.action === 'labeled' && request.payload.label.name === ':rocket: Ready to Merge') {
const belongsToPix = await githubService.checkUserBelongsToPix(request.payload.sender.login);
if (!belongsToPix) {
return `Ignoring ${request.payload.sender.login} label action`;
}
const repositoryName = request.payload.repository.full_name;
const isAllowedRepository = config.github.automerge.allowedRepositories.includes(repositoryName);
if (isAllowedRepository) {
await mergeQueue.managePullRequest({ repositoryName, number: request.payload.number });
}
}
if (request.payload.action === 'unlabeled' && request.payload.label.name === ':rocket: Ready to Merge') {
const repositoryName = request.payload.repository.full_name;
await mergeQueue.unmanagePullRequest({
repositoryName,
number: request.payload.number,
status: MERGE_STATUS.ABORTED,
});
}
return `Ignoring ${request.payload.action} action`;
} else if (eventName === 'check_suite') {
if (request.payload.action === 'completed') {
const repositoryName = request.payload.repository.full_name;

if (request.payload.check_suite.pull_requests.length === 0) {
return `check_suite is not related to any pull_request`;
}

const prNumber = request.payload.check_suite.pull_requests[0].number;
if (request.payload.check_suite.conclusion !== 'success') {
await mergeQueue.unmanagePullRequest({ repositoryName, number: prNumber, status: MERGE_STATUS.ABORTED });
} else {
const hasReadyToMergeLabel = await githubService.isPrLabelledWith({
repositoryName,
number: prNumber,
label: ':rocket: Ready to Merge',
});
if (hasReadyToMergeLabel) {
await mergeQueue.managePullRequest({ repositoryName, number: prNumber });
}
}
return `check_suite event handle`;
}
return `Ignoring '${request.payload.action}' action for check_suite event`;
if (eventName === 'issue_comment' && request.payload.action === 'edited') {
return handleIssueComment(request);
} else {
return `Ignoring ${eventName} event`;
}
Expand Down Expand Up @@ -341,6 +357,12 @@ function _handleNoRACase(request) {
return { shouldContinue: true };
}

function getDeployAfter() {
const now = new Date();
const deployAfter = new Date(now.getTime() + config.scalingo.reviewApps.deployDebounce);
return deployAfter;
}

export {
_addMessageToPullRequest as addMessageToPullRequest,
getMessage,
Expand Down
44 changes: 44 additions & 0 deletions build/repositories/review-app-deployment-repository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { knex } from '../../db/knex-database-connection.js';

const TABLE_NAME = 'review-apps-deployments';

/**
* @typedef {{
* appName: string
* scmRef: string
* after: Date
* }} Deployment
*
* @typedef {import('knex').Knex} Knex
*/

/**
* @param {Deployment} deployment
* @returns {Promise<void>}
*/
export async function save({ appName, scmRef, after }) {
await knex.insert({ appName, scmRef, after }).into(TABLE_NAME).onConflict('appName').merge();
}

/**
* @param {string} appName
* @param {Knex} knexConn
* @returns {Promise<void>}
*/
export async function remove(appName, knexConn = knex) {
await knexConn.delete().from(TABLE_NAME).where('appName', appName);
}

/**
* @param {Knex} knexConn
* @returns {Promise<Deployment[]>}
*/
export async function listForDeployment(knexConn = knex) {
const deployments = await knexConn
.select()
.from(TABLE_NAME)
.where('after', '<', knexConn.raw('NOW()'))
.orderBy('after')
.forUpdate();
return deployments;
}
70 changes: 70 additions & 0 deletions build/services/review-app-deployment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { setTimeout } from 'node:timers/promises';

import * as reviewAppDeploymentRepository from '../repositories/review-app-deployment-repository.js';
import ScalingoClient from '../../common/services/scalingo-client.js';
import { logger } from '../../common/services/logger.js';
import { config } from '../../config.js';
import { knex } from '../../db/knex-database-connection.js';

let started = false;
let stopped;

export async function start() {
let scalingoClient;

try {
scalingoClient = await ScalingoClient.getInstance('reviewApps');
} catch (error) {
throw new Error(`Scalingo auth APIError: ${error.message}`);
}

const delay = config.scalingo.reviewApps.deployDebounce / 5;

started = true;
stopped = Promise.withResolvers();

(async () => {
while (started) {
const [result] = await Promise.allSettled([
deploy({ reviewAppDeploymentRepository, scalingoClient }),
Promise.race(setTimeout(delay), stopped.promise),
]);

if (result.status === 'rejected') {
logger.error({
message: 'an error occured while deploying review apps',
data: result.reason,
});
}
}
})();
}

export function stop() {
started = false;
stopped.resolve();
}

export async function deploy({ reviewAppDeploymentRepository, scalingoClient }) {
await knex.transaction(async (transaction) => {
const deployments = await reviewAppDeploymentRepository.listForDeployment(transaction);

for (const deployment of deployments) {
try {
const reviewAppStillExists = await scalingoClient.reviewAppExists(deployment.appName);

if (reviewAppStillExists) {
await scalingoClient.deployUsingSCM(deployment.appName, deployment.scmRef);
}

await reviewAppDeploymentRepository.remove(deployment.appName, transaction);
} catch (err) {
logger.error({
event: 'scalingo',
message: 'error while trying to deploy review app',
data: err,
});
}
}
});
}
17 changes: 7 additions & 10 deletions build/templates/pull-request-messages/pix.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
Une fois les applications déployées, elles seront accessibles via les liens suivants :
* [App (.fr)](https://app-pr{{pullRequestId}}.review.pix.fr)
* [App (.org)](https://app-pr{{pullRequestId}}.review.pix.org)
* [Orga (.fr)](https://orga-pr{{pullRequestId}}.review.pix.fr)
* [Orga (.org)](https://orga-pr{{pullRequestId}}.review.pix.org)
* [Certif (.fr)](https://certif-pr{{pullRequestId}}.review.pix.fr)
* [Certif (.org)](https://certif-pr{{pullRequestId}}.review.pix.org)
* [Junior](https://junior-pr{{pullRequestId}}.review.pix.fr)
* [Admin](https://admin-pr{{pullRequestId}}.review.pix.fr)
* [API](https://api-pr{{pullRequestId}}.review.pix.fr/api/)
* [Audit Logger](https://pix-audit-logger-review-pr{{pullRequestId}}.osc-fr1.scalingo.io/api/)
- [API](https://api-pr{{pullRequestId}}.review.pix.fr/api/)
- [Audit Logger](https://pix-audit-logger-review-pr{{pullRequestId}}.osc-fr1.scalingo.io/api/)
- [ ] [App (.fr)](https://app-pr{{pullRequestId}}.review.pix.fr) / [App (.org)](https://app-pr{{pullRequestId}}.review.pix.org) <!-- mon-pix -->
- [ ] [Orga (.fr)](https://orga-pr{{pullRequestId}}.review.pix.fr) / [Orga (.org)](https://orga-pr{{pullRequestId}}.review.pix.org) <!-- orga -->
- [ ] [Certif (.fr)](https://certif-pr{{pullRequestId}}.review.pix.fr) / [Certif (.org)](https://certif-pr{{pullRequestId}}.review.pix.org) <!-- certif -->
- [ ] [Junior](https://junior-pr{{pullRequestId}}.review.pix.fr) <!-- junior -->
- [ ] [Admin](https://admin-pr{{pullRequestId}}.review.pix.fr) <!-- admin -->

Les variables d'environnement seront accessibles via les liens suivants :
* [scalingo front](https://dashboard.scalingo.com/apps/osc-fr1/pix-front-review-pr{{pullRequestId}}/environment)
Expand Down
6 changes: 6 additions & 0 deletions common/services/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,12 @@ const github = {
const { data } = await octokit.request(`GET /repos/${repositoryName}/pulls/${number}`);
return data.labels.some((ghLabel) => ghLabel.name === label);
},

async getPullRequestBranchName({ owner, repo, pull_number }) {
const { pulls } = _createOctokit();
const pull = await pulls.get({ owner, repo, pull_number });
return pull.data.head.ref;
},
};

export default github;
Loading
Loading