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/// An issue's comments, oldest first.
134pub async fn comments(db: &toasty::Db, issue_id: i64) -> Result<Vec<IssueComment>> {
135 let mut conn = db.clone();
136 let comments = IssueComment::filter(IssueComment::fields().issue_id().eq(issue_id))
137 .order_by(IssueComment::fields().id().asc())
138 .exec(&mut conn)
139 .await?;
140 Ok(comments)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[tokio::test]
148 async fn numbering_listing_and_comments() {
149 let dir = tempfile::tempdir().unwrap();
150 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
151
152 let a = create(&db, 1, 10, "first", "body").await.unwrap();
153 let b = create(&db, 1, 10, "second", "").await.unwrap();
154 let other = create(&db, 2, 10, "other repo", "").await.unwrap();
155 assert_eq!((a.number, b.number), (1, 2));
156 assert_eq!(other.number, 1, "numbering is per-repo");
157
158 assert!(create(&db, 1, 10, " ", "x").await.is_err(), "blank title");
159
160 // Activity ordering: commenting on #1 moves it to the front.
161 let mut a = find(&db, 1, 1).await.unwrap().unwrap();
162 std::thread::sleep(std::time::Duration::from_millis(1100));
163 comment(&db, &mut a, 11, "hello").await.unwrap();
164 let open = list(&db, 1, state::OPEN).await.unwrap();
165 assert_eq!(open.len(), 2);
166 assert_eq!(open[0].number, 1, "recently-commented issue first");
167
168 let mut b = find(&db, 1, 2).await.unwrap().unwrap();
169 set_state(&db, &mut b, state::CLOSED).await.unwrap();
170 assert_eq!(counts(&db, 1).await.unwrap(), (1, 1));
171 assert_eq!(list(&db, 1, state::CLOSED).await.unwrap()[0].number, 2);
172
173 let cs = comments(&db, a.id).await.unwrap();
174 assert_eq!(cs.len(), 1);
175 assert_eq!(cs[0].body, "hello");
176 assert!(
177 comment(&db, &mut a, 11, " ").await.is_err(),
178 "blank comment"
179 );
180 }
181}