github_issue_url/
lib.rs

1//! # GitHub Issue URL
2//! GitHub prefilled issue URL builder
3//!
4//! ## Motivation
5//!
6//! You can have issue form fields prefilled by specifying certain query parameters
7//! in the "New Issue" URL (https://github.com/<User | Organization>/<Repository>/issues/new).
8//!
9//! Example:
10//!
11//! ```ignore
12//! https://github.com/EstebanBorai/github-issue-url/issues/new?
13//! title=Null%3A+The+Billion+Dollar+Mistake
14//! &body=Null+is+a+flag.+It+represents+different+situations
15//! &template=bug_report.md
16//! &labels=bug%2Cproduction%2Chigh-severity
17//! &assignee=EstebanBorai
18//! &milestone=1
19//! &projects=1
20//! ```
21//!
22//! This way you can provide a one click "Open Issue" button to your Rust applications,
23//! for instance you could have some stack trace, or details read from the host system
24//! where your application is running to let the user open an issue on GitHub without
25//! the need of specifying system /or application details themselves.
26//!
27//! ## Contributing
28//!
29//! Every contribution to this project is welcome! Feel free to open a pull request or an issue.
30//!
31//! ## License
32//!
33//! Licensed under both the MIT License and the Apache 2.0 License.
34pub mod error;
35
36use url::Url;
37
38use self::error::{Error, Result};
39
40/// GitHub issue struct with support for every field available.
41///
42/// This struct is holds repository, username or organization name and
43/// fields to prefill when opening the issue url.
44///
45/// # Example
46///
47/// ```
48/// use github_issue_url::Issue;
49///
50/// const GITHUB_ISSUE_LINK: &str = "https://github.com/EstebanBorai/github-issue-url/issues/new?title=Null%3A+The+Billion+Dollar+Mistake&body=Null+is+a+flag.+It+represents+different+situations+depending+on+the+context+in+which+it+is+used+and+invoked.+This+yields+the+most+serious+error+in+software+development%3A+Coupling+a+hidden+decision+in+the+contract+between+an+object+and+who+uses+it.&template=bug_report.md&labels=bug%2Cproduction%2Chigh-severity&assignee=EstebanBorai&milestone=1&projects=1";
51/// const SAMPLE_ISSUE_BODY: &str = r#"Null is a flag. It represents different situations depending on the context in which it is used and invoked. This yields the most serious error in software development: Coupling a hidden decision in the contract between an object and who uses it."#;
52///
53/// let mut have = Issue::new("github-issue-url", "EstebanBorai").unwrap();
54///
55/// have.title("Null: The Billion Dollar Mistake");
56/// have.body(SAMPLE_ISSUE_BODY);
57/// have.template("bug_report.md");
58/// have.labels("bug,production,high-severity");
59/// have.assignee("EstebanBorai");
60/// have.milestone("1");
61/// have.projects("1");
62
63/// let have = have.url().unwrap();
64///
65/// assert_eq!(have, GITHUB_ISSUE_LINK.to_string());
66/// ```
67///
68#[derive(Debug, PartialEq, Eq)]
69pub struct Issue<'a> {
70    repository_name: &'a str,
71    repository_owner: &'a str,
72    params: Vec<(&'a str, &'a str)>,
73}
74
75/// GitHub Issue including the repository name and the repository owner username.
76///
77/// Issue fields are kept in a `Vec<(&'a str, &'a str)>` for easy parsing when
78/// parsing the URL with query params.
79///
80/// Every optional param is specified using the setter methods.
81impl<'a> Issue<'a> {
82    pub fn new(repository_name: &'a str, repository_owner: &'a str) -> Result<Self> {
83        if repository_name.is_empty() {
84            return Err(Error::EmptyRepositoryName);
85        }
86
87        if repository_owner.is_empty() {
88            return Err(Error::EmptyRepositoryOwner);
89        }
90
91        Ok(Issue {
92            repository_name,
93            repository_owner,
94            params: Vec::new(),
95        })
96    }
97
98    /// The username of the issue's assignee.
99    ///
100    /// The issue author requires write access to the repository in order to
101    /// use this feature
102    pub fn assignee(&mut self, assignee: &'a str) {
103        self.params.push(("assignee", assignee));
104    }
105
106    /// Prefilled issue body content
107    pub fn body(&mut self, body: &'a str) {
108        self.params.push(("body", body));
109    }
110
111    /// Issue labels separated by comma (`,`).
112    /// Example: `bug,production,high-severity`
113    ///
114    /// The issue author requires write access to the repository in order to
115    /// use this feature
116    pub fn labels(&mut self, labels: &'a str) {
117        self.params.push(("labels", labels));
118    }
119
120    /// The ID (number) of the milestone linked to this issue.
121    ///
122    /// The milestone ID can be found in the Issues/Milestone section.
123    ///
124    /// https://github.com/<owner>/<repository>/milestone/<milestone id>
125    ///
126    /// The issue author requires write access to the repository in order to
127    /// use this feature
128    pub fn milestone(&mut self, milestone: &'a str) {
129        self.params.push(("milestone", milestone));
130    }
131
132    /// The IDs (number) of the projects to link this issue to separated by
133    /// comma (`,`).
134    ///
135    /// Projects IDs are found in the repository session.
136    ///
137    /// https://github.com/<owner>/<repository>/projects/<project id>
138    ///
139    /// The issue author requires write access to the repository in order to
140    /// use this feature
141    pub fn projects(&mut self, projects: &'a str) {
142        self.params.push(("projects", projects));
143    }
144
145    /// Prefilled issue title
146    pub fn title(&mut self, title: &'a str) {
147        self.params.push(("title", title));
148    }
149
150    /// The name of the issue template to use when opening the final link.
151    /// An issue template lives in .github/ISSUE_TEMPLATE/<issue template name>.md,
152    /// if the template you want to use when opening this link is ISSUE_TEMPLATE/bugs.md
153    /// the value for `Issue.template` must be `bugs.md`
154    pub fn template(&mut self, template: &'a str) {
155        self.params.push(("template", template));
156    }
157
158    pub fn url(&'a self) -> Result<String> {
159        let repository_url = format!(
160            "https://github.com/{}/{}/issues/new",
161            self.repository_owner, self.repository_name
162        );
163        let url = Url::parse_with_params(repository_url.as_str(), self.params.iter())
164            .map_err(|e| Error::UrlParseError(e.to_string()))?;
165
166        Ok(url.to_string())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    const GITHUB_ISSUE_LINK: &str = "https://github.com/EstebanBorai/github-issue-url/issues/new?title=Null%3A+The+Billion+Dollar+Mistake&body=Null+is+a+flag.+It+represents+different+situations+depending+on+the+context+in+which+it+is+used+and+invoked.+This+yields+the+most+serious+error+in+software+development%3A+Coupling+a+hidden+decision+in+the+contract+between+an+object+and+who+uses+it.&template=bug_report.md&labels=bug%2Cproduction%2Chigh-severity&assignee=EstebanBorai&milestone=1&projects=1";
175    const SAMPLE_ISSUE_BODY: &str = r#"Null is a flag. It represents different situations depending on the context in which it is used and invoked. This yields the most serious error in software development: Coupling a hidden decision in the contract between an object and who uses it."#;
176
177    #[test]
178    fn build_issue_url() {
179        let mut have = Issue::new("github-issue-url", "EstebanBorai").unwrap();
180
181        have.title("Null: The Billion Dollar Mistake");
182        have.body(SAMPLE_ISSUE_BODY);
183        have.template("bug_report.md");
184        have.labels("bug,production,high-severity");
185        have.assignee("EstebanBorai");
186        have.milestone("1");
187        have.projects("1");
188
189        let have = have.url().unwrap();
190
191        assert_eq!(have, GITHUB_ISSUE_LINK.to_string());
192    }
193
194    #[test]
195    fn return_error_if_repository_owner_is_invalid() {
196        let have = Issue::new("github-issue-url", "");
197
198        assert!(have.is_err());
199        assert!(matches!(have, Err(Error::EmptyRepositoryOwner)));
200        assert_eq!(
201            String::from("Repository owner name is not defined"),
202            have.err().unwrap().to_string()
203        );
204    }
205
206    #[test]
207    fn return_error_if_repository_name_is_invalid() {
208        let have = Issue::new("", "EstebanBorai");
209
210        assert!(have.is_err());
211        assert!(matches!(have, Err(Error::EmptyRepositoryName)));
212        assert_eq!(
213            String::from("Repository name is not defined"),
214            have.err().unwrap().to_string()
215        );
216    }
217}