1use chrono::{DateTime, Utc};
2use pivotal_tracker_derive::BrandedInt;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::{fmt::Display, num::ParseIntError, str::FromStr};
6
7use crate::{
8 blocker::BlockerID,
9 branch::BranchID,
10 client::{Client, RequestError},
11 comment::CommentID,
12 cycle_time_details::CycleTimeDetails,
13 integration::{IntegrationExternalID, IntegrationID},
14 label::Label,
15 person::PersonID,
16 project::ProjectID,
17 pull_request::PullRequestID,
18 review::ReviewID,
19 story_transition::StoryTransition,
20 task::TaskID,
21};
22
23const STORY_LINK_BASE: &str = "https://www.pivotaltracker.com/n/projects";
24const STORY_TILE_LINK_BASE: &str = "https://www.pivotaltracker.com/story/show";
25
26impl Client {
27 pub async fn get_story(
28 &self,
29 options: GetStoryOptions,
30 ) -> Result<Story, RequestError> {
31 self
32 .request::<Story, _>(|client, base_url| {
33 client.get(format!("{base_url}/stories/{}", options.id))
34 })
35 .await
36 }
37}
38
39pub struct GetStoryOptions {
40 pub id: StoryID,
41}
42
43#[derive(Serialize, Deserialize, Debug)]
45pub struct Story {
46 pub accepted_at: Option<DateTime<Utc>>,
47
48 #[serde(rename = "points_accepted")]
53 pub accepted_points: Option<f32>,
54
55 #[serde(rename = "counts_accepted")]
59 pub accepted_stories_count: Option<u64>,
60
61 #[serde(rename = "after_id")]
64 pub after_story_id: Option<StoryID>,
65
66 #[serde(rename = "before_id")]
69 pub before_story_id: Option<StoryID>,
70
71 pub blocked_story_ids: Option<Vec<StoryID>>,
75 pub blocker_ids: Option<Vec<BlockerID>>,
76 pub branch_ids: Option<Vec<BranchID>>,
77 pub comment_ids: Option<Vec<CommentID>>,
78 pub created_at: DateTime<Utc>,
79
80 pub cycle_time_details: Option<CycleTimeDetails>,
85
86 #[serde(rename = "deadline")]
88 pub deadline_at: Option<DateTime<Utc>>,
89
90 pub description: Option<String>,
92
93 pub estimate: Option<f32>,
95 pub follower_ids: Option<Vec<PersonID>>,
96
97 pub id: StoryID,
99
100 #[serde(rename = "external_id")]
102 pub integration_external_id: Option<IntegrationExternalID>,
103 pub integration_id: Option<IntegrationID>,
104
105 pub kind: String,
107 pub labels: Vec<Label>,
108 pub name: String,
109 pub owner_ids: Vec<PersonID>,
110
111 #[serde(rename = "points_total")]
115 pub points_count: Option<f32>,
116 pub project_id: ProjectID,
117 pub pull_request_ids: Option<Vec<PullRequestID>>,
118
119 pub projected_completion_at: Option<DateTime<Utc>>,
121 pub requested_by_id: PersonID,
122 pub review_ids: Option<Vec<ReviewID>>,
123
124 #[serde(rename = "current_state")]
126 pub state: StoryState,
127
128 #[serde(rename = "counts_total")]
132 pub stories_count: Option<u64>,
133 pub story_type: StoryType,
134 pub task_ids: Option<TaskID>,
135
136 pub transitions: Option<Vec<StoryTransition>>,
140
141 pub updated_at: DateTime<Utc>,
143 pub url: url::Url,
144}
145
146#[derive(Debug, Serialize, Deserialize, BrandedInt)]
147pub struct StoryID(pub u64);
148
149impl FromStr for StoryID {
150 type Err = ParseIntError;
151
152 fn from_str(s: &str) -> Result<Self, Self::Err> {
153 let normalized_str = match s {
154 s if s.starts_with('#') => &s[1..],
156
157 s if s.starts_with(STORY_TILE_LINK_BASE) => {
159 let matcher_link_example = format!("{STORY_TILE_LINK_BASE}/<story-id>");
160 let matcher =
161 Regex::new(&format!(r"^{STORY_TILE_LINK_BASE}/(?P<story_id>\d+)"))
162 .expect(&format!(
163 "Failed to create regex for story tile link ({matcher_link_example})"
164 ));
165
166 matcher
167 .captures(s)
168 .expect(&format!(
169 "Failed to match story tile link ({matcher_link_example}) with {s}",
170 ))
171 .name("story_id")
172 .expect(&format!(
173 "Failed to get story ID from story tile link ({matcher_link_example}) with {s}",
174 ))
175 .as_str()
176 }
177
178 s => {
181 let matcher_link_example =
182 format!("{STORY_LINK_BASE}/<project-id>/stories/<story-id>");
183 let matcher = Regex::new(&format!(
184 r"{STORY_LINK_BASE}/\d+/stories/(?P<story_id>\d+)"
185 ))
186 .expect(&format!(
187 "Failed to create regex for story link ({matcher_link_example})"
188 ));
189
190 match matcher.captures(s) {
191 Some(captures) => captures
192 .name("story_id")
193 .expect(&format!(
194 "Failed to get story ID from story link ({matcher_link_example}) with {s}"
195 ))
196 .as_str(),
197 None => s,
198 }
199 }
200 };
201 let story_id = normalized_str.parse()?;
202
203 Ok(StoryID(story_id))
204 }
205}
206
207#[derive(Debug, Serialize, Deserialize)]
208#[serde(rename_all = "lowercase")]
209pub enum StoryState {
210 Accepted,
211 Delivered,
212 Finished,
213 Planned,
214 Rejected,
215 Started,
216 Unscheduled,
217 Unstarted,
218}
219
220#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
221#[serde(rename_all = "lowercase")]
222pub enum StoryType {
223 Bug,
224 Chore,
225 Feature,
226 Release,
227}
228
229impl Display for StoryType {
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 let story_type_str = format!("{self:?}").to_lowercase();
232
233 write!(f, "{}", story_type_str)
234 }
235}