├── .gitignore ├── Makefile ├── tests ├── issues_test.rs ├── rep_test.rs ├── boards_test.rs ├── builder_test.rs └── sprints_test.rs ├── Cargo.toml ├── src ├── resolution.rs ├── transitions.rs ├── errors.rs ├── search.rs ├── boards.rs ├── builder.rs ├── sprints.rs ├── issues.rs ├── lib.rs └── rep.rs ├── examples ├── transitions.rs └── search.rs ├── LICENSE ├── CHANGELOG.md ├── README.md └── .travis.yml /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | *.bk -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | @cargo build 3 | 4 | clean: 5 | @cargo clean 6 | 7 | docs: 8 | @cargo docs 9 | 10 | test: 11 | @cargo test 12 | 13 | .PHONY: build clean docs 14 | -------------------------------------------------------------------------------- /tests/issues_test.rs: -------------------------------------------------------------------------------- 1 | extern crate goji; 2 | extern crate serde_json; 3 | 4 | use goji::issues::*; 5 | 6 | #[test] 7 | fn deserialise_issue_results() { 8 | let issue_results_str = r#"{ 9 | "expand": "names,schema", 10 | "startAt": 0, 11 | "maxResults": 50, 12 | "total": 0, 13 | "issues": [] 14 | }"#; 15 | 16 | let results: IssueResults = serde_json::from_str(issue_results_str).unwrap(); 17 | 18 | assert_eq!(results.expand, "names,schema"); 19 | assert_eq!(results.start_at, 0); 20 | assert_eq!(results.max_results, 50); 21 | assert_eq!(results.total, 0); 22 | assert_eq!(results.issues.len(), 0); 23 | } 24 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "goji" 3 | version = "0.2.4" 4 | authors = ["softprops "] 5 | description = "Rust interface for Jira" 6 | documentation = "http://softprops.github.io/goji" 7 | homepage = "https://github.com/softprops/goji" 8 | repository = "https://github.com/softprops/goji" 9 | keywords = ["hyper", "jira"] 10 | license = "MIT" 11 | readme = "README.md" 12 | edition = "2018" 13 | 14 | [badges] 15 | travis-ci = { repository = "softprops/goji" } 16 | maintenance = { status = "actively-developed" } 17 | 18 | [dev-dependencies] 19 | env_logger = "0.4" 20 | 21 | [dependencies] 22 | log = "0.4.5" 23 | reqwest = { version = "0.10", features = ['blocking'] } 24 | serde = "1.0" 25 | serde_derive = "1.0" 26 | serde_json = "1.0" 27 | url = "2.1" 28 | -------------------------------------------------------------------------------- /src/resolution.rs: -------------------------------------------------------------------------------- 1 | //! Interfaces for accessing and managing resolutions 2 | 3 | // Third party 4 | use std::collections::BTreeMap; 5 | 6 | // Ours 7 | use crate::{Jira, Result}; 8 | 9 | #[derive(Debug)] 10 | pub struct Resolution { 11 | jira: Jira, 12 | } 13 | 14 | #[derive(Deserialize, Debug, Clone)] 15 | pub struct Resolved { 16 | pub id: String, 17 | pub title: String, 18 | #[serde(rename = "type")] 19 | pub resolution_type: String, 20 | pub properties: BTreeMap, 21 | #[serde(rename = "additionalProperties")] 22 | pub additional_properties: bool, 23 | } 24 | 25 | impl Resolution { 26 | pub fn new(jira: &Jira) -> Resolution { 27 | Resolution { jira: jira.clone() } 28 | } 29 | 30 | pub fn get(&self, id: I) -> Result 31 | where 32 | I: Into, 33 | { 34 | self.jira.get("api", &format!("/resolution/{}", id.into())) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/transitions.rs: -------------------------------------------------------------------------------- 1 | extern crate env_logger; 2 | extern crate goji; 3 | 4 | use goji::{Credentials, Jira, TransitionTriggerOptions}; 5 | use std::env; 6 | 7 | fn main() { 8 | drop(env_logger::init()); 9 | if let (Ok(host), Ok(user), Ok(pass), Ok(key)) = ( 10 | env::var("JIRA_HOST"), 11 | env::var("JIRA_USER"), 12 | env::var("JIRA_PASS"), 13 | env::var("JIRA_KEY"), 14 | ) { 15 | let jira = Jira::new(host, Credentials::Basic(user, pass)).unwrap(); 16 | 17 | println!("{:#?}", jira.issues().get(key.clone())); 18 | let transitions = jira.transitions(key); 19 | for option in transitions.list() { 20 | println!("{:#?}", option); 21 | } 22 | if let Ok(transition_id) = env::var("JIRA_TRANSITION_ID") { 23 | transitions 24 | .trigger(TransitionTriggerOptions::new(transition_id)) 25 | .unwrap() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/rep_test.rs: -------------------------------------------------------------------------------- 1 | extern crate goji; 2 | extern crate serde_json; 3 | 4 | use goji::*; 5 | 6 | const JIRA_HOST: &str = "http://jira.com"; 7 | 8 | #[test] 9 | fn issue_getters() { 10 | let issue_str = r#"{ 11 | "self": "https://jira.com/rest/agile/1.0/issue/1234", 12 | "id": "1234", 13 | "key": "MYPROJ-1234", 14 | "fields": { 15 | "resolutiondate": "2018-07-11T16:56:12.000+0000" 16 | } 17 | }"#; 18 | 19 | let credentials = Credentials::Basic("user".to_string(), "pwd".to_string()); 20 | let jira = Jira::new(JIRA_HOST, credentials).unwrap(); 21 | let issue: Issue = serde_json::from_str(issue_str).unwrap(); 22 | 23 | let expected_permalink = format!("{}/browse/{}", JIRA_HOST, issue.key); 24 | let expected_resolution_date = Some("2018-07-11T16:56:12.000+0000".to_owned()); 25 | 26 | assert_eq!(issue.permalink(&jira), expected_permalink); 27 | assert_eq!(issue.resolution_date(), expected_resolution_date); 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2018 Doug Tangren 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.4 2 | 3 | * added boards issue search api interface [#30](https://github.com/softprops/goji/pull/30) 4 | * added `Issue.permalink` convenience method [#31](https://github.com/softprops/goji/pull/31) 5 | * fixed issue with boards iterator [#32](https://github.com/softprops/goji/pull/32) 6 | * fix naming issue with `issue.resolutiondate` field [#34](https://github.com/softprops/goji/pull/34/files) 7 | 8 | # 0.2.3 9 | 10 | * fix breaking changes with agile api paths 11 | 12 | # 0.2.2 13 | 14 | * added sprints interfaces [#24](https://github.com/softprops/goji/pull/24) 15 | * added boarders interfaces [#21](https://github.com/softprops/goji/pull/21) 16 | * added agile api [#20](https://github.com/softprops/goji/pull/20) 17 | 18 | # 0.2.1 19 | 20 | * updated issue and attachment interfaces 21 | 22 | # 0.2.0 23 | 24 | * replace hyper client with reqwest 25 | 26 | # 0.1.1 27 | 28 | * expanded search interface with and `iter` method that implements an `Iterator` over `Issues` 29 | * changed `SearchListOptionsBuilder#max` to `max_results` be more consistent with the underlying api 30 | * introduced `Error::Unauthorized` to handle invalid credentials with more grace 31 | * replaced usage of `u32` with `u64` for a more consistent interface 32 | * renamed `TransitionTrigger` to `TransitionTriggerOptions` for a more consistent api 33 | 34 | # 0.1.0 35 | 36 | * initial release 37 | -------------------------------------------------------------------------------- /tests/boards_test.rs: -------------------------------------------------------------------------------- 1 | extern crate goji; 2 | extern crate serde_json; 3 | 4 | use goji::boards::*; 5 | 6 | #[test] 7 | fn deserialise_board() { 8 | let board_str = r#"{ 9 | "id": 1, 10 | "self": "https://my.atlassian.net/rest/agile/1.0/board/1", 11 | "name": "TEST board", 12 | "type": "kanban" 13 | }"#; 14 | 15 | let board: Board = serde_json::from_str(board_str).unwrap(); 16 | 17 | assert_eq!(board.id, 1u64); 18 | assert_eq!( 19 | board.self_link, 20 | "https://my.atlassian.net/rest/agile/1.0/board/1" 21 | ); 22 | assert_eq!(board.name, "TEST board"); 23 | assert_eq!(board.type_name, "kanban"); 24 | } 25 | 26 | #[test] 27 | fn deserialise_board_results() { 28 | let board_results_str = r#"{ 29 | "maxResults": 50, 30 | "startAt": 0, 31 | "total": 2, 32 | "isLast": true, 33 | "values": [{ 34 | "id": 1, 35 | "self": "https://my.atlassian.net/rest/agile/1.0/board/1", 36 | "name": "TEST board", 37 | "type": "kanban" 38 | }] 39 | }"#; 40 | 41 | let board_results: BoardResults = serde_json::from_str(board_results_str).unwrap(); 42 | 43 | assert_eq!(board_results.max_results, 50u64); 44 | assert_eq!(board_results.start_at, 0u64); 45 | assert_eq!(board_results.is_last, true); 46 | assert_eq!(board_results.values.len(), 1); 47 | } 48 | -------------------------------------------------------------------------------- /src/transitions.rs: -------------------------------------------------------------------------------- 1 | //! Interfaces for accessing and managing transition 2 | 3 | // Ours 4 | use crate::{Error, Jira, Result, TransitionOption, TransitionOptions, TransitionTriggerOptions}; 5 | 6 | /// issue transition interface 7 | #[derive(Debug)] 8 | pub struct Transitions { 9 | jira: Jira, 10 | key: String, 11 | } 12 | 13 | impl Transitions { 14 | pub fn new(jira: &Jira, key: K) -> Transitions 15 | where 16 | K: Into, 17 | { 18 | Transitions { 19 | jira: jira.clone(), 20 | key: key.into(), 21 | } 22 | } 23 | 24 | /// return list of transitions options for this issue 25 | pub fn list(&self) -> Result> { 26 | self.jira 27 | .get::( 28 | "api", 29 | &format!("/issue/{}/transitions?expand=transitions.fields", self.key), 30 | ) 31 | .map(|wrapper| wrapper.transitions) 32 | } 33 | 34 | /// trigger a issue transition 35 | /// to transition with a resolution use TransitionTrigger::builder(id).resolution(name) 36 | pub fn trigger(&self, trans: TransitionTriggerOptions) -> Result<()> { 37 | self.jira 38 | .post::<(), TransitionTriggerOptions>( 39 | "api", 40 | &format!("/issue/{}/transitions", self.key), 41 | trans, 42 | ) 43 | .or_else(|e| match e { 44 | Error::Serde(_) => Ok(()), 45 | e => Err(e), 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/search.rs: -------------------------------------------------------------------------------- 1 | extern crate env_logger; 2 | extern crate goji; 3 | 4 | use goji::{Credentials, Jira}; 5 | use std::env; 6 | 7 | fn main() { 8 | drop(env_logger::init()); 9 | if let (Ok(host), Ok(user), Ok(pass)) = ( 10 | env::var("JIRA_HOST"), 11 | env::var("JIRA_USER"), 12 | env::var("JIRA_PASS"), 13 | ) { 14 | let query = env::args().nth(1).unwrap_or("assignee=doug".to_owned()); 15 | 16 | let jira = Jira::new(host, Credentials::Basic(user, pass)).unwrap(); 17 | 18 | match jira.search().iter(query, &Default::default()) { 19 | Ok(results) => { 20 | for issue in results { 21 | println!( 22 | "{} {} ({}): reporter {} assignee {}", 23 | issue.key, 24 | issue.summary().unwrap_or("???".to_owned()), 25 | issue 26 | .status() 27 | .map(|value| value.name,) 28 | .unwrap_or("???".to_owned(),), 29 | issue 30 | .reporter() 31 | .map(|value| value.display_name,) 32 | .unwrap_or("???".to_owned(),), 33 | issue 34 | .assignee() 35 | .map(|value| value.display_name,) 36 | .unwrap_or("???".to_owned(),) 37 | ); 38 | } 39 | } 40 | Err(err) => panic!("{:#?}", err), 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/builder_test.rs: -------------------------------------------------------------------------------- 1 | extern crate goji; 2 | extern crate serde_json; 3 | extern crate url; 4 | 5 | use goji::*; 6 | use std::collections::HashMap; 7 | use url::form_urlencoded; 8 | 9 | macro_rules! builder_pattern { 10 | ($($name:ident: ($param:ident, $value:expr, $query_param:expr, $query_value:expr),)*) => { 11 | $( 12 | #[test] 13 | fn $name() { 14 | let options = SearchOptionsBuilder::new() 15 | .$param($value) 16 | .build(); 17 | 18 | let options_str = options.serialize().unwrap(); 19 | 20 | let mut expected: HashMap<&str, &str> = HashMap::new(); 21 | expected.insert($query_param, $query_value); 22 | 23 | let expected_str = form_urlencoded::Serializer::new(String::new()) 24 | .extend_pairs(&expected) 25 | .finish(); 26 | 27 | assert_eq!(options_str, expected_str); 28 | } 29 | )* 30 | } 31 | } 32 | 33 | builder_pattern! { 34 | build_pattern_validate: (validate, true, "validateQuery", "true"), 35 | build_pattern_fields: (fields, vec!["field1", "field2"], "fields", "field1,field2"), 36 | build_pattern_max_results: (max_results, 50, "maxResults", "50"), 37 | build_pattern_start_at: (start_at, 10, "startAt", "10"), 38 | build_pattern_type_name: (type_name, "my_type", "type", "my_type"), 39 | build_pattern_name: (name, "my_name", "name", "my_name"), 40 | build_pattern_project_key_or_id: (project_key_or_id, "1234", "projectKeyOrId", "1234"), 41 | build_pattern_expand: (expand, vec!["expand1", "expand2"], "expand", "expand1,expand2"), 42 | build_pattern_state: (state, "my_state", "state","my_state"), 43 | build_pattern_jql: (jql, "project = '1234'", "jql", "project = '1234'"), 44 | build_pattern_jalidate_query: (validate_query, true, "validateQuery", "true"), 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goji [![Build Status](https://travis-ci.org/softprops/goji.svg?branch=master)](https://travis-ci.org/softprops/goji) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) [![crates.io](http://meritbadge.herokuapp.com/goji)](https://crates.io/crates/goji) [![Released API docs](https://docs.rs/goji/badge.svg)](http://docs.rs/goji) [![Master API docs](https://img.shields.io/badge/docs-master-green.svg)](https://softprops.github.io/goji) 2 | 3 | > a rust interface for [jira](https://www.atlassian.com/software/jira) 4 | 5 | ## install 6 | 7 | Add the following to your `Cargo.toml` file 8 | 9 | ```toml 10 | [dependencies] 11 | goji = "0.2" 12 | ``` 13 | 14 | ## usage 15 | 16 | Please browse the [examples](examples/) directory in this repo for some example applications. 17 | 18 | Basic usage requires a jira host, and a flavor of `jira::Credentials` for authorization. For user authenticated requests you'll typically want to use `jira::Credentials::Basic` with your jira username and password. 19 | 20 | Current support api support is limited to search and issue transitioning. 21 | 22 | ```rust 23 | extern crate env_logger; 24 | extern crate goji; 25 | 26 | use goji::{Credentials, Jira}; 27 | use std::env; 28 | 29 | fn main() { 30 | drop(env_logger::init()); 31 | if let (Ok(host), Ok(user), Ok(pass)) = 32 | ( 33 | env::var("JIRA_HOST"), 34 | env::var("JIRA_USER"), 35 | env::var("JIRA_PASS"), 36 | ) 37 | { 38 | let query = env::args().nth(1).unwrap_or("assignee=doug".to_owned()); 39 | 40 | let jira = Jira::new(host, Credentials::Basic(user, pass)).unwrap(); 41 | 42 | match jira.search().iter(query, &Default::default()) { 43 | Ok(results) => { 44 | for issue in results { 45 | println!("{:#?}", issue); 46 | } 47 | } 48 | Err(err) => panic!("{:#?}", err), 49 | } 50 | } 51 | } 52 | ``` 53 | 54 | ## what's with the name 55 | 56 | Jira's name is a [shortened form of gojira](https://en.wikipedia.org/wiki/Jira_(software)), 57 | another name for godzilla. Goji is a play on that. 58 | 59 | Doug Tangren (softprops) 2016-2018 60 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | // Third party 2 | use reqwest::Error as HttpError; 3 | use reqwest::StatusCode; 4 | use serde_json::error::Error as SerdeError; 5 | use std::io::Error as IoError; 6 | 7 | // Ours 8 | use crate::Errors; 9 | 10 | /// an enumeration over potential errors 11 | /// that may happen when sending a request to jira 12 | #[derive(Debug)] 13 | pub enum Error { 14 | /// error associated with http request 15 | Http(HttpError), 16 | /// error associated IO 17 | IO(IoError), 18 | /// error associated with parsing or serializing 19 | Serde(SerdeError), 20 | /// client request errors 21 | Fault { code: StatusCode, errors: Errors }, 22 | /// invalid credentials 23 | Unauthorized, 24 | /// HTTP method is not allowed 25 | MethodNotAllowed, 26 | /// Page not found 27 | NotFound, 28 | } 29 | 30 | impl From for Error { 31 | fn from(error: SerdeError) -> Error { 32 | Error::Serde(error) 33 | } 34 | } 35 | 36 | impl From for Error { 37 | fn from(error: HttpError) -> Error { 38 | Error::Http(error) 39 | } 40 | } 41 | 42 | impl From for Error { 43 | fn from(error: IoError) -> Error { 44 | Error::IO(error) 45 | } 46 | } 47 | 48 | impl ::std::fmt::Display for Error { 49 | fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { 50 | use crate::Error::*; 51 | 52 | match *self { 53 | Http(ref e) => writeln!(f, "Http Error: {}", e), 54 | IO(ref e) => writeln!(f, "IO Error: {}", e), 55 | Serde(ref e) => writeln!(f, "Serialization Error: {}", e), 56 | Fault { 57 | ref code, 58 | ref errors, 59 | } => writeln!(f, "Jira Client Error ({}):\n{:#?}", code, errors), 60 | _ => writeln!(f, "Could not connect to Jira: {}!", self), 61 | } 62 | } 63 | } 64 | 65 | impl ::std::error::Error for Error { 66 | fn description(&self) -> &str { 67 | use crate::Error::*; 68 | 69 | match *self { 70 | Http(ref e) => e.description(), 71 | IO(ref e) => e.description(), 72 | Serde(ref e) => e.description(), 73 | Fault { .. } => "Jira client error", 74 | Unauthorized => "Unauthorized", 75 | MethodNotAllowed => "MethodNotAllowed", 76 | NotFound => "NotFound", 77 | } 78 | } 79 | 80 | fn cause(&self) -> Option<&dyn (::std::error::Error)> { 81 | use crate::Error::*; 82 | 83 | match *self { 84 | Http(ref e) => Some(e), 85 | IO(ref e) => Some(e), 86 | Serde(ref e) => Some(e), 87 | Fault { .. } => None, 88 | _ => None, 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/sprints_test.rs: -------------------------------------------------------------------------------- 1 | extern crate goji; 2 | extern crate serde_json; 3 | 4 | use goji::sprints::*; 5 | 6 | #[test] 7 | fn deserialise_sprint() { 8 | let sprint_str = r#"{ 9 | "id": 72, 10 | "self": "http://www.example.com/jira/rest/agile/1.0/sprint/73", 11 | "name": "sprint 2" 12 | }"#; 13 | 14 | let sprint: Sprint = serde_json::from_str(sprint_str).unwrap(); 15 | 16 | assert_eq!(sprint.id, 72u64); 17 | assert_eq!(sprint.name, "sprint 2"); 18 | assert_eq!( 19 | sprint.self_link, 20 | "http://www.example.com/jira/rest/agile/1.0/sprint/73" 21 | ); 22 | assert_eq!(sprint.state, None); 23 | assert_eq!(sprint.start_date, None); 24 | assert_eq!(sprint.end_date, None); 25 | assert_eq!(sprint.complete_date, None); 26 | assert_eq!(sprint.origin_board_id, None); 27 | } 28 | 29 | #[test] 30 | fn deserialise_sprint_with_optional_fields() { 31 | let sprint_str = r#"{ 32 | "id": 72, 33 | "self": "http://www.example.com/jira/rest/agile/1.0/sprint/73", 34 | "state": "future", 35 | "name": "sprint 2", 36 | "startDate": "2015-04-11T15:22:00.000+10:00", 37 | "endDate": "2015-04-20T01:22:00.000+10:00", 38 | "completeDate": "2015-04-20T11:04:00.000+10:00", 39 | "originBoardId": 5 40 | }"#; 41 | 42 | let sprint: Sprint = serde_json::from_str(sprint_str).unwrap(); 43 | 44 | assert_eq!(sprint.id, 72u64); 45 | assert_eq!(sprint.state, Some("future".to_owned())); 46 | assert_eq!(sprint.name, "sprint 2"); 47 | assert_eq!( 48 | sprint.self_link, 49 | "http://www.example.com/jira/rest/agile/1.0/sprint/73" 50 | ); 51 | assert_eq!( 52 | sprint.start_date, 53 | Some("2015-04-11T15:22:00.000+10:00".to_owned()) 54 | ); 55 | assert_eq!( 56 | sprint.end_date, 57 | Some("2015-04-20T01:22:00.000+10:00".to_owned()) 58 | ); 59 | assert_eq!( 60 | sprint.complete_date, 61 | Some("2015-04-20T11:04:00.000+10:00".to_owned()) 62 | ); 63 | assert_eq!(sprint.origin_board_id, Some(5)); 64 | } 65 | 66 | #[test] 67 | fn deserialise_sprint_results() { 68 | let sprint_results_str = r#"{ 69 | "maxResults": 50, 70 | "startAt": 0, 71 | "isLast": true, 72 | "values": [{ 73 | "id": 72, 74 | "self": "http://www.example.com/jira/rest/agile/1.0/sprint/73", 75 | "state": "future", 76 | "name": "sprint 2" 77 | }] 78 | }"#; 79 | 80 | let sprint_results: SprintResults = serde_json::from_str(sprint_results_str).unwrap(); 81 | 82 | assert_eq!(sprint_results.max_results, 50u64); 83 | assert_eq!(sprint_results.start_at, 0u64); 84 | assert_eq!(sprint_results.is_last, true); 85 | assert_eq!(sprint_results.values.len(), 1); 86 | } 87 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: rust 4 | 5 | branches: 6 | only: 7 | - master 8 | 9 | matrix: 10 | fast_finish: true 11 | include: 12 | - rust: stable 13 | - rust: beta 14 | - rust: nightly 15 | allow_failures: 16 | - rust: nightly 17 | 18 | 19 | install: | 20 | # should only be necessary until rustfmt produces consistent results in stable/nightly 21 | # see also https://github.com/xd009642/tarpaulin/issues/150 for tarpaulin nightly dependency 22 | if [[ "$TRAVIS_RUST_VERSION" == nightly ]]; then 23 | `RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install --force cargo-tarpaulin` 24 | rustup component add rustfmt-preview 25 | fi 26 | 27 | script: 28 | - | 29 | if [[ "$TRAVIS_RUST_VERSION" == nightly ]]; then 30 | cargo fmt --all -- --check 31 | fi 32 | - cargo test 33 | 34 | # Cache `cargo install`ed tools, but don't cache the project's `target` 35 | # directory (which ends up over-caching and filling all disk space!) 36 | # https://levans.fr/rust_travis_cache.html 37 | cache: 38 | directories: 39 | - /home/travis/.cargo 40 | 41 | before_cache: 42 | # But don't cache the cargo registry 43 | - rm -rf /home/travis/.cargo/registry 44 | # Travis can't cache files that are not readable by "others" 45 | - chmod -R a+r $HOME/.cargo 46 | 47 | addons: 48 | apt: 49 | packages: 50 | # required by tarpaulin code coverage tool 51 | - libssl-dev 52 | 53 | after_success: 54 | - '[ $TRAVIS_EVENT_TYPE != "cron" ] && 55 | [ $TRAVIS_RUST_VERSION = nightly ] && 56 | [ $TRAVIS_BRANCH = master ] && 57 | [ $TRAVIS_PULL_REQUEST = false ] && 58 | cargo tarpaulin --ciserver travis-ci --coveralls $TRAVIS_JOB_ID || true' 59 | - '[ $TRAVIS_RUST_VERSION = stable ] && 60 | [ $TRAVIS_BRANCH = master ] && 61 | [ $TRAVIS_PULL_REQUEST = false ] 62 | && cargo doc --no-deps && 63 | echo "" > target/doc/index.html' 64 | 65 | deploy: 66 | provider: pages 67 | skip-cleanup: true 68 | github-token: $GH_TOKEN 69 | local-dir: target/doc 70 | keep-history: false 71 | on: 72 | branch: master 73 | condition: $TRAVIS_RUST_VERSION = stable 74 | 75 | env: 76 | global: 77 | secure: AijJsdo/C95ZJYoIkdJXCUkgl4jzkkduYcz84S9/b2kd16mntv96tCN8c3x1IRyr6vnlRdBwZn6ZcvKSHJXdRKfrDYHrh2/GZnMOOP99w1YcCYK2U5geK2/6ubQ7BzhVDulaGDEZz2uXkkdEIpFD9Satq2UiYNlIc6xNbU0wkPi8c/H6JcLNh9cYyK38OJTdGa3ljzrCI3GxVdVE2rzxyZjnSm9TDmb8KhiC2LJqN10h4oG3czcS0n1lZxSIp0wJQuM0L1i79omHRKNfJo1h0JpKqDDJMk6KzBVI3uRU3MvUpVMrnO+zyK8LnZjcXHczs6Ms29sPKIDTYLwg1MMBYPm4bmpeogP/8wMttSCYjCR55lao6xJBcMzgdMLtjT/GEyahlCfDOFrSLd7ZwyJUiep2TcECguz68P32x4gf1CmGu3X9DownBEcUBdKgCw973XbBbQEM3qW4p0EhG2ljS32roe9uzBmVSaRPjEsAiYl7CKOUQxuXxN1qp4TYa9IdXsjrIlFmEVILKmsZ7IcgHVSxaS8LnonwwrCH8DgdMErGo06ldKYJLaJIHVvrvexsAUpTVEeJtDpNc6aETP/+0g4/ZyCkq8ujqnOllVqotcJZNMAIANZhtkwW6VZvgz6LlDydruuYozfTlPS3PXwlr3mEqjLx9ut8yvIDYt4Ty70= 78 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | //! Interfaces for searching for issues 2 | 3 | // Third party 4 | use url::form_urlencoded; 5 | 6 | // Ours 7 | use crate::{Issue, Jira, Result, SearchOptions, SearchResults}; 8 | 9 | /// Search interface 10 | #[derive(Debug)] 11 | pub struct Search { 12 | jira: Jira, 13 | } 14 | 15 | impl Search { 16 | pub fn new(jira: &Jira) -> Search { 17 | Search { jira: jira.clone() } 18 | } 19 | 20 | /// Returns a single page of search results 21 | /// 22 | /// See the [jira docs](https://docs.atlassian.com/jira/REST/latest/#api/2/search) 23 | /// for more information 24 | pub fn list(&self, jql: J, options: &SearchOptions) -> Result 25 | where 26 | J: Into, 27 | { 28 | let mut path = vec!["/search".to_owned()]; 29 | let query_options = options.serialize().unwrap_or_default(); 30 | let query = form_urlencoded::Serializer::new(query_options) 31 | .append_pair("jql", &jql.into()) 32 | .finish(); 33 | path.push(query); 34 | self.jira 35 | .get::("api", path.join("?").as_ref()) 36 | } 37 | 38 | /// Return a type which may be used to iterate over consecutive pages of results 39 | /// 40 | /// See the [jira docs](https://docs.atlassian.com/jira/REST/latest/#api/2/search) 41 | /// for more information 42 | pub fn iter<'a, J>(&self, jql: J, options: &'a SearchOptions) -> Result> 43 | where 44 | J: Into, 45 | { 46 | Iter::new(jql, options, &self.jira) 47 | } 48 | } 49 | 50 | /// provides an iterator over multiple pages of search results 51 | #[derive(Debug)] 52 | pub struct Iter<'a> { 53 | jira: Jira, 54 | jql: String, 55 | results: SearchResults, 56 | search_options: &'a SearchOptions, 57 | } 58 | 59 | impl<'a> Iter<'a> { 60 | fn new(jql: J, options: &'a SearchOptions, jira: &Jira) -> Result 61 | where 62 | J: Into, 63 | { 64 | let query = jql.into(); 65 | let results = jira.search().list(query.clone(), options)?; 66 | Ok(Iter { 67 | jira: jira.clone(), 68 | jql: query, 69 | results, 70 | search_options: options, 71 | }) 72 | } 73 | 74 | fn more(&self) -> bool { 75 | (self.results.start_at + self.results.issues.len() as u64) < self.results.total 76 | } 77 | } 78 | 79 | impl<'a> Iterator for Iter<'a> { 80 | type Item = Issue; 81 | fn next(&mut self) -> Option { 82 | self.results.issues.pop().or_else(|| { 83 | if self.more() { 84 | match self.jira.search().list( 85 | self.jql.clone(), 86 | &self 87 | .search_options 88 | .as_builder() 89 | .max_results(self.results.max_results) 90 | .start_at(self.results.start_at + self.results.max_results) 91 | .build(), 92 | ) { 93 | Ok(new_results) => { 94 | self.results = new_results; 95 | self.results.issues.pop() 96 | } 97 | _ => None, 98 | } 99 | } else { 100 | None 101 | } 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/boards.rs: -------------------------------------------------------------------------------- 1 | //! Interfaces for accessing and managing boards 2 | 3 | // Third party 4 | use url::form_urlencoded; 5 | 6 | // Ours 7 | use crate::{Jira, Result, SearchOptions}; 8 | 9 | #[derive(Debug)] 10 | pub struct Boards { 11 | jira: Jira, 12 | } 13 | 14 | #[derive(Deserialize, Debug, Clone)] 15 | pub struct Board { 16 | #[serde(rename = "self")] 17 | pub self_link: String, 18 | pub id: u64, 19 | pub name: String, 20 | #[serde(rename = "type")] 21 | pub type_name: String, 22 | } 23 | 24 | #[derive(Deserialize, Debug)] 25 | pub struct BoardResults { 26 | #[serde(rename = "maxResults")] 27 | pub max_results: u64, 28 | #[serde(rename = "startAt")] 29 | pub start_at: u64, 30 | #[serde(rename = "isLast")] 31 | pub is_last: bool, 32 | pub values: Vec, 33 | } 34 | 35 | impl Boards { 36 | pub fn new(jira: &Jira) -> Boards { 37 | Boards { jira: jira.clone() } 38 | } 39 | 40 | /// Get a single board 41 | /// 42 | /// See this [jira docs](https://docs.atlassian.com/jira-software/REST/7.0.4/#agile/1.0/board-getBoard) 43 | /// for more information 44 | pub fn get(&self, id: I) -> Result 45 | where 46 | I: Into, 47 | { 48 | self.jira.get("agile", &format!("/board/{}", id.into())) 49 | } 50 | 51 | /// Returns a single page of board results 52 | /// 53 | /// See the [jira docs](https://docs.atlassian.com/jira-software/REST/latest/#agile/1.0/board-getAllBoards) 54 | /// for more information 55 | pub fn list(&self, options: &SearchOptions) -> Result { 56 | let mut path = vec!["/board".to_owned()]; 57 | let query_options = options.serialize().unwrap_or_default(); 58 | let query = form_urlencoded::Serializer::new(query_options).finish(); 59 | 60 | path.push(query); 61 | 62 | self.jira 63 | .get::("agile", path.join("?").as_ref()) 64 | } 65 | 66 | /// Returns a type which may be used to iterate over consecutive pages of results 67 | /// 68 | /// See the [jira docs](https://docs.atlassian.com/jira-software/REST/latest/#agile/1.0/board-getAllBoards) 69 | /// for more information 70 | pub fn iter<'a>(&self, options: &'a SearchOptions) -> Result> { 71 | BoardsIter::new(options, &self.jira) 72 | } 73 | } 74 | 75 | /// Provides an iterator over multiple pages of search results 76 | #[derive(Debug)] 77 | pub struct BoardsIter<'a> { 78 | jira: Jira, 79 | results: BoardResults, 80 | search_options: &'a SearchOptions, 81 | } 82 | 83 | impl<'a> BoardsIter<'a> { 84 | fn new(options: &'a SearchOptions, jira: &Jira) -> Result { 85 | let results = jira.boards().list(options)?; 86 | Ok(BoardsIter { 87 | jira: jira.clone(), 88 | results, 89 | search_options: options, 90 | }) 91 | } 92 | 93 | fn more(&self) -> bool { 94 | !self.results.is_last 95 | } 96 | } 97 | 98 | impl<'a> Iterator for BoardsIter<'a> { 99 | type Item = Board; 100 | fn next(&mut self) -> Option { 101 | self.results.values.pop().or_else(|| { 102 | if self.more() { 103 | match self.jira.boards().list( 104 | &self 105 | .search_options 106 | .as_builder() 107 | .max_results(self.results.max_results) 108 | .start_at(self.results.start_at + self.results.max_results) 109 | .build(), 110 | ) { 111 | Ok(new_results) => { 112 | self.results = new_results; 113 | self.results.values.pop() 114 | } 115 | _ => None, 116 | } 117 | } else { 118 | None 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/builder.rs: -------------------------------------------------------------------------------- 1 | // Third party 2 | use std::collections::HashMap; 3 | use url::form_urlencoded; 4 | 5 | /// options availble for search 6 | #[derive(Default, Clone, Debug)] 7 | pub struct SearchOptions { 8 | params: HashMap<&'static str, String>, 9 | } 10 | 11 | impl SearchOptions { 12 | /// return a new instance of a builder for options 13 | pub fn builder() -> SearchOptionsBuilder { 14 | SearchOptionsBuilder::new() 15 | } 16 | 17 | /// serialize options as a string. returns None if no options are defined 18 | pub fn serialize(&self) -> Option { 19 | if self.params.is_empty() { 20 | None 21 | } else { 22 | Some( 23 | form_urlencoded::Serializer::new(String::new()) 24 | .extend_pairs(&self.params) 25 | .finish(), 26 | ) 27 | } 28 | } 29 | 30 | pub fn as_builder(&self) -> SearchOptionsBuilder { 31 | SearchOptionsBuilder::copy_from(self) 32 | } 33 | } 34 | 35 | /// a builder interface for search option 36 | /// Typically this is initialized with SearchOptions::builder() 37 | #[derive(Default, Debug)] 38 | pub struct SearchOptionsBuilder { 39 | params: HashMap<&'static str, String>, 40 | } 41 | 42 | impl SearchOptionsBuilder { 43 | pub fn new() -> SearchOptionsBuilder { 44 | SearchOptionsBuilder { 45 | ..Default::default() 46 | } 47 | } 48 | 49 | fn copy_from(search_options: &SearchOptions) -> SearchOptionsBuilder { 50 | SearchOptionsBuilder { 51 | params: search_options.params.clone(), 52 | } 53 | } 54 | 55 | pub fn fields(&mut self, fs: Vec) -> &mut SearchOptionsBuilder 56 | where 57 | F: Into, 58 | { 59 | self.params.insert( 60 | "fields", 61 | fs.into_iter() 62 | .map(|f| f.into()) 63 | .collect::>() 64 | .join(","), 65 | ); 66 | self 67 | } 68 | 69 | pub fn validate(&mut self, v: bool) -> &mut SearchOptionsBuilder { 70 | self.params.insert("validateQuery", v.to_string()); 71 | self 72 | } 73 | 74 | pub fn max_results(&mut self, m: u64) -> &mut SearchOptionsBuilder { 75 | self.params.insert("maxResults", m.to_string()); 76 | self 77 | } 78 | 79 | pub fn start_at(&mut self, s: u64) -> &mut SearchOptionsBuilder { 80 | self.params.insert("startAt", s.to_string()); 81 | self 82 | } 83 | 84 | pub fn type_name(&mut self, t: &str) -> &mut SearchOptionsBuilder { 85 | self.params.insert("type", t.to_string()); 86 | self 87 | } 88 | 89 | pub fn name(&mut self, n: &str) -> &mut SearchOptionsBuilder { 90 | self.params.insert("name", n.to_string()); 91 | self 92 | } 93 | 94 | pub fn project_key_or_id(&mut self, id: &str) -> &mut SearchOptionsBuilder { 95 | self.params.insert("projectKeyOrId", id.to_string()); 96 | self 97 | } 98 | 99 | pub fn expand(&mut self, ex: Vec) -> &mut SearchOptionsBuilder 100 | where 101 | E: Into, 102 | { 103 | self.params.insert( 104 | "expand", 105 | ex.into_iter() 106 | .map(|e| e.into()) 107 | .collect::>() 108 | .join(","), 109 | ); 110 | self 111 | } 112 | 113 | pub fn state(&mut self, s: &str) -> &mut SearchOptionsBuilder { 114 | self.params.insert("state", s.to_string()); 115 | self 116 | } 117 | 118 | pub fn jql(&mut self, s: &str) -> &mut SearchOptionsBuilder { 119 | self.params.insert("jql", s.to_string()); 120 | self 121 | } 122 | 123 | pub fn validate_query(&mut self, v: bool) -> &mut SearchOptionsBuilder { 124 | self.params.insert("validateQuery", v.to_string()); 125 | self 126 | } 127 | 128 | pub fn build(&self) -> SearchOptions { 129 | SearchOptions { 130 | params: self.params.clone(), 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/sprints.rs: -------------------------------------------------------------------------------- 1 | //! Interfaces for accessing and managing sprints 2 | 3 | // Third party 4 | use url::form_urlencoded; 5 | 6 | // Ours 7 | use crate::{Board, EmptyResponse, Jira, Result, SearchOptions}; 8 | 9 | #[derive(Debug)] 10 | pub struct Sprints { 11 | jira: Jira, 12 | } 13 | 14 | #[derive(Deserialize, Debug, Clone)] 15 | pub struct Sprint { 16 | pub id: u64, 17 | #[serde(rename = "self")] 18 | pub self_link: String, 19 | pub name: String, 20 | pub state: Option, 21 | #[serde(rename = "startDate")] 22 | pub start_date: Option, 23 | #[serde(rename = "endDate")] 24 | pub end_date: Option, 25 | #[serde(rename = "completeDate")] 26 | pub complete_date: Option, 27 | #[serde(rename = "originBoardId")] 28 | pub origin_board_id: Option, 29 | } 30 | 31 | #[derive(Deserialize, Debug)] 32 | pub struct SprintResults { 33 | #[serde(rename = "maxResults")] 34 | pub max_results: u64, 35 | #[serde(rename = "startAt")] 36 | pub start_at: u64, 37 | #[serde(rename = "isLast")] 38 | pub is_last: bool, 39 | pub values: Vec, 40 | } 41 | 42 | #[derive(Serialize, Debug)] 43 | struct MoveIssues { 44 | issues: Vec, 45 | } 46 | 47 | impl Sprints { 48 | pub fn new(jira: &Jira) -> Sprints { 49 | Sprints { jira: jira.clone() } 50 | } 51 | 52 | /// returns a single page of board results 53 | /// https://docs.atlassian.com/jira-software/REST/latest/#agile/1.0/board/{boardId}/sprint-getAllSprints 54 | pub fn list(&self, board: &Board, options: &SearchOptions) -> Result { 55 | let mut path = vec![format!("/board/{}/sprint", board.id.to_string())]; 56 | let query_options = options.serialize().unwrap_or_default(); 57 | let query = form_urlencoded::Serializer::new(query_options).finish(); 58 | 59 | path.push(query); 60 | 61 | self.jira 62 | .get::("agile", path.join("?").as_ref()) 63 | } 64 | 65 | /// move issues into sprint 66 | /// https://docs.atlassian.com/jira-software/REST/7.3.1/#agile/1.0/sprint-moveIssuesToSprint 67 | pub fn move_issues(&self, sprint_id: u64, issues: Vec) -> Result { 68 | let path = format!("/sprint/{}/issue", sprint_id); 69 | let data = MoveIssues { issues }; 70 | 71 | self.jira.post("agile", &path, data) 72 | } 73 | 74 | /// runs a type why may be used to iterate over consecutive pages of results 75 | /// https://docs.atlassian.com/jira-software/REST/latest/#agile/1.0/board-getAllBoards 76 | pub fn iter<'a>( 77 | &self, 78 | board: &'a Board, 79 | options: &'a SearchOptions, 80 | ) -> Result> { 81 | SprintsIter::new(board, options, &self.jira) 82 | } 83 | } 84 | 85 | /// provides an iterator over multiple pages of search results 86 | #[derive(Debug)] 87 | pub struct SprintsIter<'a> { 88 | jira: Jira, 89 | board: &'a Board, 90 | results: SprintResults, 91 | search_options: &'a SearchOptions, 92 | } 93 | 94 | impl<'a> SprintsIter<'a> { 95 | fn new(board: &'a Board, options: &'a SearchOptions, jira: &Jira) -> Result { 96 | let results = jira.sprints().list(board, options)?; 97 | Ok(SprintsIter { 98 | board, 99 | jira: jira.clone(), 100 | results, 101 | search_options: options, 102 | }) 103 | } 104 | 105 | fn more(&self) -> bool { 106 | !self.results.is_last 107 | } 108 | } 109 | 110 | impl<'a> Iterator for SprintsIter<'a> { 111 | type Item = Sprint; 112 | fn next(&mut self) -> Option { 113 | self.results.values.pop().or_else(|| { 114 | if self.more() { 115 | match self.jira.sprints().list( 116 | self.board, 117 | &self 118 | .search_options 119 | .as_builder() 120 | .max_results(self.results.max_results) 121 | .start_at(self.results.start_at + self.results.max_results) 122 | .build(), 123 | ) { 124 | Ok(new_results) => { 125 | self.results = new_results; 126 | self.results.values.pop() 127 | } 128 | _ => None, 129 | } 130 | } else { 131 | None 132 | } 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/issues.rs: -------------------------------------------------------------------------------- 1 | //! Interfaces for accessing and managing issues 2 | 3 | // Third party 4 | use url::form_urlencoded; 5 | 6 | // Ours 7 | use crate::{Board, Issue, Jira, Result, SearchOptions}; 8 | 9 | /// issue options 10 | #[derive(Debug)] 11 | pub struct Issues { 12 | jira: Jira, 13 | } 14 | 15 | #[derive(Serialize, Debug, Clone)] 16 | pub struct Assignee { 17 | pub name: String, 18 | } 19 | 20 | #[derive(Serialize, Debug, Clone)] 21 | pub struct IssueType { 22 | pub id: String, 23 | } 24 | 25 | #[derive(Serialize, Debug, Clone)] 26 | pub struct Priority { 27 | pub id: String, 28 | } 29 | 30 | #[derive(Serialize, Debug, Clone)] 31 | pub struct Project { 32 | pub key: String, 33 | } 34 | 35 | #[derive(Serialize, Debug, Clone)] 36 | pub struct Component { 37 | pub name: String, 38 | } 39 | 40 | #[derive(Serialize, Debug)] 41 | #[serde(rename_all = "camelCase")] 42 | pub struct Fields { 43 | pub assignee: Assignee, 44 | pub components: Vec, 45 | pub description: String, 46 | pub environment: String, 47 | pub issuetype: IssueType, 48 | pub priority: Priority, 49 | pub project: Project, 50 | pub reporter: Assignee, 51 | pub summary: String, 52 | } 53 | 54 | #[derive(Serialize, Debug)] 55 | pub struct CreateIssue { 56 | pub fields: Fields, 57 | } 58 | 59 | #[derive(Debug, Deserialize)] 60 | pub struct CreateResponse { 61 | pub id: String, 62 | pub key: String, 63 | #[serde(rename = "self")] 64 | pub url: String, 65 | } 66 | 67 | #[derive(Deserialize, Debug)] 68 | pub struct IssueResults { 69 | pub expand: String, 70 | #[serde(rename = "maxResults")] 71 | pub max_results: u64, 72 | #[serde(rename = "startAt")] 73 | pub start_at: u64, 74 | pub total: u64, 75 | pub issues: Vec, 76 | } 77 | 78 | impl Issues { 79 | pub fn new(jira: &Jira) -> Issues { 80 | Issues { jira: jira.clone() } 81 | } 82 | 83 | pub fn get(&self, id: I) -> Result 84 | where 85 | I: Into, 86 | { 87 | self.jira.get("api", &format!("/issue/{}", id.into())) 88 | } 89 | pub fn create(&self, data: CreateIssue) -> Result { 90 | self.jira.post("api", "/issue", data) 91 | } 92 | 93 | /// returns a single page of issues results 94 | /// https://docs.atlassian.com/jira-software/REST/latest/#agile/1.0/board-getIssuesForBoard 95 | pub fn list(&self, board: &Board, options: &SearchOptions) -> Result { 96 | let mut path = vec![format!("/board/{}/issue", board.id)]; 97 | let query_options = options.serialize().unwrap_or_default(); 98 | let query = form_urlencoded::Serializer::new(query_options).finish(); 99 | 100 | path.push(query); 101 | 102 | self.jira 103 | .get::("agile", path.join("?").as_ref()) 104 | } 105 | 106 | /// runs a type why may be used to iterate over consecutive pages of results 107 | /// https://docs.atlassian.com/jira-software/REST/latest/#agile/1.0/board-getIssuesForBoard 108 | pub fn iter<'a>(&self, board: &'a Board, options: &'a SearchOptions) -> Result> { 109 | IssuesIter::new(board, options, &self.jira) 110 | } 111 | } 112 | 113 | /// provides an iterator over multiple pages of search results 114 | #[derive(Debug)] 115 | pub struct IssuesIter<'a> { 116 | jira: Jira, 117 | board: &'a Board, 118 | results: IssueResults, 119 | search_options: &'a SearchOptions, 120 | } 121 | 122 | impl<'a> IssuesIter<'a> { 123 | fn new(board: &'a Board, options: &'a SearchOptions, jira: &Jira) -> Result { 124 | let results = jira.issues().list(board, options)?; 125 | Ok(IssuesIter { 126 | board, 127 | jira: jira.clone(), 128 | results, 129 | search_options: options, 130 | }) 131 | } 132 | 133 | fn more(&self) -> bool { 134 | (self.results.start_at + self.results.max_results) <= self.results.total 135 | } 136 | } 137 | 138 | impl<'a> Iterator for IssuesIter<'a> { 139 | type Item = Issue; 140 | fn next(&mut self) -> Option { 141 | self.results.issues.pop().or_else(|| { 142 | if self.more() { 143 | match self.jira.issues().list( 144 | self.board, 145 | &self 146 | .search_options 147 | .as_builder() 148 | .max_results(self.results.max_results) 149 | .start_at(self.results.start_at + self.results.max_results) 150 | .build(), 151 | ) { 152 | Ok(new_results) => { 153 | self.results = new_results; 154 | self.results.issues.pop() 155 | } 156 | _ => None, 157 | } 158 | } else { 159 | None 160 | } 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Goji provides an interface for Jira's REST api 2 | 3 | #[macro_use] 4 | extern crate log; 5 | extern crate reqwest; 6 | extern crate serde; 7 | #[macro_use] 8 | extern crate serde_derive; 9 | extern crate serde_json; 10 | extern crate url; 11 | 12 | use std::io::Read; 13 | 14 | use reqwest::header::CONTENT_TYPE; 15 | use reqwest::{blocking::Client, Method, StatusCode}; 16 | use serde::de::DeserializeOwned; 17 | use serde::Serialize; 18 | 19 | mod builder; 20 | mod errors; 21 | pub mod issues; 22 | mod rep; 23 | mod search; 24 | mod transitions; 25 | 26 | pub use crate::builder::*; 27 | pub use crate::errors::*; 28 | pub use crate::issues::*; 29 | pub use crate::rep::*; 30 | pub use crate::search::Search; 31 | pub use crate::transitions::*; 32 | pub mod boards; 33 | pub mod resolution; 34 | pub use crate::boards::*; 35 | pub mod sprints; 36 | pub use crate::sprints::*; 37 | 38 | #[derive(Deserialize, Debug)] 39 | pub struct EmptyResponse; 40 | 41 | pub type Result = std::result::Result; 42 | 43 | /// Types of authentication credentials 44 | #[derive(Clone, Debug)] 45 | pub enum Credentials { 46 | /// username and password credentials 47 | Basic(String, String), // todo: OAuth 48 | } 49 | 50 | /// Entrypoint into client interface 51 | /// https://docs.atlassian.com/jira/REST/latest/ 52 | #[derive(Clone, Debug)] 53 | pub struct Jira { 54 | host: String, 55 | credentials: Credentials, 56 | client: Client, 57 | } 58 | 59 | impl Jira { 60 | /// creates a new instance of a jira client 61 | pub fn new(host: H, credentials: Credentials) -> Result 62 | where 63 | H: Into, 64 | { 65 | Ok(Jira { 66 | host: host.into(), 67 | client: Client::new(), 68 | credentials, 69 | }) 70 | } 71 | 72 | /// creates a new instance of a jira client using a specified reqwest client 73 | pub fn from_client(host: H, credentials: Credentials, client: Client) -> Result 74 | where 75 | H: Into, 76 | { 77 | Ok(Jira { 78 | host: host.into(), 79 | credentials, 80 | client, 81 | }) 82 | } 83 | 84 | /// return transitions interface 85 | pub fn transitions(&self, key: K) -> Transitions 86 | where 87 | K: Into, 88 | { 89 | Transitions::new(self, key) 90 | } 91 | 92 | /// return search interface 93 | pub fn search(&self) -> Search { 94 | Search::new(self) 95 | } 96 | 97 | // return issues interface 98 | pub fn issues(&self) -> Issues { 99 | Issues::new(self) 100 | } 101 | 102 | // return boards interface 103 | pub fn boards(&self) -> Boards { 104 | Boards::new(self) 105 | } 106 | 107 | // return boards interface 108 | pub fn sprints(&self) -> Sprints { 109 | Sprints::new(self) 110 | } 111 | 112 | fn post(&self, api_name: &str, endpoint: &str, body: S) -> Result 113 | where 114 | D: DeserializeOwned, 115 | S: Serialize, 116 | { 117 | let data = serde_json::to_string::(&body)?; 118 | debug!("Json request: {}", data); 119 | self.request::(Method::POST, api_name, endpoint, Some(data.into_bytes())) 120 | } 121 | 122 | fn get(&self, api_name: &str, endpoint: &str) -> Result 123 | where 124 | D: DeserializeOwned, 125 | { 126 | self.request::(Method::GET, api_name, endpoint, None) 127 | } 128 | 129 | fn request( 130 | &self, 131 | method: Method, 132 | api_name: &str, 133 | endpoint: &str, 134 | body: Option>, 135 | ) -> Result 136 | where 137 | D: DeserializeOwned, 138 | { 139 | let url = format!("{}/rest/{}/latest{}", self.host, api_name, endpoint); 140 | debug!("url -> {:?}", url); 141 | 142 | let req = self.client.request(method, &url); 143 | let builder = match self.credentials { 144 | Credentials::Basic(ref user, ref pass) => req 145 | .basic_auth(user.to_owned(), Some(pass.to_owned())) 146 | .header(CONTENT_TYPE, "application/json"), 147 | }; 148 | 149 | let mut res = match body { 150 | Some(bod) => builder.body(bod).send()?, 151 | _ => builder.send()?, 152 | }; 153 | 154 | let mut body = String::new(); 155 | res.read_to_string(&mut body)?; 156 | debug!("status {:?} body '{:?}'", res.status(), body); 157 | match res.status() { 158 | StatusCode::UNAUTHORIZED => Err(Error::Unauthorized), 159 | StatusCode::METHOD_NOT_ALLOWED => Err(Error::MethodNotAllowed), 160 | StatusCode::NOT_FOUND => Err(Error::NotFound), 161 | client_err if client_err.is_client_error() => Err(Error::Fault { 162 | code: res.status(), 163 | errors: serde_json::from_str::(&body)?, 164 | }), 165 | _ => { 166 | let data = if body == "" { "null" } else { &body }; 167 | Ok(serde_json::from_str::(data)?) 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/rep.rs: -------------------------------------------------------------------------------- 1 | // Third party 2 | 3 | use serde::Deserialize; 4 | use serde::Serialize; 5 | use serde_json; 6 | use std::collections::BTreeMap; 7 | 8 | // Ours 9 | use crate::{Jira, Result}; 10 | 11 | /// represents an general jira error response 12 | #[derive(Deserialize, Debug)] 13 | pub struct Errors { 14 | #[serde(rename = "errorMessages")] 15 | pub error_messages: Vec, 16 | pub errors: BTreeMap, 17 | } 18 | 19 | /// represents a single jira issue 20 | #[derive(Deserialize, Debug, Clone)] 21 | pub struct Issue { 22 | #[serde(rename = "self")] 23 | pub self_link: String, 24 | pub key: String, 25 | pub id: String, 26 | pub fields: BTreeMap, 27 | pub changelog: Option, 28 | } 29 | 30 | impl Issue { 31 | /// resolves a typed field from an issues lists of arbitrary fields 32 | pub fn field(&self, name: &str) -> Option> 33 | where 34 | for<'de> F: Deserialize<'de>, 35 | { 36 | self.fields 37 | .get(name) 38 | .map(|value| Ok(serde_json::value::from_value::(value.clone())?)) 39 | } 40 | 41 | fn user_field(&self, name: &str) -> Option> { 42 | self.field::(name) 43 | } 44 | 45 | fn string_field(&self, name: &str) -> Option> { 46 | self.field::(name) 47 | } 48 | 49 | /// user assigned to issue 50 | pub fn assignee(&self) -> Option { 51 | self.user_field("assignee").and_then(|value| value.ok()) 52 | } 53 | 54 | /// user that created the issue 55 | pub fn creator(&self) -> Option { 56 | self.user_field("creator").and_then(|value| value.ok()) 57 | } 58 | 59 | /// user that reported the issue 60 | pub fn reporter(&self) -> Option { 61 | self.user_field("reporter").and_then(|value| value.ok()) 62 | } 63 | 64 | /// the current status of the issue 65 | pub fn status(&self) -> Option { 66 | self.field::("status").and_then(|value| value.ok()) 67 | } 68 | 69 | /// brief summary of the issue 70 | pub fn summary(&self) -> Option { 71 | self.string_field("summary").and_then(|value| value.ok()) 72 | } 73 | 74 | /// description of the issue 75 | pub fn description(&self) -> Option { 76 | self.string_field("description") 77 | .and_then(|value| value.ok()) 78 | } 79 | 80 | /// updated timestamp 81 | pub fn updated(&self) -> Option { 82 | self.string_field("updated").and_then(|value| value.ok()) 83 | } 84 | 85 | /// created timestamp 86 | pub fn created(&self) -> Option { 87 | self.string_field("created").and_then(|value| value.ok()) 88 | } 89 | 90 | pub fn resolution_date(&self) -> Option { 91 | self.string_field("resolutiondate") 92 | .and_then(|value| value.ok()) 93 | } 94 | 95 | /// an issue type 96 | pub fn issue_type(&self) -> Option { 97 | self.field::("issuetype") 98 | .and_then(|value| value.ok()) 99 | } 100 | 101 | /// labels associated with the issue 102 | pub fn labels(&self) -> Vec { 103 | self.field::>("labels") 104 | .and_then(|value| value.ok()) 105 | .unwrap_or_default() 106 | } 107 | 108 | /// list of versions associated with the issue 109 | pub fn fix_versions(&self) -> Vec { 110 | self.field::>("fixVersions") 111 | .and_then(|value| value.ok()) 112 | .unwrap_or_default() 113 | } 114 | 115 | /// priority of the issue 116 | pub fn priority(&self) -> Option { 117 | self.field::("priority") 118 | .and_then(|value| value.ok()) 119 | } 120 | 121 | /// links to other issues 122 | pub fn links(&self) -> Option>> { 123 | self.field::>("issuelinks") //.and_then(|value| value.ok()).unwrap_or(vec![]) 124 | } 125 | 126 | pub fn project(&self) -> Option { 127 | self.field::("project") 128 | .and_then(|value| value.ok()) 129 | } 130 | 131 | pub fn resolution(&self) -> Option { 132 | self.field::("resolution") 133 | .and_then(|value| value.ok()) 134 | } 135 | 136 | pub fn attachment(&self) -> Vec { 137 | self.field::>("attachment") 138 | .and_then(|value| value.ok()) 139 | .unwrap_or_default() 140 | } 141 | 142 | pub fn comment(&self) -> Vec { 143 | self.field::("comment") 144 | .and_then(|value| value.ok()) 145 | .map(|value| value.comments) 146 | .unwrap_or_default() 147 | } 148 | 149 | pub fn permalink(&self, jira: &Jira) -> String { 150 | format!("{}/browse/{}", jira.host, self.key) 151 | } 152 | } 153 | 154 | #[derive(Deserialize, Debug)] 155 | pub struct Attachment { 156 | pub id: String, 157 | #[serde(rename = "self")] 158 | pub self_link: String, 159 | pub filename: String, 160 | pub author: User, 161 | pub created: String, 162 | pub size: u64, 163 | #[serde(rename = "mimeType")] 164 | pub mime_type: String, 165 | pub content: String, 166 | pub thumbnail: Option, 167 | } 168 | 169 | #[derive(Deserialize, Debug)] 170 | pub struct Comments { 171 | pub comments: Vec, 172 | } 173 | 174 | #[derive(Deserialize, Debug)] 175 | pub struct Comment { 176 | pub id: Option, 177 | #[serde(rename = "self")] 178 | pub self_link: String, 179 | pub author: Option, 180 | #[serde(rename = "updateAuthor")] 181 | pub update_author: Option, 182 | pub created: String, 183 | pub updated: String, 184 | pub body: String, 185 | pub visibility: Option, 186 | } 187 | 188 | #[derive(Deserialize, Debug)] 189 | pub struct Visibility { 190 | #[serde(rename = "type")] 191 | pub visibility_type: String, 192 | pub value: String, 193 | } 194 | 195 | #[derive(Deserialize, Debug, Clone)] 196 | pub struct Changelog { 197 | pub histories: Vec, 198 | } 199 | 200 | #[derive(Deserialize, Debug, Clone)] 201 | pub struct History { 202 | pub author: User, 203 | pub created: String, 204 | pub items: Vec, 205 | } 206 | 207 | #[derive(Deserialize, Debug, Clone)] 208 | pub struct HistoryItem { 209 | pub field: String, 210 | pub from: Option, 211 | #[serde(rename = "fromString")] 212 | pub from_string: Option, 213 | pub to: Option, 214 | #[serde(rename = "toString")] 215 | pub to_string: Option, 216 | } 217 | 218 | #[derive(Deserialize, Debug)] 219 | pub struct Project { 220 | pub id: String, 221 | pub key: String, 222 | pub name: String, 223 | } 224 | 225 | /// represents link relationship between issues 226 | #[derive(Deserialize, Debug)] 227 | pub struct IssueLink { 228 | pub id: String, 229 | #[serde(rename = "self")] 230 | pub self_link: String, 231 | #[serde(rename = "outwardIssue")] 232 | pub outward_issue: Option, 233 | #[serde(rename = "inwardIssue")] 234 | pub inward_issue: Option, 235 | #[serde(rename = "type")] 236 | pub link_type: LinkType, 237 | } 238 | 239 | /// represents type of issue relation 240 | #[derive(Deserialize, Debug)] 241 | pub struct LinkType { 242 | pub id: String, 243 | pub inward: String, 244 | pub name: String, 245 | pub outward: String, 246 | #[serde(rename = "self")] 247 | pub self_link: String, 248 | } 249 | 250 | #[derive(Deserialize, Debug)] 251 | pub struct Version { 252 | pub archived: bool, 253 | pub id: String, 254 | pub name: String, 255 | pub released: bool, 256 | #[serde(rename = "self")] 257 | pub self_link: String, 258 | } 259 | 260 | #[derive(Deserialize, Debug, Clone)] 261 | pub struct User { 262 | pub active: bool, 263 | #[serde(rename = "avatarUrls")] 264 | pub avatar_urls: BTreeMap, 265 | #[serde(rename = "displayName")] 266 | pub display_name: String, 267 | #[serde(rename = "emailAddress")] 268 | pub email_address: String, 269 | pub key: Option, 270 | pub name: String, 271 | #[serde(rename = "self")] 272 | pub self_link: String, 273 | #[serde(rename = "timeZone")] 274 | pub timezone: Option, 275 | } 276 | 277 | #[derive(Deserialize, Debug)] 278 | pub struct Status { 279 | pub description: String, 280 | #[serde(rename = "iconUrl")] 281 | pub icon_url: String, 282 | pub id: String, 283 | pub name: String, 284 | #[serde(rename = "self")] 285 | pub self_link: String, 286 | } 287 | 288 | #[derive(Deserialize, Debug)] 289 | pub struct Priority { 290 | pub icon_url: String, 291 | pub id: String, 292 | pub name: String, 293 | #[serde(rename = "self")] 294 | pub self_link: String, 295 | } 296 | 297 | #[derive(Deserialize, Debug)] 298 | pub struct IssueType { 299 | pub description: String, 300 | #[serde(rename = "iconUrl")] 301 | pub icon_url: String, 302 | pub id: String, 303 | pub name: String, 304 | #[serde(rename = "self")] 305 | pub self_link: String, 306 | pub subtask: bool, 307 | } 308 | 309 | #[derive(Deserialize, Debug)] 310 | pub struct SearchResults { 311 | pub total: u64, 312 | #[serde(rename = "maxResults")] 313 | pub max_results: u64, 314 | #[serde(rename = "startAt")] 315 | pub start_at: u64, 316 | pub expand: Option, 317 | pub issues: Vec, 318 | } 319 | 320 | #[derive(Deserialize, Debug)] 321 | pub struct TransitionOption { 322 | pub id: String, 323 | pub name: String, 324 | pub to: TransitionTo, 325 | } 326 | 327 | #[derive(Deserialize, Debug)] 328 | pub struct TransitionTo { 329 | pub name: String, 330 | pub id: String, 331 | } 332 | 333 | /// contains list of options an issue can transitions through 334 | #[derive(Deserialize, Debug)] 335 | pub struct TransitionOptions { 336 | pub transitions: Vec, 337 | } 338 | 339 | #[derive(Serialize, Debug)] 340 | pub struct TransitionTriggerOptions { 341 | pub transition: Transition, 342 | pub fields: BTreeMap, 343 | } 344 | 345 | impl TransitionTriggerOptions { 346 | /// creates a new instance 347 | pub fn new(id: I) -> TransitionTriggerOptions 348 | where 349 | I: Into, 350 | { 351 | TransitionTriggerOptions { 352 | transition: Transition { id: id.into() }, 353 | fields: BTreeMap::new(), 354 | } 355 | } 356 | 357 | pub fn builder(id: I) -> TransitionTriggerOptionsBuilder 358 | where 359 | I: Into, 360 | { 361 | TransitionTriggerOptionsBuilder::new(id) 362 | } 363 | } 364 | 365 | pub struct TransitionTriggerOptionsBuilder { 366 | pub transition: Transition, 367 | pub fields: BTreeMap, 368 | } 369 | 370 | impl TransitionTriggerOptionsBuilder { 371 | /// creates a new instance 372 | pub fn new(id: I) -> TransitionTriggerOptionsBuilder 373 | where 374 | I: Into, 375 | { 376 | TransitionTriggerOptionsBuilder { 377 | transition: Transition { id: id.into() }, 378 | fields: BTreeMap::new(), 379 | } 380 | } 381 | 382 | /// appends a field to update as part of transition 383 | pub fn field(&mut self, name: N, value: V) -> &mut TransitionTriggerOptionsBuilder 384 | where 385 | N: Into, 386 | V: Serialize, 387 | { 388 | self.fields.insert( 389 | name.into(), 390 | serde_json::to_value(value).expect("Value to serialize"), 391 | ); 392 | self 393 | } 394 | 395 | /// updates resolution in transition 396 | pub fn resolution(&mut self, name: R) -> &mut TransitionTriggerOptionsBuilder 397 | where 398 | R: Into, 399 | { 400 | self.field("resolution", Resolution { name: name.into() }); 401 | self 402 | } 403 | 404 | pub fn build(&self) -> TransitionTriggerOptions { 405 | TransitionTriggerOptions { 406 | transition: self.transition.clone(), 407 | fields: self.fields.clone(), 408 | } 409 | } 410 | } 411 | 412 | #[derive(Serialize, Debug, Deserialize)] 413 | pub struct Resolution { 414 | name: String, 415 | } 416 | 417 | #[derive(Serialize, Clone, Debug)] 418 | pub struct Transition { 419 | pub id: String, 420 | } 421 | --------------------------------------------------------------------------------