diff --git a/Taskfile.yml b/Taskfile.yml index 8d69a4f..a265394 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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: diff --git a/app/controllers/epic_history_controller.rb b/app/controllers/epic_history_controller.rb new file mode 100644 index 0000000..1c91052 --- /dev/null +++ b/app/controllers/epic_history_controller.rb @@ -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 diff --git a/app/models/epic_event.rb b/app/models/epic_event.rb new file mode 100644 index 0000000..ae0dd6e --- /dev/null +++ b/app/models/epic_event.rb @@ -0,0 +1,5 @@ +class EpicEvent < ApplicationRecord + belongs_to :epic, optional: true + + scope :recent, -> { order(occurred_at: :desc) } +end diff --git a/app/services/jira_sync.rb b/app/services/jira_sync.rb index efa76fb..25ce0f9 100644 --- a/app/services/jira_sync.rb +++ b/app/services/jira_sync.rb @@ -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, @@ -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) @@ -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? @@ -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 @@ -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"), @@ -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") @@ -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 diff --git a/app/views/epic_history/show.html.slim b/app/views/epic_history/show.html.slim new file mode 100644 index 0000000..ba232e1 --- /dev/null +++ b/app/views/epic_history/show.html.slim @@ -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; + } diff --git a/config/routes.rb b/config/routes.rb index a983d0c..e0cf2a9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/db/migrate/20260707000001_create_epic_events.rb b/db/migrate/20260707000001_create_epic_events.rb new file mode 100644 index 0000000..4c97078 --- /dev/null +++ b/db/migrate/20260707000001_create_epic_events.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 5f38744..b0b70ac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_01_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_07_07_000001) do create_table "board_orders", force: :cascade do |t| t.json "column_order", default: [], null: false t.datetime "created_at", null: false @@ -23,6 +23,18 @@ t.integer "version", default: 0, null: false end + create_table "epic_events", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "epic_id" + t.string "event_type", null: false + t.string "jira_key", null: false + t.string "name", null: false + t.datetime "occurred_at", null: false + t.datetime "updated_at", null: false + t.index ["jira_key"], name: "index_epic_events_on_jira_key" + t.index ["occurred_at"], name: "index_epic_events_on_occurred_at" + end + create_table "epics", force: :cascade do |t| t.datetime "created_at", null: false t.string "jira_key", null: false diff --git a/lib/tasks/epic_events.rake b/lib/tasks/epic_events.rake new file mode 100644 index 0000000..7203357 --- /dev/null +++ b/lib/tasks/epic_events.rake @@ -0,0 +1,15 @@ +namespace :epic_events do + desc "Backfill EpicEvent timestamps from Jira's changelog (fixes rows stamped at sync time before this existed)" + task backfill: :environment do + count = JiraSync.new.backfill_event_times! + puts "Updated #{count} epic event timestamp(s)." + end + + desc "Discover epics already closed (SUCCESS/FAILURE/REJECTED/etc) before this app ever tracked them, and backfill their add+remove history" + task :discover_closed, [ :jql ] => :environment do |_, args| + jql = args[:jql].presence || + 'issuetype = Epic AND project = PG AND labels = "Priority" AND status IN (SUCCESS, FAILURE, REJECTED, "GONE BAD")' + count = JiraSync.new.discover_closed_epics!(jql) + puts "Backfilled #{count} previously-untracked closed epic(s) using: #{jql}" + end +end diff --git a/test/controllers/epic_history_controller_test.rb b/test/controllers/epic_history_controller_test.rb new file mode 100644 index 0000000..258c651 --- /dev/null +++ b/test/controllers/epic_history_controller_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class EpicHistoryControllerTest < ActionDispatch::IntegrationTest + 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" } + ) + get "/auth/google_oauth2/callback" + end + + test "renders recorded epic events" do + EpicEvent.create!(jira_key: "PG-1", name: "Epic A", event_type: "added", occurred_at: Time.current) + EpicEvent.create!(jira_key: "PG-2", name: "Epic B", event_type: "removed", occurred_at: Time.current) + + get "/history" + + assert_response :success + assert_select ".kb-history-row.is-added", text: /Epic A/ + assert_select ".kb-history-row.is-removed", text: /Epic B/ + base = LASER_FOCUS_CONFIG.jira.base_url.chomp("/") + assert_select ".kb-history-row.is-added a.kb-history-link[href=?]", "#{base}/browse/PG-1", text: /Epic A/ + assert_select ".kb-history-row.is-removed a.kb-history-link[href=?]", "#{base}/browse/PG-2", text: /Epic B/ + end + + test "redirects to login when unauthenticated" do + reset! + get "/history" + assert_redirected_to "/login" + end +end diff --git a/test/services/jira_sync_test.rb b/test/services/jira_sync_test.rb index a4081d8..677208d 100644 --- a/test/services/jira_sync_test.rb +++ b/test/services/jira_sync_test.rb @@ -281,6 +281,243 @@ class JiraSyncTest < ActiveSupport::TestCase assert_not SyncRun.most_recent.first.ok end + test "records an EpicEvent when a new epic first appears" 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-1", "fields" => { "summary" => "Epic A", + "status" => { "name" => "In Progress" }, + "priority" => { "id" => "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 + + assert_difference -> { EpicEvent.count }, 1 do + JiraSync.new(epic_query: 'project = PG AND labels = "Priority"', unplanned_query: nil).run! + end + + event = EpicEvent.last + assert_equal "added", event.event_type + assert_equal "PG-1", event.jira_key + assert_equal "Epic A", event.name + + # A second sync with the epic still present must not record another event. + assert_no_difference -> { EpicEvent.count } do + JiraSync.new(epic_query: 'project = PG AND labels = "Priority"', unplanned_query: nil).run! + end + end + + test "records an EpicEvent when an epic drops out of the query" do + epic = Epic.create!(jira_key: "PG-9", name: "Gone soon", priority: 1, jira_status: "In Progress") + + stub_request(:get, %r{/search}).to_return( + status: 200, + body: { "issues" => [], "total" => 0, "startAt" => 0, "maxResults" => 50 }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + assert_difference -> { EpicEvent.count }, 1 do + JiraSync.new(epic_query: 'project = PG AND labels = "Priority"').run! + end + + event = EpicEvent.last + assert_equal "removed", event.event_type + assert_equal epic.jira_key, event.jira_key + assert_not_nil epic.reload.removed_at + end + + test "backdates the added event to when the Priority label actually landed" do + labeled_at = "2026-06-01T08:00:00.000+0000" + + 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-1", "fields" => { "summary" => "Epic A", + "status" => { "name" => "In Progress" }, + "priority" => { "id" => "1" } }, + "changelog" => { "histories" => [ + { "created" => "2026-05-20T09:00:00.000+0000", + "items" => [ { "field" => "labels", "toString" => "backend", + "fromString" => "" } ] }, + { "created" => labeled_at, + "items" => [ { "field" => "labels", "toString" => "backend Priority", + "fromString" => "backend" } ] } + ] } } + ], "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"', unplanned_query: nil).run! + + event = EpicEvent.last + assert_equal "added", event.event_type + assert_equal Time.parse(labeled_at), event.occurred_at + end + + test "backdates the removed event to when the epic dropped out via a follow-up changelog lookup" do + epic = Epic.create!(jira_key: "PG-9", name: "Gone soon", priority: 1, jira_status: "In Progress") + dropped_at = "2026-06-15T14:30:00.000+0000" + + stub_request(:get, %r{/search}).to_return do |req| + decoded = CGI.unescape(req.uri.to_s) + body = if decoded =~ /key\s+in\s*\(.*PG-9.*\)/i + { "issues" => [ + { "key" => "PG-9", "fields" => { "summary" => "Gone soon" }, + "changelog" => { "histories" => [ + { "created" => dropped_at, + "items" => [ { "field" => "status", "fromString" => "In Progress", + "toString" => "SUCCESS" } ] } + ] } } + ], "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! + + event = EpicEvent.last + assert_equal "removed", event.event_type + assert_equal epic.jira_key, event.jira_key + assert_equal Time.parse(dropped_at), event.occurred_at + end + + test "backfill_event_times! corrects a stale bootstrap timestamp from the changelog" do + real_time = "2026-05-10T11:00:00.000+0000" + event = EpicEvent.create!(jira_key: "PG-20", name: "Backfill me", event_type: "added", + occurred_at: Time.current) + + stub_request(:get, %r{/search}).to_return do |req| + decoded = CGI.unescape(req.uri.to_s) + body = if decoded =~ /key\s+in\s*\(.*PG-20.*\)/i + { "issues" => [ + { "key" => "PG-20", "fields" => { "summary" => "Backfill me" }, + "changelog" => { "histories" => [ + { "created" => real_time, + "items" => [ { "field" => "labels", "toString" => "Priority", + "fromString" => "" } ] } + ] } } + ], "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 + + updated = JiraSync.new.backfill_event_times! + + assert_equal 1, updated + assert_equal Time.parse(real_time), event.reload.occurred_at + end + + test "backfill_event_times! skips a jira_key with more than one event" do + EpicEvent.create!(jira_key: "PG-21", name: "Ambiguous", event_type: "added", occurred_at: 2.days.ago) + EpicEvent.create!(jira_key: "PG-21", name: "Ambiguous", event_type: "removed", occurred_at: 1.day.ago) + + stub_request(:get, %r{/search}).to_return( + status: 200, + body: { "issues" => [ + { "key" => "PG-21", "fields" => { "summary" => "Ambiguous" }, + "changelog" => { "histories" => [ + { "created" => "2026-05-10T11:00:00.000+0000", + "items" => [ { "field" => "status", "fromString" => "In Progress", "toString" => "SUCCESS" } ] } + ] } } + ], "total" => 1, "startAt" => 0, "maxResults" => 50 }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + assert_equal 0, JiraSync.new.backfill_event_times! + end + + test "backfill_event_times! makes no request when there are no events" do + assert_equal 0, JiraSync.new.backfill_event_times! + assert_not_requested(:get, %r{/search}) + end + + test "discover_closed_epics! backfills add+remove history for an epic closed before tracking began" do + labeled_at = "2026-04-01T09:00:00.000+0000" + closed_at = "2026-04-20T16:00:00.000+0000" + + stub_request(:get, %r{/search}).to_return( + status: 200, + body: { "issues" => [ + { "key" => "PG-2502", "fields" => { "summary" => "Old finished epic", + "status" => { "name" => "SUCCESS" }, + "created" => "2026-03-01T09:00:00.000+0000" }, + "changelog" => { "histories" => [ + { "created" => labeled_at, + "items" => [ { "field" => "labels", "toString" => "Priority", "fromString" => "" } ] }, + { "created" => closed_at, + "items" => [ { "field" => "status", "fromString" => "In Progress", "toString" => "SUCCESS" } ] } + ] } } + ], "total" => 1, "startAt" => 0, "maxResults" => 50 }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + created = JiraSync.new.discover_closed_epics!('status IN (SUCCESS, FAILURE, REJECTED, "GONE BAD")') + + assert_equal 1, created + epic = Epic.find_by!(jira_key: "PG-2502") + assert_not_nil epic.removed_at + + added = EpicEvent.find_by!(jira_key: "PG-2502", event_type: "added") + removed = EpicEvent.find_by!(jira_key: "PG-2502", event_type: "removed") + assert_equal Time.parse(labeled_at), added.occurred_at + assert_equal Time.parse(closed_at), removed.occurred_at + end + + test "discover_closed_epics! falls back to the epic's own created date when labels history is missing" do + created_at = "2026-03-01T09:00:00.000+0000" + closed_at = "2026-04-20T16:00:00.000+0000" + + stub_request(:get, %r{/search}).to_return( + status: 200, + body: { "issues" => [ + { "key" => "PG-2503", "fields" => { "summary" => "Created already labeled", + "status" => { "name" => "FAILURE" }, + "created" => created_at }, + "changelog" => { "histories" => [ + { "created" => closed_at, + "items" => [ { "field" => "status", "fromString" => "In Progress", "toString" => "FAILURE" } ] } + ] } } + ], "total" => 1, "startAt" => 0, "maxResults" => 50 }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + JiraSync.new.discover_closed_epics!('status IN (SUCCESS, FAILURE, REJECTED, "GONE BAD")') + + added = EpicEvent.find_by!(jira_key: "PG-2503", event_type: "added") + assert_equal Time.parse(created_at), added.occurred_at + end + + test "discover_closed_epics! skips a key that's already tracked" do + Epic.create!(jira_key: "PG-2504", name: "Already known", priority: 1, jira_status: "SUCCESS", + removed_at: Time.current) + + stub_request(:get, %r{/search}).to_return( + status: 200, + body: { "issues" => [ + { "key" => "PG-2504", "fields" => { "summary" => "Already known", "status" => { "name" => "SUCCESS" } } } + ], "total" => 1, "startAt" => 0, "maxResults" => 50 }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + assert_no_difference -> { EpicEvent.count } do + assert_equal 0, JiraSync.new.discover_closed_epics!('status IN (SUCCESS, FAILURE, REJECTED, "GONE BAD")') + end + end + def jira_time(t) = t.strftime("%Y-%m-%dT%H:%M:%S.000%z") test "upserts a fresh new-status candidate as provisional orphan" do