pivotal_tracker/
story.rs

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// [Pivotal Tracker API](https://www.pivotaltracker.com/help/api/rest/v5#story_resource)
44#[derive(Serialize, Deserialize, Debug)]
45pub struct Story {
46	pub accepted_at: Option<DateTime<Utc>>,
47
48	/// Sum of estimates of accepted stories in a release (for a
49	/// release-type story).
50	///
51	/// This field is read only.
52	#[serde(rename = "points_accepted")]
53	pub accepted_points: Option<f32>,
54
55	/// Sum of all accepted stories in a release (for a release-type story).
56	///
57	/// This field is read only.
58	#[serde(rename = "counts_accepted")]
59	pub accepted_stories_count: Option<u64>,
60
61	/// ID of the story that the current story is located after. `None` if story
62	/// is the first one in the project.
63	#[serde(rename = "after_id")]
64	pub after_story_id: Option<StoryID>,
65
66	/// ID of the story that the current story is located before. `None` if
67	/// story is last one in the project.
68	#[serde(rename = "before_id")]
69	pub before_story_id: Option<StoryID>,
70
71	/// IDs of other stories that are blocked by this story.
72	///
73	/// This field is read only.
74	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	/// All information regarding a story's cycle time and state transitions
81	/// (duration and occurrences).
82	///
83	/// This field is read only.
84	pub cycle_time_details: Option<CycleTimeDetails>,
85
86	/// Due date/time (for a release-type story).
87	#[serde(rename = "deadline")]
88	pub deadline_at: Option<DateTime<Utc>>,
89
90	/// In-depth explanation of the story requirements.
91	pub description: Option<String>,
92
93	/// Point value of the story.
94	pub estimate: Option<f32>,
95	pub follower_ids: Option<Vec<PersonID>>,
96
97	/// This field is read only.
98	pub id: StoryID,
99
100	/// The integration's specific ID for the story.
101	#[serde(rename = "external_id")]
102	pub integration_external_id: Option<IntegrationExternalID>,
103	pub integration_id: Option<IntegrationID>,
104
105	/// This field is read only.
106	pub kind: String,
107	pub labels: Vec<Label>,
108	pub name: String,
109	pub owner_ids: Vec<PersonID>,
110
111	/// Sum of estimates of all stories in a release (for a release-type story).
112	///
113	/// This field is read only.
114	#[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	/// This field is read only.
120	pub projected_completion_at: Option<DateTime<Utc>>,
121	pub requested_by_id: PersonID,
122	pub review_ids: Option<Vec<ReviewID>>,
123
124	/// Story's state of completion.
125	#[serde(rename = "current_state")]
126	pub state: StoryState,
127
128	/// Sum of all stories in a release (for a release-type story).
129	///
130	/// This field is read only.
131	#[serde(rename = "counts_total")]
132	pub stories_count: Option<u64>,
133	pub story_type: StoryType,
134	pub task_ids: Option<TaskID>,
135
136	/// All state transitions for the story.
137	///
138	/// This field is read only.
139	pub transitions: Option<Vec<StoryTransition>>,
140
141	/// This field is read only.
142	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			// #181439777
155			s if s.starts_with('#') => &s[1..],
156
157			// https://www.pivotaltracker.com/story/show/181439777
158			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			// 181439777 or
179			// https://www.pivotaltracker.com/n/projects/2553178/stories/181439777
180			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}