Skip to content

Commit

Permalink
Skip up-to-dates PRs (#17)
Browse files Browse the repository at this point in the history
Resolves #16

- Added Graphql implementation to get `viewerCanUpdateBranch` field.
  - Filtered PRs which have this value false
- Implemented [`GraphQL-Codegen`](https://the-guild.dev/graphql/codegen)
- Updated types to be extracted from `@octokit/graphql-schema` and
auto-generated.
- The type that implements this looks messy because all the nested
objects are nullable
  - Added utility to copy `graphql` files into raw strings
- Updated version to `0.2.1`
  • Loading branch information
Bullrich authored Apr 10, 2024
1 parent e53f825 commit 21dab3f
Show file tree
Hide file tree
Showing 12 changed files with 2,480 additions and 90 deletions.
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
node_modules
dist
.git

# Graphql Generated files
src/github/queries/*
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@
!.vscode/extensions.json
!.vscode/settings.json
.idea

# Graphql Generated files
src/github/queries/*.ts
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ WORKDIR /action

COPY package.json yarn.lock ./

RUN yarn install --frozen-lockfile
RUN yarn install --frozen-lockfile --ignore-scripts

COPY . .

Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ outputs:

runs:
using: 'docker'
image: 'docker://ghcr.io/paritytech/up-to-date-action/action:0.2.0'
image: 'docker://ghcr.io/paritytech/up-to-date-action/action:0.2.1'
14 changes: 14 additions & 0 deletions codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
overwrite: true,
schema: "./node_modules/@octokit/graphql-schema/schema.graphql",
documents: "src/**/*.graphql",
generates: {
"src/github/queries/index.ts": {
plugins: ["typescript", "typescript-operations"]
}
}
};

export default config;
17 changes: 12 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
{
"name": "up-to-date-action",
"version": "0.2.0",
"version": "0.2.1",
"description": "Keep all your PRs up to date when a new commit is pushed to the main branch",
"main": "src/index.ts",
"scripts": {
"start": "node dist",
"build": "ncc build --license LICENSE",
"test": "jest",
"fix": "eslint --fix 'src/**/*'",
"lint": "eslint 'src/**/*'"
"lint": "eslint 'src/**/*'",
"codegen": "graphql-codegen --config codegen.ts",
"graphql": "node scripts/copy-files && yarn codegen",
"postinstall": "yarn run graphql",
"prebuild": "yarn run graphql"
},
"engines": {
"node": "^20"
Expand All @@ -26,15 +30,18 @@
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0",
"@octokit/webhooks-types": "^7.3.2"
"@octokit/webhooks-types": "^7.5.0",
"graphql": "^16.8.1"
},
"devDependencies": {
"@eng-automation/js-style": "^2.3.0",
"@graphql-codegen/cli": "^5.0.2",
"@octokit/graphql-schema": "^15.5.1",
"@types/jest": "^29.5.12",
"@vercel/ncc": "^0.38.1",
"jest": "^29.7.0",
"jest-mock-extended": "^3.0.5",
"jest-mock-extended": "^3.0.6",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
"typescript": "^5.4.4"
}
}
24 changes: 24 additions & 0 deletions scripts/copy-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const { readdirSync, writeFileSync, readFileSync } = require("fs");

const files = readdirSync("src/github/queries/");

/**
* Copy a file and replace it's extension
* @param {string} fileName Name of the file to copy
* @param {string} extension New extension to put
*/
const copyFile = (fileName, extension) => {
console.log("Copying content of %s into a .ts file", fileName);
const content = readFileSync(fileName);
const oldExtension = fileName.split(".").pop();
writeFileSync(
fileName.replace(oldExtension, extension),
`// Generated from ${fileName}\nexport default \`${content}\`;`,
);
};

for (const file of files) {
if (file.endsWith(".graphql")) {
copyFile(`src/github/queries/${file}`, "ts");
}
}
64 changes: 58 additions & 6 deletions src/github/pullRequest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { PullRequestsQuery, PullRequestsQueryVariables } from "./queries";
import PRQuery from "./queries/PullRequests";
import { ActionLogger, GitHubClient } from "./types";

type PullData = { number: number; title: string };

export type PullRequest = NonNullable<
NonNullable<
NonNullable<
NonNullable<PullRequestsQuery["repository"]>["pullRequests"]["edges"]
>[number]
>["node"]
>;

/** API class that uses the default token to access the data from the pull request and the repository */
export class PullRequestApi {
constructor(
Expand All @@ -10,23 +20,65 @@ export class PullRequestApi {
private readonly logger: ActionLogger,
) {}

async fetchOpenPRs(): Promise<PullRequest[]> {
const prs: PullRequest[] = [];

let cursor: string | null | undefined = null;
let hasNextPage: boolean = false;

do {
const variables: PullRequestsQueryVariables = {
cursor,
repo: this.repo.repo,
owner: this.repo.owner,
};
const query = await this.api.graphql<PullRequestsQuery>(
PRQuery,
variables,
);
if (!query.repository?.pullRequests) {
throw new Error("Could not fetch pull requests");
}
const { edges, pageInfo } = query.repository.pullRequests;
if (!edges) {
this.logger.warn("Query returned undefined values");
break;
}

for (const edge of edges) {
const node = edge?.node as PullRequest;
prs.push(node);
}

hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
} while (hasNextPage);

return prs;
}

async listPRs(onlyAutoMerge: boolean): Promise<PullData[]> {
this.logger.debug("Fetching list of PR");

const openPRs = await this.api.paginate(this.api.rest.pulls.list, {
...this.repo,
state: "open",
});
const openPRs = await this.fetchOpenPRs();

this.logger.debug(JSON.stringify(openPRs));

const prs: PullData[] = [];
for (const pr of openPRs) {
const { number, title } = pr;

if (pr.draft) {
const autoMergeEnabled: boolean =
pr.autoMergeRequest?.enabledAt != undefined &&
pr.autoMergeRequest?.enabledAt != null;

if (pr.isDraft) {
this.logger.debug(`❕ - Ignoring #${number} because it is a draft`);
} else if (!pr.auto_merge && onlyAutoMerge) {
} else if (!pr.viewerCanUpdateBranch) {
this.logger.info(
`⭕️ - Skipping #${number} because the viewer can not update the branch`,
);
} else if (!autoMergeEnabled && onlyAutoMerge) {
this.logger.debug(
`❗️ - Ignoring #${number} because auto-merge is not enabled`,
);
Expand Down
27 changes: 27 additions & 0 deletions src/github/queries/PullRequests.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
query PullRequests($cursor: String, $owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(
first: 100
states: OPEN
orderBy: { field: UPDATED_AT, direction: ASC }
after: $cursor) {
edges {
node {
number
title
viewerCanUpdateBranch
isDraft
autoMergeRequest {
enabledAt
}
}
}
pageInfo {
endCursor
startCursor
hasNextPage
hasPreviousPage
}
}
}
}
91 changes: 68 additions & 23 deletions src/test/pullRequest.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DeepMockProxy, mock, mockDeep, MockProxy } from "jest-mock-extended";

import { PullRequestApi } from "../github/pullRequest";
import { PullRequest, PullRequestApi } from "../github/pullRequest";
import { ActionLogger, GitHubClient } from "../github/types";

describe("Pull Request API Tests", () => {
Expand All @@ -19,52 +19,97 @@ describe("Pull Request API Tests", () => {
});

test("Should filter prs without auto-merge", async () => {
const mockedPrs = [
{ number: 1, auto_merge: true, title: "one" },
{ number: 2, auto_merge: false, title: "two" },
const mockedPrs: { node: Partial<PullRequest> }[] = [
{
node: {
number: 1,
autoMergeRequest: { enabledAt: "abc" },
viewerCanUpdateBranch: true,
title: "one",
},
},
{ node: { number: 2, viewerCanUpdateBranch: true, title: "two" } },
];
client.paginate.mockResolvedValue(mockedPrs);
client.graphql.mockResolvedValue({
repository: {
pullRequests: { edges: mockedPrs, pageInfo: { hasNextPage: false } },
},
});
const prs = await api.listPRs(true);
expect(prs).toHaveLength(1);
expect(prs).toContainEqual({
number: mockedPrs[0].number,
title: mockedPrs[0].title,
number: mockedPrs[0].node.number,
title: mockedPrs[0].node.title,
});
expect(prs).not.toContainEqual({
number: mockedPrs[1].number,
title: mockedPrs[1].title,
number: mockedPrs[1].node.number,
title: mockedPrs[1].node.title,
});
});

test("Should return all prs without filter", async () => {
const mockedPrs = [
{ number: 1, auto_merge: true, title: "one" },
{ number: 2, auto_merge: false, title: "two" },
const mockedPrs: { node: Partial<PullRequest> }[] = [
{
node: {
number: 1,
autoMergeRequest: { enabledAt: "abc" },
viewerCanUpdateBranch: true,
title: "one",
},
},
{ node: { number: 2, viewerCanUpdateBranch: true, title: "two" } },
];
client.paginate.mockResolvedValue(mockedPrs);
client.graphql.mockResolvedValue({
repository: {
pullRequests: { edges: mockedPrs, pageInfo: { hasNextPage: false } },
},
});
const prs = await api.listPRs(false);
expect(prs).toHaveLength(2);
expect(prs).toEqual([
{ number: mockedPrs[0].number, title: mockedPrs[0].title },
{ number: mockedPrs[1].number, title: mockedPrs[1].title },
{ number: mockedPrs[0].node.number, title: mockedPrs[0].node.title },
{ number: mockedPrs[1].node.number, title: mockedPrs[1].node.title },
]);
});

test("Should filter drafts PRs", async () => {
const mockedPrs = [
{ number: 1, auto_merge: false, title: "one" },
{ number: 2, auto_merge: false, draft: true, title: "two" },
const mockedPrs: { node: Partial<PullRequest> }[] = [
{
node: {
number: 1,
autoMergeRequest: { enabledAt: "abc" },
viewerCanUpdateBranch: true,
title: "one",
},
},
{
node: {
number: 2,
viewerCanUpdateBranch: true,
isDraft: true,
title: "two",
},
},
];
client.paginate.mockResolvedValue(mockedPrs);
client.graphql.mockResolvedValue({
repository: {
pullRequests: { edges: mockedPrs, pageInfo: { hasNextPage: false } },
},
});
client.graphql.mockResolvedValue({
repository: {
pullRequests: { edges: mockedPrs, pageInfo: { hasNextPage: false } },
},
});
const prs = await api.listPRs(false);
expect(prs).toHaveLength(1);
expect(prs).toContainEqual({
number: mockedPrs[0].number,
title: mockedPrs[0].title,
number: mockedPrs[0].node.number,
title: mockedPrs[0].node.title,
});
expect(prs).not.toContainEqual({
number: mockedPrs[1].number,
title: mockedPrs[1].title,
number: mockedPrs[1].node.number,
title: mockedPrs[1].node.title,
});
});
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
},
"exclude": [
"node_modules",
"codegen.ts"
]
}
Loading

0 comments on commit 21dab3f

Please sign in to comment.