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.

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