diff --git a/AGENTS.md b/AGENTS.md index e1d4de1..aed8e74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -487,6 +487,10 @@ describe("checkWeeks", () => { - GitHub 인증 로직 (`generateGitHubAppToken`, `createJWT` 등)은 모든 기능에서 공통으로 사용 - 새 기능 추가 시 기존 유틸리티 함수 활용 +7. **코멘트 숨김 마커 직렬화 포맷 변경** + - 코멘트에 `` 형태로 숨겨 저장하는 데이터의 직렬화 포맷(객체↔배열 등)을 바꿀 때는 **정규식·문서 주석·테스트를 같은 PR에서 함께 갱신** + - 파싱이 `Array.isArray` 같은 방어 코드로 빈 값에 fallback하면 회귀가 조용히 묻혀 디버깅이 어려워짐 + ## 관련 문서 - [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/) diff --git a/tests/learningComment.test.js b/tests/learningComment.test.js new file mode 100644 index 0000000..eb74a3d --- /dev/null +++ b/tests/learningComment.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach } from "bun:test"; + +import { upsertLearningStatusComment } from "../utils/learningComment.js"; + +const REPO_OWNER = "DaleStudy"; +const REPO_NAME = "leetcode-study"; +const PR_NUMBER = 42; +const APP_TOKEN = "fake-app-token"; +const COMMENT_MARKER = ""; + +function ok(body) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + json: () => Promise.resolve(body), + }); +} + +function parseBody(call) { + return JSON.parse(call.init.body).body; +} + +describe("upsertLearningStatusComment — usage history accumulation", () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + it("posts a new comment with a single usage row when no prior comment exists", async () => { + const calls = []; + globalThis.fetch = (url, init = {}) => { + calls.push({ url: String(url), init }); + if (init.method === "POST") return ok({}); + return ok([]); // no existing comments + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## body`, + APP_TOKEN, + { prompt_tokens: 100, completion_tokens: 50 } + ); + + const post = calls.find((c) => c.init.method === "POST"); + expect(post).toBeDefined(); + + const body = parseBody(post); + expect(body).toContain("🔢 API 사용량 (gpt-4.1-nano)"); + expect(body).toContain("| #1 | 100 | 50 | 150 |"); + // single-row history => no totals row + expect(body).not.toContain("**합계**"); + // hidden marker stores an array + expect(body).toMatch(//); + + globalThis.fetch = originalFetch; + }); + + it("accumulates usage rows across PR updates when prior usage marker is present", async () => { + const previousBody = [ + COMMENT_MARKER, + "## existing body", + "", + ``, + ].join("\n"); + + const calls = []; + globalThis.fetch = (url, init = {}) => { + const u = String(url); + calls.push({ url: u, init }); + if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) { + return ok([ + { + id: 999, + user: { type: "Bot" }, + body: previousBody, + }, + ]); + } + if (init.method === "PATCH") return ok({}); + return ok([]); + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## new body`, + APP_TOKEN, + { prompt_tokens: 300, completion_tokens: 120 } + ); + + const patch = calls.find((c) => c.init.method === "PATCH"); + expect(patch).toBeDefined(); + expect(patch.url).toContain("/issues/comments/999"); + + const body = parseBody(patch); + // all three calls present, in order + expect(body).toContain("| #1 | 100 | 50 | 150 |"); + expect(body).toContain("| #2 | 200 | 80 | 280 |"); + expect(body).toContain("| #3 | 300 | 120 | 420 |"); + // totals row appears once history.length > 1 + expect(body).toContain("| **합계** | **600** | **250** | **850** |"); + // marker is rewritten with the full array + expect(body).toMatch( + // + ); + + globalThis.fetch = originalFetch; + }); + + it("falls back to a single-row history when the prior marker is malformed", async () => { + // legacy / corrupt marker (object instead of array) — should not crash, just reset + const previousBody = [ + COMMENT_MARKER, + "## existing body", + "", + ``, + ].join("\n"); + + const calls = []; + globalThis.fetch = (url, init = {}) => { + const u = String(url); + calls.push({ url: u, init }); + if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) { + return ok([ + { id: 1, user: { type: "Bot" }, body: previousBody }, + ]); + } + if (init.method === "PATCH") return ok({}); + return ok([]); + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## new body`, + APP_TOKEN, + { prompt_tokens: 999, completion_tokens: 111 } + ); + + const patch = calls.find((c) => c.init.method === "PATCH"); + const body = parseBody(patch); + + expect(body).toContain("| #1 | 999 | 111 | 1,110 |"); + expect(body).not.toContain("**합계**"); + + globalThis.fetch = originalFetch; + }); + + it("omits the usage section entirely when no usage is provided", async () => { + const calls = []; + globalThis.fetch = (url, init = {}) => { + calls.push({ url: String(url), init }); + if (init.method === "POST") return ok({}); + return ok([]); + }; + + await upsertLearningStatusComment( + REPO_OWNER, + REPO_NAME, + PR_NUMBER, + `${COMMENT_MARKER}\n## body`, + APP_TOKEN + // no usage + ); + + const post = calls.find((c) => c.init.method === "POST"); + const body = parseBody(post); + expect(body).not.toContain("🔢 API 사용량"); + expect(body).not.toContain("usage-data:"); + + globalThis.fetch = originalFetch; + }); +}); diff --git a/utils/learningComment.js b/utils/learningComment.js index 28b34f1..4dfad34 100644 --- a/utils/learningComment.js +++ b/utils/learningComment.js @@ -10,10 +10,15 @@ import { getGitHubHeaders } from "./github.js"; const COMMENT_MARKER = ""; /** - * Hidden marker for embedding cumulative usage data in the comment. - * Format: + * Hidden marker for embedding per-request usage history in the comment. + * Format: + * + * The capture group must match the array — earlier versions of this regex + * matched only `{...}` and silently captured the first object inside the + * array, which made `parseUsageFromComment` always return `[]` and broke + * cumulative aggregation across PR updates. */ -const USAGE_DATA_RE = //; +const USAGE_DATA_RE = //; /** gpt-4.1-nano pricing (USD per token) */ const INPUT_COST_PER_TOKEN = 0.10 / 1_000_000;