Skip to content

Commit 61331f2

Browse files
Merge pull request #1101 from smalruby/feature/admin-claude-prompts
feat(admin): バグ報告詳細に状態別のClaude連携プロンプト(ワンクリックコピー)
2 parents 2913579 + 6b8584e commit 61331f2

7 files changed

Lines changed: 241 additions & 5 deletions

File tree

docs/admin/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
1. **みんなの課題**(S3 #1083): 通報キュー(多い順)/ 全投稿一覧 / 詳細(ページ・画像・クレジット)/ 非公開⇄再公開(2 段階確認・audit)
2020
2. **クラス・課題**(S4 #1084): 全クラス検索(参加コード完全一致・名前部分一致)/ 詳細(参加・提出カウント)/ アーカイブ切替 / **期限切れクラスの復元**(ddb-archive スナップショット検索 → dry-run プラン → 実行。EPIC #1049 の CLI `bin/restore-classroom.ts` の UI 後継)
21-
3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)
21+
3. **バグ報告**(S5 #1085 + 対応機能追加): 既存バグ報告の一覧・状態フィルタ・詳細・添付 presigned DL に加え、**状態の変更と進捗コメント(開発者からの返信)**を既存 bug-report admin API の PATCH で行える(2 段階確認・終端ステータスは自動削除 TTL の警告つき。返信は報告者の「私の不具合報告」に表示され、非表示にしていた報告も再表示される — サーバー側の既存挙動)。詳細には**状態に応じた Claude 連携プロンプト**`/bug-report` スキル向け・受付→Issue 化 / 改修 / 解決返信 / 再開)が表示され、ワンクリックでコピーして Claude Code に貼り付けられる
2222

2323
## 認証・認可モデル(要点)
2424

@@ -44,6 +44,7 @@
4444
| `src/components/bug-reports-view.jsx` | バグ報告閲覧(read-only) |
4545
| `src/lib/admin-api.js` | admin API クライアント(トークンはモジュールメモリ) |
4646
| `src/lib/bug-report-api.js` | bug-report API クライアント(一覧・詳細 + 状態/返信の PATCH。既存 API のみ使用) |
47+
| `src/lib/bug-report-prompts.js` | 状態別の Claude 連携プロンプト生成(`/bug-report` スキルにワンクリックコピーで渡す) |
4748
| `src/lib/google-auth.js` | GIS ロード + `?devlogin=` バイパス(stg のみ) |
4849
| `webpack.config.js` | 独立ビルド(publicPath `/admin/`、port 8602、DefinePlugin で endpoint 埋め込み) |
4950

packages/admin/src/components/app.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,24 @@ body {
279279
border: 1px solid hsl(220, 15%, 75%);
280280
border-radius: 0.4rem;
281281
}
282+
283+
.admin-prompt {
284+
margin-top: 0.75rem;
285+
}
286+
287+
.admin-prompt-head {
288+
display: flex;
289+
align-items: center;
290+
gap: 0.75rem;
291+
margin-bottom: 0.25rem;
292+
}
293+
294+
.admin-prompt-head button {
295+
padding: 0.2rem 0.7rem;
296+
border: 1px solid hsl(260, 60%, 55%);
297+
border-radius: 0.4rem;
298+
background: white;
299+
color: hsl(260, 60%, 45%);
300+
cursor: pointer;
301+
font-size: 0.85rem;
302+
}

packages/admin/src/components/app.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ const App = () => {
112112
<h2>{SECTIONS.find(s => s.key === section).label}</h2>
113113
{section === 'shared' && <SharedAssignmentsView />}
114114
{section === 'classrooms' && <ClassroomsView />}
115-
{section === 'bug-reports' && <BugReportsView />}
115+
{section === 'bug-reports' && <BugReportsView stage={me.stage} />}
116116
</main>
117117
{sessionExpired && (
118118
<div

packages/admin/src/components/bug-reports-view.jsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import PropTypes from 'prop-types';
99
import {useCallback, useEffect, useState} from 'react';
1010
import {fetchBugReport, fetchBugReports, updateBugReport} from '../lib/bug-report-api.js';
11+
import {buildClaudePrompts} from '../lib/bug-report-prompts.js';
1112

1213
const STATUS_LABELS = {
1314
open: '未対応',
@@ -44,14 +45,15 @@ const hideBrokenImage = event => {
4445
event.currentTarget.style.display = 'none';
4546
};
4647

47-
const BugReportDetail = ({reportId, onBack, onChanged}) => {
48+
const BugReportDetail = ({reportId, stage, onBack, onChanged}) => {
4849
const [detail, setDetail] = useState(null);
4950
const [error, setError] = useState('');
5051
const [nextStatus, setNextStatus] = useState('');
5152
const [reply, setReply] = useState('');
5253
const [confirming, setConfirming] = useState(false);
5354
const [busy, setBusy] = useState(false);
5455
const [savedAt, setSavedAt] = useState(null);
56+
const [copiedKey, setCopiedKey] = useState(null);
5557

5658
useEffect(() => {
5759
fetchBugReport(reportId)
@@ -93,6 +95,16 @@ const BugReportDetail = ({reportId, onBack, onChanged}) => {
9395
}
9496
}, [detail, nextStatus, reply, reportId, onChanged]);
9597

98+
const handleCopyPrompt = useCallback(async e => {
99+
const {key, prompt} = e.currentTarget.dataset;
100+
try {
101+
await navigator.clipboard.writeText(prompt);
102+
setCopiedKey(key);
103+
} catch {
104+
setCopiedKey(null);
105+
}
106+
}, []);
107+
96108
if (error && !detail) {
97109
return (<p
98110
className="admin-error"
@@ -220,17 +232,47 @@ const BugReportDetail = ({reportId, onBack, onChanged}) => {
220232
) : null}
221233
</div>
222234
</div>
235+
<div
236+
className="admin-respond"
237+
data-testid="bug-admin-claude"
238+
>
239+
<h4>{'Claude に依頼(/bug-report スキル)'}</h4>
240+
<p className="admin-meta">
241+
{'いまの状態に合わせたプロンプト案です。コピーして Claude Code に貼り付けてください。'}
242+
</p>
243+
{buildClaudePrompts(detail, stage).map(suggestion => (
244+
<div
245+
className="admin-prompt"
246+
key={suggestion.key}
247+
>
248+
<div className="admin-prompt-head">
249+
<strong>{suggestion.title}</strong>
250+
<button
251+
data-key={suggestion.key}
252+
data-prompt={suggestion.prompt}
253+
data-testid={`bug-admin-copy-${suggestion.key}`}
254+
type="button"
255+
onClick={handleCopyPrompt}
256+
>
257+
{copiedKey === suggestion.key ? 'コピーしました ✓' : 'コピー'}
258+
</button>
259+
</div>
260+
<pre className="admin-pre">{suggestion.prompt}</pre>
261+
</div>
262+
))}
263+
</div>
223264
</div>
224265
);
225266
};
226267

227268
BugReportDetail.propTypes = {
228269
onBack: PropTypes.func.isRequired,
229270
onChanged: PropTypes.func.isRequired,
230-
reportId: PropTypes.string.isRequired
271+
reportId: PropTypes.string.isRequired,
272+
stage: PropTypes.string
231273
};
232274

233-
const BugReportsView = () => {
275+
const BugReportsView = ({stage}) => {
234276
const [status, setStatus] = useState('');
235277
const [reports, setReports] = useState(null);
236278
const [selectedId, setSelectedId] = useState(null);
@@ -254,6 +296,7 @@ const BugReportsView = () => {
254296
return (
255297
<BugReportDetail
256298
reportId={selectedId}
299+
stage={stage}
257300
onBack={handleBack}
258301
onChanged={reload}
259302
/>
@@ -323,4 +366,8 @@ const BugReportsView = () => {
323366
);
324367
};
325368

369+
BugReportsView.propTypes = {
370+
stage: PropTypes.string
371+
};
372+
326373
export default BugReportsView;
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Claude 連携プロンプト生成 (バグ報告)。
3+
*
4+
* バグ報告の受付・改修・返信は Claude の /bug-report スキル
5+
* (.claude/skills/bug-report/SKILL.md) で進める運用。この lib は報告の
6+
* 状態に応じた「次にやること」のプロンプト案を組み立て、管理画面から
7+
* ワンクリックでコピーして Claude に貼れるようにする。
8+
*
9+
* スキル仕様に合わせる点:
10+
* - サブコマンド: show / issue / reply <reportId> [stg|prod]
11+
* - ステージ引数は prod がデフォルトなので stg のときだけ付ける
12+
* - 返信 (developerReply) は子どもが読む前提のやさしい日本語
13+
* - メールアドレスはマスキングされる前提 (プロンプトには含めない)
14+
*/
15+
16+
/**
17+
* @param {string} [stage] - deployment stage
18+
* @returns {string} the stage argument for the skill ('' on prod)
19+
*/
20+
const stageArg = stage => (stage && stage !== 'prod' ? ` ${stage}` : '');
21+
22+
/**
23+
* 報告の 1 行コンテキスト (コピペ先で取り違えに気付けるように)。
24+
* メールは含めない (スキル側のマスキング方針に合わせる)。
25+
* @param {object} report - report detail
26+
* @returns {string} one-line summary
27+
*/
28+
const contextLine = report => {
29+
const name = report.projectName || '(プロジェクト名なし)';
30+
const desc = String(report.description || '').slice(0, 60);
31+
return `# ${name} / ${String(report.createdAt || '').slice(0, 10)} / ${desc}`;
32+
};
33+
34+
/**
35+
* 状態に応じた Claude 向けプロンプト案を返す。
36+
* @param {object} report - report detail ({reportId, status, ...})
37+
* @param {string} [stage] - 'stg' | 'prod' (既定 prod)
38+
* @returns {{key: string, title: string, prompt: string}[]} suggestions
39+
*/
40+
const buildClaudePrompts = (report, stage) => {
41+
const id = report.reportId;
42+
const s = stageArg(stage);
43+
const ctx = contextLine(report);
44+
45+
if (report.status === 'open') {
46+
return [
47+
{
48+
key: 'triage',
49+
title: '受付 → 再現確認 → Issue 化',
50+
prompt: `/bug-report show ${id}${s}
51+
52+
${ctx}
53+
上記の不具合報告を受け付けてください。添付作品をダウンロードして再現確認し、\
54+
再現したら /bug-report issue ${id}${s} で GitHub Issue 化してください。\
55+
最後に /bug-report reply ${id}${s} で状態を in_progress にし、\
56+
報告者へ「受け付けました」のやさしい返信を書き戻してください。`
57+
},
58+
{
59+
key: 'quick-look',
60+
title: 'まず内容だけ確認',
61+
prompt: `/bug-report show ${id}${s}
62+
63+
${ctx}
64+
上記の不具合報告の内容と添付を確認して、再現手順・原因の当たり・対応の要否を要約してください。書き戻しはまだしないでください。`
65+
}
66+
];
67+
}
68+
69+
if (report.status === 'in_progress') {
70+
return [
71+
{
72+
key: 'fix',
73+
title: '改修を進める',
74+
prompt: `/bug-report show ${id}${s}
75+
76+
${ctx}
77+
上記の不具合報告は対応中です。関連する GitHub Issue と修正状況を確認し、未修正なら原因を特定して改修を進めてください(TDD で。トピックブランチ + PR の通常フロー)。`
78+
},
79+
{
80+
key: 'resolve',
81+
title: '修正済み → 解決の返信',
82+
prompt: `/bug-report reply ${id}${s}
83+
84+
${ctx}
85+
上記の不具合の修正がリリース済みです。状態を resolved にして、報告者へ修正内容のやさしい返信(子ども向けの日本語)を書き戻してください。`
86+
}
87+
];
88+
}
89+
90+
// resolved / wont_fix — 完了後の見直し・再開
91+
return [
92+
{
93+
key: 'reopen',
94+
title: '対応を再開する',
95+
prompt: `/bug-report show ${id}${s}
96+
97+
${ctx}
98+
上記の報告は完了扱い(${report.status})ですが、対応を再開します。内容を再確認し、\
99+
/bug-report reply ${id}${s} で状態を open に戻して(自動削除タイマーが解除されます)、\
100+
次のアクションを提案してください。`
101+
}
102+
];
103+
};
104+
105+
export {buildClaudePrompts};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {buildClaudePrompts} from '../../src/lib/bug-report-prompts.js';
2+
3+
const report = {
4+
reportId: 'abcd1234-5678-90ef-ghij-klmnopqrstuv',
5+
projectName: 'ねこあつめ',
6+
description: 'ブロックが消える',
7+
createdAt: '2026-07-18T00:00:00.000Z',
8+
status: 'open'
9+
};
10+
11+
describe('buildClaudePrompts (/bug-report スキル連携)', () => {
12+
test('open: 受付〜Issue 化 と 内容確認 の 2 案、フル reportId 入り', () => {
13+
const prompts = buildClaudePrompts(report, 'prod');
14+
expect(prompts.map(p => p.key)).toEqual(['triage', 'quick-look']);
15+
expect(prompts[0].prompt).toContain(`/bug-report show ${report.reportId}`);
16+
expect(prompts[0].prompt).toContain('in_progress');
17+
expect(prompts[0].prompt).toContain('やさしい返信');
18+
// 取り違え防止のコンテキスト行 (メールは含めない)
19+
expect(prompts[0].prompt).toContain('# ねこあつめ / 2026-07-18 / ブロックが消える');
20+
});
21+
22+
test('stg では skill のステージ引数が付き、prod では付かない (スキルの既定が prod)', () => {
23+
const stg = buildClaudePrompts(report, 'stg');
24+
expect(stg[0].prompt).toContain(`/bug-report show ${report.reportId} stg`);
25+
const prod = buildClaudePrompts(report, 'prod');
26+
expect(prod[0].prompt).not.toContain(' stg');
27+
});
28+
29+
test('in_progress: 改修続行 と 解決の返信 (reply サブコマンド)', () => {
30+
const prompts = buildClaudePrompts({...report, status: 'in_progress'}, 'prod');
31+
expect(prompts.map(p => p.key)).toEqual(['fix', 'resolve']);
32+
expect(prompts[1].prompt).toContain(`/bug-report reply ${report.reportId}`);
33+
expect(prompts[1].prompt).toContain('resolved');
34+
});
35+
36+
test('resolved / wont_fix: 再開プロンプト (open へ戻すと TTL 解除の注意つき)', () => {
37+
for (const status of ['resolved', 'wont_fix']) {
38+
const prompts = buildClaudePrompts({...report, status}, 'prod');
39+
expect(prompts.map(p => p.key)).toEqual(['reopen']);
40+
expect(prompts[0].prompt).toContain('open に戻して');
41+
expect(prompts[0].prompt).toContain('自動削除タイマー');
42+
}
43+
});
44+
});

packages/admin/test/unit/bug-reports-view.test.jsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ describe('BugReportsView (issue #1085 + 状態変更/コメント)', () => {
112112
await waitFor(() => expect(mockUpdateReport).toHaveBeenCalledWith('r1', {status: 'resolved'}));
113113
});
114114

115+
test('Claude prompt suggestions match the status and copy on one click', async () => {
116+
const writeText = jest.fn().mockResolvedValue();
117+
Object.assign(navigator, {clipboard: {writeText}});
118+
119+
render(<BugReportsView stage="stg" />);
120+
await waitFor(() => screen.getByTestId('bug-admin-item-r1'));
121+
fireEvent.click(screen.getByTestId('bug-admin-item-r1'));
122+
await waitFor(() => screen.getByTestId('bug-admin-detail'));
123+
124+
// status=open → 受付プロンプトが提示され、スキル呼び出し + stg 引数を含む
125+
expect(screen.getByTestId('bug-admin-claude')).toBeInTheDocument();
126+
const copy = screen.getByTestId('bug-admin-copy-triage');
127+
fireEvent.click(copy);
128+
await waitFor(() => expect(writeText).toHaveBeenCalled());
129+
expect(writeText.mock.calls[0][0]).toContain('/bug-report show r1 stg');
130+
await waitFor(() => expect(copy.textContent).toContain('コピーしました'));
131+
});
132+
115133
test('the confirmation can be cancelled without any API call', async () => {
116134
render(<BugReportsView />);
117135
await waitFor(() => screen.getByTestId('bug-admin-item-r1'));

0 commit comments

Comments
 (0)