Skip to content
Merged
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
126 changes: 19 additions & 107 deletions lib/github/build/summary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require_relative '../../bamboo_ci/running_plan'
require_relative '../../helpers/github_logger'
require_relative '../../bamboo_ci/result'
require_relative 'summary/message_formatter'

module Github
module Build
Expand All @@ -29,6 +30,7 @@ def initialize(job, logger_level: Logger::INFO, agent: 'Github')
end

@pr_log = GithubLogger.instance.create("pr#{@check_suite.pull_request.github_pr_id}.log", logger_level)
@formatter = MessageFormatter.new
end

def build_summary
Expand Down Expand Up @@ -62,13 +64,19 @@ def bamboo_info
end

def must_update_previous_stage(current_stage)
previous_stage = current_stage.previous_stage
current_position = current_stage.configuration&.position.to_i

return if previous_stage.nil? or !(previous_stage.in_progress? or previous_stage.queued?)

logger(Logger::INFO, "must_update_previous_stage: #{previous_stage.inspect}")
Stage
.joins(:configuration)
.where(check_suite: @check_suite)
.where(configuration: { position: (0...current_position) })
.where(status: %i[in_progress queued])
.each do |stage|
next if stage.running?

finished_stage_summary(previous_stage)
logger(Logger::INFO, "must_update_previous_stage: #{stage.inspect}")
finished_stage_summary(stage)
end
end

def must_cancel_next_stages(current_stage)
Expand Down Expand Up @@ -125,7 +133,7 @@ def finished_stage_summary(stage)
url = "https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{stage.check_suite.bamboo_ci_ref}"
output = {
title: "#{stage.name} summary",
summary: "#{summary_basic_output(stage)}\nDetails at [#{url}](#{url}).".force_encoding('utf-8')
summary: "#{@formatter.summary_basic_output(stage)}\nDetails at [#{url}](#{url}).".force_encoding('utf-8')
}

finished_stage_update(stage, output)
Expand All @@ -149,114 +157,14 @@ def update_summary(stage)
url = "https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{@check_suite.bamboo_ci_ref}"
output = {
title: "#{stage.name} summary",
summary: "#{summary_basic_output(stage)}\nDetails at [#{url}](#{url}).".force_encoding('utf-8')
summary: "#{@formatter.summary_basic_output(stage)}\nDetails at [#{url}](#{url}).".force_encoding('utf-8')
}

logger(Logger::WARN, "Updating stage: #{stage.name} to in_progress")
stage.in_progress(@github, output: output)
stage.update_output(@github, output: output)
end

def summary_basic_output(stage)
jobs = stage.jobs.reload

header = queued_message(jobs)
header += in_progress_message(jobs)
header += generate_success_failure_info(stage.name, jobs)

header[0..65_535]
end

def generate_success_failure_info(name, jobs)
header = ''

[
{
title: ':heavy_multiplication_x: Jobs Failure',
queue: other_message(name, jobs),
size: jobs.where.not(status: %i[in_progress queued success]).size
},
{
title: ':heavy_check_mark: Jobs Success',
queue: success_message(jobs),
size: jobs.where(status: :success).size
}
].each do |info|
next if info[:queue].nil? or info[:queue].empty?

header += "\n#{info[:title]}: #{info[:size]}/#{jobs.size}\n\n#{info[:queue]}"
end

header
end

def in_progress_message(jobs)
in_progress = jobs.where(status: :in_progress)

message = "\n\n:arrow_right: Jobs in progress: #{in_progress.size}/#{jobs.size}\n\n"

message + jobs.where(status: %i[in_progress]).map do |job|
"- **#{job.name}** -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n"
end.join("\n")
end

def queued_message(jobs)
queued = jobs.where(status: :queued)

message = ":arrow_right: Jobs queued: #{queued.size}/#{jobs.size}\n\n"
message +
queued.map do |job|
"- **#{job.name}** -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n"
end.join("\n")
end

def success_message(jobs)
jobs.where(status: :success).map do |job|
"- **#{job.name}** -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n"
end.join("\n")
end

def other_message(name, jobs)
jobs.where.not(status: %i[in_progress queued success]).map do |job|
generate_message(name, job)
end.join("\n")
end

def generate_message(name, job)
failures = tests_message(job)
failures = build_message(job) if name.downcase.match?('build')
failures = checkout_message(job) if name.downcase.match?('source')

"- #{job.name} -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n#{failures}"
end

def tests_message(job)
failure = job.topotest_failures.first

return '' if failure.nil?

"\t :no_entry_sign: #{failure.test_suite} #{failure.test_case}\n ```\n#{failure.message}\n```\n"
end

def checkout_message(job)
failures = job.summary

return '' if failures.nil?

"```\n#{failures.gsub('<br>', "\n")}\n```\n"
end

def build_message(job)
output = BambooCi::Result.fetch(job.job_ref, expand: 'testResults.failedTests.testResult.errors,artifacts')
entry = output.dig('artifacts', 'artifact')&.find { |elem| elem['name'] == 'ErrorLog' }

return '' if entry.nil? or entry.empty?

body = BambooCi::Download.build_log(entry.dig('link', 'href'))

"```\n#{body}\n```\n"
end

def fetch_parent_stage
jobs = BambooCi::RunningPlan.fetch(@check_suite.bamboo_ci_ref)
info = jobs.find { |job| job[:name] == @job.name }
Expand All @@ -278,6 +186,10 @@ def first_or_create_stage(info)
stage
end

def generate_message(name, job)
@formatter.generate_message(name, job)
end

def logger(severity, message)
@loggers.each do |logger_object|
logger_object.add(severity, message)
Expand Down
119 changes: 119 additions & 0 deletions lib/github/build/summary/message_formatter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# SPDX-License-Identifier: BSD-2-Clause
#
# message_formatter.rb
# Part of NetDEF CI System
#
# Copyright (c) 2026 by
# Network Device Education Foundation, Inc. ("NetDEF")
#
# frozen_string_literal: true

module Github
module Build
class Summary
class MessageFormatter
def summary_basic_output(stage)
jobs = stage.jobs.reload

header = queued_message(jobs)
header += in_progress_message(jobs)
header += generate_success_failure_info(stage.name, jobs)

header[0..65_535]
end

def generate_message(name, job)
failures = tests_message(job)
failures = build_message(job) if name.downcase.match?('build')
failures = checkout_message(job) if name.downcase.match?('source')

"- #{job.name} -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n#{failures}"
end

private

def generate_success_failure_info(name, jobs)
header = ''

[
{
title: ':heavy_multiplication_x: Jobs Failure',
queue: other_message(name, jobs),
size: jobs.where.not(status: %i[in_progress queued success]).size
},
{
title: ':heavy_check_mark: Jobs Success',
queue: success_message(jobs),
size: jobs.where(status: :success).size
}
].each do |info|
next if info[:queue].nil? or info[:queue].empty?

header += "\n#{info[:title]}: #{info[:size]}/#{jobs.size}\n\n#{info[:queue]}"
end

header
end

def in_progress_message(jobs)
in_progress = jobs.where(status: :in_progress)

message = "\n\n:arrow_right: Jobs in progress: #{in_progress.size}/#{jobs.size}\n\n"

message + jobs.where(status: %i[in_progress]).map do |job|
"- **#{job.name}** -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n"
end.join("\n")
end

def queued_message(jobs)
queued = jobs.where(status: :queued)

message = ":arrow_right: Jobs queued: #{queued.size}/#{jobs.size}\n\n"
message +
queued.map do |job|
"- **#{job.name}** -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n"
end.join("\n")
end

def success_message(jobs)
jobs.where(status: :success).map do |job|
"- **#{job.name}** -> https://#{GitHubApp::Configuration.instance.ci_url}/browse/#{job.job_ref}\n"
end.join("\n")
end

def other_message(name, jobs)
jobs.where.not(status: %i[in_progress queued success]).map do |job|
generate_message(name, job)
end.join("\n")
end

def tests_message(job)
failure = job.topotest_failures.first

return '' if failure.nil?

"\t :no_entry_sign: #{failure.test_suite} #{failure.test_case}\n ```\n#{failure.message}\n```\n"
end

def checkout_message(job)
failures = job.summary

return '' if failures.nil?

"```\n#{failures.gsub('<br>', "\n")}\n```\n"
end

def build_message(job)
output = BambooCi::Result.fetch(job.job_ref, expand: 'testResults.failedTests.testResult.errors,artifacts')
entry = output.dig('artifacts', 'artifact')&.find { |elem| elem['name'] == 'ErrorLog' }

return '' if entry.nil? or entry.empty?

body = BambooCi::Download.build_log(entry.dig('link', 'href'))

"```\n#{body}\n```\n"
end
end
end
end
end
8 changes: 8 additions & 0 deletions spec/factories/stage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

configuration { create(:stage_configuration, github_check_run_name: name.split(' - ').first) }

trait :in_progress do
status { :in_progress }
end

trait :failure do
status { :failure }
end
Expand All @@ -24,6 +28,10 @@
status { :success }
end

trait :cancelled do
status { :cancelled }
end

trait :build do
name { 'Build' }
end
Expand Down
69 changes: 69 additions & 0 deletions spec/lib/github/build/summary_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,75 @@
end
end

context 'when a previous stage is stuck in_progress after pipeline advances' do
let(:stage1_config) { create(:stage_configuration, position: 1) }
let(:stage2_config) { create(:stage_configuration, position: 2) }
let(:stage1) { create(:stage, :in_progress, name: 'Build', configuration: stage1_config, check_suite: check_suite) }
let(:stage2) { create(:stage, :in_progress, name: 'Tests', configuration: stage2_config, check_suite: check_suite) }
let(:ci_job_stage1) { create(:ci_job, :success, check_suite: check_suite, stage: stage1) }
let(:ci_job) { create(:ci_job, :in_progress, check_suite: check_suite, stage: stage2) }

before do
ci_job_stage1
ci_job
end

it 'finalizes the stuck previous stage' do
summary.build_summary
expect(stage1.reload.status).to eq('success')
expect(stage2.reload.status).to eq('in_progress')
end
end

context 'when multiple previous stages are stuck in_progress after pipeline advances' do
let(:stage1_config) { create(:stage_configuration, position: 1) }
let(:stage2_config) { create(:stage_configuration, position: 2) }
let(:stage3_config) { create(:stage_configuration, position: 3) }
let(:stage1) { create(:stage, :in_progress, name: 'Build', configuration: stage1_config, check_suite: check_suite) }
let(:stage2) { create(:stage, :in_progress, name: 'Tests', configuration: stage2_config, check_suite: check_suite) }
let(:stage3) do
create(:stage, :in_progress, name: 'Verify Source', configuration: stage3_config, check_suite: check_suite)
end
let(:ci_job_stage1) { create(:ci_job, :success, check_suite: check_suite, stage: stage1) }
let(:ci_job_stage2) { create(:ci_job, :success, check_suite: check_suite, stage: stage2) }
let(:ci_job) { create(:ci_job, :in_progress, check_suite: check_suite, stage: stage3) }

before do
ci_job_stage1
ci_job_stage2
ci_job
end

it 'finalizes all previous stages with no running jobs' do
summary.build_summary
expect(stage1.reload.status).to eq('success')
expect(stage2.reload.status).to eq('success')
expect(stage3.reload.status).to eq('in_progress')
end
end

context 'when a previous stage is stuck in_progress but still has running jobs' do
let(:stage1_config) { create(:stage_configuration, position: 1) }
let(:stage2_config) { create(:stage_configuration, position: 2) }
let(:stage1) { create(:stage, :in_progress, name: 'Build', configuration: stage1_config, check_suite: check_suite) }
let(:stage2) { create(:stage, :in_progress, name: 'Tests', configuration: stage2_config, check_suite: check_suite) }
let(:ci_job_stage1_done) { create(:ci_job, :success, check_suite: check_suite, stage: stage1) }
let(:ci_job_stage1_running) { create(:ci_job, :in_progress, check_suite: check_suite, stage: stage1) }
let(:ci_job) { create(:ci_job, :in_progress, check_suite: check_suite, stage: stage2) }

before do
ci_job_stage1_done
ci_job_stage1_running
ci_job
end

it 'does not prematurely finalize a stage with active jobs' do
summary.build_summary
expect(stage1.reload.status).to eq('in_progress')
expect(stage2.reload.status).to eq('in_progress')
end
end

context 'when the current stage is cancelled' do
let(:stage) { create(:stage, :cancelled, check_suite: check_suite) }
let(:ci_job) { create(:ci_job, stage: stage, check_suite: check_suite) }
Expand Down
Loading