forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
10.6 KiB
1//! Issue, comment, label and milestone types, and their validation. Stored shapes are in
2//! migrations/0004_issues.sql; the store functions live in store/issues.rs and store/labels.rs.
3
4use crate::error::{Error, Result};
5use crate::model::Id;
6
7/// Longest accepted issue title, in characters.
8pub const MAX_TITLE: usize = 256;
9/// Largest accepted issue, comment or edit body, in bytes.
10pub const MAX_BODY: usize = 65536;
11/// Longest accepted label name, in characters.
12pub const MAX_LABEL_NAME: usize = 50;
13/// Most assignees one issue can have.
14pub const MAX_ASSIGNEES: usize = 10;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum IssueState {
18 Open,
19 Closed,
20}
21
22impl IssueState {
23 pub fn as_str(&self) -> &'static str {
24 match self {
25 IssueState::Open => "open",
26 IssueState::Closed => "closed",
27 }
28 }
29}
30
31impl std::str::FromStr for IssueState {
32 type Err = Error;
33
34 fn from_str(s: &str) -> Result<Self> {
35 match s {
36 "open" => Ok(IssueState::Open),
37 "closed" => Ok(IssueState::Closed),
38 other => Err(Error::Invalid(format!("issue state {other:?}"))),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum StateReason {
45 Completed,
46 NotPlanned,
47}
48
49impl StateReason {
50 pub fn as_str(&self) -> &'static str {
51 match self {
52 StateReason::Completed => "completed",
53 StateReason::NotPlanned => "not_planned",
54 }
55 }
56}
57
58impl std::str::FromStr for StateReason {
59 type Err = Error;
60
61 fn from_str(s: &str) -> Result<Self> {
62 match s {
63 "completed" => Ok(StateReason::Completed),
64 "not_planned" => Ok(StateReason::NotPlanned),
65 other => Err(Error::Invalid(format!("state reason {other:?}"))),
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq)]
71pub struct Issue {
72 pub id: Id,
73 pub repo_id: Id,
74 pub number: i64,
75 pub title: String,
76 pub body: String,
77 pub state: IssueState,
78 pub state_reason: Option<StateReason>,
79 pub author_id: Id,
80 pub milestone_id: Option<Id>,
81 pub comment_count: i64,
82 pub created_at: String,
83 pub updated_at: String,
84 pub closed_at: Option<String>,
85 /// Locked conversations accept writes only from collaborators with Triage or higher.
86 pub locked: bool,
87 /// One of `LOCK_REASONS`, or `None` when no reason was given.
88 pub lock_reason: Option<String>,
89}
90
91/// Reasons accepted when locking a conversation.
92pub const LOCK_REASONS: &[&str] = &["off_topic", "too_heated", "resolved", "spam"];
93
94#[derive(Debug, Clone, PartialEq)]
95pub struct Comment {
96 pub id: Id,
97 pub issue_id: Id,
98 pub author_id: Id,
99 pub body: String,
100 pub created_at: String,
101 pub updated_at: String,
102 pub edited: bool,
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub struct IssueEvent {
107 pub id: Id,
108 pub issue_id: Id,
109 /// `None` for system events.
110 pub actor_id: Option<Id>,
111 pub kind: String,
112 pub data: serde_json::Value,
113 pub created_at: String,
114}
115
116#[derive(Debug, Clone, PartialEq)]
117pub struct Label {
118 pub id: Id,
119 pub repo_id: Id,
120 pub name: String,
121 /// Lowercase `#rrggbb`.
122 pub color: String,
123 pub description: String,
124}
125
126#[derive(Debug, Clone, PartialEq)]
127pub struct Milestone {
128 pub id: Id,
129 pub repo_id: Id,
130 pub title: String,
131 pub description: String,
132 /// `YYYY-MM-DD` or `None`.
133 pub due_on: Option<String>,
134 pub state: IssueState,
135 pub created_at: String,
136 pub closed_at: Option<String>,
137 /// Counts filled by list/get queries.
138 pub open_issues: i64,
139 pub closed_issues: i64,
140}
141
142#[derive(Debug, Clone, PartialEq)]
143pub struct IssueFilter {
144 pub state: Option<IssueState>,
145 /// Label name.
146 pub label: Option<String>,
147 /// Milestone title.
148 pub milestone: Option<String>,
149 /// Author username.
150 pub author: Option<String>,
151 /// Assignee username.
152 pub assignee: Option<String>,
153 /// Substring of the title, case-insensitive.
154 pub query: Option<String>,
155 pub sort: IssueSort,
156 /// 1-based.
157 pub page: u32,
158 pub per_page: u32,
159}
160
161impl Default for IssueFilter {
162 fn default() -> Self {
163 IssueFilter {
164 state: Some(IssueState::Open),
165 label: None,
166 milestone: None,
167 author: None,
168 assignee: None,
169 query: None,
170 sort: IssueSort::Newest,
171 page: 1,
172 per_page: 25,
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub enum IssueSort {
179 #[default]
180 Newest,
181 Oldest,
182 RecentlyUpdated,
183}
184
185impl IssueSort {
186 /// The value used in the `sort` query parameter.
187 pub fn as_str(&self) -> &'static str {
188 match self {
189 IssueSort::Newest => "newest",
190 IssueSort::Oldest => "oldest",
191 IssueSort::RecentlyUpdated => "updated",
192 }
193 }
194}
195
196impl std::str::FromStr for IssueSort {
197 type Err = Error;
198
199 fn from_str(s: &str) -> Result<Self> {
200 match s {
201 "newest" => Ok(IssueSort::Newest),
202 "oldest" => Ok(IssueSort::Oldest),
203 "updated" => Ok(IssueSort::RecentlyUpdated),
204 other => Err(Error::Invalid(format!("sort {other:?}"))),
205 }
206 }
207}
208
209#[derive(Debug, Clone, PartialEq)]
210pub struct IssueListItem {
211 pub issue: Issue,
212 /// Display username of the author.
213 pub author: String,
214 pub labels: Vec<Label>,
215 /// Display usernames of the assignees.
216 pub assignees: Vec<String>,
217}
218
219/// Trimmed title: non-empty, at most `MAX_TITLE` characters, no control characters.
220pub fn validate_title(t: &str) -> Result<String> {
221 let title = t.trim();
222 if title.is_empty() {
223 return Err(Error::Invalid("title is empty".into()));
224 }
225 if title.chars().count() > MAX_TITLE {
226 return Err(Error::Invalid(format!(
227 "title is longer than {MAX_TITLE} characters"
228 )));
229 }
230 if title.chars().any(char::is_control) {
231 return Err(Error::Invalid("title contains control characters".into()));
232 }
233 Ok(title.to_string())
234}
235
236/// Body: at most `MAX_BODY` bytes. An empty body is allowed.
237pub fn validate_body(b: &str) -> Result<()> {
238 if b.len() > MAX_BODY {
239 return Err(Error::Invalid(format!(
240 "body is longer than {MAX_BODY} bytes"
241 )));
242 }
243 Ok(())
244}
245
246/// Returns `(trimmed name, normalized color)`. The name is 1..=50 characters with no control
247/// characters. The color is `#rgb` or `#rrggbb` in any case and comes back as lowercase `#rrggbb`.
248pub fn validate_label(name: &str, color: &str) -> Result<(String, String)> {
249 let name = name.trim();
250 let len = name.chars().count();
251 if len == 0 || len > MAX_LABEL_NAME {
252 return Err(Error::Invalid(format!(
253 "label name must be 1 to {MAX_LABEL_NAME} characters"
254 )));
255 }
256 if name.chars().any(char::is_control) {
257 return Err(Error::Invalid(
258 "label name contains control characters".into(),
259 ));
260 }
261 let color = normalize_color(color)
262 .ok_or_else(|| Error::Invalid(format!("label color {color:?} is not #rgb or #rrggbb")))?;
263 Ok((name.to_string(), color))
264}
265
266fn normalize_color(color: &str) -> Option<String> {
267 let hex = color.strip_prefix('#')?;
268 if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
269 return None;
270 }
271 let lower = hex.to_ascii_lowercase();
272 match lower.len() {
273 3 => {
274 let mut out = String::with_capacity(7);
275 out.push('#');
276 for c in lower.chars() {
277 out.push(c);
278 out.push(c);
279 }
280 Some(out)
281 }
282 6 => Some(format!("#{lower}")),
283 _ => None,
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn title_is_trimmed_and_checked() {
293 assert_eq!(validate_title(" Fix it ").unwrap(), "Fix it");
294 assert!(validate_title(" ").is_err());
295 assert!(validate_title("").is_err());
296 assert!(validate_title("a\tb").is_err());
297 assert!(validate_title("a\nb").is_err());
298 assert!(validate_title(&"x".repeat(MAX_TITLE)).is_ok());
299 assert!(validate_title(&"x".repeat(MAX_TITLE + 1)).is_err());
300 // Counted in characters, not bytes.
301 assert!(validate_title(&"é".repeat(MAX_TITLE)).is_ok());
302 assert!(validate_title(&"é".repeat(MAX_TITLE + 1)).is_err());
303 }
304
305 #[test]
306 fn body_is_checked_in_bytes() {
307 assert!(validate_body("").is_ok());
308 assert!(validate_body(&"x".repeat(MAX_BODY)).is_ok());
309 assert!(validate_body(&"x".repeat(MAX_BODY + 1)).is_err());
310 // 2 bytes per char: 32768 chars is exactly MAX_BODY bytes.
311 assert!(validate_body(&"é".repeat(MAX_BODY / 2)).is_ok());
312 assert!(validate_body(&"é".repeat(MAX_BODY / 2 + 1)).is_err());
313 }
314
315 #[test]
316 fn label_name_and_color_are_normalized() {
317 assert_eq!(
318 validate_label(" bug ", "#FF0000").unwrap(),
319 ("bug".to_string(), "#ff0000".to_string())
320 );
321 assert_eq!(
322 validate_label("x", "#AbC").unwrap(),
323 ("x".to_string(), "#aabbcc".to_string())
324 );
325 assert!(validate_label("", "#fff").is_err());
326 assert!(validate_label(" ", "#fff").is_err());
327 assert!(validate_label(&"a".repeat(MAX_LABEL_NAME), "#fff").is_ok());
328 assert!(validate_label(&"a".repeat(MAX_LABEL_NAME + 1), "#fff").is_err());
329 assert!(validate_label("a\u{7}", "#fff").is_err());
330 }
331
332 #[test]
333 fn label_color_must_be_hex() {
334 for bad in [
335 "", "fff", "#ff", "#ffff", "#fffffff", "#gggggg", "red", "# fff", "#ff00 0",
336 ] {
337 assert!(validate_label("x", bad).is_err(), "{bad:?} accepted");
338 }
339 }
340
341 #[test]
342 fn enums_round_trip() {
343 for s in [IssueState::Open, IssueState::Closed] {
344 assert_eq!(s.as_str().parse::<IssueState>().ok(), Some(s));
345 }
346 assert!("Open".parse::<IssueState>().is_err());
347 for r in [StateReason::Completed, StateReason::NotPlanned] {
348 assert_eq!(r.as_str().parse::<StateReason>().ok(), Some(r));
349 }
350 assert!("duplicate".parse::<StateReason>().is_err());
351 for s in [
352 IssueSort::Newest,
353 IssueSort::Oldest,
354 IssueSort::RecentlyUpdated,
355 ] {
356 assert_eq!(s.as_str().parse::<IssueSort>().ok(), Some(s));
357 }
358 assert!("bogus".parse::<IssueSort>().is_err());
359 }
360
361 #[test]
362 fn default_filter_is_open_first_page() {
363 let f = IssueFilter::default();
364 assert_eq!(f.state, Some(IssueState::Open));
365 assert_eq!(f.page, 1);
366 assert_eq!(f.per_page, 25);
367 assert_eq!(f.sort, IssueSort::Newest);
368 }
369}