Files
gitlore/phase-a-review.html
Taylor Eernisse 549a0646d7 chore: Add test-runner agent, agent-swarm-launcher skill, review artifacts, and beads updates
- .claude/agents/test-runner.md: New Claude Code agent definition for
  running cargo test suites and analyzing results, configured with
  haiku model for fast execution.

- skills/agent-swarm-launcher/: New skill for bootstrapping coordinated
  multi-agent workflows with AGENTS.md reconnaissance, Agent Mail
  coordination, and beads task tracking.

- api-review.html, phase-a-review.html: Self-contained HTML review
  artifacts for API audit and Phase A search pipeline review.

- .beads/issues.jsonl, .beads/last-touched: Updated issue tracker
  state reflecting current project work items.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:36:05 -05:00

1261 lines
62 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Phase A: Complete API Field Capture — Review</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2129;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--green-bg: #0d2818;
--yellow: #d29922;
--yellow-bg: #2d2000;
--red: #f85149;
--red-bg: #3d1418;
--purple: #bc8cff;
--purple-bg: #1e1534;
--blue-bg: #0c2d6b;
--orange: #db6d28;
--cyan: #39d2c0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
/* Header */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 20px 32px;
position: sticky;
top: 0;
z-index: 100;
}
.header h1 { font-size: 20px; font-weight: 600; }
.header .subtitle { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
.header .badges { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;
}
.badge-green { background: var(--green-bg); color: var(--green); border: 1px solid #1a4d2e; }
.badge-yellow { background: var(--yellow-bg); color: var(--yellow); border: 1px solid #4d3800; }
.badge-purple { background: var(--purple-bg); color: var(--purple); border: 1px solid #3b2a5e; }
.badge-blue { background: var(--blue-bg); color: var(--accent); border: 1px solid #1a4480; }
/* Tabs */
.tab-bar {
display: flex;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 32px;
overflow-x: auto;
position: sticky;
top: 72px;
z-index: 99;
}
.tab {
padding: 10px 16px;
font-size: 13px;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab .count {
background: var(--surface2);
color: var(--text-dim);
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
margin-left: 5px;
}
/* Content */
.content { max-width: 1100px; margin: 0 auto; padding: 24px 32px 80px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body { padding: 18px; }
/* Principle banner */
.principle {
background: var(--blue-bg);
border: 1px solid #1a4480;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 20px;
font-size: 14px;
}
.principle strong { color: var(--accent); }
/* Scope cards */
.scope-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-bottom: 20px; }
.scope-item {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
}
.scope-item .num { font-size: 28px; font-weight: 700; color: var(--accent); }
.scope-item .label { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
/* Exclusion cards */
.exclusion {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 18px;
margin-bottom: 10px;
}
.exclusion .field-name { font-weight: 600; color: var(--red); font-size: 14px; }
.exclusion .rationale { color: var(--text-dim); font-size: 13px; margin-top: 4px; }
.exclusion .note { color: var(--yellow); font-size: 12px; margin-top: 6px; font-style: italic; }
/* Tables */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
text-align: left;
padding: 10px 14px;
background: var(--surface2);
color: var(--text-dim);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 1;
}
td {
padding: 8px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
tr:hover td { background: rgba(88,166,255,0.04); }
code {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
background: var(--surface2);
padding: 2px 6px;
border-radius: 4px;
color: var(--cyan);
}
td code { background: transparent; padding: 0; }
/* Status dots */
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
position: relative;
top: -1px;
}
.dot-green { background: var(--green); }
.dot-yellow { background: var(--yellow); }
.dot-purple { background: var(--purple); }
.dot-red { background: var(--red); }
/* Category badges in tables */
.cat {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.cat-meta { background: #1a2740; color: #79b8ff; }
.cat-engagement { background: #2a1e00; color: #f0b832; }
.cat-time { background: #1e2d1e; color: #7ee787; }
.cat-task { background: #2d1e3a; color: #d2a8ff; }
.cat-ref { background: #1a3040; color: #56d4dd; }
.cat-tracking { background: #2d1a1a; color: #ff9492; }
.cat-premium { background: #2a1a3d; color: #bc8cff; }
.cat-merge { background: #0d2818; color: #3fb950; }
.cat-fork { background: #1e2d1e; color: #7ee787; }
.cat-branch { background: #2d2700; color: #e3b341; }
.cat-schedule { background: #1a2740; color: #79b8ff; }
.cat-import { background: #2d1a1a; color: #ff9492; }
.cat-author { background: #1a3040; color: #56d4dd; }
/* Filter bar */
.filter-bar {
display: flex;
gap: 8px;
padding: 12px 0;
flex-wrap: wrap;
align-items: center;
}
.filter-btn {
padding: 4px 12px;
border-radius: 14px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.filter-btn:hover { border-color: var(--accent); color: var(--text); }
.filter-btn.active { background: var(--blue-bg); border-color: var(--accent); color: var(--accent); }
.search-input {
flex: 1;
min-width: 180px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
font-size: 13px;
outline: none;
}
.search-input:focus { border-color: var(--accent); }
.search-input::placeholder { color: var(--text-dim); }
/* SQL block */
.sql-block {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12.5px;
line-height: 1.7;
overflow-x: auto;
white-space: pre;
color: var(--text);
max-height: 600px;
overflow-y: auto;
}
.sql-comment { color: var(--text-dim); }
.sql-keyword { color: var(--accent); font-weight: 600; }
.sql-type { color: var(--green); }
.sql-default { color: var(--yellow); }
.sql-divider { color: var(--text-dim); }
.sql-line { display: block; padding: 0 4px; }
.sql-line:hover { background: rgba(88,166,255,0.06); }
/* Struct block */
.struct-block {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12.5px;
line-height: 1.8;
overflow-x: auto;
}
.struct-field { color: var(--text); }
.struct-type { color: var(--green); }
.struct-comment { color: var(--text-dim); }
.struct-line { display: block; padding: 0 4px; }
.struct-line:hover { background: rgba(88,166,255,0.06); }
/* File list */
.file-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.file-item:last-child { border-bottom: none; }
.file-icon {
width: 32px; height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.file-icon-new { background: var(--green-bg); color: var(--green); }
.file-icon-mod { background: var(--yellow-bg); color: var(--yellow); }
.file-path { font-family: 'SF Mono', monospace; font-size: 13px; color: var(--accent); }
.file-desc { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
/* Decision table */
.decision-row {
display: flex;
gap: 16px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.decision-row:last-child { border-bottom: none; }
.decision-field {
font-weight: 600;
min-width: 100px;
font-family: 'SF Mono', monospace;
font-size: 13px;
}
.decision-verdict {
font-weight: 600;
min-width: 140px;
}
.verdict-excluded { color: var(--red); }
.verdict-flatten { color: var(--green); }
.decision-rationale { color: var(--text-dim); font-size: 13px; flex: 1; }
/* Transform rules */
.transform-rule {
display: flex;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
align-items: baseline;
}
.transform-rule:last-child { border-bottom: none; }
.transform-from {
font-family: 'SF Mono', monospace;
font-size: 12px;
color: var(--yellow);
min-width: 200px;
}
.transform-arrow { color: var(--text-dim); min-width: 24px; text-align: center; }
.transform-to { font-size: 13px; color: var(--text); flex: 1; }
/* Not-included list */
.not-included {
list-style: none;
padding: 0;
}
.not-included li {
padding: 8px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text-dim);
}
.not-included li:last-child { border-bottom: none; }
.not-included li::before {
content: "\2717";
color: var(--red);
margin-right: 10px;
font-weight: bold;
}
/* Column count summary */
.col-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.col-summary-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px;
}
.col-summary-card h3 { font-size: 14px; margin-bottom: 10px; }
.col-bar {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
background: var(--surface2);
}
.col-bar-existing { background: var(--text-dim); }
.col-bar-new { background: var(--green); }
.col-legend {
display: flex;
gap: 16px;
font-size: 11px;
color: var(--text-dim);
}
.col-legend span { display: flex; align-items: center; gap: 4px; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
/* Toggles for SQL sections */
.toggle-header {
cursor: pointer;
user-select: none;
}
.toggle-header::after {
content: " \25BC";
font-size: 10px;
color: var(--text-dim);
}
.toggle-header.collapsed::after {
content: " \25B6";
}
/* Responsive */
@media (max-width: 768px) {
.header { padding: 14px 16px; }
.tab-bar { padding: 0 16px; }
.content { padding: 16px; }
.col-summary { grid-template-columns: 1fr; }
.scope-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>Phase A: Complete API Field Capture</h1>
<div class="subtitle">Migration 007 &mdash; Mirror all GitLab API response data into the local DB</div>
<div class="badges">
<span class="badge badge-green">No new API calls</span>
<span class="badge badge-green">No new tables</span>
<span class="badge badge-yellow">1 migration</span>
<span class="badge badge-purple">6 files touched</span>
<span class="badge badge-blue">Independent of CP3</span>
</div>
</div>
<div class="tab-bar" id="tabBar">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="issues">Issues <span class="count" id="issueCount"></span></div>
<div class="tab" data-tab="mrs">Merge Requests <span class="count" id="mrCount"></span></div>
<div class="tab" data-tab="migration">Migration SQL</div>
<div class="tab" data-tab="structs">Serde Structs</div>
<div class="tab" data-tab="transforms">Transformers</div>
<div class="tab" data-tab="files">Files Touched</div>
<div class="tab" data-tab="decisions">Decisions</div>
</div>
<div class="content">
<!-- OVERVIEW -->
<div class="tab-content active" id="tab-overview">
<div class="principle">
<strong>Guiding Principle:</strong> Mirror everything GitLab gives us. The local DB should be a complete representation of all data returned by the API. This ensures maximum context for processing and analysis in later steps.
</div>
<div class="scope-grid">
<div class="scope-item">
<div class="num" id="totalNewCols">0</div>
<div class="label">New columns total</div>
</div>
<div class="scope-item">
<div class="num" id="issueNewCols">0</div>
<div class="label">New issue columns</div>
</div>
<div class="scope-item">
<div class="num" id="mrNewCols">0</div>
<div class="label">New MR columns</div>
</div>
<div class="scope-item">
<div class="num">6</div>
<div class="label">New serde helper types</div>
</div>
<div class="scope-item">
<div class="num">2</div>
<div class="label">Fields excluded</div>
</div>
<div class="scope-item">
<div class="num">6</div>
<div class="label">Files touched</div>
</div>
</div>
<div class="col-summary">
<div class="col-summary-card">
<h3>Issues table</h3>
<div class="col-bar">
<div class="col-bar-existing" id="issueBarExisting"></div>
<div class="col-bar-new" id="issueBarNew"></div>
</div>
<div class="col-legend">
<span><span class="legend-dot" style="background:var(--text-dim)"></span> <span id="issueExistingNum">18</span> existing</span>
<span><span class="legend-dot" style="background:var(--green)"></span> <span id="issueNewNum">0</span> new</span>
</div>
</div>
<div class="col-summary-card">
<h3>Merge Requests table</h3>
<div class="col-bar">
<div class="col-bar-existing" id="mrBarExisting"></div>
<div class="col-bar-new" id="mrBarNew"></div>
</div>
<div class="col-legend">
<span><span class="legend-dot" style="background:var(--text-dim)"></span> <span id="mrExistingNum">29</span> existing</span>
<span><span class="legend-dot" style="background:var(--green)"></span> <span id="mrNewNum">0</span> new</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Scope</div>
<div class="card-body" style="font-size:14px;">
<p style="margin-bottom:10px;">One migration. Three categories of work:</p>
<ol style="padding-left:20px; color:var(--text-dim); line-height:2;">
<li><strong style="color:var(--text)">New columns</strong> on <code>issues</code> and <code>merge_requests</code> for fields currently dropped by serde or during transform</li>
<li><strong style="color:var(--text)">New serde fields</strong> on <code>GitLabIssue</code> and <code>GitLabMergeRequest</code> to deserialize currently-silently-dropped JSON fields</li>
<li><strong style="color:var(--text)">Transformer + insert updates</strong> to pass the new fields through to the DB</li>
</ol>
</div>
</div>
<div class="card">
<div class="card-header">What this does NOT include</div>
<div class="card-body" style="padding:0;">
<ul class="not-included">
<li>No new API endpoints called</li>
<li>No new tables (except reusing existing <code>milestones</code> for MRs)</li>
<li>No CLI changes (new fields stored but not surfaced in <code>lore issues</code> / <code>lore mrs</code>)</li>
<li>No changes to discussion/note ingestion (Phase A is issues + MRs only)</li>
<li>No observability instrumentation (that's Phase B)</li>
</ul>
</div>
</div>
</div>
<!-- ISSUES TAB -->
<div class="tab-content" id="tab-issues">
<h2 style="font-size:16px; margin-bottom:16px;">Issues: Field Gap Inventory</h2>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">Currently Stored</div>
<div class="card-body" style="font-size:13px; color:var(--text-dim); line-height:2;">
<span style="color:var(--text)">id</span>, <span style="color:var(--text)">iid</span>, <span style="color:var(--text)">project_id</span>, <span style="color:var(--text)">title</span>, <span style="color:var(--text)">description</span>, <span style="color:var(--text)">state</span>, <span style="color:var(--text)">author_username</span>, <span style="color:var(--text)">created_at</span>, <span style="color:var(--text)">updated_at</span>, <span style="color:var(--text)">web_url</span>, <span style="color:var(--text)">due_date</span>, <span style="color:var(--text)">milestone_id</span>, <span style="color:var(--text)">milestone_title</span>, <span style="color:var(--text)">raw_payload_id</span>, <span style="color:var(--text)">last_seen_at</span>, <span style="color:var(--text)">discussions_synced_for_updated_at</span>, <span style="color:var(--text)">labels</span> (junction), <span style="color:var(--text)">assignees</span> (junction)
</div>
</div>
<div class="filter-bar">
<input type="text" class="search-input" id="issueSearch" placeholder="Filter fields...">
<button class="filter-btn active" data-filter="all" data-table="issues">All</button>
<button class="filter-btn" data-filter="wired" data-table="issues">Deserialized, not stored</button>
<button class="filter-btn" data-filter="new" data-table="issues">New from API</button>
</div>
<div class="card">
<div class="card-body" style="padding:0; overflow-x:auto;">
<table id="issueTable">
<thead>
<tr>
<th>API Field</th>
<th>Type</th>
<th>DB Column</th>
<th>Category</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<!-- MRs TAB -->
<div class="tab-content" id="tab-mrs">
<h2 style="font-size:16px; margin-bottom:16px;">Merge Requests: Field Gap Inventory</h2>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">Currently Stored</div>
<div class="card-body" style="font-size:13px; color:var(--text-dim); line-height:2;">
<span style="color:var(--text)">id</span>, <span style="color:var(--text)">iid</span>, <span style="color:var(--text)">project_id</span>, <span style="color:var(--text)">title</span>, <span style="color:var(--text)">description</span>, <span style="color:var(--text)">state</span>, <span style="color:var(--text)">draft</span>, <span style="color:var(--text)">author_username</span>, <span style="color:var(--text)">source_branch</span>, <span style="color:var(--text)">target_branch</span>, <span style="color:var(--text)">head_sha</span>, <span style="color:var(--text)">references_short</span>, <span style="color:var(--text)">references_full</span>, <span style="color:var(--text)">detailed_merge_status</span>, <span style="color:var(--text)">merge_user_username</span>, <span style="color:var(--text)">created_at</span>, <span style="color:var(--text)">updated_at</span>, <span style="color:var(--text)">merged_at</span>, <span style="color:var(--text)">closed_at</span>, <span style="color:var(--text)">last_seen_at</span>, <span style="color:var(--text)">web_url</span>, <span style="color:var(--text)">raw_payload_id</span>, <span style="color:var(--text)">discussions_synced_for_updated_at</span>, <span style="color:var(--text)">labels</span> (junction), <span style="color:var(--text)">assignees</span> (junction), <span style="color:var(--text)">reviewers</span> (junction)
</div>
</div>
<div class="filter-bar">
<input type="text" class="search-input" id="mrSearch" placeholder="Filter fields...">
<button class="filter-btn active" data-filter="all" data-table="mrs">All</button>
<button class="filter-btn" data-filter="wired" data-table="mrs">Deserialized, not stored</button>
<button class="filter-btn" data-filter="new" data-table="mrs">New from API</button>
</div>
<div class="card">
<div class="card-body" style="padding:0; overflow-x:auto;">
<table id="mrTable">
<thead>
<tr>
<th>API Field</th>
<th>Type</th>
<th>DB Column</th>
<th>Category</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<!-- MIGRATION SQL TAB -->
<div class="tab-content" id="tab-migration">
<h2 style="font-size:16px; margin-bottom:16px;">Migration 007: complete_field_capture.sql</h2>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">
<span>Issues columns</span>
<span class="badge badge-green" id="issueSqlCount"></span>
</div>
<div class="card-body" style="padding:0;">
<div class="sql-block" id="issueSql"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<span>Merge Requests columns</span>
<span class="badge badge-green" id="mrSqlCount"></span>
</div>
<div class="card-body" style="padding:0;">
<div class="sql-block" id="mrSql"></div>
</div>
</div>
</div>
<!-- SERDE STRUCTS TAB -->
<div class="tab-content" id="tab-structs">
<h2 style="font-size:16px; margin-bottom:16px;">Serde Struct Changes</h2>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">New Helper Types</div>
<div class="card-body" style="padding:0;">
<div class="struct-block" id="helperTypes"></div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">
<span>GitLabIssue: new fields</span>
<span class="badge badge-yellow" id="issueFieldCount"></span>
</div>
<div class="card-body" style="padding:0;">
<div class="struct-block" id="issueStruct"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<span>GitLabMergeRequest: new fields</span>
<span class="badge badge-yellow" id="mrFieldCount"></span>
</div>
<div class="card-body" style="padding:0;">
<div class="struct-block" id="mrStruct"></div>
</div>
</div>
</div>
<!-- TRANSFORMERS TAB -->
<div class="tab-content" id="tab-transforms">
<h2 style="font-size:16px; margin-bottom:16px;">Transformer Changes</h2>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">IssueRow: transform rules</div>
<div class="card-body" style="padding:0;">
<p style="padding:12px 16px; font-size:13px; color:var(--text-dim); border-bottom:1px solid var(--border);">
All new fields map 1:1 from the serde struct <strong style="color:var(--text)">except</strong> these special cases:
</p>
<div id="issueTransforms"></div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">NormalizedMergeRequest: transform rules</div>
<div class="card-body" style="padding:0;">
<p style="padding:12px 16px; font-size:13px; color:var(--text-dim); border-bottom:1px solid var(--border);">
Same patterns as issues, plus:
</p>
<div id="mrTransforms"></div>
</div>
</div>
<div class="card">
<div class="card-header">Insert statement changes</div>
<div class="card-body" style="font-size:13px; color:var(--text-dim);">
Both <code>process_issue_in_transaction</code> and <code>process_mr_in_transaction</code> need their
<strong style="color:var(--text)">INSERT</strong> and <strong style="color:var(--text)">ON CONFLICT DO UPDATE</strong>
statements extended with all new columns. The ON CONFLICT clause should update all new fields on re-sync.
</div>
</div>
</div>
<!-- FILES TOUCHED TAB -->
<div class="tab-content" id="tab-files">
<h2 style="font-size:16px; margin-bottom:16px;">Files Touched</h2>
<div class="card">
<div class="card-body" style="padding:0;" id="filesList"></div>
</div>
</div>
<!-- DECISIONS TAB -->
<div class="tab-content" id="tab-decisions">
<h2 style="font-size:16px; margin-bottom:16px;">Resolved Decisions</h2>
<div class="card" style="margin-bottom:20px;">
<div class="card-header">Exclusions (2 fields)</div>
<div class="card-body">
<div class="exclusion">
<div class="field-name"><code>subscribed</code> &mdash; Excluded</div>
<div class="rationale">User-relative field. Reflects the token holder's subscription state, not a property of the entity itself. Changes meaning if the token is rotated to a different user. Not entity data.</div>
</div>
<div class="exclusion">
<div class="field-name"><code>_links</code> &mdash; Excluded</div>
<div class="rationale">HATEOAS API navigation metadata, not entity data. Every URL is deterministically constructable from <code>project_id</code> + <code>iid</code> + GitLab base URL.</div>
<div class="note">Note: <code>closed_as_duplicate_of</code> inside <code>_links</code> contains a real entity reference. Extracting that is deferred to a future phase.</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Included with special handling</div>
<div class="card-body">
<div class="exclusion" style="margin-bottom:0;">
<div class="field-name" style="color:var(--green);"><code>epic</code> / <code>iteration</code> &mdash; Flatten to columns</div>
<div class="rationale">Same denormalization pattern as milestones. Epic gets 5 columns (<code>epic_id</code>, <code>epic_iid</code>, <code>epic_title</code>, <code>epic_url</code>, <code>epic_group_id</code>). Iteration gets 6 columns (<code>iteration_id</code>, <code>iteration_iid</code>, <code>iteration_title</code>, <code>iteration_state</code>, <code>iteration_start_date</code>, <code>iteration_due_date</code>). Both nullable (null on Free tier).</div>
</div>
</div>
</div>
</div>
</div>
<script>
// ============================================================
// DATA
// ============================================================
const issueFields = [
// Deserialized but not stored
{ api: 'closed_at', type: 'String', col: 'closed_at', cat: 'tracking', catLabel: 'Tracking', status: 'wired', notes: 'Deserialized in serde struct, DB column exists (migration 002), but transformer never populates it' },
{ api: 'author.id', type: 'i64', col: 'author_id', cat: 'author', catLabel: 'Author', status: 'wired', notes: 'Already deserialized, just not passed to DB' },
{ api: 'author.name', type: 'String', col: 'author_name', cat: 'author', catLabel: 'Author', status: 'wired', notes: 'Already deserialized, just not passed to DB' },
// New from API
{ api: 'type', type: 'String', col: 'issue_type', cat: 'meta', catLabel: 'Metadata', status: 'new', notes: '"ISSUE", "INCIDENT", "TEST_CASE"' },
{ api: 'upvotes', type: 'i64', col: 'upvotes', cat: 'engagement', catLabel: 'Engagement', status: 'new', notes: '' },
{ api: 'downvotes', type: 'i64', col: 'downvotes', cat: 'engagement', catLabel: 'Engagement', status: 'new', notes: '' },
{ api: 'user_notes_count', type: 'i64', col: 'user_notes_count', cat: 'engagement', catLabel: 'Engagement', status: 'new', notes: 'Useful for discussion sync optimization' },
{ api: 'merge_requests_count', type: 'i64', col: 'merge_requests_count', cat: 'engagement', catLabel: 'Engagement', status: 'new', notes: 'Count of linked MRs' },
{ api: 'confidential', type: 'bool', col: 'confidential', cat: 'meta', catLabel: 'Metadata', status: 'new', notes: '0/1' },
{ api: 'discussion_locked', type: 'bool', col: 'discussion_locked', cat: 'meta', catLabel: 'Metadata', status: 'new', notes: '0/1' },
{ api: 'weight', type: 'Option<i64>', col: 'weight', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'Premium/Ultimate, null on Free' },
{ api: 'time_stats.time_estimate', type: 'i64', col: 'time_estimate', cat: 'time', catLabel: 'Time', status: 'new', notes: 'Seconds' },
{ api: 'time_stats.total_time_spent', type: 'i64', col: 'time_spent', cat: 'time', catLabel: 'Time', status: 'new', notes: 'Seconds' },
{ api: 'time_stats.human_time_estimate', type: 'Option<String>', col: 'human_time_estimate', cat: 'time', catLabel: 'Time', status: 'new', notes: 'e.g. "3h 30m"' },
{ api: 'time_stats.human_total_time_spent', type: 'Option<String>', col: 'human_time_spent', cat: 'time', catLabel: 'Time', status: 'new', notes: 'e.g. "1h 15m"' },
{ api: 'task_completion_status.count', type: 'i64', col: 'task_count', cat: 'task', catLabel: 'Tasks', status: 'new', notes: 'Checkbox total' },
{ api: 'task_completion_status.completed_count', type: 'i64', col: 'task_completed_count', cat: 'task', catLabel: 'Tasks', status: 'new', notes: 'Checkboxes checked' },
{ api: 'has_tasks', type: 'bool', col: 'has_tasks', cat: 'task', catLabel: 'Tasks', status: 'new', notes: '0/1' },
{ api: 'severity', type: 'Option<String>', col: 'severity', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'Incident severity' },
{ api: 'closed_by', type: 'Option<object>', col: 'closed_by_username', cat: 'tracking', catLabel: 'Tracking', status: 'new', notes: 'Username only (consistent with author pattern)' },
{ api: 'imported', type: 'bool', col: 'imported', cat: 'import', catLabel: 'Import', status: 'new', notes: '0/1' },
{ api: 'imported_from', type: 'Option<String>', col: 'imported_from', cat: 'import', catLabel: 'Import', status: 'new', notes: 'Import source' },
{ api: 'moved_to_id', type: 'Option<i64>', col: 'moved_to_id', cat: 'tracking', catLabel: 'Tracking', status: 'new', notes: 'Target issue if moved' },
{ api: 'references.short', type: 'String', col: 'references_short', cat: 'ref', catLabel: 'References', status: 'new', notes: 'e.g. "#42"' },
{ api: 'references.relative', type: 'String', col: 'references_relative', cat: 'ref', catLabel: 'References', status: 'new', notes: 'e.g. "#42" or "group/proj#42"' },
{ api: 'references.full', type: 'String', col: 'references_full', cat: 'ref', catLabel: 'References', status: 'new', notes: 'e.g. "group/project#42"' },
{ api: 'health_status', type: 'Option<String>', col: 'health_status', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'Ultimate only' },
{ api: 'issue_type', type: 'Option<String>', col: '(same as type)', cat: 'meta', catLabel: 'Metadata', status: 'new', notes: 'Alias, use whichever GitLab returns' },
{ api: 'epic.id', type: 'Option<i64>', col: 'epic_id', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'Premium/Ultimate, null on Free' },
{ api: 'epic.iid', type: 'Option<i64>', col: 'epic_iid', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'epic.title', type: 'Option<String>', col: 'epic_title', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'epic.url', type: 'Option<String>', col: 'epic_url', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'epic.group_id', type: 'Option<i64>', col: 'epic_group_id', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'iteration.id', type: 'Option<i64>', col: 'iteration_id', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'Premium/Ultimate, null on Free' },
{ api: 'iteration.iid', type: 'Option<i64>', col: 'iteration_iid', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'iteration.title', type: 'Option<String>', col: 'iteration_title', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'iteration.state', type: 'Option<i64>', col: 'iteration_state', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '1=upcoming, 2=current, 3=closed' },
{ api: 'iteration.start_date', type: 'Option<String>', col: 'iteration_start_date', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'ISO date' },
{ api: 'iteration.due_date', type: 'Option<String>', col: 'iteration_due_date', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'ISO date' },
];
const mrFields = [
// Deserialized but not stored
{ api: 'author.id', type: 'i64', col: 'author_id', cat: 'author', catLabel: 'Author', status: 'wired', notes: 'Already deserialized, just not passed to DB' },
{ api: 'author.name', type: 'String', col: 'author_name', cat: 'author', catLabel: 'Author', status: 'wired', notes: 'Already deserialized, just not passed to DB' },
// New from API
{ api: 'upvotes', type: 'i64', col: 'upvotes', cat: 'engagement', catLabel: 'Engagement', status: 'new', notes: '' },
{ api: 'downvotes', type: 'i64', col: 'downvotes', cat: 'engagement', catLabel: 'Engagement', status: 'new', notes: '' },
{ api: 'user_notes_count', type: 'i64', col: 'user_notes_count', cat: 'engagement', catLabel: 'Engagement', status: 'new', notes: '' },
{ api: 'source_project_id', type: 'i64', col: 'source_project_id', cat: 'fork', catLabel: 'Fork', status: 'new', notes: 'Fork source' },
{ api: 'target_project_id', type: 'i64', col: 'target_project_id', cat: 'fork', catLabel: 'Fork', status: 'new', notes: 'Fork target' },
{ api: 'milestone', type: 'Option<object>', col: 'milestone_id, milestone_title', cat: 'meta', catLabel: 'Metadata', status: 'new', notes: 'Reuse issue milestone pattern' },
{ api: 'merge_when_pipeline_succeeds', type: 'bool', col: 'merge_when_pipeline_succeeds', cat: 'merge', catLabel: 'Merge', status: 'new', notes: '0/1, auto-merge flag' },
{ api: 'merge_commit_sha', type: 'Option<String>', col: 'merge_commit_sha', cat: 'merge', catLabel: 'Merge', status: 'new', notes: 'Commit ref after merge' },
{ api: 'squash_commit_sha', type: 'Option<String>', col: 'squash_commit_sha', cat: 'merge', catLabel: 'Merge', status: 'new', notes: 'Commit ref after squash' },
{ api: 'discussion_locked', type: 'bool', col: 'discussion_locked', cat: 'meta', catLabel: 'Metadata', status: 'new', notes: '0/1' },
{ api: 'should_remove_source_branch', type: 'Option<bool>', col: 'should_remove_source_branch', cat: 'branch', catLabel: 'Branch', status: 'new', notes: '0/1' },
{ api: 'force_remove_source_branch', type: 'Option<bool>', col: 'force_remove_source_branch', cat: 'branch', catLabel: 'Branch', status: 'new', notes: '0/1' },
{ api: 'squash', type: 'bool', col: 'squash', cat: 'merge', catLabel: 'Merge', status: 'new', notes: '0/1' },
{ api: 'squash_on_merge', type: 'bool', col: 'squash_on_merge', cat: 'merge', catLabel: 'Merge', status: 'new', notes: '0/1' },
{ api: 'has_conflicts', type: 'bool', col: 'has_conflicts', cat: 'merge', catLabel: 'Merge', status: 'new', notes: '0/1' },
{ api: 'blocking_discussions_resolved', type: 'bool', col: 'blocking_discussions_resolved', cat: 'merge', catLabel: 'Merge', status: 'new', notes: '0/1' },
{ api: 'time_stats.time_estimate', type: 'i64', col: 'time_estimate', cat: 'time', catLabel: 'Time', status: 'new', notes: 'Seconds' },
{ api: 'time_stats.total_time_spent', type: 'i64', col: 'time_spent', cat: 'time', catLabel: 'Time', status: 'new', notes: 'Seconds' },
{ api: 'time_stats.human_time_estimate', type: 'Option<String>', col: 'human_time_estimate', cat: 'time', catLabel: 'Time', status: 'new', notes: '' },
{ api: 'time_stats.human_total_time_spent', type: 'Option<String>', col: 'human_time_spent', cat: 'time', catLabel: 'Time', status: 'new', notes: '' },
{ api: 'task_completion_status.count', type: 'i64', col: 'task_count', cat: 'task', catLabel: 'Tasks', status: 'new', notes: '' },
{ api: 'task_completion_status.completed_count', type: 'i64', col: 'task_completed_count', cat: 'task', catLabel: 'Tasks', status: 'new', notes: '' },
{ api: 'closed_by', type: 'Option<object>', col: 'closed_by_username', cat: 'tracking', catLabel: 'Tracking', status: 'new', notes: '' },
{ api: 'prepared_at', type: 'Option<String>', col: 'prepared_at', cat: 'schedule', catLabel: 'Schedule', status: 'new', notes: 'ms epoch, nullable' },
{ api: 'merge_after', type: 'Option<String>', col: 'merge_after', cat: 'schedule', catLabel: 'Schedule', status: 'new', notes: 'ms epoch, nullable (scheduled merge)' },
{ api: 'imported', type: 'bool', col: 'imported', cat: 'import', catLabel: 'Import', status: 'new', notes: '0/1' },
{ api: 'imported_from', type: 'Option<String>', col: 'imported_from', cat: 'import', catLabel: 'Import', status: 'new', notes: '' },
{ api: 'approvals_before_merge', type: 'Option<i64>', col: 'approvals_before_merge', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'Deprecated but still returned' },
{ api: 'references.relative', type: 'String', col: 'references_relative', cat: 'ref', catLabel: 'References', status: 'new', notes: 'Currently only short + full stored' },
{ api: 'confidential', type: 'bool', col: 'confidential', cat: 'meta', catLabel: 'Metadata', status: 'new', notes: '0/1 (MRs can be confidential too)' },
{ api: 'iteration.id', type: 'Option<i64>', col: 'iteration_id', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'Premium/Ultimate, null on Free' },
{ api: 'iteration.iid', type: 'Option<i64>', col: 'iteration_iid', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'iteration.title', type: 'Option<String>', col: 'iteration_title', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'iteration.state', type: 'Option<i64>', col: 'iteration_state', cat: 'premium', catLabel: 'Premium', status: 'new', notes: '' },
{ api: 'iteration.start_date', type: 'Option<String>', col: 'iteration_start_date', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'ISO date' },
{ api: 'iteration.due_date', type: 'Option<String>', col: 'iteration_due_date', cat: 'premium', catLabel: 'Premium', status: 'new', notes: 'ISO date' },
];
const issueSqlLines = [
{ type: 'comment', text: '-- Migration 007: Capture all remaining GitLab API response fields.' },
{ type: 'comment', text: '-- Principle: mirror everything GitLab returns. No field left behind.' },
{ type: 'blank' },
{ type: 'divider', text: '-- ============================================================' },
{ type: 'divider', text: '-- ISSUES: new columns' },
{ type: 'divider', text: '-- ============================================================' },
{ type: 'blank' },
{ type: 'comment', text: '-- Fields currently deserialized but not stored' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN closed_at INTEGER;', note: 'ms epoch' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN author_id INTEGER;', note: 'GitLab user ID' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN author_name TEXT;', note: 'Display name' },
{ type: 'blank' },
{ type: 'comment', text: '-- Issue metadata' },
{ type: 'sql', text: "ALTER TABLE issues ADD COLUMN issue_type TEXT;", note: "'issue' | 'incident' | 'test_case'" },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN discussion_locked INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Engagement' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN upvotes INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN downvotes INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN user_notes_count INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN merge_requests_count INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Time tracking' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN time_estimate INTEGER NOT NULL DEFAULT 0;', note: 'seconds' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN time_spent INTEGER NOT NULL DEFAULT 0;', note: 'seconds' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN human_time_estimate TEXT;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN human_time_spent TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Task lists' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN task_count INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN task_completed_count INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN has_tasks INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- References' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN references_short TEXT;', note: 'e.g. "#42"' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN references_relative TEXT;', note: 'context-dependent' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN references_full TEXT;', note: 'e.g. "group/project#42"' },
{ type: 'blank' },
{ type: 'comment', text: '-- Close/move tracking' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN closed_by_username TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Premium/Ultimate fields (nullable, null on Free tier)' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN weight INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN severity TEXT;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN health_status TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Import tracking' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN imported INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN imported_from TEXT;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN moved_to_id INTEGER;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Epic (Premium/Ultimate, null on Free)' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN epic_id INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN epic_iid INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN epic_title TEXT;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN epic_url TEXT;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN epic_group_id INTEGER;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Iteration (Premium/Ultimate, null on Free)' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN iteration_id INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN iteration_iid INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN iteration_title TEXT;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN iteration_state INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN iteration_start_date TEXT;' },
{ type: 'sql', text: 'ALTER TABLE issues ADD COLUMN iteration_due_date TEXT;' },
];
const mrSqlLines = [
{ type: 'divider', text: '-- ============================================================' },
{ type: 'divider', text: '-- MERGE REQUESTS: new columns' },
{ type: 'divider', text: '-- ============================================================' },
{ type: 'blank' },
{ type: 'comment', text: '-- Author enrichment' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN author_id INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN author_name TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Engagement' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN upvotes INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN downvotes INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN user_notes_count INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Fork tracking' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN source_project_id INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN target_project_id INTEGER;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Milestone (parity with issues)' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN milestone_id INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN milestone_title TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Merge behavior' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN merge_when_pipeline_succeeds INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN merge_commit_sha TEXT;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN squash_commit_sha TEXT;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN squash INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN squash_on_merge INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Merge readiness' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN has_conflicts INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN blocking_discussions_resolved INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Branch cleanup' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN should_remove_source_branch INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN force_remove_source_branch INTEGER;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Discussion lock' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN discussion_locked INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Time tracking' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN time_estimate INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN time_spent INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN human_time_estimate TEXT;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN human_time_spent TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Task lists' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN task_count INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN task_completed_count INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Close tracking' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN closed_by_username TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Scheduling' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN prepared_at INTEGER;', note: 'ms epoch' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN merge_after INTEGER;', note: 'ms epoch' },
{ type: 'blank' },
{ type: 'comment', text: '-- References (add relative, short + full already exist)' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN references_relative TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Import tracking' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN imported INTEGER NOT NULL DEFAULT 0;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN imported_from TEXT;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Premium/Ultimate' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN approvals_before_merge INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;' },
{ type: 'blank' },
{ type: 'comment', text: '-- Iteration (Premium/Ultimate, null on Free)' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN iteration_id INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN iteration_iid INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN iteration_title TEXT;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN iteration_state INTEGER;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN iteration_start_date TEXT;' },
{ type: 'sql', text: 'ALTER TABLE merge_requests ADD COLUMN iteration_due_date TEXT;' },
];
const issueTransformRules = [
{ from: 'closed_at', to: 'iso_to_ms() conversion (already in serde struct, just not passed through)' },
{ from: 'time_stats', to: 'Flatten to 4 individual fields: time_estimate, time_spent, human_time_estimate, human_time_spent' },
{ from: 'task_completion_status', to: 'Flatten to 2 individual fields: task_count, task_completed_count' },
{ from: 'references', to: 'Flatten to 3 individual fields: references_short, references_relative, references_full' },
{ from: 'closed_by', to: 'Extract username only (consistent with author pattern)' },
{ from: 'author', to: 'Additionally extract id and name (currently only username)' },
{ from: 'type / issue_type', to: 'Prefer issue_type, fall back to type field' },
{ from: 'epic', to: 'Flatten to 5 fields: epic_id, epic_iid, epic_title, epic_url, epic_group_id' },
{ from: 'iteration', to: 'Flatten to 6 fields: iteration_id, iteration_iid, iteration_title, iteration_state, iteration_start_date, iteration_due_date' },
];
const mrTransformRules = [
{ from: 'milestone', to: 'Reuse upsert_milestone_tx from issue pipeline, add milestone_id + milestone_title' },
{ from: 'prepared_at, merge_after', to: 'iso_to_ms() conversion' },
{ from: 'source_project_id, target_project_id', to: 'Direct pass-through' },
{ from: 'iteration', to: 'Flatten to 6 fields (same as issues)' },
];
const filesTouched = [
{ path: 'migrations/007_complete_field_capture.sql', change: 'New file', isNew: true },
{ path: 'src/gitlab/types.rs', change: 'Add fields to GitLabIssue, GitLabMergeRequest; add GitLabTimeStats, GitLabTaskCompletionStatus, GitLabEpic, GitLabIteration', isNew: false },
{ path: 'src/gitlab/transformers/issue.rs', change: 'Extend IssueRow, IssueWithMetadata, transform_issue()', isNew: false },
{ path: 'src/gitlab/transformers/merge_request.rs', change: 'Extend NormalizedMergeRequest, MergeRequestWithMetadata, transform_merge_request()', isNew: false },
{ path: 'src/ingestion/issues.rs', change: 'Extend INSERT/UPSERT SQL, add milestone for completeness', isNew: false },
{ path: 'src/ingestion/merge_requests.rs', change: 'Extend INSERT/UPSERT SQL, add milestone upsert', isNew: false },
];
const helperTypes = [
{ name: 'GitLabTimeStats', fields: 'time_estimate, total_time_spent, human_time_estimate, human_total_time_spent' },
{ name: 'GitLabTaskCompletionStatus', fields: 'count, completed_count' },
{ name: 'GitLabReferences', fields: 'short, relative, full (already exists for MRs, reuse for issues)' },
{ name: 'GitLabClosedBy', fields: 'id, username, name (reuse GitLabAuthor shape)' },
{ name: 'GitLabEpic', fields: 'id, iid, title, url, group_id' },
{ name: 'GitLabIteration', fields: 'id, iid, title, state, start_date, due_date' },
];
const issueStructFields = [
{ field: 'r#type', type: 'Option<String>', comment: '#[serde(rename = "type")]' },
{ field: 'upvotes', type: 'i64', comment: '#[serde(default)]' },
{ field: 'downvotes', type: 'i64', comment: '#[serde(default)]' },
{ field: 'user_notes_count', type: 'i64', comment: '#[serde(default)]' },
{ field: 'merge_requests_count', type: 'i64', comment: '#[serde(default)]' },
{ field: 'confidential', type: 'bool', comment: '#[serde(default)]' },
{ field: 'discussion_locked', type: 'bool', comment: '#[serde(default)]' },
{ field: 'weight', type: 'Option<i64>', comment: '' },
{ field: 'time_stats', type: 'Option<GitLabTimeStats>', comment: '' },
{ field: 'task_completion_status', type: 'Option<GitLabTaskCompletionStatus>', comment: '' },
{ field: 'has_tasks', type: 'bool', comment: '#[serde(default)]' },
{ field: 'references', type: 'Option<GitLabReferences>', comment: '' },
{ field: 'closed_by', type: 'Option<GitLabAuthor>', comment: '' },
{ field: 'severity', type: 'Option<String>', comment: '' },
{ field: 'health_status', type: 'Option<String>', comment: '' },
{ field: 'imported', type: 'bool', comment: '#[serde(default)]' },
{ field: 'imported_from', type: 'Option<String>', comment: '' },
{ field: 'moved_to_id', type: 'Option<i64>', comment: '' },
{ field: 'issue_type', type: 'Option<String>', comment: 'alias for type' },
{ field: 'epic', type: 'Option<GitLabEpic>', comment: '' },
{ field: 'iteration', type: 'Option<GitLabIteration>', comment: '' },
];
const mrStructFields = [
{ field: 'upvotes', type: 'i64', comment: '#[serde(default)]' },
{ field: 'downvotes', type: 'i64', comment: '#[serde(default)]' },
{ field: 'user_notes_count', type: 'i64', comment: '#[serde(default)]' },
{ field: 'source_project_id', type: 'Option<i64>', comment: '' },
{ field: 'target_project_id', type: 'Option<i64>', comment: '' },
{ field: 'milestone', type: 'Option<GitLabMilestone>', comment: 'reuse existing type' },
{ field: 'merge_when_pipeline_succeeds', type: 'bool', comment: '#[serde(default)]' },
{ field: 'merge_commit_sha', type: 'Option<String>', comment: '' },
{ field: 'squash_commit_sha', type: 'Option<String>', comment: '' },
{ field: 'squash', type: 'bool', comment: '#[serde(default)]' },
{ field: 'squash_on_merge', type: 'bool', comment: '#[serde(default)]' },
{ field: 'has_conflicts', type: 'bool', comment: '#[serde(default)]' },
{ field: 'blocking_discussions_resolved', type: 'bool', comment: '#[serde(default)]' },
{ field: 'should_remove_source_branch', type: 'Option<bool>', comment: '' },
{ field: 'force_remove_source_branch', type: 'Option<bool>', comment: '' },
{ field: 'discussion_locked', type: 'bool', comment: '#[serde(default)]' },
{ field: 'time_stats', type: 'Option<GitLabTimeStats>', comment: '' },
{ field: 'task_completion_status', type: 'Option<GitLabTaskCompletionStatus>', comment: '' },
{ field: 'closed_by', type: 'Option<GitLabAuthor>', comment: '' },
{ field: 'prepared_at', type: 'Option<String>', comment: '' },
{ field: 'merge_after', type: 'Option<String>', comment: '' },
{ field: 'imported', type: 'bool', comment: '#[serde(default)]' },
{ field: 'imported_from', type: 'Option<String>', comment: '' },
{ field: 'approvals_before_merge', type: 'Option<i64>', comment: '' },
{ field: 'confidential', type: 'bool', comment: '#[serde(default)]' },
{ field: 'iteration', type: 'Option<GitLabIteration>', comment: '' },
];
// ============================================================
// RENDER
// ============================================================
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderFieldTable(fields, tbodyId) {
const tbody = document.querySelector(`#${tbodyId} tbody`);
fields.forEach(f => {
const tr = document.createElement('tr');
tr.dataset.status = f.status;
tr.dataset.search = `${f.api} ${f.col} ${f.cat} ${f.notes}`.toLowerCase();
const dotClass = f.status === 'wired' ? 'dot-yellow' : 'dot-green';
const statusText = f.status === 'wired' ? 'Wire up' : 'New capture';
tr.innerHTML = `
<td><code>${esc(f.api)}</code></td>
<td style="color:var(--text-dim); font-size:12px; font-family:monospace;">${esc(f.type)}</td>
<td><code>${esc(f.col)}</code></td>
<td><span class="cat cat-${f.cat}">${esc(f.catLabel)}</span></td>
<td><span class="dot ${dotClass}"></span>${statusText}</td>
<td style="color:var(--text-dim); font-size:12px;">${esc(f.notes)}</td>
`;
tbody.appendChild(tr);
});
}
function renderSqlBlock(lines, containerId) {
const container = document.getElementById(containerId);
let count = 0;
const html = lines.map(l => {
if (l.type === 'blank') return '<span class="sql-line">&nbsp;</span>';
if (l.type === 'comment') return `<span class="sql-line"><span class="sql-comment">${esc(l.text)}</span></span>`;
if (l.type === 'divider') return `<span class="sql-line"><span class="sql-divider">${esc(l.text)}</span></span>`;
count++;
let formatted = l.text
.replace(/^(ALTER TABLE)/, '<span class="sql-keyword">$1</span>')
.replace(/(ADD COLUMN)/, '<span class="sql-keyword">$1</span>')
.replace(/(INTEGER|TEXT)/, '<span class="sql-type">$1</span>')
.replace(/(NOT NULL DEFAULT \d+)/, '<span class="sql-default">$1</span>')
.replace(/(NOT NULL DEFAULT 0)/, '<span class="sql-default">$1</span>');
const note = l.note ? ` <span class="sql-comment">-- ${esc(l.note)}</span>` : '';
return `<span class="sql-line">${formatted}${note}</span>`;
}).join('\n');
container.innerHTML = html;
return count;
}
function renderStructBlock(fields, containerId) {
const container = document.getElementById(containerId);
const html = fields.map(f => {
const comment = f.comment ? ` <span class="struct-comment">// ${esc(f.comment)}</span>` : '';
return `<span class="struct-line"><span class="struct-field">${esc(f.field)}</span>: <span class="struct-type">${esc(f.type)}</span>,${comment}</span>`;
}).join('\n');
container.innerHTML = html;
}
function renderHelperTypes() {
const container = document.getElementById('helperTypes');
const html = helperTypes.map(t => {
return `<span class="struct-line"><span class="struct-type">${esc(t.name)}</span> <span class="struct-comment">{ ${esc(t.fields)} }</span></span>`;
}).join('\n');
container.innerHTML = html;
}
function renderTransforms(rules, containerId) {
const container = document.getElementById(containerId);
rules.forEach(r => {
const div = document.createElement('div');
div.className = 'transform-rule';
div.innerHTML = `
<span class="transform-from"><code>${esc(r.from)}</code></span>
<span class="transform-arrow">&rarr;</span>
<span class="transform-to">${esc(r.to)}</span>
`;
container.appendChild(div);
});
}
function renderFiles() {
const container = document.getElementById('filesList');
filesTouched.forEach(f => {
const div = document.createElement('div');
div.className = 'file-item';
const iconClass = f.isNew ? 'file-icon-new' : 'file-icon-mod';
const icon = f.isNew ? '+' : '~';
div.innerHTML = `
<div class="file-icon ${iconClass}">${icon}</div>
<div>
<div class="file-path">${esc(f.path)}</div>
<div class="file-desc">${esc(f.change)}</div>
</div>
`;
container.appendChild(div);
});
}
// ============================================================
// TABS
// ============================================================
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
// ============================================================
// FILTERS
// ============================================================
function setupFilters(tableId, searchId, filterTable) {
const search = document.getElementById(searchId);
const rows = document.querySelectorAll(`#${tableId} tbody tr`);
function applyFilters() {
const query = search.value.toLowerCase();
const activeFilter = document.querySelector(`.filter-btn.active[data-table="${filterTable}"]`);
const filter = activeFilter ? activeFilter.dataset.filter : 'all';
rows.forEach(row => {
const matchesSearch = !query || row.dataset.search.includes(query);
const matchesFilter = filter === 'all' || row.dataset.status === filter;
row.style.display = (matchesSearch && matchesFilter) ? '' : 'none';
});
}
search.addEventListener('input', applyFilters);
document.querySelectorAll(`.filter-btn[data-table="${filterTable}"]`).forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll(`.filter-btn[data-table="${filterTable}"]`).forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyFilters();
});
});
}
// ============================================================
// INIT
// ============================================================
renderFieldTable(issueFields, 'issueTable');
renderFieldTable(mrFields, 'mrTable');
const issueSqlCount = renderSqlBlock(issueSqlLines, 'issueSql');
const mrSqlCount = renderSqlBlock(mrSqlLines, 'mrSql');
renderStructBlock(issueStructFields, 'issueStruct');
renderStructBlock(mrStructFields, 'mrStruct');
renderHelperTypes();
renderTransforms(issueTransformRules, 'issueTransforms');
renderTransforms(mrTransformRules, 'mrTransforms');
renderFiles();
setupFilters('issueTable', 'issueSearch', 'issues');
setupFilters('mrTable', 'mrSearch', 'mrs');
// Counts
document.getElementById('issueCount').textContent = issueFields.length;
document.getElementById('mrCount').textContent = mrFields.length;
document.getElementById('issueSqlCount').textContent = issueSqlCount + ' ALTER statements';
document.getElementById('mrSqlCount').textContent = mrSqlCount + ' ALTER statements';
document.getElementById('issueFieldCount').textContent = issueStructFields.length + ' fields';
document.getElementById('mrFieldCount').textContent = mrStructFields.length + ' fields';
document.getElementById('issueNewCols').textContent = issueSqlCount;
document.getElementById('mrNewCols').textContent = mrSqlCount;
document.getElementById('totalNewCols').textContent = issueSqlCount + mrSqlCount;
document.getElementById('issueNewNum').textContent = issueSqlCount;
document.getElementById('mrNewNum').textContent = mrSqlCount;
// Bars
const issueExisting = 18;
const mrExisting = 29;
const issueTotal = issueExisting + issueSqlCount;
const mrTotal = mrExisting + mrSqlCount;
document.getElementById('issueBarExisting').style.width = (issueExisting / issueTotal * 100) + '%';
document.getElementById('issueBarNew').style.width = (issueSqlCount / issueTotal * 100) + '%';
document.getElementById('mrBarExisting').style.width = (mrExisting / mrTotal * 100) + '%';
document.getElementById('mrBarNew').style.width = (mrSqlCount / mrTotal * 100) + '%';
</script>
</body>
</html>