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

fix: add cron job to pick up hanging prs #1217

Merged
merged 12 commits into from
Dec 23, 2020
110 changes: 109 additions & 1 deletion packages/merge-on-green/src/merge-on-green.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ interface WatchPR {
author: string;
url: string;
reactionId: number;
installationId: number;
installationId?: number;
}

interface Label {
Expand Down Expand Up @@ -331,6 +331,98 @@ handler.checkPRMergeability = async function checkPRMergeability(
}
};

/**
* For a given repository, looks through all the PRs and checks to see if they have a MOG label
* @param owner the owner of the repo
* @param repo the repo name
* @param context the context of the webhook payload
* @returns void
*/
handler.pickUpPR = async function pickUpPR(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we call this scanForMissingPullRequests or something like that?

owner: string,
repo: string,
github: Context['github']
) {
const prs = await github.paginate(await github.pulls.list, {
owner,
repo,
state: 'open',
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can probably reduce the # of queries, by using the search API: https://docs.github.com/en/free-pro-team@latest/rest/reference/search#search-issues-and-pull-requests


for (const pr of prs) {
const labels = await github.paginate(
await github.issues.listLabelsOnIssue,
{
owner,
repo,
issue_number: pr.number,
}
);

const labelFound = labels.find(
(label: Label) =>
label.name === MERGE_ON_GREEN_LABEL ||
label.name === MERGE_ON_GREEN_LABEL_SECURE
);

if (labelFound) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should only do this if there's not already an entry in datastore for the given PR right?

// check to see if the owner has branch protection rules
let branchProtection: string[] | undefined;
try {
branchProtection = (
await github.repos.getBranchProtection({
owner,
repo,
branch: 'master',
})
).data.required_status_checks.contexts;
logger.info(
`checking branch protection for ${owner}/${repo}: ${branchProtection}`
);
} catch (err) {
err.message = `Error in getting branch protection\n\n${err.message}`;
await github.issues.createComment({
owner,
repo,
issue_number: pr.number,
body:
"Your PR doesn't have any required checks. Please add required checks to your master branch and then re-add the label. Learn more about enabling these checks here: https://help.github.com/en/github/administering-a-repository/enabling-required-status-checks.",
});
logger.error(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this logic share the same code as the webhook handling?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I agree, I think the logic could be simplified if we:

  1. check to see if the PR is already tracked in datastore (if it is tracked do nothing.).
  2. if the PR is not tracked, but an automerge label exists, make a synthetic call to app.on('pull_request.labeled', async context; I think you could do this by looking at the fields you use from context, and then making sure you populate these in a fake context object you pass.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about faking an webhook - we could probably extract that common logic into a function to execute from both paths.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I simplified the logic here!

}

// if the owner has branch protection set up, add this PR to the Datastore table
if (branchProtection) {
// try to create reaction. Save this reaction in the Datastore table since I don't (think)
// it is on the pull_request payload.
const reactionId = (
await github.reactions.createForIssue({
owner,
repo,
issue_number: pr.number,
content: 'eyes',
})
).data.id;

await handler.addPR(
{
number: pr.number,
owner,
repo,
state: 'continue',
url: pr.html_url,
branchProtection: branchProtection,
label: labelFound.name,
author: pr.user.login,
reactionId,
},
pr.html_url
);
}
}
}
};

// TODO: refactor into multiple function exports, this will take some work in
// gcf-utils.

Expand All @@ -350,6 +442,22 @@ function handler(app: Application) {
await handler.cleanDatastoreTable(watchedPRs, app, context);
return;
}

//because we're searching for the PRs, and not getting the installation ID, we have to use
//the bot's installation ID to call the API. So, we need to make sure it matches the repo owner
if (context.payload.cron_org) {
logger.info('Entering job to pick up any hanging PRs');
const owner = context.payload.organization.login;
const repo = context.payload.repository.name;

if (context.payload.cron_org !== owner) {
logger.info(`skipping run for ${context.payload.cron_org}`);
return;
}
await handler.pickUpPR(owner, repo, context.github);
return;
}

const start = Date.now();
await handler.checkPRMergeability(watchedPRs, app, context);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth trying to do these both async and awaiting them at the end? (it's going to complete all of pickUpPR before trying to do the merges.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I'm expecting this to run on a separate cron job - we won't try merging on the same run as we try picking up the PRs.

logger.info(`mergeOnGreen check took ${Date.now() - start}ms`);
Expand Down
127 changes: 127 additions & 0 deletions packages/merge-on-green/test/merge-on-green.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ interface CheckRuns {
conclusion: string;
}

interface PR {
number: number;
owner: string;
repo: string;
state: string;
html_url: string;
user: {
login: string;
};
}

nock.disableNetConnect();

const fixturesPath = resolve(__dirname, '../../test/Fixtures');
Expand Down Expand Up @@ -168,6 +179,12 @@ function getPR(mergeable: boolean, mergeableState: string, state: string) {
});
}

function getPRsForRepo(pr: PR[]) {
return nock('https://api.github.com')
.get('/repos/testOwner/testRepo/pulls?state=open')
.reply(200, pr);
}

//meta-note about the schedule.repository as any; currently GH does not support this type, see
//open issue for a fix: https://github.com/octokit/webhooks.js/issues/277
describe('merge-on-green', () => {
Expand Down Expand Up @@ -892,6 +909,116 @@ describe('merge-on-green', () => {
});
});

describe('pick up PRs', () => {
it('adds a PR if the PR was not picked up by a webhook event', async () => {
const scopes = [
getPRsForRepo([
{
number: 1,
owner: 'testOwner',
repo: 'testRepo',
state: 'continue',
html_url: 'https://github.com/testOwner/testRepo/pull/6',
user: {
login: 'testOwner',
},
},
]),
getLabels('automerge'),
react(),
getBranchProtection(200, ['Special Check']),
];

await probot.receive({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name: 'schedule.repository' as any,
payload: {
repository: {
name: 'testRepo',
owner: {
login: 'testOwner',
},
},
organization: {
login: 'testOwner',
},
org: 'testOwner',
cron_org: 'testOwner',
},
id: 'abc123',
});

scopes.forEach(s => s.done());
assert(addPRStub.called);
});

it('does not add a PR if no labels were found', async () => {
const scopes = [
getPRsForRepo([
{
number: 1,
owner: 'testOwner',
repo: 'testRepo',
state: 'continue',
html_url: 'https://github.com/testOwner/testRepo/pull/6',
user: {
login: 'testOwner',
},
},
]),
getLabels('notTheRightLabel'),
];

await probot.receive({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name: 'schedule.repository' as any,
payload: {
repository: {
name: 'testRepo',
owner: {
login: 'testOwner',
},
},
organization: {
login: 'testOwner',
},
org: 'testOwner',
cron_org: 'testOwner',
},
id: 'abc123',
});

scopes.forEach(s => s.done());
assert(!addPRStub.called);
});

it('does not add a PR if no PRs were found', async () => {
const scopes = [getPRsForRepo([])];

await probot.receive({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name: 'schedule.repository' as any,
payload: {
repository: {
name: 'testRepo',
owner: {
login: 'testOwner',
},
},
organization: {
login: 'testOwner',
},
org: 'testOwner',
cron_org: 'testOwner',
},
id: 'abc123',
});

scopes.forEach(s => s.done());
assert(!addPRStub.called);
});
});

describe('PRs when labeled', () => {
handler.allowlist = ['testOwner'];
it('adds a PR when label is added correctly', async () => {
Expand Down