Skip to content
Merged
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
90 changes: 85 additions & 5 deletions src/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,11 @@ function makeSponsorshipResponse(
Promise.resolve({
data: {
organization: {
sponsorshipsAsMaintainer: {
sponsorsActivities: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sponsorsActivities 도 있었군요! 덕분에 많이 배웁니다

nodes: sponsors.map(({ login, createdAt = "2024-01-01T00:00:00Z", isOneTimePayment = true, amount = 5 }) => ({
createdAt,
isOneTimePayment,
tier: { monthlyPriceInDollars: amount },
sponsorEntity: { login },
timestamp: createdAt,
sponsorsTier: { monthlyPriceInDollars: amount, isOneTime: isOneTimePayment },
sponsor: { login },
})),
pageInfo: { hasNextPage, endCursor },
},
Expand Down Expand Up @@ -171,6 +170,87 @@ describe("checkSponsorship", () => {
const body = JSON.parse(options.body);
expect(body.query).toContain("includePrivate: true");
});

it("sponsorsActivities를 NEW_SPONSORSHIP 액션으로 조회한다", async () => {
const mockFetch = vi
.fn()
.mockResolvedValue(makeSponsorshipResponse([{ login: "octocat" }]));
vi.stubGlobal("fetch", mockFetch);

await checkSponsorship("octocat", "DaleStudy", "my-token");

const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.query).toContain("sponsorsActivities");
expect(body.query).toContain("NEW_SPONSORSHIP");
});

it("결제 이벤트의 timestamp를 record의 createdAt으로 사용한다", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
makeSponsorshipResponse([{ login: "octocat", createdAt: "2026-06-27T09:00:00Z", amount: 5 }]),
),
);

const result = await checkSponsorship("octocat", "DaleStudy", "test-token");
expect(result.records).toEqual([
{ createdAt: "2026-06-27T09:00:00Z", isOneTimePayment: true, amount: 5 },
]);
});

it("재참여자의 반복 일시후원이 각각 별도 레코드로 집계된다", async () => {
// 같은 후원자가 지난 기수와 새 기수에 각각 일시후원한 경우,
// 두 결제가 각자의 timestamp로 별도 기록되어 호출부의 날짜 필터가 정확히 동작한다.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
makeSponsorshipResponse([
{ login: "u-siop", createdAt: "2026-02-10T00:00:00Z", amount: 5 },
{ login: "u-siop", createdAt: "2026-06-27T09:00:00Z", amount: 5 },
]),
),
);

const result = await checkSponsorship("u-siop", "DaleStudy", "test-token");
expect(result.sponsored).toBe(true);
expect(result.records).toHaveLength(2);
expect(result.records.map((r) => r.createdAt)).toEqual([
"2026-02-10T00:00:00Z",
"2026-06-27T09:00:00Z",
]);
});

it("null 노드가 섞여 있어도 크래시 없이 건너뛴다", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: () =>
Promise.resolve({
data: {
organization: {
sponsorsActivities: {
nodes: [
null,
{
timestamp: "2026-06-27T00:00:00Z",
sponsorsTier: { monthlyPriceInDollars: 5, isOneTime: true },
sponsor: { login: "octocat" },
},
null,
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
}),
}),
);

const result = await checkSponsorship("octocat", "DaleStudy", "test-token");
expect(result.sponsored).toBe(true);
expect(result.records).toHaveLength(1);
});
});

describe("getTeamCreatedAt", () => {
Expand Down
32 changes: 18 additions & 14 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ export interface SponsorshipInfo {
}

/**
* GitHub 후원 여부 확인 (과거 후원 포함, GraphQL 페이지네이션)
* GitHub 후원 여부 확인
*
* 후원 "관계"가 아니라 결제 "이벤트"(NEW_SPONSORSHIP)를 조회한다. 각 결제의 실제 timestamp 를
* 쓰므로 반복 일시후원·재참여 후원을 결제일 기준으로 집계할 수 있다.
* (팀 생성일 이후만 합산하는 날짜 필터는 호출부에서 수행)
*/
export async function checkSponsorship(
githubUsername: string,
Expand All @@ -51,13 +55,13 @@ export async function checkSponsorship(
const query = `
query($org: String!, $cursor: String) {
organization(login: $org) {
sponsorshipsAsMaintainer(first: 100, after: $cursor, activeOnly: false, includePrivate: true) {
sponsorsActivities(first: 100, after: $cursor, period: ALL, includePrivate: true, actions: [NEW_SPONSORSHIP]) {
nodes {
createdAt
isOneTimePayment
tier { monthlyPriceInDollars }
sponsorEntity {
timestamp
sponsorsTier { monthlyPriceInDollars isOneTime }
sponsor {
... on User { login }
... on Organization { login }
}
}
pageInfo {
Expand Down Expand Up @@ -90,20 +94,20 @@ export async function checkSponsorship(
throw new Error(`GraphQL error: ${JSON.stringify(result.errors)}`);
}

const sponsorships = result.data?.organization?.sponsorshipsAsMaintainer;
const nodes = sponsorships?.nodes ?? [];
const activities = result.data?.organization?.sponsorsActivities;
const nodes = activities?.nodes ?? [];

for (const node of nodes) {
if (node.sponsorEntity?.login?.toLowerCase() !== githubUsername.toLowerCase()) continue;
if (node?.sponsor?.login?.toLowerCase() !== githubUsername.toLowerCase()) continue;
records.push({
createdAt: node.createdAt,
isOneTimePayment: node.isOneTimePayment ?? false,
amount: node.tier?.monthlyPriceInDollars ?? 0,
createdAt: node.timestamp,
isOneTimePayment: node.sponsorsTier?.isOneTime ?? false,
amount: node.sponsorsTier?.monthlyPriceInDollars ?? 0,
});
}

if (!sponsorships?.pageInfo?.hasNextPage) break;
cursor = sponsorships.pageInfo.endCursor;
if (!activities?.pageInfo?.hasNextPage) break;
cursor = activities.pageInfo.endCursor;
}

if (records.length === 0) {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async function processVerify(
console.log(`[verify] teamCreatedAt=${teamCreatedAt} eligibleRecords=${eligibleRecords.length} totalAmount=${totalAmount}`);

if (totalAmount < 5) {
return `❌ 가장 최근 후원 금액($${totalAmount})이 $5 미만입니다. 여러 번 후원하셨다면 API 한계로 최근 1건만 확인됩니다. 운영자에게 문의해주세요.`;
return `❌ 팀 생성일 이후 후원 금액($${totalAmount})이 $5 미만입니다. 후원 내역이 맞다면 운영자에게 문의해주세요.`;
}

const existingMembership = await getTeamMembership(env.GITHUB_ORG, teamConfig.teamSlug, githubUsername, token);
Expand Down
Loading