Skip to content

Commit

Permalink
feat: support cross-org
Browse files Browse the repository at this point in the history
  • Loading branch information
whilefoo committed Jan 30, 2025
1 parent 9371de6 commit bd63352
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 12 deletions.
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
54 changes: 43 additions & 11 deletions src/handlers/user-start-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { HttpStatusCode, Result } from "./result-types";
import { getDeadline } from "./shared/generate-assignment-comment";
import { start } from "./shared/start";
import { stop } from "./shared/stop";
import { customOctokit } from "@ubiquity-os/plugin-sdk/octokit";
import { createAppAuth } from "@octokit/auth-app";

export async function commandHandler(context: Context): Promise<Result> {
if (!isIssueCommentEvent(context)) {
Expand Down Expand Up @@ -77,22 +79,35 @@ 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) {
continue;
}
if (owner !== issue.repository.owner.login) {
context.logger.info(`Skipping linked issue ${issue.number} because it is not in the same organization.`);
continue;
}
const repository = (
await context.octokit.rest.repos.get({
owner: issue.repository.owner.login,
repo: issue.repository.name,
})
).data as Context<"issue_comment.created">["payload"]["repository"];

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 context.octokit.rest.issues.get({
await repoOctokit.rest.issues.get({
owner: issue.repository.owner.login,
repo: issue.repository.name,
issue_number: issue.number,
Expand All @@ -103,12 +118,29 @@ export async function userPullRequest(context: Context<"pull_request.opened" | "
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 {
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
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();
});
});

0 comments on commit bd63352

Please sign in to comment.