diff --git a/app/assets/stylesheets/korkban.css b/app/assets/stylesheets/korkban.css
index 3566f8d..9ac8ebe 100644
--- a/app/assets/stylesheets/korkban.css
+++ b/app/assets/stylesheets/korkban.css
@@ -162,7 +162,8 @@ html, body { background: var(--kb-bg); font-family: var(--kb-ui); color: var(--k
font: 600 11.5px var(--kb-ui); color: var(--kb-text-soft);
}
-.kb-sync { display: inline-flex; align-items: center; gap: 6px; font: 600 11.5px var(--kb-ui); color: var(--kb-text-mute); }
+.kb-sync { display: inline-flex; align-items: center; gap: 6px; font: 600 11.5px var(--kb-ui); color: var(--kb-text-mute); text-decoration: none; background: none; border: none; padding: 0; cursor: pointer; }
+.kb-sync:hover { opacity: 0.7; }
.kb-sync .dot { width: 7px; height: 7px; border-radius: 50%; }
.kb-sync .dot-green { background: #22c55e; }
.kb-sync .dot-amber { background: #eab308; }
@@ -228,6 +229,8 @@ html, body { background: var(--kb-bg); font-family: var(--kb-ui); color: var(--k
flex: 1; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.kb-col-count { font-size: 11px; font-weight: 700; color: var(--kb-text-mute); flex: 0 0 auto; }
+a.kb-col-name { text-decoration: none; color: inherit; }
+a.kb-col-name:hover { text-decoration: underline; }
.kb-col-meta { display: flex; align-items: center; gap: 6px; margin: 6px 0 7px; }
.kb-col-key { font-family: var(--kb-mono); font-size: 9px; color: var(--kb-text-faint); font-weight: 600; }
.kb-col-stale {
@@ -299,14 +302,32 @@ html, body { background: var(--kb-bg); font-family: var(--kb-ui); color: var(--k
.kb-card[data-spotlight="1"] { box-shadow: 0 0 0 2px #1e293b, 0 8px 20px rgba(0,0,0,.18); }
.kb-card[data-critical="1"] { box-shadow: 0 0 0 3px rgba(239,68,68,.12); }
.kb-card[data-hidden="1"] { display: none; }
+.kb-card-subtask {
+ margin-left: 20px;
+ padding-top: 7px;
+ padding-bottom: 7px;
+ border-left-width: 3px;
+}
+.kb-card-subtask::before {
+ content: "";
+ position: absolute;
+ left: -15px;
+ top: -7px;
+ width: 11px;
+ height: 21px;
+ border-left: 2px solid var(--kb-border-strong);
+ border-bottom: 2px solid var(--kb-border-strong);
+ border-bottom-left-radius: 6px;
+ pointer-events: none;
+}
+.kb-card-subtask .kb-card-title { font-size: 12px; }
.kb-card-row1 { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; }
.kb-card-id {
font-family: var(--kb-mono); font-size: 10.5px; font-weight: 600;
color: #5b6675; letter-spacing: .2px;
- opacity: 0; transition: opacity .13s;
+ opacity: 1;
}
-.kb-card:hover .kb-card-id { opacity: 1; }
.kb-card-meta { margin-left: auto; display: flex; align-items: center; gap: 5px; }
.kb-step-chip {
font: 800 9.5px var(--kb-ui);
@@ -364,6 +385,13 @@ html, body { background: var(--kb-bg); font-family: var(--kb-ui); color: var(--k
[data-theme="dark"] .kb-card[data-staleness="somewhat"] .kb-avatar { box-shadow: 0 0 0 1.5px #2a2317; }
[data-theme="dark"] .kb-card[data-staleness="really"] .kb-avatar { box-shadow: 0 0 0 1.5px #2c1818; }
+/* ---------- column drag-and-drop ---------- */
+.kb-col-head { cursor: grab; user-select: none; }
+.kb-col-head:active { cursor: grabbing; }
+.kb-col[data-dragging="1"] { opacity: 0.4; }
+.kb-col[data-drop-side="left"] { box-shadow: -3px 0 0 0 #3b82f6; }
+.kb-col[data-drop-side="right"] { box-shadow: 3px 0 0 0 #3b82f6; }
+
/* animations */
.kb-fade-in { animation: pgfade .16s ease-out; }
@keyframes pgfade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
diff --git a/app/controllers/syncs_controller.rb b/app/controllers/syncs_controller.rb
new file mode 100644
index 0000000..b05b9fa
--- /dev/null
+++ b/app/controllers/syncs_controller.rb
@@ -0,0 +1,6 @@
+class SyncsController < ApplicationController
+ def create
+ JiraSyncJob.perform_later
+ head :no_content
+ end
+end
diff --git a/app/helpers/board_helper.rb b/app/helpers/board_helper.rb
index 1385741..4efb6f2 100644
--- a/app/helpers/board_helper.rb
+++ b/app/helpers/board_helper.rb
@@ -22,6 +22,8 @@ module BoardHelper
TYPE_STYLE = {
"story" => { color: "#16a34a", shape: :square },
"task" => { color: "#2563eb", shape: :square },
+ "sub-task" => { color: "#0d9488", shape: :square },
+ "subtask" => { color: "#0d9488", shape: :square },
"bug" => { color: "#dc2626", shape: :circle },
"spike" => { color: "#7c3aed", shape: :square },
"epic" => { color: "#a855f7", shape: :square }
@@ -128,6 +130,8 @@ def type_icon_svg(issue_type, size: 14)
''
when "task"
''
+ when "sub-task", "subtask"
+ ''
when "bug"
''
when "spike"
diff --git a/app/javascript/controllers/board_controller.js b/app/javascript/controllers/board_controller.js
index 73bfe68..9fada10 100644
--- a/app/javascript/controllers/board_controller.js
+++ b/app/javascript/controllers/board_controller.js
@@ -21,11 +21,24 @@ export default class extends Controller {
this._onWinResize = () => this._positionTooltip()
window.addEventListener("scroll", this._onWinResize, true)
window.addEventListener("resize", this._onWinResize)
+ this._setupColumnDrag()
+ this._restoreColOrder()
+ this._colObserver = new MutationObserver(() => {
+ clearTimeout(this._morphTimer)
+ this._morphTimer = setTimeout(() => {
+ this._colObserver.disconnect()
+ this._restoreColOrder()
+ this._colObserver.observe(this.rootTarget, { childList: true })
+ }, 0)
+ })
+ this._colObserver.observe(this.rootTarget, { childList: true })
}
disconnect() {
window.removeEventListener("scroll", this._onWinResize, true)
window.removeEventListener("resize", this._onWinResize)
+ this._teardownColumnDrag()
+ if (this._colObserver) this._colObserver.disconnect()
}
// ---------- search ----------
@@ -194,6 +207,15 @@ export default class extends Controller {
const staleClass = stale ? ds.tooltipStale : null
const staleColor = ds.tooltipStale === "critical" ? "#fca5a5"
: ds.tooltipStale === "stale" ? "#fdba74" : "#e8ecf2"
+ const parentRow = ds.tooltipParent
+ ? `Parent${this._esc(ds.tooltipParent)}`
+ : ""
+ const labelsRow = ds.tooltipLabels
+ ? `Labels${this._esc(ds.tooltipLabels)}`
+ : ""
+ const componentsRow = ds.tooltipComponents
+ ? `Components${this._esc(ds.tooltipComponents)}`
+ : ""
this.tooltipTarget.innerHTML = `
${ds.tooltipId || ""}
@@ -202,6 +224,9 @@ export default class extends Controller {
${this._esc(ds.tooltipTitle || "")}
Type${this._esc(ds.tooltipType || "—")}
+ ${parentRow}
+ ${labelsRow}
+ ${componentsRow}
Assignee${this._esc(ds.tooltipAssignee || "Unassigned")}
In state${ds.tooltipDays ? ds.tooltipDays + " days" : "—"}${stale ? " · " + staleClass : ""}
Priority${this._esc(ds.tooltipPriority || "Medium")}
@@ -235,6 +260,98 @@ export default class extends Controller {
}[c]))
}
+ // ---------- column drag-and-drop ----------
+ _setupColumnDrag() {
+ const root = this.rootTarget
+ this._draggedCol = null
+ this._dropTarget = null
+ this._dropBefore = false
+
+ this._onColDragStart = (e) => {
+ const head = e.target.closest(".kb-col-head")
+ if (!head) return
+ const col = head.closest(".kb-col")
+ if (!col) return
+ this._draggedCol = col
+ col.dataset.dragging = "1"
+ e.dataTransfer.effectAllowed = "move"
+ e.dataTransfer.setData("text/plain", col.dataset.epicKey || "")
+ }
+
+ this._onColDragOver = (e) => {
+ if (!this._draggedCol) return
+ const col = e.target.closest(".kb-col")
+ if (!col || col === this._draggedCol) return
+ e.preventDefault()
+ e.dataTransfer.dropEffect = "move"
+ const rect = col.getBoundingClientRect()
+ const before = e.clientX < rect.left + rect.width / 2
+ if (this._dropTarget !== col || this._dropBefore !== before) {
+ root.querySelectorAll(".kb-col[data-drop-side]").forEach(c => delete c.dataset.dropSide)
+ col.dataset.dropSide = before ? "left" : "right"
+ this._dropTarget = col
+ this._dropBefore = before
+ }
+ }
+
+ this._onColDrop = (e) => {
+ e.preventDefault()
+ root.querySelectorAll(".kb-col[data-drop-side]").forEach(c => delete c.dataset.dropSide)
+ if (this._dropTarget && this._draggedCol) {
+ if (this._dropBefore) {
+ root.insertBefore(this._draggedCol, this._dropTarget)
+ } else {
+ this._dropTarget.after(this._draggedCol)
+ }
+ this._saveColOrder()
+ }
+ this._dropTarget = null
+ }
+
+ this._onColDragEnd = () => {
+ if (this._draggedCol) {
+ delete this._draggedCol.dataset.dragging
+ this._draggedCol = null
+ }
+ root.querySelectorAll(".kb-col[data-drop-side]").forEach(c => delete c.dataset.dropSide)
+ this._dropTarget = null
+ }
+
+ root.addEventListener("dragstart", this._onColDragStart)
+ root.addEventListener("dragover", this._onColDragOver)
+ root.addEventListener("drop", this._onColDrop)
+ root.addEventListener("dragend", this._onColDragEnd)
+ }
+
+ _teardownColumnDrag() {
+ if (!this.hasRootTarget) return
+ const root = this.rootTarget
+ root.removeEventListener("dragstart", this._onColDragStart)
+ root.removeEventListener("dragover", this._onColDragOver)
+ root.removeEventListener("drop", this._onColDrop)
+ root.removeEventListener("dragend", this._onColDragEnd)
+ }
+
+ _saveColOrder() {
+ const order = [...this.rootTarget.querySelectorAll(":scope > .kb-col")].map(c => c.dataset.epicKey)
+ localStorage.setItem("kb-col-order", JSON.stringify(order))
+ }
+
+ _restoreColOrder() {
+ try {
+ const saved = JSON.parse(localStorage.getItem("kb-col-order") || "null")
+ if (!saved || !Array.isArray(saved) || saved.length === 0) return
+ const root = this.rootTarget
+ const cols = [...root.querySelectorAll(":scope > .kb-col")]
+ const byKey = Object.fromEntries(cols.map(c => [c.dataset.epicKey, c]))
+ const seen = new Set()
+ saved.forEach(key => {
+ if (byKey[key]) { root.appendChild(byKey[key]); seen.add(key) }
+ })
+ cols.forEach(c => { if (!seen.has(c.dataset.epicKey)) root.appendChild(c) })
+ } catch {}
+ }
+
// ---------- persistence ----------
persist() {
const state = {
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 92e95e4..e7ad7ff 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -3,4 +3,28 @@ class Issue < ApplicationRecord
scope :active, -> { where(removed_at: nil) }
scope :orphan, -> { where(epic_id: nil) }
+
+ def parent_jira_key
+ fields = raw_fields || {}
+ parent = fields["parent"] || fields[:parent]
+ return nil unless parent.respond_to?(:[])
+
+ parent["key"] || parent[:key]
+ end
+
+ def labels
+ fields = raw_fields || {}
+ Array(fields["labels"] || fields[:labels]).filter_map { |label| label.to_s.presence }
+ end
+
+ def components
+ fields = raw_fields || {}
+ Array(fields["components"] || fields[:components]).filter_map do |component|
+ if component.is_a?(Hash)
+ (component["name"] || component[:name]).to_s.presence
+ else
+ component.to_s.presence
+ end
+ end
+ end
end
diff --git a/app/services/board_presenter.rb b/app/services/board_presenter.rb
index b0f3997..3212f78 100644
--- a/app/services/board_presenter.rb
+++ b/app/services/board_presenter.rb
@@ -1,5 +1,8 @@
+require "set"
+
class BoardPresenter
Warning = Struct.new(:issue_key, :status, :reason)
+ IssueRow = Struct.new(:postit, :depth)
IssuePresenter = Struct.new(:issue, :display_status, :staleness) do
def jira_key = issue.jira_key
@@ -8,6 +11,9 @@ def assignee = issue.assignee_username
def jira_status = issue.jira_status
def issue_type = issue.issue_type
def priority = issue.priority
+ def parent_jira_key = issue.parent_jira_key
+ def labels = issue.labels
+ def components = issue.components
def created_at_jira = issue.created_at_jira
def status_changed_at_jira = issue.status_changed_at_jira
def transitioned_at = issue.status_changed_at_jira || issue.created_at_jira
@@ -17,9 +23,32 @@ def transitioned_at = issue.status_changed_at_jira || issue.created_at_jira
def all_issues
new_issues + middle_groups.values.flatten + done_issues
end
+
+ def issue_rows_for(postits)
+ issue_keys = Set.new(postits.map(&:jira_key))
+ children_by_parent = Hash.new { |h, k| h[k] = [] }
+ child_keys = Set.new
+
+ postits.each do |postit|
+ parent_key = postit.parent_jira_key
+ next if parent_key.blank? || !issue_keys.include?(parent_key)
+
+ children_by_parent[parent_key] << postit
+ child_keys << postit.jira_key
+ end
+
+ postits.each_with_object([]) do |postit, rows|
+ next if child_keys.include?(postit.jira_key)
+
+ rows << IssueRow.new(postit, 0)
+ children_by_parent[postit.jira_key].each do |child|
+ rows << IssueRow.new(child, 1)
+ end
+ end
+ end
end
- UNPLANNED_EPIC = Struct.new(:jira_key, :name).new("UNPLANNED", "Unplanned")
+ UNPLANNED_EPIC = Struct.new(:jira_key, :name).new("UNPLANNED", "Unplanned Work")
def initialize(epics:, status_map:, new_statuses:, done_statuses:, staleness:, orphan_issues: [])
@epics = epics
diff --git a/app/services/jira_sync.rb b/app/services/jira_sync.rb
index b3c9ba8..472d355 100644
--- a/app/services/jira_sync.rb
+++ b/app/services/jira_sync.rb
@@ -1,6 +1,6 @@
class JiraSync
EPIC_FIELDS = %w[summary status priority].freeze
- ISSUE_FIELDS = %w[summary status issuetype assignee priority created parent].freeze
+ ISSUE_FIELDS = %w[summary status issuetype assignee priority created parent labels components].freeze
def initialize(epic_query: KORKBAN_CONFIG.board.epic_query,
unplanned_query: KORKBAN_CONFIG.board.unplanned_query,
@@ -23,15 +23,26 @@ def run!
end
if epics_by_key.any?
- keys_list = epics_by_key.keys.map { |k| %Q("#{k}") }.join(",")
+ keys_list = jira_key_list(epics_by_key.keys)
child_jql = "parent in (#{keys_list})"
children = @client.search_all(child_jql, fields: ISSUE_FIELDS, expand: "changelog")
+ subtasks = []
+
+ if children.any?
+ subtask_jql = "parent in (#{jira_key_list(children.map(&:key))})"
+ subtasks = @client.search_all(subtask_jql, fields: ISSUE_FIELDS, expand: "changelog")
+ end
children_by_epic = Hash.new { |h, k| h[k] = [] }
children.each do |ji|
parent_key = ji.fields.dig("parent", "key")
children_by_epic[parent_key] << ji
end
+ subtasks_by_parent = Hash.new { |h, k| h[k] = [] }
+ subtasks.each do |ji|
+ parent_key = ji.fields.dig("parent", "key")
+ subtasks_by_parent[parent_key] << ji
+ end
epics_by_key.each do |epic_key, epic|
epic_children = children_by_epic[epic_key]
@@ -39,10 +50,14 @@ def run!
epic_children.each do |ji|
upsert_issue(ji, epic, now)
seen_issue_keys << ji.key
+ subtasks_by_parent[ji.key].each do |subtask|
+ upsert_issue(subtask, epic, now)
+ seen_issue_keys << subtask.key
+ end
end
epic.issues.active.where.not(jira_key: seen_issue_keys).update_all(removed_at: now)
end
- fetched += children.size
+ fetched += children.size + subtasks.size
end
Epic.active.where.not(jira_key: epics_by_key.keys).update_all(removed_at: now)
@@ -51,6 +66,9 @@ def run!
orphans = @client.search_all(@unplanned_query, fields: ISSUE_FIELDS, expand: "changelog")
seen_orphan_keys = []
orphans.each do |ji|
+ if (clashing_epic = epics_by_key.delete(ji.key))
+ clashing_epic.update!(removed_at: now)
+ end
upsert_issue(ji, nil, now)
seen_orphan_keys << ji.key
end
@@ -65,6 +83,12 @@ def run!
partial: "board/board_morph",
locals: { presenter: build_presenter, last_sync: run }
)
+ Turbo::StreamsChannel.broadcast_replace_to(
+ "sync_status",
+ target: "kb-sync-status",
+ partial: "board/stale_banner",
+ locals: { last_sync: run }
+ )
run
rescue => e
run.update!(finished_at: Time.current, ok: false, error_message: e.message)
@@ -109,6 +133,10 @@ def upsert_issue(ji, epic, now)
issue
end
+ def jira_key_list(keys)
+ keys.map { |k| %Q("#{k}") }.join(",")
+ end
+
def last_status_change_at(ji)
histories = ji.attrs.dig("changelog", "histories") || []
times = histories.flat_map do |h|
diff --git a/app/views/board/_column.html.slim b/app/views/board/_column.html.slim
index 8f0496a..e3ffe10 100644
--- a/app/views/board/_column.html.slim
+++ b/app/views/board/_column.html.slim
@@ -1,23 +1,32 @@
- epic = column.epic
- all_issues = column.all_issues
+- todo_rows = column.issue_rows_for(column.new_issues)
+- middle_rows = column.middle_groups.transform_values { |postits| column.issue_rows_for(postits) }
+- done_rows = column.issue_rows_for(column.done_issues)
- total = all_issues.size
-- todo_count = column.new_issues.size
-- done_count = column.done_issues.size
-- middle_count = column.middle_groups.values.sum(&:size)
+- todo_count = todo_rows.size
+- done_count = done_rows.size
+- middle_count = middle_rows.values.sum(&:size)
+- visible_middle_group_count = middle_rows.values.count(&:any?)
- stale_count = column.middle_groups.values.flatten.count { |p| %i[somewhat really].include?(p.staleness) }
- accent_palette = %w[#6366f1 #e11d48 #d97706 #0ea5e9 #8b5cf6 #0d9488 #10b981 #ea580c #c026d3 #64748b]
- accent = accent_palette[(epic.jira_key.to_s.bytes.sum % accent_palette.size)]
- dist_buckets = ordered_display_states.map { |s| n = all_issues.count { |p| p.display_status == s[:id] }; [s, n] }.select { |_, n| n > 0 }
section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-todo-open-value="false" data-stack-done-open-value="false"
- .kb-col-head
+ .kb-col-head draggable="true"
.kb-col-accent style="background:#{accent};"
.kb-col-title-row
- span.kb-col-name title=epic.name
- = epic.name
+ - if epic.jira_key == "UNPLANNED"
+ span.kb-col-name title=epic.name
+ = epic.name
+ - else
+ a.kb-col-name href=jira_url(epic.jira_key) target="_blank" rel="noreferrer" title=epic.name draggable="false"
+ = epic.name
span.kb-col-count= total
.kb-col-meta
- span.kb-col-key= epic.jira_key
+ - unless epic.jira_key == "UNPLANNED"
+ span.kb-col-key= epic.jira_key
- if stale_count > 0
span.kb-col-stale
| ⚠ #{stale_count} stale
@@ -39,8 +48,8 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to
span.kb-stack-trail data-stack-target="todoTrail"
| show
.kb-stack-body data-stack-target="todoBody" hidden=true
- - column.new_issues.each do |pp|
- = render "board/postit", p: pp
+ - todo_rows.each do |row|
+ = render "board/postit", p: row.postit, nested: row.depth > 0
- if middle_count > 0 && todo_count > 0
.kb-divider data-stack-target="middleDivider" hidden=true
@@ -49,15 +58,16 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to
span.kb-divider-line
- column.middle_groups.each do |display_status, postits|
- - next if postits.empty?
+ - rows = middle_rows[display_status]
+ - next if rows.empty?
- st = state_meta(display_status)
- - if column.middle_groups.size > 1
+ - if visible_middle_group_count > 1
.kb-divider
span.kb-divider-text style="color:#{st[:deep]};"
= st[:label].upcase
span.kb-divider-line
- - postits.each do |pp|
- = render "board/postit", p: pp
+ - rows.each do |row|
+ = render "board/postit", p: row.postit, nested: row.depth > 0
- if done_count > 0
.kb-stack-wrap data-stack-target="doneWrap"
@@ -72,5 +82,5 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to
span.kb-stack-trail data-stack-target="doneTrail"
| show
.kb-stack-body data-stack-target="doneBody" hidden=true
- - column.done_issues.each do |pp|
- = render "board/postit", p: pp
+ - done_rows.each do |row|
+ = render "board/postit", p: row.postit, nested: row.depth > 0
diff --git a/app/views/board/_postit.html.slim b/app/views/board/_postit.html.slim
index b0a0efc..8e8df55 100644
--- a/app/views/board/_postit.html.slim
+++ b/app/views/board/_postit.html.slim
@@ -1,8 +1,12 @@
+- nested = local_assigns.fetch(:nested, false)
- state = state_meta(p.display_status)
- stale = staleness_meta(p.staleness)
- stale_lvl = staleness_label(p.staleness)
- avatar = avatar_for(p.assignee)
- days = days_in_state(p)
+- parent_key = p.parent_jira_key
+- labels = p.labels
+- components = p.components
- step_idx = state_step_index(p.display_status)
- step_total = state_total_steps
- age_show = p.staleness != :fresh
@@ -10,9 +14,15 @@
- card_style << "--kb-card-border:#{stale[:border]};"
- card_style << "--kb-paper:#{stale[:paper]};"
- card_style << "--kb-avatar-ring:#{stale[:paper]};"
-- data_attrs = { assignee: p.assignee.to_s, display_status: p.display_status.to_s, staleness: p.staleness.to_s, days_since_change: days.to_s, search: [p.jira_key, p.summary, p.assignee, state[:label]].compact.join(" ").downcase, board_target: "card", action: "mouseenter->board#showTooltip mouseleave->board#hideTooltip", tooltip_id: p.jira_key, tooltip_title: p.summary, tooltip_type: p.issue_type.to_s, tooltip_state: state[:label], tooltip_state_color: state[:color], tooltip_assignee: avatar[:name], tooltip_days: days.to_s, tooltip_stale: stale_lvl, tooltip_priority: priority_label(p.priority), tooltip_status_raw: p.jira_status.to_s }
+- data_attrs = { assignee: p.assignee.to_s, display_status: p.display_status.to_s, staleness: p.staleness.to_s, days_since_change: days.to_s, search: [p.jira_key, p.summary, p.assignee, state[:label], parent_key].compact.join(" ").downcase, board_target: "card", action: "mouseenter->board#showTooltip mouseleave->board#hideTooltip", tooltip_id: p.jira_key, tooltip_title: p.summary, tooltip_type: p.issue_type.to_s, tooltip_state: state[:label], tooltip_state_color: state[:color], tooltip_assignee: avatar[:name], tooltip_days: days.to_s, tooltip_stale: stale_lvl, tooltip_priority: priority_label(p.priority), tooltip_status_raw: p.jira_status.to_s }
+- data_attrs[:parent_issue] = parent_key if parent_key.present?
+- data_attrs[:tooltip_parent] = parent_key if parent_key.present?
+- data_attrs[:tooltip_labels] = labels.join(", ") if labels.any?
+- data_attrs[:tooltip_components] = components.join(", ") if components.any?
- data_attrs[:critical] = "1" if stale_lvl == "critical"
-= link_to jira_url(p.jira_key), data: data_attrs, class: "kb-card", style: card_style, target: "_blank", rel: "noreferrer" do
+- card_classes = [ "kb-card" ]
+- card_classes << "kb-card-subtask" if nested
+= link_to jira_url(p.jira_key), data: data_attrs, class: card_classes.join(" "), style: card_style, target: "_blank", rel: "noreferrer" do
.kb-card-row1
= type_icon_svg(p.issue_type, size: 14)
span.kb-card-id= p.jira_key
diff --git a/app/views/board/_stale_banner.html.slim b/app/views/board/_stale_banner.html.slim
index abab286..a56d2d2 100644
--- a/app/views/board/_stale_banner.html.slim
+++ b/app/views/board/_stale_banner.html.slim
@@ -1,11 +1,11 @@
- if last_sync.nil?
- span.kb-sync title="No sync yet"
+ a.kb-sync#kb-sync-status href="/sync" data-turbo-method="post" title="No sync yet — click to sync"
span.dot.dot-amber
- | No sync yet
+ | Sync now
- else
- age_min = ((Time.current - last_sync.finished_at) / 60).to_i
- level = age_min > 30 ? :red : age_min > 5 ? :amber : :green
- span.kb-sync title="Last sync: #{last_sync.finished_at.iso8601} (#{age_min} min ago)"
+ a.kb-sync#kb-sync-status href="/sync" data-turbo-method="post" title="Last sync: #{last_sync.finished_at.iso8601} (#{age_min}m ago) — click to sync"
span class="dot dot-#{level}"
- if level == :red
| JIRA sync stalled (#{age_min}m)
diff --git a/app/views/board/show.html.slim b/app/views/board/show.html.slim
index 3a2b3c6..3e0e3b5 100644
--- a/app/views/board/show.html.slim
+++ b/app/views/board/show.html.slim
@@ -1,4 +1,5 @@
= turbo_stream_from "board"
+= turbo_stream_from "sync_status"
- all_issues = @presenter.columns.flat_map(&:all_issues)
- total_tickets = all_issues.size
diff --git a/config/routes.rb b/config/routes.rb
index 954df6a..5aa4750 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,5 +1,6 @@
Rails.application.routes.draw do
root "board#show"
+ post "/sync", to: "syncs#create"
get "/login", to: "sessions#new"
get "/auth/:provider/callback", to: "sessions#create"
get "/auth/failure", to: "sessions#failure"
diff --git a/test/models/issue_test.rb b/test/models/issue_test.rb
index 8fe7eb1..03caebe 100644
--- a/test/models/issue_test.rb
+++ b/test/models/issue_test.rb
@@ -16,4 +16,19 @@ class IssueTest < ActiveSupport::TestCase
)
assert_equal "foo", Issue.find(issue.id).raw_fields["labels"].first
end
+
+ test "normalizes labels and component names from raw fields" do
+ issue = Issue.create!(
+ jira_key: "PG-11", epic: @epic,
+ issue_type: "Task", summary: "Do it too",
+ jira_status: "To Do",
+ raw_fields: {
+ "labels" => [ "backend", "", nil ],
+ "components" => [ { "name" => "API" }, { "name" => "" }, "Docs" ]
+ }
+ )
+
+ assert_equal [ "backend" ], issue.labels
+ assert_equal [ "API", "Docs" ], issue.components
+ end
end
diff --git a/test/services/board_presenter_test.rb b/test/services/board_presenter_test.rb
index 43cd164..921fb5f 100644
--- a/test/services/board_presenter_test.rb
+++ b/test/services/board_presenter_test.rb
@@ -37,6 +37,48 @@ def build_presenter(orphan_issues: [])
assert_equal [ "PG-11" ], epic1.middle_groups["review"].map(&:jira_key)
end
+ test "issue rows nest same-state subtasks and keep other statuses separate" do
+ epic = epics(:priority_one)
+ story = Issue.create!(
+ jira_key: "PG-20",
+ epic: epic,
+ issue_type: "Story",
+ summary: "Parent story",
+ jira_status: "In Progress",
+ status_changed_at_jira: 2.hours.ago,
+ created_at_jira: 3.hours.ago,
+ raw_fields: { "parent" => { "key" => epic.jira_key } }
+ )
+ Issue.create!(
+ jira_key: "PG-21",
+ epic: epic,
+ issue_type: "Sub-task",
+ summary: "Done subtask",
+ jira_status: "Done",
+ status_changed_at_jira: 1.hour.ago,
+ created_at_jira: 2.hours.ago,
+ raw_fields: { "parent" => { "key" => story.jira_key } }
+ )
+ Issue.create!(
+ jira_key: "PG-22",
+ epic: epic,
+ issue_type: "Sub-task",
+ summary: "Nested subtask",
+ jira_status: "In Progress",
+ status_changed_at_jira: 30.minutes.ago,
+ created_at_jira: 1.hour.ago,
+ raw_fields: { "parent" => { "key" => story.jira_key } }
+ )
+
+ column = build_presenter.columns.detect { |col| col.epic.jira_key == epic.jira_key }
+ rows = column.issue_rows_for(column.middle_groups["in_progress"])
+ story_index = rows.index { |row| row.postit.jira_key == "PG-20" }
+
+ assert_equal "PG-22", rows[story_index + 1].postit.jira_key
+ assert_equal 1, rows[story_index + 1].depth
+ assert_equal 0, column.issue_rows_for(column.done_issues).detect { |row| row.postit.jira_key == "PG-21" }.depth
+ end
+
test "warnings include unmapped statuses" do
warnings = build_presenter.warnings
assert_includes warnings.map(&:issue_key), "PG-14"
diff --git a/test/services/jira_sync_test.rb b/test/services/jira_sync_test.rb
index 51d8b55..1cc8f3d 100644
--- a/test/services/jira_sync_test.rb
+++ b/test/services/jira_sync_test.rb
@@ -25,7 +25,9 @@ class JiraSyncTest < ActiveSupport::TestCase
"status" => { "name" => "To Do" },
"issuetype" => { "name" => "Task" },
"assignee" => { "name" => "alice" },
- "parent" => { "key" => "PG-1" } } }
+ "parent" => { "key" => "PG-1" },
+ "labels" => [ "backend", "priority" ],
+ "components" => [ { "name" => "API" } ] } }
], "total" => 1, "startAt" => 0, "maxResults" => 50 }
else
{ "issues" => [], "total" => 0, "startAt" => 0, "maxResults" => 50 }
@@ -40,6 +42,47 @@ class JiraSyncTest < ActiveSupport::TestCase
assert_equal "Epic A", Epic.first.name
assert_equal 1, Issue.count
assert_equal "PG-10", Issue.first.jira_key
+ assert_equal [ "backend", "priority" ], Issue.first.labels
+ assert_equal [ "API" ], Issue.first.components
+ end
+
+ test "fetches subtasks below epic children" do
+ stub_request(:get, %r{/search}).to_return do |req|
+ decoded = CGI.unescape(req.uri.to_s)
+ body = case decoded
+ when /parent\s+in\s*\([^)]*PG-10[^)]*\)/i
+ { "issues" => [
+ { "key" => "PG-11", "fields" => { "summary" => "Subtask A",
+ "status" => { "name" => "In Progress" },
+ "issuetype" => { "name" => "Sub-task" },
+ "parent" => { "key" => "PG-10" } } }
+ ], "total" => 1, "startAt" => 0, "maxResults" => 50 }
+ when /labels.*Priority/i
+ { "issues" => [
+ { "key" => "PG-1", "fields" => { "summary" => "Epic A",
+ "status" => { "name" => "In Progress" },
+ "priority" => { "id" => "1" } } }
+ ], "total" => 1, "startAt" => 0, "maxResults" => 50 }
+ when /parent\s+in\s*\([^)]*PG-1[^)]*\)/i
+ { "issues" => [
+ { "key" => "PG-10", "fields" => { "summary" => "Story A",
+ "status" => { "name" => "In Progress" },
+ "issuetype" => { "name" => "Story" },
+ "parent" => { "key" => "PG-1" } } }
+ ], "total" => 1, "startAt" => 0, "maxResults" => 50 }
+ else
+ { "issues" => [], "total" => 0, "startAt" => 0, "maxResults" => 50 }
+ end
+ { status: 200, body: body.to_json,
+ headers: { "Content-Type" => "application/json" } }
+ end
+
+ JiraSync.new(epic_query: 'project = PG AND labels = "Priority"').run!
+
+ story = Issue.find_by!(jira_key: "PG-10")
+ subtask = Issue.find_by!(jira_key: "PG-11")
+ assert_equal story.epic_id, subtask.epic_id
+ assert_equal "PG-10", subtask.parent_jira_key
end
test "uses last status change from changelog for status_changed_at_jira" do
@@ -168,6 +211,38 @@ class JiraSyncTest < ActiveSupport::TestCase
assert_not_nil stale.reload.removed_at
end
+ test "does not create orphan Issue when the same key is already an epic" do
+ stub_request(:get, %r{/search}).to_return do |req|
+ decoded = CGI.unescape(req.uri.to_s)
+ body = case decoded
+ when /labels.*Priority/i
+ { "issues" => [
+ { "key" => "PG-5", "fields" => { "summary" => "Priority issue without parent",
+ "status" => { "name" => "In Progress" },
+ "priority" => { "id" => "2" } } }
+ ], "total" => 1, "startAt" => 0, "maxResults" => 100 }
+ when /parent is EMPTY/i
+ { "issues" => [
+ { "key" => "PG-5", "fields" => { "summary" => "Priority issue without parent",
+ "status" => { "name" => "In Progress" },
+ "issuetype" => { "name" => "Story" } } }
+ ], "total" => 1, "startAt" => 0, "maxResults" => 100 }
+ else
+ { "issues" => [], "total" => 0, "startAt" => 0, "maxResults" => 100 }
+ end
+ { status: 200, body: body.to_json, headers: { "Content-Type" => "application/json" } }
+ end
+
+ JiraSync.new(
+ epic_query: 'project = PG AND labels = "Priority"',
+ unplanned_query: "project = PG AND parent is EMPTY"
+ ).run!
+
+ assert_equal 0, Epic.active.count, "PG-5 must not appear as a column"
+ assert_equal 1, Issue.active.orphan.count
+ assert_equal "PG-5", Issue.active.orphan.first.jira_key
+ end
+
test "skips unplanned fetch when unplanned_query is blank" do
stub_request(:get, %r{/search}).to_return(
status: 200,
diff --git a/test/system/board_test.rb b/test/system/board_test.rb
index a9ba4c2..d5dc44f 100644
--- a/test/system/board_test.rb
+++ b/test/system/board_test.rb
@@ -17,4 +17,27 @@ class BoardSystemTest < ApplicationSystemTestCase
assert_selector ".kb-col", minimum: 2
assert_selector ".kb-card", minimum: 4, visible: :all
end
+
+ test "epic name in column header links to its jira epic" do
+ visit "/auth/google_oauth2/callback"
+ visit "/"
+ link = find(".kb-col[data-epic-key='PG-1'] a.kb-col-name")
+ assert_equal "https://example.atlassian.net/browse/PG-1", link["href"]
+ assert_equal "_blank", link["target"]
+ end
+
+ test "epic name link is absent for the unplanned column" do
+ Issue.where(epic_id: nil).delete_all
+ orphan = Issue.create!(
+ jira_key: "PG-99", epic: nil, issue_type: "Task",
+ summary: "Orphan task", jira_status: "In Progress",
+ status_changed_at_jira: 1.day.ago, created_at_jira: 1.day.ago
+ )
+ visit "/auth/google_oauth2/callback"
+ visit "/"
+ assert_selector ".kb-col[data-epic-key='UNPLANNED'] span.kb-col-name"
+ assert_no_selector ".kb-col[data-epic-key='UNPLANNED'] a.kb-col-name"
+ ensure
+ orphan&.destroy
+ end
end
diff --git a/test/system/column_reorder_test.rb b/test/system/column_reorder_test.rb
new file mode 100644
index 0000000..89f46ec
--- /dev/null
+++ b/test/system/column_reorder_test.rb
@@ -0,0 +1,84 @@
+require "application_system_test_case"
+
+class ColumnReorderTest < ApplicationSystemTestCase
+ fixtures :epics, :issues, :sync_runs
+
+ setup do
+ OmniAuth.config.test_mode = true
+ OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
+ provider: "google_oauth2", uid: "u1",
+ info: { email: "alice@example.com", name: "Alice" }
+ )
+ visit "/auth/google_oauth2/callback"
+ visit "/"
+ assert_selector "main.kb-board#board-root"
+ end
+
+ test "dragging a column header reorders columns" do
+ assert_equal %w[PG-1 PG-2], column_keys
+
+ drag_col("PG-2", "PG-1", side: :left)
+
+ assert_selector ".kb-col:first-child[data-epic-key='PG-2']"
+ assert_equal %w[PG-2 PG-1], column_keys
+ end
+
+ test "column order is restored from localStorage after page reload" do
+ drag_col("PG-2", "PG-1", side: :left)
+ assert_selector ".kb-col:first-child[data-epic-key='PG-2']"
+
+ visit "/"
+
+ assert_selector ".kb-col:first-child[data-epic-key='PG-2']"
+ assert_equal %w[PG-2 PG-1], column_keys
+ end
+
+ test "column order is preserved after a live broadcast" do
+ drag_col("PG-2", "PG-1", side: :left)
+ assert_selector ".kb-col:first-child[data-epic-key='PG-2']"
+
+ broadcast_board
+
+ assert_selector ".kb-col:first-child[data-epic-key='PG-2']"
+ assert_equal %w[PG-2 PG-1], column_keys
+ end
+
+ private
+
+ def column_keys
+ all(".kb-col").map { |c| c["data-epic-key"] }
+ end
+
+ def drag_col(from_key, to_key, side:)
+ source = find(".kb-col[data-epic-key='#{from_key}'] .kb-col-head")
+ target = find(".kb-col[data-epic-key='#{to_key}'] .kb-col-head")
+ x_offset = side == :left ? -50 : 50
+ source.drag_to(target, drop_offset: { x: x_offset, y: 0 })
+ end
+
+ def broadcast_board
+ Turbo::StreamsChannel.broadcast_render_to(
+ "board",
+ partial: "board/board_morph",
+ locals: { presenter: build_presenter, last_sync: SyncRun.ok.most_recent.first }
+ )
+ end
+
+ def build_presenter
+ BoardPresenter.new(
+ epics: Epic.active.ordered.includes(:issues),
+ orphan_issues: Issue.active.orphan,
+ status_map: KORKBAN_CONFIG.board.status_map,
+ new_statuses: KORKBAN_CONFIG.board.new_statuses,
+ done_statuses: KORKBAN_CONFIG.board.done_statuses,
+ staleness: StalenessCalculator.new(
+ now: Time.current,
+ somewhat_days: KORKBAN_CONFIG.board.staleness.somewhat_days,
+ really_days: KORKBAN_CONFIG.board.staleness.really_days,
+ ignore_for_new: KORKBAN_CONFIG.board.ignore_staleness_for_new_issues,
+ new_display_statuses: KORKBAN_CONFIG.board.new_statuses,
+ done_display_statuses: KORKBAN_CONFIG.board.done_statuses
+ )
+ )
+ end
+end