// Copyright © 2017 Teddy Wing // // This file is part of Kipper. // // Kipper is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Kipper is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Kipper. If not, see . // maybe wait a few seconds to be sure a Jenkins job was created. This happens at the caller. // make request to [branch]-branches // if it comes back successfully with a `builds` hash // request all URLs in `builds` // if its `displayName` matches [branch]-commitsha{5} // check `result` ('SUCCESS', 'FAILURE', nonexistent) // update GitHub commit status // if pending // start a thread that checks every 30 seconds for the `result` and update GitHub commit status // if time spent > 20 minutes // set GH commit status to error (timeout) // if `result` is successful or failed, update status and stop // set GH status to error (no job found) // fn update_github_status(commit_ref) // fn get_jobs(repo_name) // fn af83 job name from commit_ref (separate af83 module) // fn update_github_commit_status(status, message) (lives in GitHub module) // fn request_job(url) // fn result_from_job(payload) extern crate json; extern crate mockito; extern crate reqwest; extern crate url; use std::error::Error; use std::thread::sleep; use std::time::{Duration, Instant}; use self::reqwest::header; use self::url::Url; use af83; use github; use pull_request::CommitRef; #[derive(Debug, PartialEq, Eq)] pub enum JobStatus { Success, Failure, Pending, Unknown, } impl JobStatus { fn commit_status(&self) -> github::CommitStatus { match *self { JobStatus::Success => github::CommitStatus::Success, JobStatus::Failure => github::CommitStatus::Failure, JobStatus::Pending => github::CommitStatus::Pending, JobStatus::Unknown => github::CommitStatus::Error, } } } pub struct Job { display_name: String, result: JobStatus, } impl Job { fn new(payload: String) -> Result> { let mut job = json::parse(payload.as_ref())?; Ok( Job { display_name: job["displayName"].take_string().unwrap_or_default(), result: result_from_job(job["result"].take_string()), } ) } } pub fn find_and_track_build_and_update_status( commit_ref: CommitRef, jenkins_url: String, jenkins_user_id: &String, jenkins_token: &String, github_token: String, ) -> Result<(), Box> { let jenkins_client = jenkins_request_client( &jenkins_user_id, &jenkins_token )?; let jobs = get_jobs( &jenkins_url, &jenkins_client, commit_ref.repo.as_ref() )?; let t20_minutes = 60 * 20; for job_url in jobs { debug!("Looking for job: {}", job_url); let mut job = request_job( &jenkins_url, &jenkins_client, job_url.as_ref() )?; // Does `displayName` match if job_for_commit(&job, &commit_ref) { debug!("Job found: {}", job_url); // Start timer let now = Instant::now(); let commit_status = job.result.commit_status(); let job_console_url = jenkins_console_url_path(&job_url); github::update_commit_status( &github_token, &commit_ref, &commit_status, job_console_url.clone(), None, "continuous-integration/jenkins".to_owned() ).expect( format!( "GitHub pending status update failed for {}/{} {}.", commit_ref.owner, commit_ref.repo, commit_ref.sha ).as_ref() ); while job.result == JobStatus::Pending { // loop // if timer > 20 minutes // call github::update_commit_status with timeout error // return // wait 30 seconds // call request_job again // if the status is different // call github::update_commit_status // stop debug!("Waiting for job to finish"); if now.elapsed().as_secs() == t20_minutes { github::update_commit_status( &github_token, &commit_ref, &github::CommitStatus::Error, job_console_url.clone(), Some("The status checker timed out.".to_owned()), "continuous-integration/jenkins".to_owned() ).expect( format!( "GitHub timeout error status update failed for {}/{} {}.", commit_ref.owner, commit_ref.repo, commit_ref.sha ).as_ref() ); return Ok(()) } sleep(Duration::from_secs(30)); let updated_job = request_job( &jenkins_url, &jenkins_client, job_url.as_ref() ).expect( format!("Failed to request job '{}'.", job_url).as_ref() ); if job.result != updated_job.result { github::update_commit_status( &github_token, &commit_ref, &updated_job.result.commit_status(), job_console_url.clone(), None, "continuous-integration/jenkins".to_owned() ).expect( format!( "GitHub status update failed for {}/{} {}.", commit_ref.owner, commit_ref.repo, commit_ref.sha ).as_ref() ); return Ok(()) } job = updated_job; } return Ok(()) } } Ok(()) } pub fn auth_credentials(user_id: String, token: String) -> header::Basic { header::Basic { username: user_id, password: Some(token), } } pub fn get_jobs( jenkins_url: &String, client: &reqwest::Client, repo_name: &str ) -> Result, Box> { let mut response = client.get( &format!("{}/job/{}-branches/api/json", jenkins_url, repo_name) ).send()?; let body = response.text()?; let jobs = json::parse(body.as_ref())?; Ok( jobs["builds"].members() .map(|job| { job["url"].clone().take_string().unwrap_or_default() }) .collect::>() ) } pub fn request_job( jenkins_url: &String, client: &reqwest::Client, url: &str ) -> Result> { let url = Url::parse(url.as_ref())?; let mut response = client.get( &format!("{}{}/api/json", jenkins_url, url.path()) ).send()?; let body = response.text()?; let job = Job::new(body)?; Ok(job) } // Does the `commit_ref` correspond to the job? pub fn job_for_commit(job: &Job, commit_ref: &CommitRef) -> bool { job.display_name == af83::job_name(&commit_ref) } pub fn result_from_job(status: Option) -> JobStatus { match status { None => JobStatus::Pending, Some(s) => { match s.as_ref() { "SUCCESS" => JobStatus::Success, "FAILURE" => JobStatus::Failure, _ => JobStatus::Unknown, } } } } pub fn jenkins_console_url_path(job_url: &String) -> String { format!("{}console", job_url) } fn jenkins_request_client(user_id: &String, token: &String) -> Result> { let credentials = auth_credentials(user_id.to_owned(), token.to_owned()); let mut headers = header::Headers::new(); headers.set(header::Authorization(credentials)); let client = reqwest::Client::builder() .default_headers(headers) .build()?; Ok(client) } #[cfg(test)] mod tests { use self::mockito::mock; use super::*; fn test_request_client() -> reqwest::Client { jenkins_request_client( &"username".to_owned(), &"token".to_owned() ).expect("Failed to build Jenkins request client") } #[test] fn job_new_creates_a_job_from_payload() { let payload = r#"{ "displayName": "3296-fix-typo-700d0", "result": "SUCCESS" }"#.to_owned(); let job = Job::new(payload).expect("Failed to create job from payload"); assert_eq!(job.display_name, "3296-fix-typo-700d0"); assert_eq!(job.result, JobStatus::Success); } #[test] fn get_jobs_queries_jobs_from_jenkins_api() { let _mock = mock("GET", "/job/changes-branches/api/json") .with_status(200) .with_header("content-type", "application/json;charset=utf-8") .with_body(r#" { "displayName": "changes-branches", "builds": [ { "_class": "hudson.model.FreeStyleBuild", "number": 18, "url": "http://jenkins.example.com/job/changes-branches/18/" }, { "_class": "hudson.model.FreeStyleBuild", "number": 17, "url": "http://jenkins.example.com/job/changes-branches/17/" } ] } "#) .create(); let jobs = get_jobs( &mockito::SERVER_URL.to_owned(), &test_request_client(), "changes" ).expect("Failed to request jobs"); assert_eq!( jobs, [ "http://jenkins.example.com/job/changes-branches/18/", "http://jenkins.example.com/job/changes-branches/17/" ] ); } #[test] fn request_job_queries_a_job_from_the_jenkins_api() { let _mock = mock("GET", "/job/changes-branches/15/api/json") .with_status(200) .with_header("content-type", "application/json;charset=utf-8") .with_body(r#" { "displayName": "2388-delete-the-codes-391af", "result": "SUCCESS" } "#) .create(); let job = request_job( &mockito::SERVER_URL.to_owned(), &test_request_client(), "http://jenkins.example.com/job/changes-branches/15" ).expect("Failed to request job"); let expected = Job { display_name: "2388-delete-the-codes-391af".to_owned(), result: JobStatus::Success, }; assert_eq!(job.display_name, expected.display_name); assert_eq!(job.result, expected.result); } #[test] fn job_for_commit_returns_true_when_commit_matches_job() { let job = Job { display_name: "1753-fix-everything-b4a28".to_owned(), result: JobStatus::Pending, }; let commit_ref = CommitRef { owner: "uso".to_owned(), repo: "vivid-system".to_owned(), sha: "b4a286e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c".to_owned(), branch: "1753-fix-everything".to_owned(), }; assert_eq!(job_for_commit(&job, &commit_ref), true); } #[test] fn job_for_commit_returns_false_when_commit_doesnt_match_job() { let job = Job { display_name: "5234-eliminate-widgetmacallit-5a28c".to_owned(), result: JobStatus::Success, }; let commit_ref = CommitRef { owner: "uso".to_owned(), repo: "vivid-system".to_owned(), sha: "b4a286e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c".to_owned(), branch: "1753-fix-everything".to_owned(), }; assert_eq!(job_for_commit(&job, &commit_ref), false); } #[test] fn result_from_job_is_success() { assert_eq!( result_from_job(Some("SUCCESS".to_owned())), JobStatus::Success ); } #[test] fn result_from_job_is_failure() { assert_eq!( result_from_job(Some("FAILURE".to_owned())), JobStatus::Failure ); } #[test] fn result_from_job_is_pending() { assert_eq!( result_from_job(None), JobStatus::Pending ); } #[test] fn jenkins_console_url_path_returns_url_to_console_page() { assert_eq!( jenkins_console_url_path( &"https://jenkins.example.com/job/changes-branches/15/".to_owned() ), "https://jenkins.example.com/job/changes-branches/15/console" ); } }