Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ tasks:
cmds:
- docker compose run --rm app bin/rails db:migrate

backfill-history:
desc: "Backfill EpicEvent timestamps from Jira's changelog (one-time fix for the initial rollout)"
cmds:
- docker compose run --rm app bin/rails epic_events:backfill

discover-closed-epics:
desc: "Find epics already closed (SUCCESS/FAILURE/REJECTED/etc) before this app ever tracked them, and backfill their history"
cmds:
- docker compose run --rm app bin/rails epic_events:discover_closed

lint:
desc: "Run RuboCop (matches CI lint job)"
cmds:
Expand Down
5 changes: 5 additions & 0 deletions app/controllers/epic_history_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class EpicHistoryController < ApplicationController
def show
@events_by_day = EpicEvent.recent.limit(500).group_by { |e| e.occurred_at.to_date }
end
end
5 changes: 5 additions & 0 deletions app/models/epic_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class EpicEvent < ApplicationRecord
belongs_to :epic, optional: true

scope :recent, -> { order(occurred_at: :desc) }
end
131 changes: 128 additions & 3 deletions app/services/jira_sync.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class JiraSync
EPIC_FIELDS = %w[summary status priority assignee].freeze
EPIC_FIELDS = %w[summary status priority assignee created].freeze
ISSUE_FIELDS = %w[summary status issuetype assignee priority created parent labels components description].freeze

def initialize(epic_query: LASER_FOCUS_CONFIG.board.epic_query,
Expand All @@ -23,7 +23,7 @@ def run!
fetched = 0
now = Time.current

epics_jira = @client.search_all(@epic_query, fields: EPIC_FIELDS)
epics_jira = @client.search_all(@epic_query, fields: EPIC_FIELDS, expand: "changelog")
epics_by_key = {}
epics_jira.each do |je|
epic = upsert_epic(je, now)
Expand Down Expand Up @@ -70,7 +70,10 @@ def run!
fetched += children.size + subtasks.size
end

dropped_epics = Epic.active.where.not(jira_key: epics_by_key.keys).pluck(:id, :jira_key, :name)
Epic.active.where.not(jira_key: epics_by_key.keys).update_all(removed_at: now)
dropped_times = epic_removal_times(dropped_epics.map { |(_, jira_key, _)| jira_key }, now)
record_epic_events(dropped_epics, "removed", now, times_by_key: dropped_times)

seen_orphan_keys = []
if @unplanned_query.present?
Expand All @@ -79,6 +82,9 @@ def run!
orphans.each do |ji|
if (clashing_epic = epics_by_key.delete(ji.key))
clashing_epic.update!(removed_at: now)
occurred_at = last_field_change_at(ji, %w[status labels]) || now
record_epic_events([ [ clashing_epic.id, clashing_epic.jira_key, clashing_epic.name ] ], "removed", now,
times_by_key: { clashing_epic.jira_key => occurred_at })
end
upsert_issue(ji, nil, now)
seen_orphan_keys << ji.key
Expand Down Expand Up @@ -130,10 +136,79 @@ def run!
run
end

# One-time (re-runnable) backfill for EpicEvent rows created before we
# started deriving occurred_at from Jira's changelog — they were stamped
# with the sync time they happened to be discovered at instead of the real
# label/status change time. Safe to run repeatedly; only touches rows whose
# timestamp actually changes, and skips any jira_key with more than one
# event since we can't tell which one a single changelog lookup applies to.
def backfill_event_times!
jira_keys = EpicEvent.distinct.pluck(:jira_key)
return 0 if jira_keys.empty?

updated = 0
results = @client.search_all("key in (#{jira_key_list(jira_keys)})", fields: EPIC_FIELDS, expand: "changelog")
results.each do |ji|
changed_at = last_field_change_at(ji, %w[status labels])
next unless changed_at

events = EpicEvent.where(jira_key: ji.key).to_a
if events.size > 1
Rails.logger.warn("[JiraSync] backfill skipped #{ji.key}: #{events.size} events, ambiguous which to update")
next
end

event = events.first
next if event.nil? || event.occurred_at == changed_at

event.update!(occurred_at: changed_at)
updated += 1
end
updated
end

# One-time discovery for epics that hit a terminal status (SUCCESS, FAILURE,
# REJECTED, "GONE BAD", ...) *before* this app ever synced them — they never
# matched epic_query, so the normal add/remove diffing in `run!` never saw
# them and has no baseline to log a removal against. `jql` should select
# exactly those already-closed epics (mirror epic_query but flip the status
# filter, e.g. `status IN (SUCCESS, FAILURE, REJECTED, "GONE BAD")`).
# Backfills both the "added" and "removed" EpicEvent for each one found,
# skipping any jira_key we already have a row for.
def discover_closed_epics!(jql)
created = 0
results = @client.search_all(jql, fields: EPIC_FIELDS, expand: "changelog")
results.each do |je|
next if Epic.exists?(jira_key: je.key)

added_at = last_field_change_at(je, %w[labels]) || parse_time(je.fields["created"]) || Time.current
removed_at = last_field_change_at(je, %w[status]) || Time.current

epic = Epic.create!(
jira_key: je.key,
name: je.fields["summary"],
jira_status: je.fields.dig("status", "name"),
priority: priority_int(je.fields["priority"]),
raw_fields: je.fields,
last_seen_in_query_at: Time.current,
removed_at: removed_at
)
EpicEvent.create!(epic: epic, jira_key: epic.jira_key, name: epic.name,
event_type: "added", occurred_at: added_at)
EpicEvent.create!(epic: epic, jira_key: epic.jira_key, name: epic.name,
event_type: "removed", occurred_at: removed_at)
created += 1
end
created
end

private

def upsert_epic(je, now)
epic = Epic.find_or_initialize_by(jira_key: je.key)
was_new = epic.new_record?
was_removed = epic.removed_at.present?

epic.assign_attributes(
name: je.fields["summary"],
jira_status: je.fields.dig("status", "name"),
Expand All @@ -143,9 +218,47 @@ def upsert_epic(je, now)
removed_at: nil
)
epic.save!

if was_new || was_removed
EpicEvent.create!(
epic: epic,
jira_key: epic.jira_key,
name: epic.name,
event_type: "added",
occurred_at: last_field_change_at(je, %w[status labels]) || now
)
end

epic
end

def record_epic_events(epics, event_type, now, times_by_key: {})
return if epics.empty?

EpicEvent.insert_all(
epics.map do |id, jira_key, name|
{ epic_id: id, jira_key: jira_key, name: name, event_type: event_type,
occurred_at: times_by_key.fetch(jira_key, now), created_at: now, updated_at: now }
end
)
end

# Looks up the current changelog for epics that just dropped out of the
# epic_query result set (e.g. lost the Priority label, or hit a terminal
# status) so the "removed" event can be timestamped at the real Jira field
# change instead of "whenever this sync happened to run".
def epic_removal_times(jira_keys, now)
return {} if jira_keys.empty?

results = @client.search_all("key in (#{jira_key_list(jira_keys)})", fields: EPIC_FIELDS, expand: "changelog")
results.each_with_object({}) do |ji, times|
times[ji.key] = last_field_change_at(ji, %w[status labels]) || now
end
rescue => e
Rails.logger.warn("[JiraSync] epic removal time lookup failed: #{e.message}")
{}
end

def upsert_issue(ji, epic, now, provisional: false)
issue = Issue.find_or_initialize_by(jira_key: ji.key)
new_status = ji.fields.dig("status", "name")
Expand Down Expand Up @@ -174,9 +287,21 @@ def jira_key_list(keys)
end

def last_status_change_at(ji)
last_field_change_at(ji, %w[status])
end

# Most recent changelog entry touching any of `fields`. For "labels" we only
# count entries that actually mention the "Priority" label, so an unrelated
# label edit on the same epic doesn't get credited as an add/remove trigger.
def last_field_change_at(ji, fields)
histories = ji.attrs.dig("changelog", "histories") || []
times = histories.flat_map do |h|
next [] unless h["items"]&.any? { |it| it["field"] == "status" }
relevant = (h["items"] || []).any? do |it|
next false unless fields.include?(it["field"])
next true unless it["field"] == "labels"
[ it["toString"], it["fromString"] ].compact.any? { |s| s.include?("Priority") }
end
next [] unless relevant
t = parse_time(h["created"])
t ? [ t ] : []
end
Expand Down
126 changes: 126 additions & 0 deletions app/views/epic_history/show.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
- content_for :title, "Goal History – LaserFocus"

.kb-history
header.kb-history-header
h1 Goal history
a.kb-history-back href="/" ← Back to board

- if @events_by_day.empty?
p.kb-history-empty No goals have been added or removed since tracking started.
- else
- @events_by_day.each do |day, events|
section.kb-history-day
h2.kb-history-date= day.strftime("%A, %B %-d, %Y")
ul.kb-history-list
- events.each do |event|
- row_classes = [ "kb-history-row", event.event_type == "added" ? "is-added" : "is-removed" ]
li class=row_classes.join(" ")
span.kb-history-icon= event.event_type == "added" ? "+" : "−"
a.kb-history-link href=jira_url(event.jira_key) target="_blank" rel="noreferrer"
span.kb-history-name= event.name
span.kb-history-key= event.jira_key
span.kb-history-time= event.occurred_at.strftime("%-l:%M %p")

css:
.kb-history {
max-width: 720px;
margin: 0 auto;
padding: 40px 24px 80px;
font-family: var(--kb-ui);
color: var(--kb-text);
background: var(--kb-bg);
min-height: 100vh;
}
.kb-history-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 32px;
}
.kb-history-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
}
.kb-history-back {
color: var(--kb-text-soft);
text-decoration: none;
font-size: 13px;
}
.kb-history-back:hover {
color: var(--kb-text);
}
.kb-history-empty {
color: var(--kb-text-mute);
font-size: 14px;
}
.kb-history-day {
margin-bottom: 28px;
}
.kb-history-date {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--kb-text-mute);
margin: 0 0 10px;
}
.kb-history-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--kb-border);
border-radius: 10px;
background: var(--kb-surface);
overflow: hidden;
}
.kb-history-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 13.5px;
border-bottom: 1px solid var(--kb-border);
}
.kb-history-row:last-child {
border-bottom: none;
}
.kb-history-icon {
width: 18px;
text-align: center;
font-weight: 700;
font-family: var(--kb-mono);
}
.is-added .kb-history-icon { color: #16a34a; }
.is-removed .kb-history-icon { color: #dc2626; }
.is-removed .kb-history-name { text-decoration: line-through; color: var(--kb-text-mute); }
.kb-history-link {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
color: inherit;
text-decoration: none;
}
.kb-history-link:hover .kb-history-name {
text-decoration: underline;
}
.kb-history-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb-history-key {
font-family: var(--kb-mono);
font-size: 12px;
color: var(--kb-text-mute);
flex-shrink: 0;
}
.kb-history-time {
font-size: 12px;
color: var(--kb-text-faint);
width: 72px;
text-align: right;
}
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Rails.application.routes.draw do
root "board#show"
get "/history", to: "epic_history#show"
post "/sync", to: "syncs#create"
patch "/column_order", to: "column_orders#update"
get "/login", to: "sessions#new"
Expand Down
15 changes: 15 additions & 0 deletions db/migrate/20260707000001_create_epic_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class CreateEpicEvents < ActiveRecord::Migration[8.1]
def change
create_table :epic_events do |t|
t.integer :epic_id
t.string :jira_key, null: false
t.string :name, null: false
t.string :event_type, null: false
t.datetime :occurred_at, null: false

t.timestamps
end
add_index :epic_events, :occurred_at
add_index :epic_events, :jira_key
end
end
14 changes: 13 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading