[1/4] Upstream Patchwork support
diff mbox series

Message ID 20180208020140.20903-1-andrew.donnellan@au1.ibm.com
State Accepted
Headers show
Series
  • [1/4] Upstream Patchwork support
Related show

Commit Message

Andrew Donnellan Feb. 8, 2018, 2:01 a.m. UTC
From: Russell Currey <ruscur@russell.cc>

Implement support for the patch and series models in upstream Patchwork.

In order to support this different API, snowpatch has been re-architected
around having distinct concepts of patches and series.  Instead of treating
every patch as a series, we instead operate on every patch, and for patches
that are in the middle of a series, we apply all of its dependencies before
testing.

Signed-off-by: Russell Currey <ruscur@russell.cc>
Co-authored-by: Andrew Donnellan <andrew.donnellan@au1.ibm.com>
[ajd: rebase on serde changes, lots of fixes, token authentication]
Signed-off-by: Andrew Donnellan <andrew.donnellan@au1.ibm.com>
---
 README.md        |   2 +-
 src/jenkins.rs   |  14 ++-
 src/main.rs      | 160 ++++++++++++++++++++++---------
 src/patchwork.rs | 286 +++++++++++++++++++++++++++++++++++++++++++------------
 src/settings.rs  |   3 +
 5 files changed, 353 insertions(+), 112 deletions(-)

Patch
diff mbox series

diff --git a/README.md b/README.md
index 917db6af3abf..6096bdc41c36 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@  patches, applies patches on top of an existing tree, triggers appropriate
 builds and test suites, and reports the results.
 
 At present, snowpatch supports
-[patchwork-freedesktop](http://github.com/dlespiau/patchwork) and
+[Patchwork](http://jk.ozlabs.org/projects/patchwork/) and
 [Jenkins](http://jenkins-ci.org).
 
 snowpatch is named in honour of
diff --git a/src/jenkins.rs b/src/jenkins.rs
index 85a098b56a45..d8a2068a8169 100644
--- a/src/jenkins.rs
+++ b/src/jenkins.rs
@@ -108,10 +108,18 @@  impl JenkinsBackend {
     fn get_api_json_object(&self, base_url: &str) -> Value {
         // TODO: Don't panic on failure, fail more gracefully
         let url = format!("{}api/json", base_url);
-        let mut resp = self.get(&url).send().expect("HTTP request error");
         let mut result_str = String::new();
-        resp.read_to_string(&mut result_str)
-            .unwrap_or_else(|err| panic!("Couldn't read from server: {}", err));
+        loop {
+            let mut resp = self.get(&url).send().expect("HTTP request error");
+
+            if resp.status.is_server_error() {
+                sleep(Duration::from_millis(JENKINS_POLLING_INTERVAL));
+                continue;
+            }
+            resp.read_to_string(&mut result_str)
+                .unwrap_or_else(|err| panic!("Couldn't read from server: {}", err));
+            break;
+       }
         serde_json::from_str(&result_str).unwrap_or_else(
             |err| panic!("Couldn't parse JSON from Jenkins: {}", err)
         )
diff --git a/src/main.rs b/src/main.rs
index 9b24385c16c7..2bdf2ac4b314 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -73,8 +73,10 @@  mod utils;
 
 static USAGE: &'static str = "
 Usage:
-  snowpatch <config-file> [--count=<count> | --series <id>] [--project <name>]
+  snowpatch <config-file> [--count=<count>] [--project <name>]
   snowpatch <config-file> --mbox <mbox> --project <name>
+  snowpatch <config-file> --patch <id>
+  snowpatch <config-file> --series <id>
   snowpatch -v | --version
   snowpatch -h | --help
 
@@ -82,6 +84,7 @@  By default, snowpatch runs as a long-running daemon.
 
 Options:
   --count <count>           Run tests on <count> recent series.
+  --patch <id>              Run tests on the given Patchwork patch.
   --series <id>             Run tests on the given Patchwork series.
   --mbox <mbox>             Run tests on the given mbox file. Requires --project
   --project <name>          Test patches for the given project.
@@ -93,6 +96,7 @@  Options:
 struct Args {
     arg_config_file: String,
     flag_count: u16,
+    flag_patch: u32,
     flag_series: u32,
     flag_mbox: String,
     flag_project: String,
@@ -135,11 +139,11 @@  fn run_tests(settings: &Config, client: Arc<Client>, project: &Project, tag: &st
         let test_result = jenkins.get_build_result(&build_url_real).unwrap();
         info!("Jenkins job for {}/{} complete.", branch_name, job.title);
         results.push(TestResult {
-            test_name: format!("Test {} on branch {}", job.title,
-                               branch_name.to_string()).to_string(),
+            description: Some(format!("Test {} on branch {}", job.title,
+                                      branch_name.to_string()).to_string()),
             state: test_result,
-            url: Some(jenkins.get_results_url(&build_url_real, &job.parameters)),
-            summary: Some("TODO: get this summary from Jenkins".to_string()),
+            context: Some(format!("{}-{}", "snowpatch", job.title.replace("/", "_")).to_string()),
+            target_url: Some(jenkins.get_results_url(&build_url_real, &job.parameters)),
         });
     }
     results
@@ -199,19 +203,25 @@  fn test_patch(settings: &Config, client: &Arc<Client>, project: &Project, path:
             Ok(_) => {
                 successfully_applied = true;
                 results.push(TestResult {
-                    test_name: "apply_patch".to_string(),
                     state: TestState::Success,
-                    url: None,
-                    summary: Some(format!("Successfully applied to branch {}", branch_name)),
+                    description: Some(format!("{}/{}\n\n{}",
+                                              branch_name.to_string(),
+                                              "apply_patch".to_string(),
+                                              "Successfully applied".to_string())
+                                      .to_string()),
+                    .. Default::default()
                 });
             },
             Err(_) => {
                 // It didn't apply.  No need to bother testing.
                 results.push(TestResult {
-                    test_name: "apply_patch".to_string(),
                     state: TestState::Warning,
-                    url: None,
-                    summary: Some(format!("Failed to apply to branch {}", branch_name)),
+                    description: Some(format!("{}/{}\n\n{}",
+                                              branch_name.to_string(),
+                                              "apply_patch".to_string(),
+                                              "Patch failed to apply".to_string())
+                                      .to_string()),
+                    .. Default::default()
                 });
                 continue;
             }
@@ -234,10 +244,9 @@  fn test_patch(settings: &Config, client: &Arc<Client>, project: &Project, path:
 
     if !successfully_applied {
         results.push(TestResult {
-            test_name: "apply_patch".to_string(),
             state: TestState::Fail,
-            url: None,
-            summary: Some("Failed to apply to any branch".to_string()),
+            description: Some("Failed to apply to any branch".to_string()),
+            .. Default::default()
         });
     }
     results
@@ -293,14 +302,15 @@  fn main() {
     });
 
     let mut patchwork = PatchworkServer::new(&settings.patchwork.url, &client);
-    if settings.patchwork.user.is_some() {
-        debug!("Patchwork authentication set for user {}",
-               &settings.patchwork.user.clone().unwrap());
-        patchwork.set_authentication(&settings.patchwork.user.clone().unwrap(),
-                                     &settings.patchwork.pass.clone());
-    }
+    patchwork.set_authentication(&settings.patchwork.user,
+                                 &settings.patchwork.pass,
+                                 &settings.patchwork.token);
     let patchwork = patchwork;
 
+    if args.flag_series > 0 && args.flag_patch > 0 {
+        panic!("Can't specify both --series and --patch");
+    }
+
     if args.flag_mbox != "" && args.flag_project != "" {
         info!("snowpatch is testing a local patch.");
         let patch = Path::new(&args.flag_mbox);
@@ -314,64 +324,120 @@  fn main() {
         return;
     }
 
+    if args.flag_patch > 0 {
+        info!("snowpatch is testing a patch from Patchwork.");
+        let patch = patchwork.get_patch(&(args.flag_patch as u64)).unwrap();
+        match settings.projects.get(&patch.project.link_name) {
+            None => panic!("Couldn't find project {}", &patch.project.link_name),
+            Some(project) => {
+                let mbox = if patch.has_series() {
+                    let dependencies = patchwork.get_patch_dependencies(&patch);
+                    patchwork.get_patches_mbox(dependencies)
+                } else {
+                    patchwork.get_patch_mbox(&patch)
+                };
+                test_patch(&settings, &client, project, &mbox);
+            }
+        }
+        return;
+    }
+
     if args.flag_series > 0 {
         info!("snowpatch is testing a series from Patchwork.");
         let series = patchwork.get_series(&(args.flag_series as u64)).unwrap();
-        match settings.projects.get(&series.project.linkname) {
-            None => panic!("Couldn't find project {}", &series.project.linkname),
+        // The last patch in the series, so its dependencies are the whole series
+        let patch = patchwork.get_patch_by_url(&series.patches.last().unwrap().url).unwrap();
+        // We have to do it this way since there's no project field on Series
+        let project = patchwork.get_project(&patch.project.name).unwrap();
+        match settings.projects.get(&project.link_name) {
+            None => panic!("Couldn't find project {}", &project.link_name),
             Some(project) => {
-                let patch = patchwork.get_patch(&series);
-                test_patch(&settings, &client, project, &patch);
+                let dependencies = patchwork.get_patch_dependencies(&patch);
+                let mbox = patchwork.get_patches_mbox(dependencies);
+                test_patch(&settings, &client, project, &mbox);
             }
         }
-
         return;
     }
 
-    // The number of series tested so far.  If --count isn't provided, this is unused.
-    let mut series_count = 0;
+    // The number of patches tested so far.  If --count isn't provided, this is unused.
+    let mut patch_count = 0;
 
-    // Poll patchwork for new series. For each series, get patches, apply and test.
+    /*
+     * Poll Patchwork for new patches.
+     * If the patch is standalone (not part of a series), apply it.
+     * If the patch is part of a series, apply all of its dependencies.
+     * Spawn tests.
+     */
     'daemon: loop {
-        let series_list = patchwork.get_series_query().unwrap().results.unwrap();
+        let patch_list = patchwork.get_patch_query().unwrap_or_else(
+            |err| panic!("Failed to obtain patch list: {}", err));
         info!("snowpatch is ready to test new revisions from Patchwork.");
-        for series in series_list {
+        for patch in patch_list {
             // If it's already been tested, we can skip it
-            if series.test_state.is_some() {
-                debug!("Skipping already tested series {} ({})", series.name, series.id);
+            if patch.check != "pending" {
+                debug!("Skipping already tested patch {}", patch.name);
+                continue;
+            }
+
+            if !patch.action_required() {
+                debug!("Skipping patch {} in state {}", patch.name, patch.state);
                 continue;
             }
 
+            //let project = patchwork.get_project(&patch.project).unwrap();
             // Skip if we're using -p and it's the wrong project
-            if args.flag_project != "" && series.project.linkname != args.flag_project {
-                debug!("Skipping series {} ({}) (wrong project: {})",
-                       series.name, series.id, series.project.linkname);
+            if args.flag_project != "" && patch.project.link_name != args.flag_project {
+                debug!("Skipping patch {} ({}) (wrong project: {})",
+                       patch.name, patch.id, patch.project.link_name);
                 continue;
             }
 
-            match settings.projects.get(&series.project.linkname) {
+            match settings.projects.get(&patch.project.link_name) {
                 None => {
-                    debug!("Project {} not configured for series {} ({})",
-                           &series.project.linkname, series.name, series.id);
+                    debug!("Project {} not configured for patch {}",
+                           &patch.project.link_name, patch.name);
                     continue;
                 },
                 Some(project) => {
-                    let patch = patchwork.get_patch(&series);
-                    let results = test_patch(&settings, &client, project, &patch);
+                    // TODO(ajd): Refactor this.
+                    let mbox = if patch.has_series() {
+                        debug!("Patch {} has a series at {}!", &patch.name, &patch.series[0].url);
+                        let series = patchwork.get_series_by_url(&patch.series[0].url);
+                        match series {
+                            Ok(series) => {
+                                if !series.received_all {
+                                    debug!("Series is incomplete, skipping patch for now");
+                                    continue;
+                                }
+                                let dependencies = patchwork.get_patch_dependencies(&patch);
+                                patchwork.get_patches_mbox(dependencies)
+
+                            },
+                            Err(e) => {
+                                debug!("Series is not OK: {}", e);
+                                patchwork.get_patch_mbox(&patch)
+                            }
+                        }
+                    } else {
+                        patchwork.get_patch_mbox(&patch)
+                    };
+
+                    let results = test_patch(&settings, &client, project, &mbox);
+
                     // Delete the temporary directory with the patch in it
-                    fs::remove_dir_all(patch.parent().unwrap()).unwrap_or_else(
+                    fs::remove_dir_all(mbox.parent().unwrap()).unwrap_or_else(
                         |err| error!("Couldn't delete temp directory: {}", err));
                     if project.push_results {
                         for result in results {
-                            patchwork.post_test_result(result, &series.id,
-                                                       &series.version).unwrap();
+                            patchwork.post_test_result(result, &patch.checks).unwrap();
                         }
                     }
                     if args.flag_count > 0 {
-                        series_count += 1;
-                        debug!("Tested {} series out of {}",
-                               series_count, args.flag_count);
-                        if series_count >= args.flag_count {
+                        patch_count += 1;
+                        debug!("Tested {} patches out of {}",
+                               patch_count, args.flag_count);
+                        if patch_count >= args.flag_count {
                             break 'daemon;
                         }
                     }
diff --git a/src/patchwork.rs b/src/patchwork.rs
index cf41a52857b3..17d72a2b91b0 100644
--- a/src/patchwork.rs
+++ b/src/patchwork.rs
@@ -18,8 +18,9 @@  use std;
 use std::io::{self};
 use std::option::Option;
 use std::path::PathBuf;
-use std::fs::File;
+use std::fs::{File, OpenOptions};
 use std::result::Result;
+use std::collections::BTreeMap;
 
 use tempdir::TempDir;
 
@@ -31,56 +32,130 @@  use hyper::mime::{Mime, TopLevel, SubLevel, Attr, Value};
 use hyper::status::StatusCode;
 use hyper::client::response::Response;
 
+use serde::{self, Serializer};
 use serde_json;
 
 use utils;
 
 // TODO: more constants.  constants for format strings of URLs and such.
 pub static PATCHWORK_API: &'static str = "/api/1.0";
-pub static PATCHWORK_QUERY: &'static str = "?ordering=-last_updated&related=expand";
+pub static PATCHWORK_QUERY: &'static str = "?order=-id";
 
-// /api/1.0/projects/*/series/
+#[derive(Deserialize, Clone)]
+pub struct SubmitterSummary {
+    pub id: u64,
+    pub url: String,
+    pub name: String,
+    pub email: String
+}
+
+#[derive(Deserialize, Clone)]
+pub struct DelegateSummary {
+    pub id: u64,
+    pub url: String,
+    pub first_name: String,
+    pub last_name: String,
+    pub email: String
+}
 
+// /api/1.0/projects/{id}
 #[derive(Deserialize, Clone)]
 pub struct Project {
     pub id: u64,
+    pub url: String,
     pub name: String,
-    pub linkname: String,
-    pub listemail: String,
+    pub link_name: String,
+    pub list_email: String,
+    pub list_id: String,
     pub web_url: Option<String>,
     pub scm_url: Option<String>,
-    pub webscm_url: Option<String>
+    pub webscm_url: Option<String>,
+}
+
+// /api/1.0/patches/
+// This omits fields from /patches/{id}, deal with it for now.
+
+#[derive(Deserialize, Clone)]
+pub struct Patch {
+    pub id: u64,
+    pub url: String,
+    pub project: Project,
+    pub msgid: String,
+    pub date: String,
+    pub name: String,
+    pub commit_ref: Option<String>,
+    pub pull_url: Option<String>,
+    pub state: String, // TODO enum of possible states
+    pub archived: bool,
+    pub hash: Option<String>,
+    pub submitter: SubmitterSummary,
+    pub delegate: Option<DelegateSummary>,
+    pub mbox: String,
+    pub series: Vec<SeriesSummary>,
+    pub check: String, // TODO enum of possible states
+    pub checks: String,
+    pub tags: BTreeMap<String, u64>
+}
+
+impl Patch {
+    pub fn has_series(&self) -> bool {
+        !&self.series.is_empty()
+    }
+
+    pub fn action_required(&self) -> bool {
+        &self.state == "new" || &self.state == "under-review"
+    }
+}
+
+#[derive(Deserialize, Clone)]
+pub struct PatchSummary {
+    pub date: String,
+    pub id: u64,
+    pub mbox: String,
+    pub msgid: String,
+    pub name: String,
+    pub url: String
 }
 
 #[derive(Deserialize, Clone)]
-pub struct Submitter {
+pub struct CoverLetter {
+    pub date: String,
     pub id: u64,
-    pub name: String
+    pub msgid: String,
+    pub name: String,
+    pub url: String
 }
 
+// /api/1.0/series/
+// The series list and /series/{id} are the same, luckily
 #[derive(Deserialize, Clone)]
 pub struct Series {
+    pub cover_letter: Option<CoverLetter>,
+    pub date: String,
     pub id: u64,
+    pub mbox: String,
+    pub name: Option<String>,
+    pub patches: Vec<PatchSummary>,
     pub project: Project,
-    pub name: String,
-    pub n_patches: u64,
-    pub submitter: Submitter,
-    pub submitted: String,
-    pub last_updated: String,
-    pub version: u64,
-    pub reviewer: Option<String>,
-    pub test_state: Option<String>
+    pub received_all: bool,
+    pub received_total: u64,
+    pub submitter: SubmitterSummary,
+    pub total: u64,
+    pub url: String,
+    pub version: u64
 }
 
-#[derive(Deserialize)]
-pub struct SeriesList {
-    pub count: u64,
-    pub next: Option<String>,
-    pub previous: Option<String>,
-    pub results: Option<Vec<Series>>
+#[derive(Deserialize, Clone)]
+pub struct SeriesSummary {
+    pub id: u64,
+    pub url: String,
+    pub date: String,
+    pub name: Option<String>,
+    pub version: u64,
+    pub mbox: String,
 }
 
-#[derive(Serialize, Clone)]
+#[derive(Serialize, Clone, PartialEq)]
 pub enum TestState {
     #[serde(rename = "pending")]
     Pending,
@@ -99,12 +174,39 @@  impl Default for TestState {
 }
 
 // /api/1.0/series/*/revisions/*/test-results/
-#[derive(Serialize)]
+#[derive(Serialize, Default, Clone)]
 pub struct TestResult {
-    pub test_name: String,
     pub state: TestState,
-    pub url: Option<String>,
-    pub summary: Option<String>
+    #[serde(serialize_with = "TestResult::serialize_target_url")]
+    pub target_url: Option<String>,
+    pub description: Option<String>,
+    #[serde(serialize_with = "TestResult::serialize_context")]
+    pub context: Option<String>,
+}
+
+impl TestResult {
+    fn serialize_target_url<S>(target_url: &Option<String>, ser: S)
+                               -> Result<S::Ok, S::Error> where S: Serializer {
+        if target_url.is_none() {
+            serde::Serialize::serialize(&Some("http://no.url".to_string()), ser)
+        } else {
+            serde::Serialize::serialize(target_url, ser)
+        }
+    }
+
+    fn serialize_context<S>(context: &Option<String>, ser: S)
+                            -> Result<S::Ok, S::Error> where S: Serializer {
+        if context.is_none() {
+            serde::Serialize::serialize(
+                &Some(format!("{}-{}",
+                              env!("CARGO_PKG_NAME"),
+                              env!("CARGO_PKG_VERSION")).to_string()
+                      .replace(".", "_")),
+                ser)
+        } else {
+            serde::Serialize::serialize(context, ser)
+        }
+    }
 }
 
 pub struct PatchworkServer {
@@ -133,14 +235,31 @@  impl PatchworkServer {
     }
 
     #[cfg_attr(feature="cargo-clippy", allow(ptr_arg))]
-    pub fn set_authentication(&mut self, username: &String, password: &Option<String>) {
-        self.headers.set(Authorization(Basic {
-            username: username.clone(),
-            password: password.clone(),
-        }));
+    pub fn set_authentication(&mut self, username: &Option<String>,
+                              password: &Option<String>,
+                              token: &Option<String>) {
+        match (username, password, token) {
+            (&None, &None, &Some(ref token)) => {
+                self.headers.set(Authorization(
+                    format!("Token {}", token)));
+            },
+            (&Some(ref username), &Some(ref password), &None) => {
+                self.headers.set(Authorization(Basic {
+                    username: username.clone(),
+                    password: Some(password.clone()),
+                }));
+            },
+            _ => panic!("Invalid patchwork authentication details"),
+        }
     }
 
-    fn get(&self, url: &str) -> std::result::Result<String, hyper::error::Error> {
+    pub fn get_url(&self, url: &str)
+                   -> std::result::Result<Response, hyper::error::Error> {
+        self.client.get(&*url).headers(self.headers.clone())
+            .header(Connection::close()).send()
+    }
+
+    pub fn get_url_string(&self, url: &str) -> std::result::Result<String, hyper::error::Error> {
         let mut resp = try!(self.client.get(&*url).headers(self.headers.clone())
                             .header(Connection::close()).send());
         let mut body: Vec<u8> = vec![];
@@ -148,51 +267,63 @@  impl PatchworkServer {
         Ok(String::from_utf8(body).unwrap())
     }
 
-    pub fn post_test_result(&self, result: TestResult,
-                            series_id: &u64, series_revision: &u64)
+    pub fn post_test_result(&self, result: TestResult, checks_url: &str)
                             -> Result<StatusCode, hyper::error::Error> {
         let encoded = serde_json::to_string(&result).unwrap();
         let headers = self.headers.clone();
         debug!("JSON Encoded: {}", encoded);
-        let res = try!(self.client.post(&format!(
-            "{}{}/series/{}/revisions/{}/test-results/",
-            &self.url, PATCHWORK_API, &series_id, &series_revision))
-            .headers(headers).body(&encoded).send());
-        assert_eq!(res.status, hyper::status::StatusCode::Created);
-        Ok(res.status)
+        let mut resp = try!(self.client.post(checks_url)
+                        .headers(headers).body(&encoded).send());
+        let mut body: Vec<u8> = vec![];
+        io::copy(&mut resp, &mut body).unwrap();
+        trace!("{}", String::from_utf8(body).unwrap());
+        assert_eq!(resp.status, hyper::status::StatusCode::Created);
+        Ok(resp.status)
     }
 
-    pub fn get_series(&self, series_id: &u64) -> Result<Series, serde_json::Error> {
-        let url = format!("{}{}/series/{}{}", &self.url, PATCHWORK_API,
-                          series_id, PATCHWORK_QUERY);
-        serde_json::from_str(&self.get(&url).unwrap())
+    pub fn get_project(&self, url: &str) -> Result<Project, serde_json::Error> {
+        serde_json::from_str(&self.get_url_string(url).unwrap())
     }
 
-    pub fn get_series_mbox(&self, series_id: &u64, series_revision: &u64)
-                           -> std::result::Result<Response, hyper::error::Error> {
-        let url = format!("{}{}/series/{}/revisions/{}/mbox/",
-                               &self.url, PATCHWORK_API, series_id, series_revision);
-        self.client.get(&*url).headers(self.headers.clone())
-            .header(Connection::close()).send()
+    pub fn get_patch(&self, patch_id: &u64) -> Result<Patch, serde_json::Error> {
+        let url = format!("{}{}/patches/{}{}", &self.url, PATCHWORK_API,
+                          patch_id, PATCHWORK_QUERY);
+        serde_json::from_str(&self.get_url_string(&url).unwrap())
+    }
+
+    pub fn get_patch_by_url(&self, url: &str) -> Result<Patch, serde_json::Error> {
+        serde_json::from_str(&self.get_url_string(url).unwrap())
     }
 
-    pub fn get_series_query(&self) -> Result<SeriesList, serde_json::Error> {
-        let url = format!("{}{}/series/{}", &self.url,
-                          PATCHWORK_API, PATCHWORK_QUERY);
-        serde_json::from_str(&self.get(&url).unwrap_or_else(
+    pub fn get_patch_query(&self) -> Result<Vec<Patch>, serde_json::Error> {
+        let url = format!("{}{}/patches/{}", &self.url, PATCHWORK_API, PATCHWORK_QUERY);
+        serde_json::from_str(&self.get_url_string(&url).unwrap_or_else(
             |err| panic!("Failed to connect to Patchwork: {}", err)))
     }
 
-    pub fn get_patch(&self, series: &Series) -> PathBuf {
+    pub fn get_patch_dependencies(&self, patch: &Patch) -> Vec<Patch> {
+        // We assume the list of patches in a series are in order.
+        let mut dependencies: Vec<Patch> = vec!();
+        let series = self.get_series_by_url(&patch.series[0].url);
+        if series.is_err() {
+            return dependencies;
+        }
+        for dependency in series.unwrap().patches {
+            dependencies.push(self.get_patch_by_url(&dependency.url).unwrap());
+            if dependency.url == patch.url {
+                break;
+            }
+        }
+        dependencies
+    }
+
+    pub fn get_patch_mbox(&self, patch: &Patch) -> PathBuf {
         let dir = TempDir::new("snowpatch").unwrap().into_path();
         let mut path = dir.clone();
-        let tag = utils::sanitise_path(
-            format!("{}-{}-{}", series.submitter.name,
-                    series.id, series.version));
+        let tag = utils::sanitise_path(patch.name.clone());
         path.push(format!("{}.mbox", tag));
 
-        let mut mbox_resp = self.get_series_mbox(&series.id, &series.version)
-            .unwrap();
+        let mut mbox_resp = self.get_url(&patch.mbox).unwrap();
 
         debug!("Saving patch to file {}", path.display());
         let mut mbox = File::create(&path).unwrap_or_else(
@@ -201,4 +332,37 @@  impl PatchworkServer {
             |err| panic!("Couldn't save mbox from Patchwork: {}", err));
         path
     }
+
+    pub fn get_patches_mbox(&self, patches: Vec<Patch>) -> PathBuf {
+        let dir = TempDir::new("snowpatch").unwrap().into_path();
+        let mut path = dir.clone();
+        let tag = utils::sanitise_path(patches.last().unwrap().name.clone());
+        path.push(format!("{}.mbox", tag));
+
+        let mut mbox = OpenOptions::new()
+            .create(true)
+            .write(true)
+            .append(true)
+            .open(&path)
+            .unwrap_or_else(|err| panic!("Couldn't make file: {}", err));
+
+        for patch in patches {
+            let mut mbox_resp = self.get_url(&patch.mbox).unwrap();
+            debug!("Appending patch {} to file {}", patch.name, path.display());
+            io::copy(&mut mbox_resp, &mut mbox).unwrap_or_else(
+                |err| panic!("Couldn't save mbox from Patchwork: {}", err));
+        }
+        path
+    }
+
+    pub fn get_series(&self, series_id: &u64) -> Result<Series, serde_json::Error> {
+        let url = format!("{}{}/series/{}{}", &self.url, PATCHWORK_API,
+                          series_id, PATCHWORK_QUERY);
+        serde_json::from_str(&self.get_url_string(&url).unwrap())
+    }
+
+    pub fn get_series_by_url(&self, url: &str) -> Result<Series, serde_json::Error> {
+        serde_json::from_str(&self.get_url_string(url).unwrap())
+    }
+
 }
diff --git a/src/settings.rs b/src/settings.rs
index 363edc7e386a..ad5f483d579b 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -39,8 +39,10 @@  pub struct Git {
 pub struct Patchwork {
     pub url: String,
     pub port: Option<u16>,
+    // TODO: Enforce (user, pass) XOR token
     pub user: Option<String>,
     pub pass: Option<String>,
+    pub token: Option<String>,
     pub polling_interval: u64,
 }
 
@@ -63,6 +65,7 @@  pub struct Project {
     pub remote_uri: String,
     pub jobs: Vec<Job>,
     pub push_results: bool,
+    pub category: Option<String>,
 }
 
 impl Project {