Redmine へチケットを起票するボタンを追加
chktime のタスクと git 履歴から下書きを作り、まとめて Redmine へ登録できる ようにした。外部通信はこの機能を使ったときだけで、使わなければ従来どおり ローカルの jsonl を読むだけ。 - Redmine の REST API を叩く最小クライアント (標準ライブラリのみ) - 下書きは「タスクから」(1タスク=1チケット) と「git 履歴から」 (日ごと / 日 x 種別 / 期間一括) の 2 系統 - 起票は件数 x 間隔ぶん時間がかかるのでジョブにし、進捗だけ見に行く - 作成時の status_id は Redmine 側で無視されるため、作成後に付け直す - 連続アクセスに 403 を返すサーバがあるので 1 件ごとに間隔をあける (既定 7 秒) - 自己署名証明書が普通なので、既定は証明書の検証なし - API キーは redmine.json に置き、git 管理外にする Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+391
@@ -1080,6 +1080,388 @@ function stopFollow() {
|
||||
followStatus('off', '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Redmine 起票
|
||||
|
||||
// 下書きを作る → 中身を確かめる → まとめて起票する、の 3 段。
|
||||
// サーバ側が 1 件ずつ間隔をあけて投げるので、起票はジョブにして進捗だけ見に行く。
|
||||
const rm = {
|
||||
cfg: null, // /api/redmine/config (キーは含まれない)
|
||||
meta: null, // プロジェクト / トラッカー / ステータス
|
||||
settings: false, // 接続設定を開いているか
|
||||
source: 'tasks', // tasks=chktime のタスク / git=git 履歴
|
||||
mode: 'day', // git のまとめ方: day / type / all
|
||||
repo: '',
|
||||
from: '',
|
||||
to: '',
|
||||
tasks: new Map(), // 起票対象のタスク: キー -> {project, id}
|
||||
drafts: [],
|
||||
off: new Set(), // 下書きのうち外したもの (index)
|
||||
job: null,
|
||||
timer: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
async function post(path, payload) {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** タスクの識別子。プロジェクトとタスク ID の組で 1 件を指す。 */
|
||||
const taskOf = (s) => ({ project: s.project || state.project || '', id: s.id });
|
||||
const taskKey = (t) => `${t.project}/${t.id}`;
|
||||
|
||||
function rmMsg(text, kind) {
|
||||
const el = $('#rm-msg');
|
||||
el.className = kind === 'err' ? 'err-inline' : 'note';
|
||||
el.textContent = text || '';
|
||||
}
|
||||
|
||||
function rmConn() {
|
||||
const el = $('#rm-conn');
|
||||
if (!rm.cfg || !rm.cfg.url) {
|
||||
el.className = 'fstate wait';
|
||||
el.textContent = '未設定';
|
||||
} else if (!rm.cfg.hasKey) {
|
||||
el.className = 'fstate wait';
|
||||
el.textContent = 'キー未設定';
|
||||
} else {
|
||||
el.className = 'fstate live';
|
||||
el.textContent = rm.cfg.url;
|
||||
}
|
||||
}
|
||||
|
||||
async function openRedmine() {
|
||||
$('#rm-back').classList.remove('hidden');
|
||||
rmMsg('');
|
||||
try {
|
||||
rm.cfg = await api('/api/redmine/config');
|
||||
rm.settings = !rm.cfg.url || !rm.cfg.hasKey;
|
||||
if (!rm.repo) rm.repo = (state.detail && state.detail.cwd) || (state.project ? projectPathOf(state.project) : '');
|
||||
if (!rm.tasks.size && state.session) {
|
||||
const t = { project: state.sessionProject, id: state.session };
|
||||
rm.tasks.set(taskKey(t), t);
|
||||
}
|
||||
renderRedmine();
|
||||
if (rm.cfg.hasKey && !rm.meta) loadRedmineMeta();
|
||||
} catch (e) {
|
||||
$('#rm-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeRedmine() {
|
||||
$('#rm-back').classList.add('hidden');
|
||||
clearInterval(rm.timer);
|
||||
rm.timer = null;
|
||||
}
|
||||
|
||||
async function loadRedmineMeta() {
|
||||
try {
|
||||
rmMsg('Redmine に接続しています…');
|
||||
rm.meta = await api('/api/redmine/meta');
|
||||
rmMsg('');
|
||||
} catch (e) {
|
||||
rm.meta = null;
|
||||
rmMsg(e.message, 'err');
|
||||
}
|
||||
renderRedmine();
|
||||
}
|
||||
|
||||
function optionList(items, selected, label) {
|
||||
return items
|
||||
.map((it) => `<option value="${it.id}"${String(it.id) === String(selected ?? '') ? ' selected' : ''}>${esc(label(it))}</option>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderRedmine() {
|
||||
rmConn();
|
||||
const cfg = rm.cfg || {};
|
||||
const meta = rm.meta;
|
||||
|
||||
const settings = `
|
||||
<div class="rm-sec">
|
||||
<h4>接続 <button class="rng" id="rm-toggle">${rm.settings ? '隠す' : '設定'}</button></h4>
|
||||
${rm.settings
|
||||
? `<div class="rm-row"><label>URL</label>
|
||||
<input type="text" id="rm-url" value="${esc(cfg.url || '')}" placeholder="https://192.168.3.150"></div>
|
||||
<div class="rm-row"><label>APIキー</label>
|
||||
<input type="password" id="rm-key" placeholder="${cfg.hasKey ? '保存済み(変更するときだけ入力)' : '個人設定 → APIアクセスキー'}"></div>
|
||||
<div class="rm-row">
|
||||
<label class="chk"><input type="checkbox" id="rm-verify"${cfg.verifySsl ? ' checked' : ''}> 証明書を検証する</label>
|
||||
<label>間隔(秒)</label><input type="text" id="rm-int" value="${esc(cfg.interval ?? 7)}" style="flex:0 0 60px">
|
||||
<button id="rm-save">保存</button>
|
||||
</div>
|
||||
<div class="note">設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。
|
||||
自己署名証明書のサーバでは「証明書を検証する」を外してください。</div>`
|
||||
: ''}
|
||||
</div>`;
|
||||
|
||||
const target = meta
|
||||
? `<div class="rm-sec">
|
||||
<h4>起票先</h4>
|
||||
<div class="rm-row">
|
||||
<label>プロジェクト</label>
|
||||
<select id="rm-proj"><option value="">選んでください</option>${optionList(meta.projects, cfg.projectId, (p) => p.name)}</select>
|
||||
<label>トラッカー</label>
|
||||
<select id="rm-tracker">${optionList(meta.trackers, cfg.trackerId, (t) => t.name)}</select>
|
||||
<label>ステータス</label>
|
||||
<select id="rm-status">${optionList(meta.statuses, cfg.statusId, (s) => s.name)}</select>
|
||||
</div>
|
||||
<div class="rm-row">
|
||||
<label class="chk"><input type="checkbox" id="rm-assign"${cfg.assignedToId ? ' checked' : ''}> 自分(${esc(meta.user.name || meta.user.login || '')})を担当にする</label>
|
||||
<label class="chk"><input type="checkbox" id="rm-done"> 進捗を 100% にする</label>
|
||||
</div>
|
||||
</div>`
|
||||
: cfg.hasKey
|
||||
? '<div class="rm-sec"><p class="note">Redmine の情報を取得できていません。接続設定を確認してください。</p></div>'
|
||||
: '';
|
||||
|
||||
const tab = (v, label) => `<button class="rng${rm.source === v ? ' sel' : ''}" data-src="${v}">${label}</button>`;
|
||||
const picker =
|
||||
rm.source === 'tasks'
|
||||
? `<div class="rm-row">
|
||||
<span class="note">一覧に出ている ${state.sessions.length} 件から選びます</span>
|
||||
<button class="rng" id="rm-all">全選択</button><button class="rng" id="rm-none">全解除</button>
|
||||
</div>
|
||||
<div class="rm-list">${
|
||||
state.sessions.length
|
||||
? state.sessions
|
||||
.map((s) => {
|
||||
const t = taskOf(s);
|
||||
return `<div class="rm-item"><label class="h">
|
||||
<input type="checkbox" class="rm-task" data-project="${esc(t.project)}" data-id="${esc(t.id)}"${rm.tasks.has(taskKey(t)) ? ' checked' : ''}>
|
||||
<span class="t"><span class="subject">${esc(s.title)}</span>
|
||||
<span class="meta">${fmtDateTime(s.start)} · ${s.turnCount} 往復 · 作業 ${fmtDur(s.activeMs)}</span></span>
|
||||
</label></div>`;
|
||||
})
|
||||
.join('')
|
||||
: '<div class="rm-item"><div class="h"><span class="note">タスクがありません</span></div></div>'
|
||||
}</div>`
|
||||
: `<div class="rm-row">
|
||||
<label>リポジトリ</label><input type="text" id="rm-repo" value="${esc(rm.repo)}" placeholder="C:\\debug\\chktime">
|
||||
</div>
|
||||
<div class="rm-row">
|
||||
<label>期間</label><input type="date" id="rm-from" value="${rm.from}"><span class="note">〜</span><input type="date" id="rm-to" value="${rm.to}">
|
||||
<label>まとめ方</label>
|
||||
<select id="rm-mode">
|
||||
<option value="day"${rm.mode === 'day' ? ' selected' : ''}>日ごと</option>
|
||||
<option value="type"${rm.mode === 'type' ? ' selected' : ''}>日 × 種別 (feat/fix/docs…)</option>
|
||||
<option value="all"${rm.mode === 'all' ? ' selected' : ''}>期間まとめて 1 件</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="note">分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。</div>`;
|
||||
|
||||
const source = `
|
||||
<div class="rm-sec">
|
||||
<h4>取得元 <span class="rngs" style="display:inline-flex;margin-left:6px">${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}</span></h4>
|
||||
${picker}
|
||||
<div class="rm-row" style="margin-top:8px"><button id="rm-draft">下書きを作る</button></div>
|
||||
</div>`;
|
||||
|
||||
const drafts = rm.drafts.length
|
||||
? `<div class="rm-sec">
|
||||
<h4>下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)</h4>
|
||||
<div class="rm-list">${rm.drafts
|
||||
.map(
|
||||
(d, i) => `<div class="rm-item${rm.off.has(i) ? ' off' : ''}" data-i="${i}">
|
||||
<div class="h">
|
||||
<input type="checkbox" class="rm-pick" data-i="${i}"${rm.off.has(i) ? '' : ' checked'}>
|
||||
<span class="t">
|
||||
<span class="subject">${esc(d.subject)}</span>
|
||||
<span class="meta">${esc(d.start_date || '')}${d.due_date && d.due_date !== d.start_date ? ' → ' + esc(d.due_date) : ''}
|
||||
${d.source && d.source.count ? ` · ${d.source.count} コミット` : ''}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="body">${esc(d.description)}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')}</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
$('#rm-body').innerHTML = settings + target + source + drafts + (rm.job ? renderJob() : '');
|
||||
wireRedmine();
|
||||
syncRun();
|
||||
}
|
||||
|
||||
function renderJob() {
|
||||
const j = rm.job;
|
||||
const pct = j.total ? Math.round((j.done / j.total) * 100) : 0;
|
||||
const ok = j.results.filter((r) => r.ok).length;
|
||||
const ng = j.results.length - ok;
|
||||
return `<div class="rm-sec">
|
||||
<h4>起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}</h4>
|
||||
<div class="rm-prog"><i style="width:${pct}%"></i></div>
|
||||
<div class="note">成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}</div>
|
||||
<div class="rm-list" style="margin-top:6px">${j.results
|
||||
.map(
|
||||
(r) => `<div class="rm-res">
|
||||
<span class="id${r.ok ? '' : ' ng'}">${r.ok ? '#' + r.issueId : '失敗'}</span>
|
||||
<span class="s">${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */
|
||||
function wireRedmine() {
|
||||
const on = (sel, ev, fn) => {
|
||||
const el = $(sel);
|
||||
if (el) el[ev] = fn;
|
||||
};
|
||||
on('#rm-toggle', 'onclick', () => {
|
||||
rm.settings = !rm.settings;
|
||||
renderRedmine();
|
||||
});
|
||||
on('#rm-save', 'onclick', async () => {
|
||||
try {
|
||||
rmMsg('保存しています…');
|
||||
rm.cfg = await post('/api/redmine/config', {
|
||||
url: $('#rm-url').value.trim(),
|
||||
key: $('#rm-key').value.trim(),
|
||||
verifySsl: $('#rm-verify').checked,
|
||||
interval: Number($('#rm-int').value) || 7,
|
||||
});
|
||||
rm.settings = false;
|
||||
rm.meta = null;
|
||||
rmMsg('保存しました');
|
||||
await loadRedmineMeta();
|
||||
} catch (e) {
|
||||
rmMsg(e.message, 'err');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('#rm-body .rng[data-src]').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
rm.source = btn.dataset.src;
|
||||
renderRedmine();
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('#rm-body .rm-task').forEach((box) => {
|
||||
box.onchange = () => {
|
||||
const t = { project: box.dataset.project, id: box.dataset.id };
|
||||
if (box.checked) rm.tasks.set(taskKey(t), t);
|
||||
else rm.tasks.delete(taskKey(t));
|
||||
};
|
||||
});
|
||||
const pickAll = (want) => {
|
||||
state.sessions.forEach((s) => {
|
||||
const t = taskOf(s);
|
||||
if (want) rm.tasks.set(taskKey(t), t);
|
||||
else rm.tasks.delete(taskKey(t));
|
||||
});
|
||||
renderRedmine();
|
||||
};
|
||||
on('#rm-all', 'onclick', () => pickAll(true));
|
||||
on('#rm-none', 'onclick', () => pickAll(false));
|
||||
on('#rm-repo', 'onchange', (e) => (rm.repo = e.target.value.trim()));
|
||||
on('#rm-from', 'onchange', (e) => (rm.from = e.target.value));
|
||||
on('#rm-to', 'onchange', (e) => (rm.to = e.target.value));
|
||||
on('#rm-mode', 'onchange', (e) => (rm.mode = e.target.value));
|
||||
on('#rm-draft', 'onclick', makeDrafts);
|
||||
|
||||
document.querySelectorAll('#rm-body .rm-pick').forEach((box) => {
|
||||
box.onclick = (e) => e.stopPropagation(); // 本文の開閉と混ざらないように
|
||||
box.onchange = () => {
|
||||
const i = Number(box.dataset.i);
|
||||
if (box.checked) rm.off.delete(i);
|
||||
else rm.off.add(i);
|
||||
renderRedmine();
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('#rm-body .rm-item[data-i] .h').forEach((head) => {
|
||||
head.onclick = (e) => {
|
||||
if (e.target.classList.contains('rm-pick')) return;
|
||||
head.parentElement.classList.toggle('open');
|
||||
};
|
||||
});
|
||||
|
||||
['#rm-proj', '#rm-tracker', '#rm-status'].forEach((sel) => on(sel, 'onchange', syncRun));
|
||||
}
|
||||
|
||||
/** 起票ボタンの有効・無効。下書きと起票先が揃うまでは押せない。 */
|
||||
function syncRun() {
|
||||
const proj = $('#rm-proj');
|
||||
$('#rm-run').disabled =
|
||||
rm.busy || !rm.meta || !rm.drafts.length || rm.drafts.length === rm.off.size || !(proj && proj.value);
|
||||
}
|
||||
|
||||
async function makeDrafts() {
|
||||
const payload =
|
||||
rm.source === 'tasks'
|
||||
? {
|
||||
source: 'tasks',
|
||||
ids: [...rm.tasks.values()],
|
||||
}
|
||||
: { source: 'git', repo: rm.repo, from: rm.from, to: rm.to, mode: rm.mode };
|
||||
try {
|
||||
rmMsg('下書きを作っています…');
|
||||
const data = await post('/api/redmine/draft', payload);
|
||||
rm.drafts = data.drafts;
|
||||
rm.off = new Set();
|
||||
rm.job = null;
|
||||
rmMsg(`${rm.drafts.length} 件の下書きを作りました`);
|
||||
} catch (e) {
|
||||
rm.drafts = [];
|
||||
rmMsg(e.message, 'err');
|
||||
}
|
||||
renderRedmine();
|
||||
}
|
||||
|
||||
async function runRedmine() {
|
||||
const drafts = rm.drafts.filter((_, i) => !rm.off.has(i));
|
||||
if (!drafts.length) return;
|
||||
const defaults = {
|
||||
projectId: Number($('#rm-proj').value) || null,
|
||||
trackerId: Number($('#rm-tracker').value) || null,
|
||||
statusId: Number($('#rm-status').value) || null,
|
||||
assignedToId: $('#rm-assign').checked && rm.meta ? rm.meta.user.id : null,
|
||||
doneRatio: $('#rm-done').checked ? 100 : null,
|
||||
};
|
||||
const secs = Math.round(drafts.length * (rm.cfg.interval || 7) * (defaults.statusId ? 2 : 1));
|
||||
if (!confirm(`${drafts.length} 件を Redmine に起票します。\n約 ${Math.ceil(secs / 60)} 分かかります。よろしいですか?`)) return;
|
||||
try {
|
||||
rm.busy = true;
|
||||
rmMsg('起票しています…');
|
||||
// 次に開いたときも同じ設定で始められるよう、選んだ既定値を残す
|
||||
post('/api/redmine/config', {
|
||||
projectId: defaults.projectId, trackerId: defaults.trackerId,
|
||||
statusId: defaults.statusId, assignedToId: defaults.assignedToId,
|
||||
}).catch(() => {});
|
||||
rm.job = await post('/api/redmine/issues', { drafts, defaults });
|
||||
renderRedmine();
|
||||
clearInterval(rm.timer);
|
||||
rm.timer = setInterval(pollJob, 1000);
|
||||
} catch (e) {
|
||||
rm.busy = false;
|
||||
rmMsg(e.message, 'err');
|
||||
renderRedmine();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJob() {
|
||||
if (!rm.job) return;
|
||||
try {
|
||||
rm.job = await api(`/api/redmine/job?id=${encodeURIComponent(rm.job.id)}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (rm.job.state === 'done') {
|
||||
clearInterval(rm.timer);
|
||||
rm.timer = null;
|
||||
rm.busy = false;
|
||||
const ng = rm.job.results.filter((r) => !r.ok).length;
|
||||
rmMsg(ng ? `完了(失敗 ${ng} 件)` : '完了しました', ng ? 'err' : '');
|
||||
}
|
||||
renderRedmine();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 起動
|
||||
|
||||
$('#filter').oninput = renderSessions;
|
||||
@@ -1090,6 +1472,15 @@ $('#relative').onchange = (e) => {
|
||||
$('#tab-turns').onclick = () => setView('turns');
|
||||
$('#tab-daily').onclick = () => setView('daily');
|
||||
$('#tab-gantt').onclick = () => setView('gantt');
|
||||
$('#redmine').onclick = openRedmine;
|
||||
$('#rm-close').onclick = closeRedmine;
|
||||
$('#rm-run').onclick = runRedmine;
|
||||
$('#rm-back').onclick = (e) => {
|
||||
if (e.target.id === 'rm-back') closeRedmine();
|
||||
};
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !$('#rm-back').classList.contains('hidden')) closeRedmine();
|
||||
});
|
||||
$('#reload').onclick = async () => {
|
||||
await fetch('/api/reload');
|
||||
const keep = { project: state.sessionProject, session: state.session, view: state.view };
|
||||
|
||||
Reference in New Issue
Block a user