Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

179 linesCodeBlameRaw
1//! Emoji reactions to a work item's or a pull request's description and
2//! to the comments on it (`react`). Anyone who can read it may react; g1t
3//! reacts 👀 to what it was asked to take on (integrations' `summon.rs`).
4//! A conversation's reactions are read with its comments, in the same
5//! batch ([`THREAD_SQL`], [`ITEM_THREAD_SQL`]), and grouped onto them
6//! ([`attach`]).
7
8use g1t_contracts::work::{Comment, ReactArgs, ReactionContent, ReactionGroup, group_reactions};
9use g1t_contracts::{FailureCode, Outcome};
10use g1t_kit::now_ms;
11use g1t_contracts::time::rfc3339;
12use worker::wasm_bindgen::JsValue;
13use serde::Deserialize;
14use worker::Result;
15
16use crate::retired::writable;
17use crate::{On, Thread, Work};
18
19/// Unwraps an `Outcome`, returning its failure from the enclosing method.
20macro_rules! check {
21 ($outcome:expr) => {
22 match $outcome {
23 Outcome::Ok(value) => value,
24 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
25 }
26 };
27}
28
29/// A pull request's reactions, oldest first: `?1` the repository, `?2` its
30/// number.
31pub(crate) const THREAD_SQL: &str = "SELECT subject, content, user_name FROM reactions
32 WHERE repo_id = ?1 AND number = ?2 ORDER BY created_at, rowid LIMIT 2000";
33
34/// A work item's reactions, oldest first: `?1` its id.
35pub(crate) const ITEM_THREAD_SQL: &str = "SELECT subject, content, user_name FROM reactions
36 WHERE item_id = ?1 ORDER BY created_at, rowid LIMIT 2000";
37
38/// `repo.purged`: `?1` the repository.
39pub(crate) const PURGED: &str = "DELETE FROM reactions WHERE repo_id = ?1";
40
41/// An account purged: its reactions go, `?1` its id.
42pub(crate) const USER_DELETED: &str = "DELETE FROM reactions WHERE user_id = ?1";
43
44#[derive(Deserialize)]
45pub(crate) struct ReactionRow {
46 subject: String,
47 content: String,
48 user_name: String,
49}
50
51/// The reactions of the thing called `subject`, grouped.
52fn groups_of(rows: &[ReactionRow], subject: &str) -> Vec<ReactionGroup> {
53 group_reactions(
54 rows.iter()
55 .filter(|row| row.subject == subject)
56 .filter_map(|row| ReactionContent::parse(&row.content).map(|content| (content, row.user_name.as_str()))),
57 )
58}
59
60/// Puts each comment's reactions on it, and returns those to the
61/// description, whose subject is the work item's or pull request's id.
62pub(crate) fn attach(rows: &[ReactionRow], description: &str, comments: &mut [Comment]) -> Vec<ReactionGroup> {
63 if rows.is_empty() {
64 return Vec::new();
65 }
66 for comment in comments.iter_mut() {
67 comment.reactions = groups_of(rows, &comment.id);
68 }
69 groups_of(rows, description)
70}
71
72impl Work {
73 /// `react`: see [`ReactArgs`].
74 pub(crate) async fn react(&self, a: ReactArgs) -> Result<Outcome<Vec<ReactionGroup>>> {
75 if !a.actor.verified {
76 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
77 }
78 let thread = check!(self.thread(&Some(a.actor.clone()), a.repo.as_ref(), &a.workspace, a.number).await?);
79 if let Thread::Pull(repo, _) = &thread {
80 check!(writable(repo));
81 }
82 // What is reacted to: a comment in this conversation, or its description.
83 let subject = match a.comment_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
84 Some(id) => {
85 let found = match thread.on() {
86 On::Pull(repo_id, number) => {
87 self.db
88 .prepare("SELECT id FROM comments WHERE id = ? AND repo_id = ? AND number = ? AND kind = 'comment'")
89 .bind(&[id.into(), repo_id.into(), number.into()])?
90 .first::<IdOnly>(None)
91 .await?
92 }
93 On::Item(item_id) => {
94 self.db
95 .prepare("SELECT id FROM comments WHERE id = ? AND item_id = ? AND kind = 'comment'")
96 .bind(&[id.into(), item_id.into()])?
97 .first::<IdOnly>(None)
98 .await?
99 }
100 };
101 match found {
102 Some(row) => row.id,
103 None => return Ok(Outcome::fail(FailureCode::NotFound, "Comment not found.")),
104 }
105 }
106 None => thread.subject().to_owned(),
107 };
108 let (repo_id, number, item_id) = match thread.on() {
109 On::Pull(repo_id, number) => (JsValue::from(repo_id), JsValue::from(number), JsValue::NULL),
110 On::Item(item_id) => (JsValue::NULL, JsValue::NULL, JsValue::from(item_id)),
111 };
112 let statement = if a.remove {
113 self.db
114 .prepare("DELETE FROM reactions WHERE subject = ? AND user_id = ? AND content = ?")
115 .bind(&[subject.as_str().into(), a.actor.id.as_str().into(), a.content.as_str().into()])?
116 } else {
117 self.db
118 .prepare(
119 "INSERT INTO reactions (subject, repo_id, number, item_id, user_id, user_name, content, created_at)
120 VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING",
121 )
122 .bind(&[
123 subject.as_str().into(),
124 repo_id,
125 number,
126 item_id,
127 a.actor.id.as_str().into(),
128 a.actor.username.as_str().into(),
129 a.content.as_str().into(),
130 rfc3339(now_ms()).into(),
131 ])?
132 };
133 let read = self
134 .db
135 .prepare("SELECT subject, content, user_name FROM reactions WHERE subject = ? ORDER BY created_at, rowid LIMIT 500")
136 .bind(&[subject.as_str().into()])?;
137 let results = self.db.batch(vec![statement, read]).await?;
138 let rows = match results.get(1) {
139 Some(result) => result.results::<ReactionRow>()?,
140 None => Vec::new(),
141 };
142 Ok(Outcome::Ok(groups_of(&rows, &subject)))
143 }
144}
145
146#[derive(Deserialize)]
147struct IdOnly {
148 id: String,
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn row(subject: &str, content: &str, user: &str) -> ReactionRow {
156 ReactionRow { subject: subject.into(), content: content.into(), user_name: user.into() }
157 }
158
159 #[test]
160 fn reactions_group_by_what_they_react_to() {
161 let rows = vec![
162 row("iss_1", "eyes", "g1t"),
163 row("cmt_1", "heart", "ana"),
164 row("cmt_1", "+1", "bo"),
165 row("cmt_1", "+1", "ana"),
166 row("iss_1", "nonsense", "ana"),
167 ];
168 let groups = groups_of(&rows, "cmt_1");
169 assert_eq!(groups.len(), 2);
170 // In the order the picker shows them, not the order they came.
171 assert_eq!(groups[0].content, ReactionContent::PlusOne);
172 assert_eq!(groups[0].users, vec!["bo", "ana"]);
173 assert_eq!(groups[0].count, 2);
174 assert_eq!(groups[1].content, ReactionContent::Heart);
175 let body = groups_of(&rows, "iss_1");
176 assert_eq!(body.len(), 1, "an unknown reaction is left out");
177 assert_eq!(body[0].users, vec!["g1t"]);
178 }
179}