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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,10 @@ describe("checkWeeks", () => {
- GitHub 인증 로직 (`generateGitHubAppToken`, `createJWT` 등)은 모든 기능에서 공통으로 사용
- 새 기능 추가 시 기존 유틸리티 함수 활용

7. **코멘트 숨김 마커 직렬화 포맷 변경**
- 코멘트에 `<!-- xxx-data: ... -->` 형태로 숨겨 저장하는 데이터의 직렬화 포맷(객체↔배열 등)을 바꿀 때는 **정규식·문서 주석·테스트를 같은 PR에서 함께 갱신**
- 파싱이 `Array.isArray` 같은 방어 코드로 빈 값에 fallback하면 회귀가 조용히 묻혀 디버깅이 어려워짐

## 관련 문서

- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)
Expand Down
179 changes: 179 additions & 0 deletions tests/learningComment.test.js
Original file line number Diff line number Diff line change
@@ -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 = "<!-- dalestudy-learning-status -->";

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(/<!-- usage-data: \[\{"prompt":100,"completion":50\}\] -->/);

globalThis.fetch = originalFetch;
});

it("accumulates usage rows across PR updates when prior usage marker is present", async () => {
const previousBody = [
COMMENT_MARKER,
"## existing body",
"",
`<!-- usage-data: [{"prompt":100,"completion":50},{"prompt":200,"completion":80}] -->`,
].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(
/<!-- usage-data: \[\{"prompt":100,"completion":50\},\{"prompt":200,"completion":80\},\{"prompt":300,"completion":120\}\] -->/
);

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",
"",
`<!-- usage-data: {"prompt":100,"completion":50} -->`,
].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;
});
});
11 changes: 8 additions & 3 deletions utils/learningComment.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ import { getGitHubHeaders } from "./github.js";
const COMMENT_MARKER = "<!-- dalestudy-learning-status -->";

/**
* Hidden marker for embedding cumulative usage data in the comment.
* Format: <!-- usage-data: {"prompt":N,"completion":N,"requests":N} -->
* Hidden marker for embedding per-request usage history in the comment.
* Format: <!-- usage-data: [{"prompt":N,"completion":N}, ...] -->
*
* 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 = /<!-- usage-data: ({.*?}) -->/;
const USAGE_DATA_RE = /<!-- usage-data: (\[.*?\]) -->/;

/** gpt-4.1-nano pricing (USD per token) */
const INPUT_COST_PER_TOKEN = 0.10 / 1_000_000;
Expand Down