Skip to content

Commit

Permalink
Merge pull request #10 from Meniole/main
Browse files Browse the repository at this point in the history
Merge main into development
  • Loading branch information
ubiquity-os-main[bot] authored Aug 20, 2024
2 parents efc1466 + 5c4e011 commit bddf247
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 46 deletions.
2 changes: 1 addition & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"version": "0.2",
"ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log", "./src/adapters/supabase/**/**.ts"],
"ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log", "./src/adapters/supabase/**/**.ts", "eslint.config.mjs"],
"useGitignore": true,
"language": "en",
"words": ["Nektos", "dataurl", "devpool", "outdir", "servedir", "Supabase", "SUPABASE", "typebox", "ubiquibot", "Smee", "typeorm", "timespan", "mswjs"],
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "Automated merging",
"description": "Automatically merge pull-requests.",
"ubiquity:listeners": [ "push" ]
"ubiquity:listeners": [ "push", "issues_comment.created" ]
}
51 changes: 34 additions & 17 deletions src/helpers/github.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { retryAsync } from "ts-retry";
import { Context, ReposWatchSettings } from "../types";
import * as github from "@actions/github";

export function parseGitHubUrl(url: string) {
const path = new URL(url).pathname.split("/");
Expand All @@ -15,12 +14,16 @@ export function parseGitHubUrl(url: string) {
}

export type IssueParams = ReturnType<typeof parseGitHubUrl>;
export interface Requirements {
mergeTimeout: string;
requiredApprovalCount: number;
}

/**
* Gets the merge timeout depending on the status of the assignee. If there are multiple assignees with different
* statuses, the longest timeout is chosen.
*/
export async function getMergeTimeoutAndApprovalRequiredCount(context: Context, authorAssociation: string) {
export async function getMergeTimeoutAndApprovalRequiredCount(context: Context, authorAssociation: string): Promise<Requirements> {
const timeoutCollaborator = {
mergeTimeout: context.config.mergeTimeout.collaborator,
requiredApprovalCount: context.config.approvalsRequired.collaborator,
Expand All @@ -29,7 +32,7 @@ export async function getMergeTimeoutAndApprovalRequiredCount(context: Context,
mergeTimeout: context.config.mergeTimeout.contributor,
requiredApprovalCount: context.config.approvalsRequired.contributor,
};
return authorAssociation === "COLLABORATOR" || authorAssociation === "MEMBER" ? timeoutCollaborator : timeoutContributor;
return ["COLLABORATOR", "MEMBER", "OWNER"].includes(authorAssociation) ? timeoutCollaborator : timeoutContributor;
}

export async function getApprovalCount({ octokit, logger }: Context, { owner, repo, issue_number: pullNumber }: IssueParams) {
Expand Down Expand Up @@ -101,14 +104,22 @@ export async function isCiGreen({ octokit, logger, env }: Context, sha: string,
}
}

function parseTarget(target: string) {
const owner = github.context.repo.owner;
function parseTarget({ payload, logger }: Context, target: string) {
if (!payload.repository.owner) {
const errorMessage = "No repository owner has been found, the target cannot be parsed.";
logger.error(errorMessage);
throw new Error(errorMessage);
}
const owner = payload.repository.owner.login;
const [orgParsed, repoParsed] = target.split("/");
let repoTarget = null;
if (orgParsed !== owner) {
return null;
} else if (repoParsed) {
let repoTarget;
if (repoParsed) {
if (orgParsed !== owner) {
return null;
}
repoTarget = repoParsed;
} else {
repoTarget = orgParsed;
}
return { org: owner, repo: repoTarget };
}
Expand All @@ -118,36 +129,42 @@ function parseTarget(target: string) {
*
* https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests#search-only-issues-or-pull-requests
*/
export async function getOpenPullRequests({ octokit, logger }: Context, targets: ReposWatchSettings) {
export async function getOpenPullRequests(context: Context, targets: ReposWatchSettings) {
const { octokit, logger } = context;
// If no repo to monitor is set, defaults to the organization
const monitor = [...targets.monitor];
if (!monitor.length) {
monitor.push(`org: ${context.payload.repository.owner?.login}`);
}
const filter = [
...targets.monitor.reduce<string[]>((acc, curr) => {
const parsedTarget = parseTarget(curr);
...monitor.reduce<string[]>((acc, curr) => {
const parsedTarget = parseTarget(context, curr);
if (parsedTarget) {
return [...acc, parsedTarget.repo ? `repo:${parsedTarget.org}/${parsedTarget.repo}` : `org:${parsedTarget.org}`];
}
return acc;
}, []),
...targets.ignore.reduce<string[]>((acc, curr) => {
const parsedTarget = parseTarget(curr);
const parsedTarget = parseTarget(context, curr);
if (parsedTarget) {
return [...acc, parsedTarget.repo ? `-repo:${parsedTarget.org}/${parsedTarget.repo}` : `-org:${parsedTarget.org}`];
}
return acc;
}, []),
];
try {
const results = await octokit.paginate("GET /search/issues", {
const data = await octokit.paginate(octokit.rest.search.issuesAndPullRequests, {
q: `is:pr is:open draft:false ${filter.join(" ")}`,
});
return results.flat();
return data.flat();
} catch (e) {
logger.error(`Error getting open pull-requests for targets: [${filter.join(", ")}]. ${e}`);
return [];
throw e;
}
}

export async function mergePullRequest(context: Context, { repo, owner, issue_number: pullNumber }: IssueParams) {
await context.octokit.pulls.merge({
await context.octokit.rest.pulls.merge({
owner,
repo,
pull_number: pullNumber,
Expand Down
74 changes: 50 additions & 24 deletions src/helpers/update-pull-requests.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RestEndpointMethodTypes } from "@octokit/rest";
import ms from "ms";
import { getAllTimelineEvents } from "../handlers/github-events";
import { Context } from "../types";
Expand All @@ -7,8 +8,10 @@ import {
getOpenPullRequests,
getPullRequestDetails,
isCiGreen,
IssueParams,
mergePullRequest,
parseGitHubUrl,
Requirements,
} from "./github";

type IssueEvent = {
Expand All @@ -18,56 +21,79 @@ type IssueEvent = {
commented_at?: string;
};

function isIssueEvent(event: object): event is IssueEvent {
return "created_at" in event;
}

export async function updatePullRequests(context: Context) {
const { logger } = context;
if (!context.config.repos.monitor.length) {
return context.logger.info("No organizations or repo have been specified, skipping.");
return logger.info("No organizations or repo have been specified, skipping.");
}

const pullRequests = await getOpenPullRequests(context, context.config.repos);

if (!pullRequests?.length) {
return context.logger.info("Nothing to do.");
return logger.info("Nothing to do.");
}
for (const { html_url } of pullRequests) {
try {
const gitHubUrl = parseGitHubUrl(html_url);
const pullRequestDetails = await getPullRequestDetails(context, gitHubUrl);
context.logger.debug(`Processing pull-request ${html_url}...`);
logger.debug(`Processing pull-request ${html_url}...`);
if (pullRequestDetails.merged || pullRequestDetails.closed_at) {
context.logger.info(`The pull request ${html_url} is already merged or closed, nothing to do.`);
logger.info(`The pull request ${html_url} is already merged or closed, nothing to do.`);
continue;
}
const activity = await getAllTimelineEvents(context, parseGitHubUrl(html_url));
const eventDates: Date[] = activity
.map((event) => {
const e = event as IssueEvent;
return new Date(e.created_at || e.updated_at || e.timestamp || e.commented_at || "");
})
.filter((date) => !isNaN(date.getTime()));
const eventDates: Date[] = activity.reduce<Date[]>((acc, event) => {
if (isIssueEvent(event)) {
const date = new Date(event.created_at || event.updated_at || event.timestamp || event.commented_at || "");
if (!isNaN(date.getTime())) {
acc.push(date);
}
}
return acc;
}, []);

const lastActivityDate = new Date(Math.max(...eventDates.map((date) => date.getTime())));

const requirements = await getMergeTimeoutAndApprovalRequiredCount(context, pullRequestDetails.author_association);
context.logger.debug(
logger.debug(
`Requirements according to association ${pullRequestDetails.author_association}: ${JSON.stringify(requirements)} with last activity date: ${lastActivityDate}`
);
if (isNaN(lastActivityDate.getTime()) || isPastOffset(lastActivityDate, requirements.mergeTimeout)) {
if ((await getApprovalCount(context, gitHubUrl)) >= requirements.requiredApprovalCount) {
if (await isCiGreen(context, pullRequestDetails.head.sha, gitHubUrl)) {
context.logger.info(`Pull-request ${html_url} is past its due date (${requirements.mergeTimeout} after ${lastActivityDate}), will merge.`);
await mergePullRequest(context, gitHubUrl);
} else {
context.logger.info(`Pull-request ${html_url} (sha: ${pullRequestDetails.head.sha}) does not pass all CI tests, won't merge.`);
}
} else {
context.logger.info(`Pull-request ${html_url} does not have sufficient reviewer approvals to be merged.`);
}
if (isNaN(lastActivityDate.getTime())) {
logger.info(`PR ${html_url} does not seem to have any activity, nothing to do.`);
} else if (isPastOffset(lastActivityDate, requirements.mergeTimeout)) {
await attemptMerging(context, { gitHubUrl, htmlUrl: html_url, requirements, lastActivityDate, pullRequestDetails });
} else {
context.logger.info(`PR ${html_url} has activity up until (${lastActivityDate}), nothing to do.`);
logger.info(`PR ${html_url} has activity up until (${lastActivityDate}), nothing to do.`);
}
} catch (e) {
context.logger.error(`Could not process pull-request ${html_url} for auto-merge: ${e}`);
logger.error(`Could not process pull-request ${html_url} for auto-merge: ${e}`);
}
}
}

async function attemptMerging(
context: Context,
data: {
gitHubUrl: IssueParams;
htmlUrl: string;
requirements: Requirements;
lastActivityDate: Date;
pullRequestDetails: RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
}
) {
if ((await getApprovalCount(context, data.gitHubUrl)) >= data.requirements.requiredApprovalCount) {
if (await isCiGreen(context, data.pullRequestDetails.head.sha, data.gitHubUrl)) {
context.logger.info(`Pull-request ${data.htmlUrl} is past its due date (${data.requirements.mergeTimeout} after ${data.lastActivityDate}), will merge.`);
await mergePullRequest(context, data.gitHubUrl);
} else {
context.logger.info(`Pull-request ${data.htmlUrl} (sha: ${data.pullRequestDetails.head.sha}) does not pass all CI tests, won't merge.`);
}
} else {
context.logger.info(`Pull-request ${data.htmlUrl} does not have sufficient reviewer approvals to be merged.`);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Logs } from "@ubiquity-dao/ubiquibot-logger";
import { Env } from "./env";
import { PluginSettings } from "./plugin-inputs";

export type SupportedEventsU = "push";
export type SupportedEventsU = "push" | "issues_comment.created";

export type SupportedEvents = {
[K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent<K> : never;
Expand Down
50 changes: 48 additions & 2 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const actionsGithubPackage = "@actions/github";
const issueParams = { owner: "ubiquibot", repo: "automated-merging", issue_number: 1 };
const workflow = "workflow";
const githubHelpersPath = "../src/helpers/github";
const monitor = "ubiquibot/automated-merging";

describe("Action tests", () => {
beforeEach(() => {
Expand Down Expand Up @@ -60,12 +61,15 @@ describe("Action tests", () => {
inputs: {
eventName: "push",
settings: JSON.stringify({
repos: { monitor: ["ubiquibot/automated-merging"] },
repos: { monitor: [monitor] },
}),
eventPayload: JSON.stringify({
pull_request: {
html_url: htmlUrl,
},
repository: {
owner: "ubiquibot",
},
}),
env: {
workflowName: workflow,
Expand Down Expand Up @@ -119,12 +123,15 @@ describe("Action tests", () => {
inputs: {
eventName: "push",
settings: JSON.stringify({
repos: { monitor: ["ubiquibot/automated-merging"] },
repos: { monitor: [monitor] },
}),
eventPayload: JSON.stringify({
pull_request: {
html_url: htmlUrl,
},
repository: {
owner: "ubiquibot",
},
}),
env: {
workflowName: workflow,
Expand Down Expand Up @@ -229,4 +236,43 @@ describe("Action tests", () => {
} as unknown as Context;
await expect(githubHelpers.isCiGreen(context, "1", issueParams)).resolves.toEqual(true);
});

it("Should throw an error if the search fails", async () => {
server.use(
http.get("https://api.github.com/search/issues", () => {
return HttpResponse.json({ error: "Some error" }, { status: 500 });
})
);
jest.mock(actionsGithubPackage, () => ({
context: {
repo: {
owner: {
login: "ubiquibot",
},
},
workflow,
payload: {
inputs: {
eventName: "push",
settings: JSON.stringify({
repos: { monitor: [monitor] },
}),
eventPayload: JSON.stringify({
pull_request: {
html_url: htmlUrl,
},
repository: {
owner: "ubiquibot",
},
}),
env: {
workflowName: workflow,
},
},
},
},
}));
const run = (await import("../src/action")).run;
await expect(run()).rejects.toThrow();
});
});

0 comments on commit bddf247

Please sign in to comment.