diff --git a/docs/issues/Issue-1.md b/docs/issues/Issue-1.md new file mode 100644 index 0000000..eab12b0 --- /dev/null +++ b/docs/issues/Issue-1.md @@ -0,0 +1,9 @@ +--- +id: 1 +state: open +title: テストとリリース +updated_at: 2026-02-19T04:12:15Z +--- + +CPU稼働を必須要件としたのでLLMを排除したレガシーな実装に変更した。 +コンパイルが終わっているので、テストしてリリースする。 diff --git a/docs/issues/Issue-2.md b/docs/issues/Issue-2.md new file mode 100644 index 0000000..600fda1 --- /dev/null +++ b/docs/issues/Issue-2.md @@ -0,0 +1,9 @@ +--- +id: 2 +state: open +title: テーブル構造の確認 +updated_at: 2026-02-19T04:14:56Z +--- + +チャンク方式を導入したので、一つのドキュメントとして格納できてない可能性がある。 +ファイル情報とチャンクを分離する必要があるかもしれないので、実装を確認して手直しする必要がある。 diff --git "a/journals/20260223-0003-GitBucket-Issue\345\217\214\346\226\271\345\220\221\345\220\214\346\234\237\343\203\204\343\203\274\343\203\253\343\201\256\344\275\234\346\210\220.md" "b/journals/20260223-0003-GitBucket-Issue\345\217\214\346\226\271\345\220\221\345\220\214\346\234\237\343\203\204\343\203\274\343\203\253\343\201\256\344\275\234\346\210\220.md" new file mode 100644 index 0000000..2a4c2dd --- /dev/null +++ "b/journals/20260223-0003-GitBucket-Issue\345\217\214\346\226\271\345\220\221\345\220\214\346\234\237\343\203\204\343\203\274\343\203\253\343\201\256\344\275\234\346\210\220.md" @@ -0,0 +1,36 @@ +# 作業報告: GitBucket Issue 双方向同期ツールの作成 + +## 1. 作業実施の理由と指示 + +- **背景**: ユーザーより、GitBucketのリモートリポジトリに存在するIssueデータをローカル環境(`docs/issues/`)にMarkdownとして同期(プルおよびプッシュ)できないかとの要望があった。 +- **意図と指示**: 単なる一方向のダウンロードだけでなく、ローカルでMarkdownを編集した内容をリモートへ反映(Push)、また新規発行(Create)が行える双方向(2-way)同期スクリプトを構築し、GitBucketをシームレスなローカルIssueトラッカーとして利用可能にすること。 + +## 2. 指摘事項とその対応 + +- **指摘**: GitBucketからの全Issue取得と更新の仕組みが必要。 + - **対応**: GitHub API互換の `https://gitbucket.tmworks.club/api/v3/repos/dtmoyaji/TelosDB/issues` エンドポイントに対し、Node.jsの標準 `fetch` を用いて通信を行う `tools/scripts/sync_issues.mjs` を作成した。 +- **指摘**: 双方向の整合性(コンフリクト管理)をどうするか。 + - **対応**: 各Markdownの先頭にYAML形式のFrontmatterメタデータ(`id`, `state`, `title`, `updated_at`)を持たせた。スクリプト実行時にリモート側の `updated_at` とローカルファイルの更新日時を厳密に比較し、より新しい方を正として上書き(Pull / Push)を行うロジックを実装した。また、`id: new` として作成されたファイルはPOST通信を行い、新規Issueとして発番からリネームまでを自動処理する仕組みを取り入れた。 + +## 3. 作業詳細 + +AIエージェントは以下の作業を実行した: + +- 実装計画(`implementation_plan_issues.md`)の作成と、タスクリストの定義。 +- `tools/scripts/sync_issues.mjs` の実装。依存モジュール(YAMLパーサー等)を不要にするため、専用の正規表現を用いた軽量なFrontmatter解析ロジックを自作。 +- スクリプトのテスト実行を実施し、リモートに存在する既存のIssue2件(Issue-1, Issue-2)を `docs/issues/` 内に正確なMarkdownファイルとして生成(Pull)成功した。 + +## 4. AI視点での結果 + +```mermaid +graph LR + A[docs/issues/*.md] <-->|sync_issues.mjs| B((GitBucket API)) + + A -->|1. meta: id: new| B1[POST: Create Issue] + B1 -.-> A1(Rename to Issue-X.md) + A -->|2. Local is Newer| B2[PATCH: Update Issue] + B -->|3. Remote is Newer| A2[Write/Overwrite Markdown] +``` + +双方向同期スクリプトの完成により、今後開発者はIDE(VS Code等)上で直接MarkdownとしてIssueの閲覧・追記・クローズが可能になった。これによりブラウザとエディタ間を往復する手間が省かれ、開発のフローが劇的に効率化される見込みである。 +※ 注意:リモートへのPush操作を行うには `.env` に `GITBUCKET_TOKEN` の設定が必要。 diff --git a/tools/scripts/sync_issues.mjs b/tools/scripts/sync_issues.mjs new file mode 100644 index 0000000..53bafc8 --- /dev/null +++ b/tools/scripts/sync_issues.mjs @@ -0,0 +1,207 @@ +import fs from 'fs'; +import path from 'path'; + +// If running Node < 20.6, we might need dotenv, but let's try to load .env manually for zero-dependency +function loadEnv() { + const envPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf-8'); + content.split('\n').forEach(line => { + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); + if (match) { + const key = match[1]; + let value = match[2] || ''; + if (value.length > 0 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') { + value = value.replace(/\\n/gm, '\n'); + } + value = value.replace(/(^['"]|['"]$)/g, '').trim(); + process.env[key] = value; + } + }); + } +} +loadEnv(); + +const TOKEN = process.env.GITBUCKET_TOKEN; +const API_BASE = "https://gitbucket.tmworks.club/api/v3/repos/dtmoyaji/TelosDB/issues"; +const ISSUES_DIR = path.join(process.cwd(), 'docs', 'issues'); + +if (!fs.existsSync(ISSUES_DIR)) { + fs.mkdirSync(ISSUES_DIR, { recursive: true }); +} + +// ---------------------------------------------------- +// Frontmatter Parser +// ---------------------------------------------------- +function parseMarkdown(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!match) return { meta: { id: 'new' }, body: content.trim() }; + + const metaRaw = match[1]; + const body = match[2].trim(); + const meta = {}; + + metaRaw.split('\n').forEach(line => { + const idx = line.indexOf(':'); + if (idx > -1) { + const key = line.substring(0, idx).trim(); + const val = line.substring(idx + 1).trim(); + meta[key] = val; + } + }); + return { meta, body }; +} + +function stringifyMarkdown(meta, body) { + let yaml = '---\n'; + for (const [k, v] of Object.entries(meta)) { + yaml += `${k}: ${v}\n`; + } + yaml += '---\n\n' + body + '\n'; + return yaml; +} + +// ---------------------------------------------------- +// API Helpers +// ---------------------------------------------------- +async function apiCall(url, method = 'GET', bodyObj = null) { + const headers = { 'Content-Type': 'application/json' }; + if (TOKEN) { + headers['Authorization'] = `token ${TOKEN}`; + } + + const options = { method, headers }; + if (bodyObj) { + options.body = JSON.stringify(bodyObj); + } + + const res = await fetch(url, options); + if (!res.ok) { + throw new Error(`API Error ${res.status}: ${await res.text()}`); + } + return res.json(); +} + +async function fetchRemoteIssues() { + console.log("Fetching remote issues..."); + return await apiCall(`${API_BASE}?state=all`); +} + +async function updateRemoteIssue(number, title, body, state) { + console.log(`Pushing changes to remote issue #${number}...`); + return await apiCall(`${API_BASE}/${number}`, 'PATCH', { title, body, state }); +} + +async function createRemoteIssue(title, body) { + console.log(`Creating new remote issue: ${title}...`); + return await apiCall(`${API_BASE}`, 'POST', { title, body }); +} + +// ---------------------------------------------------- +// Sync Logic +// ---------------------------------------------------- +async function sync() { + try { + const remoteIssues = await fetchRemoteIssues(); + const remoteMap = new Map(remoteIssues.map(i => [i.number.toString(), i])); + + const localFiles = fs.readdirSync(ISSUES_DIR).filter(f => f.endsWith('.md')); + + // 1. Local -> Remote (Push or Create) + for (const file of localFiles) { + const filePath = path.join(ISSUES_DIR, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const localStat = fs.statSync(filePath); + const { meta, body } = parseMarkdown(content); + + if (!meta.title) meta.title = `Issue ${meta.id || 'New'}`; + + if (!meta.id || meta.id.toString() === 'new') { + if (!TOKEN) { + console.warn(`[Skip] Cannot create new issue from ${file} because GITBUCKET_TOKEN is missing.`); + continue; + } + // Create + const created = await createRemoteIssue(meta.title, body); + + // Rename file and update meta + const newMeta = { + id: created.number, + state: created.state, + title: created.title, + updated_at: created.updated_at + }; + const newContent = stringifyMarkdown(newMeta, created.body); + const newFileName = `Issue-${created.number}.md`; + fs.writeFileSync(path.join(ISSUES_DIR, newFileName), newContent); + fs.unlinkSync(filePath); + console.log(`[Created] ${newFileName}`); + remoteMap.set(created.number.toString(), created); // prevent re-pulling below + + } else { + // Update existing if local is newer + const idStr = meta.id.toString(); + const remote = remoteMap.get(idStr); + if (remote) { + const localMtime = new Date(localStat.mtime).getTime(); + const remoteUtime = new Date(remote.updated_at).getTime(); + + // Add 5 seconds buffer to prevent infinite loop + if (localMtime > remoteUtime + 5000) { + if (!TOKEN) { + console.warn(`[Skip] Cannot push updates to #${idStr} because GITBUCKET_TOKEN is missing.`); + continue; + } + const updated = await updateRemoteIssue(idStr, meta.title, body, meta.state || 'open'); + + // Update local metadata timestamp to match remote + const newMeta = { ...meta, state: updated.state, updated_at: updated.updated_at }; + fs.writeFileSync(filePath, stringifyMarkdown(newMeta, updated.body)); + console.log(`[Pushed] Updated #${idStr} on remote.`); + // Remove from remote map so we don't overwrite it in Step 2 + remoteMap.delete(idStr); + } + } + } + } + + // 2. Remote -> Local (Pull) + for (const [idStr, remote] of remoteMap.entries()) { + const fileName = `Issue-${idStr}.md`; + const filePath = path.join(ISSUES_DIR, fileName); + + const newMeta = { + id: remote.number, + state: remote.state, + title: remote.title, + updated_at: remote.updated_at + }; + const newContent = stringifyMarkdown(newMeta, remote.body || ''); + + if (fs.existsSync(filePath)) { + const currentContent = fs.readFileSync(filePath, 'utf-8'); + const { meta } = parseMarkdown(currentContent); + + // Only overwrite if remote is newer + const localUtime = meta.updated_at ? new Date(meta.updated_at).getTime() : 0; + const remoteUtime = new Date(remote.updated_at).getTime(); + + if (remoteUtime > localUtime) { + fs.writeFileSync(filePath, newContent); + console.log(`[Pulled] Updated local #${idStr}.`); + } + } else { + // New file from remote + fs.writeFileSync(filePath, newContent); + console.log(`[Pulled] Created local #${idStr}.`); + } + } + + console.log("Sync Complete."); + + } catch (err) { + console.error("Sync failed:", err); + } +} + +sync();