Skip to content

Commit eba9d8e

Browse files
sounmindsoobingclaude
committed
feat: add learning status comment formatting and upsert
Implements formatLearningStatusComment (Markdown builder with progress bar, match-rate line, and per-category progress table) and upsertLearningStatusComment (list -> find -> PATCH or POST pattern). Co-Authored-By: soobing <qls0147@naver.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 36a541f commit eba9d8e

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

utils/learningComment.js

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* Learning status comment formatting and upsert utilities
3+
*/
4+
5+
import { getGitHubHeaders } from "./github.js";
6+
7+
/**
8+
* Marker that identifies a learning status comment posted by this app.
9+
*/
10+
const COMMENT_MARKER = "<!-- dalestudy-learning-status -->";
11+
12+
/**
13+
* Returns true when `comment` is a Bot comment containing our marker.
14+
*
15+
* @param {{ user?: { type?: string }, body?: string }} comment
16+
* @returns {boolean}
17+
*/
18+
function isLearningStatusComment(comment) {
19+
return comment.user?.type === "Bot" && comment.body?.includes(COMMENT_MARKER);
20+
}
21+
22+
/**
23+
* Renders a simple block-character progress bar.
24+
*
25+
* @param {number} completed
26+
* @param {number} total
27+
* @param {number} [barLength=7]
28+
* @returns {string} e.g. "■■■□□□□"
29+
*/
30+
function progressBar(completed, total, barLength = 7) {
31+
if (total === 0) {
32+
return "□".repeat(barLength);
33+
}
34+
const filled = Math.round((completed / total) * barLength);
35+
return "■".repeat(filled) + "□".repeat(barLength - filled);
36+
}
37+
38+
/**
39+
* Formats a learning status Markdown comment.
40+
*
41+
* @param {string} username
42+
* @param {Array<{ problemName: string, difficulty: string|null, matches: boolean|null, explanation: string }>} submissions
43+
* @param {number} totalSolved
44+
* @param {number} totalProblems
45+
* @param {Array<{ category: string, solved: number, total: number, difficulties: string }>} categoryProgress
46+
* @returns {string}
47+
*/
48+
export function formatLearningStatusComment(
49+
username,
50+
submissions,
51+
totalSolved,
52+
totalProblems,
53+
categoryProgress
54+
) {
55+
const lines = [];
56+
57+
lines.push(COMMENT_MARKER);
58+
lines.push(`## 📊 ${username} 님의 학습 현황`);
59+
lines.push("");
60+
61+
// --- Submission table ---
62+
lines.push("### 이번 주 제출 문제");
63+
lines.push("| 문제 | 난이도 | 유형 분석 |");
64+
lines.push("|---|---|---|");
65+
66+
for (const { problemName, difficulty, matches } of submissions) {
67+
const difficultyCell = difficulty ?? "-";
68+
69+
let analysisCell;
70+
if (matches === null) {
71+
analysisCell = "ℹ️ 카테고리 정보 없음";
72+
} else if (matches === true) {
73+
analysisCell = "✅ 의도한 유형";
74+
} else {
75+
analysisCell = "⚠️ 유형 불일치";
76+
}
77+
78+
lines.push(`| ${problemName} | ${difficultyCell} | ${analysisCell} |`);
79+
}
80+
81+
lines.push("");
82+
83+
// --- Cumulative summary ---
84+
lines.push("### 누적 학습 요약");
85+
lines.push(`- 풀이한 문제: ${totalSolved} / ${totalProblems}개`);
86+
87+
// Match rate line — only include when at least one submission has matches !== null
88+
const gradedSubmissions = submissions.filter((s) => s.matches !== null);
89+
if (gradedSubmissions.length > 0) {
90+
const matched = gradedSubmissions.filter((s) => s.matches === true).length;
91+
const total = gradedSubmissions.length;
92+
const rate = Math.round((matched / total) * 100);
93+
lines.push(
94+
`- 이번 주 유형 일치율: ${rate}% (${total}문제 중 ${matched}문제 일치)`
95+
);
96+
}
97+
98+
lines.push("");
99+
100+
// --- Category progress ---
101+
if (categoryProgress.length > 0) {
102+
lines.push("### 문제 풀이 현황");
103+
lines.push("| 카테고리 | 진행도 | 완료 |");
104+
lines.push("|---|---|---|");
105+
106+
for (const { category, solved, total, difficulties } of categoryProgress) {
107+
const bar = progressBar(solved, total);
108+
let completionCell = `${solved} / ${total} (${difficulties})`;
109+
if (solved === 0) {
110+
completionCell += " ← 아직 시작 안 함";
111+
}
112+
lines.push(`| ${category} | ${bar} | ${completionCell} |`);
113+
}
114+
115+
lines.push("");
116+
}
117+
118+
lines.push("---");
119+
lines.push("🤖 이 댓글은 GitHub App을 통해 자동으로 작성되었습니다.");
120+
121+
return lines.join("\n");
122+
}
123+
124+
/**
125+
* Creates or updates the learning status comment on a PR.
126+
*
127+
* Searches through the first 100 issue comments for an existing Bot comment
128+
* that contains COMMENT_MARKER. If found, PATCHes it; otherwise POSTs a new one.
129+
*
130+
* @param {string} repoOwner
131+
* @param {string} repoName
132+
* @param {number} prNumber
133+
* @param {string} commentBody
134+
* @param {string} appToken
135+
* @returns {Promise<void>}
136+
*/
137+
export async function upsertLearningStatusComment(
138+
repoOwner,
139+
repoName,
140+
prNumber,
141+
commentBody,
142+
appToken
143+
) {
144+
const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`;
145+
146+
// Fetch existing comments
147+
const listResponse = await fetch(
148+
`${baseUrl}/issues/${prNumber}/comments?per_page=100`,
149+
{ headers: getGitHubHeaders(appToken) }
150+
);
151+
152+
if (!listResponse.ok) {
153+
throw new Error(
154+
`[learningStatus] Failed to list comments on PR #${prNumber}: ${listResponse.status} ${listResponse.statusText}`
155+
);
156+
}
157+
158+
const comments = await listResponse.json();
159+
const existing = comments.find(isLearningStatusComment);
160+
161+
if (existing) {
162+
// Update existing comment
163+
console.log(
164+
`[learningStatus] Updating existing comment ${existing.id} on PR #${prNumber}`
165+
);
166+
167+
const patchResponse = await fetch(
168+
`${baseUrl}/issues/comments/${existing.id}`,
169+
{
170+
method: "PATCH",
171+
headers: {
172+
...getGitHubHeaders(appToken),
173+
"Content-Type": "application/json",
174+
},
175+
body: JSON.stringify({ body: commentBody }),
176+
}
177+
);
178+
179+
if (!patchResponse.ok) {
180+
const errorData = await patchResponse.json();
181+
throw new Error(
182+
`[learningStatus] Failed to update comment ${existing.id} on PR #${prNumber}: ${JSON.stringify(errorData)}`
183+
);
184+
}
185+
186+
console.log(
187+
`[learningStatus] Updated comment ${existing.id} on PR #${prNumber}`
188+
);
189+
} else {
190+
// Post new comment
191+
console.log(
192+
`[learningStatus] Posting new learning status comment on PR #${prNumber}`
193+
);
194+
195+
const postResponse = await fetch(
196+
`${baseUrl}/issues/${prNumber}/comments`,
197+
{
198+
method: "POST",
199+
headers: {
200+
...getGitHubHeaders(appToken),
201+
"Content-Type": "application/json",
202+
},
203+
body: JSON.stringify({ body: commentBody }),
204+
}
205+
);
206+
207+
if (!postResponse.ok) {
208+
const errorData = await postResponse.json();
209+
throw new Error(
210+
`[learningStatus] Failed to post comment on PR #${prNumber}: ${JSON.stringify(errorData)}`
211+
);
212+
}
213+
214+
console.log(
215+
`[learningStatus] Created new comment on PR #${prNumber}`
216+
);
217+
}
218+
}

0 commit comments

Comments
 (0)