Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Integration

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun test
6 changes: 6 additions & 0 deletions handlers/check-weeks.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { generateGitHubAppToken, getGitHubHeaders } from "../utils/github.js";
import { corsResponse, errorResponse } from "../utils/cors.js";
import { handleWeekComment } from "../utils/prWeeks.js";
import { validateOrganization, hasMaintenanceLabel } from "../utils/validation.js";
import { ALLOWED_REPO } from "../utils/constants.js";

/**
* 모든 Open PR의 Week 설정을 검사하고 자동으로 댓글 작성/삭제
Expand All @@ -29,6 +30,11 @@ export async function checkWeeks(request, env) {
return errorResponse(`Unauthorized organization: ${repoOwner}`, 403);
}

// 허용된 repository만 처리
if (repo_name !== ALLOWED_REPO) {
return errorResponse(`Unauthorized repository: ${repo_name}`, 403);
}

// GitHub App Token 생성
const appToken = await generateGitHubAppToken(env);

Expand Down
100 changes: 100 additions & 0 deletions handlers/check-weeks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "bun:test";

vi.mock("../utils/github.js", () => ({
generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"),
getGitHubHeaders: vi.fn().mockReturnValue({
Authorization: "token fake-token",
}),
}));

vi.mock("../utils/prWeeks.js", () => ({
handleWeekComment: vi.fn().mockResolvedValue("Week 1"),
}));

import { checkWeeks } from "./check-weeks.js";
import { generateGitHubAppToken } from "../utils/github.js";

function makeRequest(body) {
return new Request("https://example.com/check-weeks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

const env = {};

describe("check-weeks repo filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
});
});

it("returns 403 for non-DaleStudy organization", async () => {
const request = makeRequest({
repo_owner: "OtherOrg",
repo_name: "leetcode-study",
});

const response = await checkWeeks(request, env);
expect(response.status).toBe(403);

const body = await response.json();
expect(body.error).toContain("Unauthorized organization");
});

it("returns 403 for non-leetcode-study repo_name", async () => {
const request = makeRequest({
repo_owner: "DaleStudy",
repo_name: "daleui",
});

const response = await checkWeeks(request, env);
expect(response.status).toBe(403);

const body = await response.json();
expect(body.error).toContain("Unauthorized repository");

expect(generateGitHubAppToken).not.toHaveBeenCalled();
});

it("processes leetcode-study repo_name successfully", async () => {
const request = makeRequest({
repo_owner: "DaleStudy",
repo_name: "leetcode-study",
});

const response = await checkWeeks(request, env);
expect(response.status).toBe(200);

const body = await response.json();
expect(body.success).toBe(true);
});

it("returns 400 when repo_name is missing", async () => {
const request = makeRequest({
repo_owner: "DaleStudy",
});

const response = await checkWeeks(request, env);
expect(response.status).toBe(400);

const body = await response.json();
expect(body.error).toContain("repo_name");
});

it("defaults repo_owner to DaleStudy when omitted", async () => {
const request = makeRequest({
repo_name: "leetcode-study",
});

const response = await checkWeeks(request, env);
expect(response.status).toBe(200);

const body = await response.json();
expect(body.success).toBe(true);
});
});
6 changes: 6 additions & 0 deletions handlers/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ async function handleProjectsV2ItemEvent(payload, env) {

const { number: prNumber, owner: repoOwner, repo: repoName } = prInfo;

// 허용된 repository만 처리 (projects_v2_item 이벤트는 payload에 repository가 없어 상위 필터를 우회함)
if (repoName !== ALLOWED_REPO) {
console.log(`Ignoring projects_v2_item for repository: ${repoName}`);
return corsResponse({ message: `Ignored: ${repoName}` });
}

// PR 상태 확인 (closed PR, maintenance 라벨 예외)
const prResponse = await fetch(
`https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}`,
Expand Down
235 changes: 235 additions & 0 deletions handlers/webhooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach } from "bun:test";

vi.mock("../utils/github.js", () => ({
generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"),
getPRInfoFromNodeId: vi.fn(),
getGitHubHeaders: vi.fn().mockReturnValue({
Authorization: "token fake-token",
}),
}));

vi.mock("../utils/prWeeks.js", () => ({
ensureWarningComment: vi.fn().mockResolvedValue(false),
removeWarningComment: vi.fn().mockResolvedValue(false),
handleWeekComment: vi.fn().mockResolvedValue("Week 1"),
}));

vi.mock("../utils/prReview.js", () => ({
performAIReview: vi.fn(),
addReactionToComment: vi.fn(),
}));

vi.mock("../utils/prActions.js", () => ({
hasApprovedReview: vi.fn(),
safeJson: vi.fn(),
}));

vi.mock("./tag-patterns.js", () => ({
tagPatterns: vi.fn(),
}));

vi.mock("./learning-status.js", () => ({
postLearningStatus: vi.fn(),
}));

import { handleWebhook } from "./webhooks.js";
import { getPRInfoFromNodeId } from "../utils/github.js";
import {
ensureWarningComment,
removeWarningComment,
handleWeekComment,
} from "../utils/prWeeks.js";

function makeRequest(eventType, payload) {
return new Request("https://example.com/webhooks", {
method: "POST",
headers: { "X-GitHub-Event": eventType },
body: JSON.stringify(payload),
});
}

const env = {};

describe("webhook repo filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({ state: "open", labels: [], draft: false }),
});
});

describe("top-level filter (payload.repository)", () => {
it("ignores immediately when payload.repository.name is not leetcode-study", async () => {
const request = makeRequest("pull_request", {
action: "opened",
organization: { login: "DaleStudy" },
repository: { name: "daleui", owner: { login: "DaleStudy" } },
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
});

const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Ignored: daleui");
});

it("passes when payload.repository.name is leetcode-study", async () => {
const request = makeRequest("pull_request", {
action: "synchronize",
organization: { login: "DaleStudy" },
repository: {
name: "leetcode-study",
owner: { login: "DaleStudy" },
},
pull_request: {
number: 1,
labels: [],
head: { sha: "abc" },
user: { login: "testuser" },
},
});

const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Processed");
});
});

describe("projects_v2_item event repo filtering", () => {
const basePayload = {
action: "edited",
organization: { login: "DaleStudy" },
projects_v2_item: {
content_type: "PullRequest",
content_node_id: "PR_node123",
},
changes: {
field_value: {
field_name: "Week",
to: { title: "Week 1" },
},
},
};

it("ignores when GraphQL lookup returns a non-leetcode-study repo", async () => {
getPRInfoFromNodeId.mockResolvedValue({
number: 962,
owner: "DaleStudy",
repo: "daleui",
});

const request = makeRequest("projects_v2_item", basePayload);
const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Ignored: daleui");
expect(ensureWarningComment).not.toHaveBeenCalled();
expect(removeWarningComment).not.toHaveBeenCalled();
});

it("processes normally when GraphQL lookup returns leetcode-study", async () => {
getPRInfoFromNodeId.mockResolvedValue({
number: 100,
owner: "DaleStudy",
repo: "leetcode-study",
});

const request = makeRequest("projects_v2_item", basePayload);
const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Processed");
});

it("ignores non-leetcode-study repo on deleted action", async () => {
getPRInfoFromNodeId.mockResolvedValue({
number: 962,
owner: "DaleStudy",
repo: "daleui",
});

const request = makeRequest("projects_v2_item", {
...basePayload,
action: "deleted",
});
const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Ignored: daleui");
expect(ensureWarningComment).not.toHaveBeenCalled();
});

it("ignores non-leetcode-study repo on created action", async () => {
getPRInfoFromNodeId.mockResolvedValue({
number: 962,
owner: "DaleStudy",
repo: "daleui",
});

const request = makeRequest("projects_v2_item", {
...basePayload,
action: "created",
});
const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Ignored: daleui");
expect(handleWeekComment).not.toHaveBeenCalled();
});
});

describe("organization filter", () => {
it("ignores non-DaleStudy organization", async () => {
const request = makeRequest("pull_request", {
action: "opened",
organization: { login: "OtherOrg" },
repository: {
name: "leetcode-study",
owner: { login: "OtherOrg" },
},
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
});

const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Ignored: not DaleStudy organization");
});

it("ignores when organization field is missing", async () => {
const request = makeRequest("pull_request", {
action: "opened",
repository: {
name: "leetcode-study",
owner: { login: "DaleStudy" },
},
pull_request: { number: 1, labels: [], head: { sha: "abc" } },
});

const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Ignored: not DaleStudy organization");
});
});

describe("event type filter", () => {
it("ignores unsupported event types", async () => {
const request = makeRequest("push", {
organization: { login: "DaleStudy" },
repository: {
name: "leetcode-study",
owner: { login: "DaleStudy" },
},
});

const response = await handleWebhook(request, env);
const body = await response.json();

expect(body.message).toBe("Ignored: push");
});
});
});