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

feat: correct context and skip issues outside of the org #134

Merged
merged 11 commits into from
Feb 3, 2025
4 changes: 4 additions & 0 deletions .github/workflows/worker-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,16 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
secrets: |
APP_ID
APP_PRIVATE_KEY
SUPABASE_URL
SUPABASE_KEY
BOT_USER_ID
CLOUDFLARE_ACCOUNT_ID
${{ secrets.KERNEL_PUBLIC_KEY && secrets.KERNEL_PUBLIC_KEY != '' && 'KERNEL_PUBLIC_KEY' || '' }}
env:
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
BOT_USER_ID: ${{ secrets.BOT_USER_ID }}
Expand Down
Binary file modified bun.lockb
Binary file not shown.
14 changes: 11 additions & 3 deletions dist/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"open-source"
],
"dependencies": {
"@octokit/auth-app": "^7.1.4",
"@octokit/graphql-schema": "15.25.0",
"@octokit/plugin-rest-endpoint-methods": "^13.2.6",
"@octokit/types": "^13.6.2",
Expand Down
107 changes: 72 additions & 35 deletions src/handlers/user-start-stop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createAppAuth } from "@octokit/auth-app";
import { Repository } from "@octokit/graphql-schema";
import { Context, isIssueCommentEvent, Label } from "../types";
import { customOctokit } from "@ubiquity-os/plugin-sdk/octokit";
import { Context, isIssueCommentEvent } from "../types";
import { QUERY_CLOSING_ISSUE_REFERENCES } from "../utils/get-closing-issue-references";
import { closePullRequest, closePullRequestForAnIssue, getOwnerRepoFromHtmlUrl } from "../utils/issue";
import { HttpStatusCode, Result } from "./result-types";
Expand Down Expand Up @@ -77,41 +79,76 @@ export async function userPullRequest(context: Context<"pull_request.opened" | "
context.logger.info("No linked issues were found, nothing to do.");
return { status: HttpStatusCode.NOT_MODIFIED };
}

const appOctokit = new customOctokit({
authStrategy: createAppAuth,
auth: {
appId: context.env.APP_ID,
privateKey: context.env.APP_PRIVATE_KEY,
},
});

for (const issue of issues) {
if (issue && !issue.assignees.nodes?.length) {
const labels =
issue.labels?.nodes?.reduce<Label[]>((acc, curr) => {
if (curr) {
acc.push({
...curr,
id: Number(curr.id),
node_id: curr.id,
default: true,
description: curr.description ?? null,
});
}
return acc;
}, []) ?? [];
const deadline = getDeadline(labels);
if (!deadline) {
context.logger.debug("Skipping deadline posting message because no deadline has been set.");
return { status: HttpStatusCode.NOT_MODIFIED };
} else {
const issueWithComment: Context<"issue_comment.created">["payload"]["issue"] = {
...issue,
assignees: issue.assignees.nodes as Context<"issue_comment.created">["payload"]["issue"]["assignees"],
labels,
html_url: issue.url,
} as unknown as Context<"issue_comment.created">["payload"]["issue"];
context.payload = Object.assign({ issue: issueWithComment }, context.payload);
try {
return await start(context, issueWithComment, pull_request.user ?? payload.sender, []);
} catch (error) {
context.logger.info("The task could not be started, closing linked pull-request.", { pull_request });
await closePullRequest(context, { number: pull_request.number });
throw error;
}
}
if (!issue || issue.assignees.nodes?.length) {
continue;
}

const installation = await appOctokit.rest.apps.getRepoInstallation({
owner: issue.repository.owner.login,
repo: issue.repository.name,
});
const repoOctokit = new customOctokit({
authStrategy: createAppAuth,
auth: {
appId: Number(context.env.APP_ID),
privateKey: context.env.APP_PRIVATE_KEY,
installationId: installation.data.id,
},
});

const linkedIssue = (
await repoOctokit.rest.issues.get({
owner: issue.repository.owner.login,
repo: issue.repository.name,
issue_number: issue.number,
})
).data as Context<"issue_comment.created">["payload"]["issue"];
const deadline = getDeadline(linkedIssue.labels);
if (!deadline) {
context.logger.debug("Skipping deadline posting message because no deadline has been set.");
return { status: HttpStatusCode.NOT_MODIFIED };
}

const repository = (
await repoOctokit.rest.repos.get({
owner: issue.repository.owner.login,
repo: issue.repository.name,
})
).data as Context<"issue_comment.created">["payload"]["repository"];
let organization: Context<"issue_comment.created">["payload"]["organization"] | undefined = undefined;
if (repository.owner.type === "Organization") {
organization = (
await repoOctokit.rest.orgs.get({
org: issue.repository.owner.login,
})
).data;
}
const newContext = {
...context,
octokit: repoOctokit,
payload: {
...context.payload,
issue: linkedIssue,
repository,
organization,
},
};
try {
return await start(newContext, linkedIssue, pull_request.user ?? payload.sender, []);
} catch (error) {
context.logger.info("The task could not be started, closing linked pull-request.", { pull_request });
await closePullRequest(context, { number: pull_request.number });
throw error;
}
}
return { status: HttpStatusCode.NOT_MODIFIED };
Expand Down
2 changes: 2 additions & 0 deletions src/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { StaticDecode } from "@sinclair/typebox";

const ERROR_MSG = "Invalid BOT_USER_ID";
export const envSchema = T.Object({
APP_ID: T.String({ minLength: 1 }),
APP_PRIVATE_KEY: T.String({ minLength: 1 }),
SUPABASE_URL: T.String(),
SUPABASE_KEY: T.String(),
BOT_USER_ID: T.Transform(T.Union([T.String(), T.Number()], { examples: 123456 }))
Expand Down
9 changes: 9 additions & 0 deletions src/utils/get-closing-issue-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const QUERY_CLOSING_ISSUE_REFERENCES = /* GraphQL */ `
id
url
number
state
labels(first: 100) {
nodes {
id
Expand All @@ -21,6 +22,14 @@ export const QUERY_CLOSING_ISSUE_REFERENCES = /* GraphQL */ `
login
}
}
repository {
id
name
owner {
id
login
}
}
}
pageInfo {
hasNextPage
Expand Down
1 change: 1 addition & 0 deletions tests/__mocks__/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const db = factory({
owner: {
login: String,
id: Number,
type: String,
},
issues: Array,
},
Expand Down
2 changes: 1 addition & 1 deletion tests/__mocks__/issue-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default {
name: "Price: 200 USD",
},
{
name: "Time: 1h",
name: "Time: <1 Hour",
},
{
name: "Priority: 1 (Normal)",
Expand Down
2 changes: 2 additions & 0 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,8 @@ export function createContext(
eventName: "issue_comment.created" as SupportedEvents,
organizations: ["ubiquity"],
env: {
APP_ID: appId,
APP_PRIVATE_KEY: "private_key",
SUPABASE_KEY: "key",
SUPABASE_URL: "url",
BOT_USER_ID: appId as unknown as number,
Expand Down
120 changes: 120 additions & 0 deletions tests/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ dotenv.config();
type Issue = Context<"issue_comment.created">["payload"]["issue"];
type PayloadSender = Context["payload"]["sender"];

const commandStartStop = "command-start-stop";
const ubiquityOsMarketplace = "ubiquity-os-marketplace";

beforeAll(() => {
server.listen();
});
Expand Down Expand Up @@ -41,6 +44,7 @@ async function setupTests() {
owner: {
login: "ubiquity",
id: 1,
type: "Organization",
},
issues: [],
});
Expand Down Expand Up @@ -107,6 +111,14 @@ describe("Collaborator tests", () => {
labels: {
nodes: [{ name: "Time: <1 Hour" }],
},
repository: {
id: 1,
name: commandStartStop,
owner: {
id: 1,
login: ubiquityOsMarketplace,
},
},
},
],
},
Expand All @@ -127,10 +139,118 @@ describe("Collaborator tests", () => {
jest.unstable_mockModule("../src/handlers/shared/start", () => ({
start,
}));
jest.unstable_mockModule("@ubiquity-os/plugin-sdk/octokit", () => ({
customOctokit: jest.fn().mockReturnValue({
rest: {
apps: {
getRepoInstallation: jest.fn(() => Promise.resolve({ data: { id: 1 } })),
},
issues: {
get: jest.fn(() => Promise.resolve({ data: issue })),
},
repos: {
get: jest.fn(() => Promise.resolve({ data: { id: 1, name: commandStartStop, owner: { id: 1, login: ubiquityOsMarketplace } } })),
},
orgs: {
get: jest.fn(() => Promise.resolve({ data: { id: 1, login: ubiquityOsMarketplace } })),
},
},
}),
}));
const { startStopTask } = await import("../src/plugin");
await startStopTask(context);
// Make sure the author is the one who starts and not the sender who modified the comment
expect(start).toHaveBeenCalledWith(expect.anything(), expect.anything(), { id: 1, login: "ubiquity-os-author" }, []);
start.mockReset();
});

it("should successfully assign if the PR and linked issue are in different organizations", async () => {
db.users.create({
id: 3,
login: "ubiquity-os-sender",
role: "admin",
});
const issue = db.issue.findFirst({ where: { id: { equals: 1 } } }) as unknown as Issue;
const sender = db.users.findFirst({ where: { id: { equals: 3 } } }) as unknown as PayloadSender;
const repository = db.repo.findFirst({ where: { id: { equals: 1 } } });

const context = createContext(issue, sender, "") as Context<"pull_request.edited">;
context.eventName = "pull_request.edited";
context.payload.pull_request = {
html_url: "https://github.com/ubiquity-os-marketplace/command-start-stop",
number: 2,
user: {
id: 1,
login: "whilefoo",
},
} as unknown as Context<"pull_request.edited">["payload"]["pull_request"];
context.payload.repository = {
id: 2,
name: commandStartStop,
owner: {
login: "ubiquity-os-marketplace",
},
} as unknown as Context<"pull_request.edited">["payload"]["repository"];
context.octokit = {
graphql: {
paginate: jest.fn(() =>
Promise.resolve({
repository: {
pullRequest: {
closingIssuesReferences: {
nodes: [
{
assignees: {
nodes: [],
},
labels: {
nodes: [{ name: "Time: <1 Hour" }],
},
repository: repository,
},
],
},
},
},
})
),
},
} as unknown as Context<"pull_request.edited">["octokit"];

jest.unstable_mockModule("@supabase/supabase-js", () => ({
createClient: jest.fn(),
}));
jest.unstable_mockModule("../src/adapters", () => ({
createAdapters: jest.fn(),
}));
const start = jest.fn();
jest.unstable_mockModule("../src/handlers/shared/start", () => ({
start,
}));
jest.unstable_mockModule("@ubiquity-os/plugin-sdk/octokit", () => ({
customOctokit: jest.fn().mockReturnValue({
rest: {
apps: {
getRepoInstallation: jest.fn(() => Promise.resolve({ data: { id: 1 } })),
},
issues: {
get: jest.fn(() => Promise.resolve({ data: issue })),
},
repos: {
get: jest.fn(() => Promise.resolve({ data: repository })),
},
orgs: {
get: jest.fn(() => Promise.resolve({ data: repository?.owner })),
},
},
}),
}));
const { startStopTask } = await import("../src/plugin");
await startStopTask(context);
expect(start.mock.calls[0][0]).toMatchObject({ payload: { issue, repository, organization: repository?.owner } });
expect(start.mock.calls[0][1]).toMatchObject({ id: 1 });
expect(start.mock.calls[0][2]).toMatchObject({ id: 1, login: "whilefoo" });
expect(start.mock.calls[0][3]).toEqual([]);
start.mockReset();
});
});