Skip to content

Commit 9f48203

Browse files
soobingclaude
andcommitted
fix: API 사용량 마커 정규식이 배열 포맷을 매칭하도록 수정
USAGE_DATA_RE 가 `({.*?})` 로 객체 패턴만 캡처하던 탓에, 실제 저장 포맷인 객체 배열 `[{prompt,completion}, ...]` 에서 첫 객체만 잡혔고 Array.isArray 분기에서 항상 빈 배열로 떨어져 직전 누적 호출 기록이 사라졌다. 결과적으로 PR 갱신 시 `🔢 API 사용량` 표가 항상 최근 1행만 표시되었다 (#36). - USAGE_DATA_RE 를 `(\[.*?\])` 로 변경하고 문서 주석을 실제 포맷에 맞춤 - tests/learningComment.test.js 추가: 누적 동작 / 손상된 마커 폴백 / usage 미제공 시 섹션 생략 회귀 테스트 - AGENTS.md 에 디버깅 기록 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ca07e64 commit 9f48203

3 files changed

Lines changed: 197 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,16 @@ describe("checkWeeks", () => {
487487
- GitHub 인증 로직 (`generateGitHubAppToken`, `createJWT` 등)은 모든 기능에서 공통으로 사용
488488
- 새 기능 추가 시 기존 유틸리티 함수 활용
489489

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

492502
- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)

tests/learningComment.test.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { describe, it, expect, beforeEach } from "bun:test";
2+
3+
import { upsertLearningStatusComment } from "../utils/learningComment.js";
4+
5+
const REPO_OWNER = "DaleStudy";
6+
const REPO_NAME = "leetcode-study";
7+
const PR_NUMBER = 42;
8+
const APP_TOKEN = "fake-app-token";
9+
const COMMENT_MARKER = "<!-- dalestudy-learning-status -->";
10+
11+
function ok(body) {
12+
return Promise.resolve({
13+
ok: true,
14+
status: 200,
15+
statusText: "OK",
16+
json: () => Promise.resolve(body),
17+
});
18+
}
19+
20+
function parseBody(call) {
21+
return JSON.parse(call.init.body).body;
22+
}
23+
24+
describe("upsertLearningStatusComment — usage history accumulation", () => {
25+
let originalFetch;
26+
27+
beforeEach(() => {
28+
originalFetch = globalThis.fetch;
29+
});
30+
31+
it("posts a new comment with a single usage row when no prior comment exists", async () => {
32+
const calls = [];
33+
globalThis.fetch = (url, init = {}) => {
34+
calls.push({ url: String(url), init });
35+
if (init.method === "POST") return ok({});
36+
return ok([]); // no existing comments
37+
};
38+
39+
await upsertLearningStatusComment(
40+
REPO_OWNER,
41+
REPO_NAME,
42+
PR_NUMBER,
43+
`${COMMENT_MARKER}\n## body`,
44+
APP_TOKEN,
45+
{ prompt_tokens: 100, completion_tokens: 50 }
46+
);
47+
48+
const post = calls.find((c) => c.init.method === "POST");
49+
expect(post).toBeDefined();
50+
51+
const body = parseBody(post);
52+
expect(body).toContain("🔢 API 사용량 (gpt-4.1-nano)");
53+
expect(body).toContain("| #1 | 100 | 50 | 150 |");
54+
// single-row history => no totals row
55+
expect(body).not.toContain("**합계**");
56+
// hidden marker stores an array
57+
expect(body).toMatch(/<!-- usage-data: \[\{"prompt":100,"completion":50\}\] -->/);
58+
59+
globalThis.fetch = originalFetch;
60+
});
61+
62+
it("accumulates usage rows across PR updates when prior usage marker is present", async () => {
63+
const previousBody = [
64+
COMMENT_MARKER,
65+
"## existing body",
66+
"",
67+
`<!-- usage-data: [{"prompt":100,"completion":50},{"prompt":200,"completion":80}] -->`,
68+
].join("\n");
69+
70+
const calls = [];
71+
globalThis.fetch = (url, init = {}) => {
72+
const u = String(url);
73+
calls.push({ url: u, init });
74+
if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) {
75+
return ok([
76+
{
77+
id: 999,
78+
user: { type: "Bot" },
79+
body: previousBody,
80+
},
81+
]);
82+
}
83+
if (init.method === "PATCH") return ok({});
84+
return ok([]);
85+
};
86+
87+
await upsertLearningStatusComment(
88+
REPO_OWNER,
89+
REPO_NAME,
90+
PR_NUMBER,
91+
`${COMMENT_MARKER}\n## new body`,
92+
APP_TOKEN,
93+
{ prompt_tokens: 300, completion_tokens: 120 }
94+
);
95+
96+
const patch = calls.find((c) => c.init.method === "PATCH");
97+
expect(patch).toBeDefined();
98+
expect(patch.url).toContain("/issues/comments/999");
99+
100+
const body = parseBody(patch);
101+
// all three calls present, in order
102+
expect(body).toContain("| #1 | 100 | 50 | 150 |");
103+
expect(body).toContain("| #2 | 200 | 80 | 280 |");
104+
expect(body).toContain("| #3 | 300 | 120 | 420 |");
105+
// totals row appears once history.length > 1
106+
expect(body).toContain("| **합계** | **600** | **250** | **850** |");
107+
// marker is rewritten with the full array
108+
expect(body).toMatch(
109+
/<!-- usage-data: \[\{"prompt":100,"completion":50\},\{"prompt":200,"completion":80\},\{"prompt":300,"completion":120\}\] -->/
110+
);
111+
112+
globalThis.fetch = originalFetch;
113+
});
114+
115+
it("falls back to a single-row history when the prior marker is malformed", async () => {
116+
// legacy / corrupt marker (object instead of array) — should not crash, just reset
117+
const previousBody = [
118+
COMMENT_MARKER,
119+
"## existing body",
120+
"",
121+
`<!-- usage-data: {"prompt":100,"completion":50} -->`,
122+
].join("\n");
123+
124+
const calls = [];
125+
globalThis.fetch = (url, init = {}) => {
126+
const u = String(url);
127+
calls.push({ url: u, init });
128+
if (u.includes(`/issues/${PR_NUMBER}/comments`) && (!init.method || init.method === "GET")) {
129+
return ok([
130+
{ id: 1, user: { type: "Bot" }, body: previousBody },
131+
]);
132+
}
133+
if (init.method === "PATCH") return ok({});
134+
return ok([]);
135+
};
136+
137+
await upsertLearningStatusComment(
138+
REPO_OWNER,
139+
REPO_NAME,
140+
PR_NUMBER,
141+
`${COMMENT_MARKER}\n## new body`,
142+
APP_TOKEN,
143+
{ prompt_tokens: 999, completion_tokens: 111 }
144+
);
145+
146+
const patch = calls.find((c) => c.init.method === "PATCH");
147+
const body = parseBody(patch);
148+
149+
expect(body).toContain("| #1 | 999 | 111 | 1,110 |");
150+
expect(body).not.toContain("**합계**");
151+
152+
globalThis.fetch = originalFetch;
153+
});
154+
155+
it("omits the usage section entirely when no usage is provided", async () => {
156+
const calls = [];
157+
globalThis.fetch = (url, init = {}) => {
158+
calls.push({ url: String(url), init });
159+
if (init.method === "POST") return ok({});
160+
return ok([]);
161+
};
162+
163+
await upsertLearningStatusComment(
164+
REPO_OWNER,
165+
REPO_NAME,
166+
PR_NUMBER,
167+
`${COMMENT_MARKER}\n## body`,
168+
APP_TOKEN
169+
// no usage
170+
);
171+
172+
const post = calls.find((c) => c.init.method === "POST");
173+
const body = parseBody(post);
174+
expect(body).not.toContain("🔢 API 사용량");
175+
expect(body).not.toContain("usage-data:");
176+
177+
globalThis.fetch = originalFetch;
178+
});
179+
});

utils/learningComment.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@ import { getGitHubHeaders } from "./github.js";
1010
const COMMENT_MARKER = "<!-- dalestudy-learning-status -->";
1111

1212
/**
13-
* Hidden marker for embedding cumulative usage data in the comment.
14-
* Format: <!-- usage-data: {"prompt":N,"completion":N,"requests":N} -->
13+
* Hidden marker for embedding per-request usage history in the comment.
14+
* Format: <!-- usage-data: [{"prompt":N,"completion":N}, ...] -->
15+
*
16+
* The capture group must match the array — earlier versions of this regex
17+
* matched only `{...}` and silently captured the first object inside the
18+
* array, which made `parseUsageFromComment` always return `[]` and broke
19+
* cumulative aggregation across PR updates.
1520
*/
16-
const USAGE_DATA_RE = /<!-- usage-data: ({.*?}) -->/;
21+
const USAGE_DATA_RE = /<!-- usage-data: (\[.*?\]) -->/;
1722

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

0 commit comments

Comments
 (0)