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}