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

## 디버깅 기록

### 학습 현황 코멘트 — `🔢 API 사용량` 누적이 안 되던 문제 (이슈 #36)

- **증상**: 같은 PR에 새 커밋이 push되어 webhook이 다시 발화하면 `🔢 API 사용량 (gpt-4.1-nano)` 표가 누적되어야 하는데, 항상 가장 최근 호출 1행만 표시되고 `합계` 행도 절대 나오지 않았다.
- **원인**: `utils/learningComment.js`의 숨김 마커 정규식 `USAGE_DATA_RE`가 `({.*?})`로 객체 패턴만 캡처하도록 작성돼 있었다. 실제 저장 포맷은 `JSON.stringify(history)` 결과인 객체 **배열**(`[{prompt,completion}, ...]`)이어서, 정규식은 배열 안의 첫 객체 `{...}`만 캡처했다. 결과적으로 `JSON.parse`는 단일 객체를 반환했고, `Array.isArray(parsed) ? parsed : []` 분기에서 항상 빈 배열로 떨어져 직전 누적값이 통째로 사라졌다. 문서 주석은 단일 객체 포맷이라고 적혀 있었지만 코드는 이미 배열 포맷으로 옮겨간 상태였다.
- **수정**: 정규식을 `(\[.*?\])`로 변경해 배열 포맷을 캡처하고, 문서 주석을 실제 저장 포맷에 맞춰 갱신. 잘못된 포맷의 기존 코멘트는 `parseUsageFromComment`가 자연스럽게 `[]`로 떨어져 새 호출부터 다시 누적된다.
- **회귀 방지**: `tests/learningComment.test.js`에 ① 신규 코멘트 1행, ② 기존 코멘트의 배열 마커로부터 #1~#3 누적 + 합계 행 검증, ③ 손상된 객체 마커는 단일행으로 리셋, ④ usage 미제공 시 섹션 자체가 빠짐 — 4개 시나리오를 박아 두었다.
- **교훈**: 직렬화 포맷을 객체→배열로 옮길 때 정규식·문서 주석·테스트가 함께 따라가지 않으면 조용히 실패한다. 특히 `Array.isArray` 같은 방어 코드가 잘못된 입력을 throw 없이 빈 배열로 무마하면 디버깅이 더 어려워진다.

## 관련 문서

- [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