anvilsign in

collin/anvil

1//! Per-repository issues: creation, listing, comments, and open/close state.
2//!
3//! Issues are numbered per repository (`#1`, `#2`, …) in creation order.
4//! Numbering is assigned as max+1 at creation, which cannot race with a
5//! single server process (our deployment shape).
6
7use crate::{
8 error::{
9 Error,
10 Result,
11 },
12 models::{
13 Issue,
14 IssueComment,
15 },
16};
17
18/// Issue state values stored in [`Issue::state`].
19pub mod state {
20 pub const OPEN: &str = "open";
21 pub const CLOSED: &str = "closed";
22}
23
24/// Create an issue, assigning the repo's next number.
25pub async fn create(
26 db: &toasty::Db,
27 repo_id: i64,
28 author_id: i64,
29 title: &str,
30 body: &str,
31) -> Result<Issue> {
32 let title = title.trim();
33 if title.is_empty() {
34 return Err(Error::Invalid("issue title must not be empty".into()));
35 }
36
37 let mut conn = db.clone();
38 let last = Issue::filter(Issue::fields().repo_id().eq(repo_id))
39 .order_by(Issue::fields().number().desc())
40 .first()
41 .exec(&mut conn)
42 .await?;
43 let number = last.map(|i| i.number).unwrap_or(0) + 1;
44
45 let now = crate::now();
46 let mut conn = db.clone();
47 let issue = toasty::create!(Issue {
48 repo_id: repo_id,
49 number: number,
50 title: title,
51 body: body.trim(),
52 author_id: author_id,
53 state: state::OPEN,
54 created_at: now,
55 updated_at: now,
56 })
57 .exec(&mut conn)
58 .await?;
59 Ok(issue)
60}
61
62/// Find one issue by its per-repo number.
63pub async fn find(db: &toasty::Db, repo_id: i64, number: i64) -> Result<Option<Issue>> {
64 let mut conn = db.clone();
65 let issue = Issue::filter(Issue::fields().repo_id().eq(repo_id))
66 .filter(Issue::fields().number().eq(number))
67 .first()
68 .exec(&mut conn)
69 .await?;
70 Ok(issue)
71}
72
73/// List a repository's issues in one state, most recently active first.
74pub async fn list(db: &toasty::Db, repo_id: i64, state: &str) -> Result<Vec<Issue>> {
75 let mut conn = db.clone();
76 let issues = Issue::filter(Issue::fields().repo_id().eq(repo_id))
77 .filter(Issue::fields().state().eq(state))
78 .order_by(Issue::fields().updated_at().desc())
79 .exec(&mut conn)
80 .await?;
81 Ok(issues)
82}
83
84/// Open/closed counts for the list page tabs.
85pub async fn counts(db: &toasty::Db, repo_id: i64) -> Result<(usize, usize)> {
86 // Two filtered fetches; issue counts stay small at our scale.
87 let open = list(db, repo_id, state::OPEN).await?.len();
88 let closed = list(db, repo_id, state::CLOSED).await?.len();
89 Ok((open, closed))
90}
91
92/// Set an issue's state (`open`/`closed`) and bump its activity time.
93pub async fn set_state(db: &toasty::Db, issue: &mut Issue, new_state: &str) -> Result<()> {
94 let mut conn = db.clone();
95 issue
96 .update()
97 .state(new_state)
98 .updated_at(crate::now())
99 .exec(&mut conn)
100 .await?;
101 Ok(())
102}
103
104/// Add a comment and bump the issue's activity time.
105pub async fn comment(
106 db: &toasty::Db,
107 issue: &mut Issue,
108 author_id: i64,
109 body: &str,
110) -> Result<IssueComment> {
111 let body = body.trim();
112 if body.is_empty() {
113 return Err(Error::Invalid("comment must not be empty".into()));
114 }
115 let mut conn = db.clone();
116 let comment = toasty::create!(IssueComment {
117 issue_id: issue.id,
118 author_id: author_id,
119 body: body,
120 created_at: crate::now(),
121 })
122 .exec(&mut conn)
123 .await?;
124 let mut conn = db.clone();
125 issue
126 .update()
127 .updated_at(crate::now())
128 .exec(&mut conn)
129 .await?;
130 Ok(comment)
131}
132
133/// Delete every issue on a repository, comments included. Used by
134/// [`repos::delete`](crate::repos::delete).
135pub async fn delete_for_repo(db: &toasty::Db, repo_id: i64) -> Result<()> {
136 let mut conn = db.clone();
137 let issues = Issue::filter(Issue::fields().repo_id().eq(repo_id))
138 .exec(&mut conn)
139 .await?;
140 for issue in issues {
141 for comment in comments(db, issue.id).await? {
142 let mut conn = db.clone();
143 comment.delete().exec(&mut conn).await?;
144 }
145 let mut conn = db.clone();
146 issue.delete().exec(&mut conn).await?;
147 }
148 Ok(())
149}
150
151/// An issue's comments, oldest first.
152pub async fn comments(db: &toasty::Db, issue_id: i64) -> Result<Vec<IssueComment>> {
153 let mut conn = db.clone();
154 let comments = IssueComment::filter(IssueComment::fields().issue_id().eq(issue_id))
155 .order_by(IssueComment::fields().id().asc())
156 .exec(&mut conn)
157 .await?;
158 Ok(comments)
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[tokio::test]
166 async fn numbering_listing_and_comments() {
167 let dir = tempfile::tempdir().unwrap();
168 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
169
170 let a = create(&db, 1, 10, "first", "body").await.unwrap();
171 let b = create(&db, 1, 10, "second", "").await.unwrap();
172 let other = create(&db, 2, 10, "other repo", "").await.unwrap();
173 assert_eq!((a.number, b.number), (1, 2));
174 assert_eq!(other.number, 1, "numbering is per-repo");
175
176 assert!(create(&db, 1, 10, " ", "x").await.is_err(), "blank title");
177
178 // Activity ordering: commenting on #1 moves it to the front.
179 let mut a = find(&db, 1, 1).await.unwrap().unwrap();
180 std::thread::sleep(std::time::Duration::from_millis(1100));
181 comment(&db, &mut a, 11, "hello").await.unwrap();
182 let open = list(&db, 1, state::OPEN).await.unwrap();
183 assert_eq!(open.len(), 2);
184 assert_eq!(open[0].number, 1, "recently-commented issue first");
185
186 let mut b = find(&db, 1, 2).await.unwrap().unwrap();
187 set_state(&db, &mut b, state::CLOSED).await.unwrap();
188 assert_eq!(counts(&db, 1).await.unwrap(), (1, 1));
189 assert_eq!(list(&db, 1, state::CLOSED).await.unwrap()[0].number, 2);
190
191 let cs = comments(&db, a.id).await.unwrap();
192 assert_eq!(cs.len(), 1);
193 assert_eq!(cs[0].body, "hello");
194 assert!(
195 comment(&db, &mut a, 11, " ").await.is_err(),
196 "blank comment"
197 );
198 }
199}