Skip to main content

fallow_output/
pr_comment_post_plan.rs

1//! Pure posting policy for sticky PR comments.
2
3use crate::PrCommentEnvelope;
4
5/// Sticky comment already present on the PR, as found by a provider adapter.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct ExistingPrComment {
8    /// Provider-assigned comment id used for the update call.
9    pub id: String,
10    /// Current comment body, compared against the new body to skip no-op posts.
11    pub body: String,
12}
13
14/// Inputs for [`plan_pr_comment_post`].
15pub struct PrCommentPostPlanInput<'a> {
16    /// Freshly rendered comment envelope.
17    pub envelope: &'a PrCommentEnvelope,
18    /// Existing sticky comment, when the adapter found one by marker.
19    pub existing: Option<&'a ExistingPrComment>,
20}
21
22/// What the provider adapter should do with the rendered comment.
23#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum PrCommentPostAction {
26    /// Post a new comment.
27    Create,
28    /// Update the existing sticky comment in place.
29    Update,
30    /// Post nothing; see the plan's `skip_reason`.
31    Skip,
32}
33
34/// Why a post was skipped.
35#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum PrCommentPostSkipReason {
38    /// Clean run with no prior comment to update; posting would only add noise.
39    CleanNoExistingComment,
40    /// Existing comment body is byte-identical to the new body.
41    Unchanged,
42}
43
44/// Posting decision handed to a provider adapter.
45#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
46pub struct PrCommentPostPlan {
47    /// Action the adapter should take.
48    pub action: PrCommentPostAction,
49    /// Identity token of the sticky comment.
50    pub marker_id: String,
51    /// Comment id to update; present only for `Update`.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub comment_id: Option<String>,
54    /// Skip explanation; present only for `Skip`.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub skip_reason: Option<PrCommentPostSkipReason>,
57    /// Body to post; present for `Create` and `Update`.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub body: Option<String>,
60}
61
62/// Decide whether the rendered comment should be created, updated, or skipped.
63///
64/// Skips when the body is unchanged, and never creates a first comment for a
65/// clean run.
66#[must_use]
67pub fn plan_pr_comment_post(input: &PrCommentPostPlanInput<'_>) -> PrCommentPostPlan {
68    match input.existing {
69        Some(existing) if existing.body == input.envelope.body => skip_unchanged(input.envelope),
70        Some(existing) => update_existing(input.envelope, existing),
71        None if input.envelope.is_clean => skip_clean(input.envelope),
72        None => create_comment(input.envelope),
73    }
74}
75
76fn create_comment(envelope: &PrCommentEnvelope) -> PrCommentPostPlan {
77    PrCommentPostPlan {
78        action: PrCommentPostAction::Create,
79        marker_id: envelope.marker_id.clone(),
80        comment_id: None,
81        skip_reason: None,
82        body: Some(envelope.body.clone()),
83    }
84}
85
86fn update_existing(
87    envelope: &PrCommentEnvelope,
88    existing: &ExistingPrComment,
89) -> PrCommentPostPlan {
90    PrCommentPostPlan {
91        action: PrCommentPostAction::Update,
92        marker_id: envelope.marker_id.clone(),
93        comment_id: Some(existing.id.clone()),
94        skip_reason: None,
95        body: Some(envelope.body.clone()),
96    }
97}
98
99fn skip_clean(envelope: &PrCommentEnvelope) -> PrCommentPostPlan {
100    PrCommentPostPlan {
101        action: PrCommentPostAction::Skip,
102        marker_id: envelope.marker_id.clone(),
103        comment_id: None,
104        skip_reason: Some(PrCommentPostSkipReason::CleanNoExistingComment),
105        body: None,
106    }
107}
108
109fn skip_unchanged(envelope: &PrCommentEnvelope) -> PrCommentPostPlan {
110    PrCommentPostPlan {
111        action: PrCommentPostAction::Skip,
112        marker_id: envelope.marker_id.clone(),
113        comment_id: None,
114        skip_reason: Some(PrCommentPostSkipReason::Unchanged),
115        body: None,
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn envelope(is_clean: bool, body: &str) -> PrCommentEnvelope {
124        PrCommentEnvelope {
125            marker_id: "fallow-results".to_owned(),
126            body: body.to_owned(),
127            is_clean,
128            details_url: None,
129            check_summary: None,
130            truncation: crate::PrCommentTruncation::default(),
131        }
132    }
133
134    #[test]
135    fn clean_without_existing_comment_skips_create() {
136        let plan = plan_pr_comment_post(&PrCommentPostPlanInput {
137            envelope: &envelope(true, "clean"),
138            existing: None,
139        });
140
141        assert_eq!(plan.action, PrCommentPostAction::Skip);
142        assert_eq!(
143            plan.skip_reason,
144            Some(PrCommentPostSkipReason::CleanNoExistingComment)
145        );
146        assert_eq!(plan.body, None);
147    }
148
149    #[test]
150    fn clean_with_existing_comment_updates_existing_body() {
151        let current = envelope(true, "clean");
152        let existing = ExistingPrComment {
153            id: "42".to_owned(),
154            body: "old".to_owned(),
155        };
156
157        let plan = plan_pr_comment_post(&PrCommentPostPlanInput {
158            envelope: &current,
159            existing: Some(&existing),
160        });
161
162        assert_eq!(plan.action, PrCommentPostAction::Update);
163        assert_eq!(plan.comment_id.as_deref(), Some("42"));
164        assert_eq!(plan.body.as_deref(), Some("clean"));
165    }
166
167    #[test]
168    fn dirty_without_existing_comment_creates_comment() {
169        let current = envelope(false, "dirty");
170
171        let plan = plan_pr_comment_post(&PrCommentPostPlanInput {
172            envelope: &current,
173            existing: None,
174        });
175
176        assert_eq!(plan.action, PrCommentPostAction::Create);
177        assert_eq!(plan.body.as_deref(), Some("dirty"));
178    }
179
180    #[test]
181    fn identical_existing_comment_skips_update() {
182        let current = envelope(false, "same");
183        let existing = ExistingPrComment {
184            id: "42".to_owned(),
185            body: "same".to_owned(),
186        };
187
188        let plan = plan_pr_comment_post(&PrCommentPostPlanInput {
189            envelope: &current,
190            existing: Some(&existing),
191        });
192
193        assert_eq!(plan.action, PrCommentPostAction::Skip);
194        assert_eq!(plan.skip_reason, Some(PrCommentPostSkipReason::Unchanged));
195    }
196}