Skip to content

Commit

Permalink
Add Issue Timeline API (#389)
Browse files Browse the repository at this point in the history
* add missing issue event types

The timeline API added in the next commits uses the issue event
types. These were incomplete. Also adding documentation to the
event types based on:

https://docs.github.com/en/webhooks-and-events/events/issue-event-types

However, this also includes some event types that aren't documented:
- "line-comment"
- "cross-referenced"
- "comment_deleted"
- "base_ref_force_pushed"

* add model for issue timeline events

* add ListTimelineEventsBuilder for Issue

See https://docs.github.com/en/rest/issues/timeline?apiVersion=2022-11-28

* add Issue Timeline Event deserialization test

* fix: column_url is sometimes not set

This would result in failing deserialization
  • Loading branch information
0xB10C authored Jun 1, 2023
1 parent 34bb086 commit bda2651
Show file tree
Hide file tree
Showing 5 changed files with 1,019 additions and 1 deletion.
66 changes: 66 additions & 0 deletions src/api/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,72 @@ impl<'octo, 'r> ListIssueCommentsBuilder<'octo, 'r> {
}
}

#[derive(serde::Serialize)]
pub struct ListTimelineEventsBuilder<'octo, 'r> {
#[serde(skip)]
handler: &'r IssueHandler<'octo>,
issue_number: u64,
#[serde(skip_serializing_if = "Option::is_none")]
per_page: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
page: Option<u32>,
}

impl<'octo, 'r> ListTimelineEventsBuilder<'octo, 'r> {
pub(crate) fn new(handler: &'r IssueHandler<'octo>, issue_number: u64) -> Self {
Self {
handler,
issue_number,
per_page: None,
page: None,
}
}

/// Results per page (max 100).
pub fn per_page(mut self, per_page: impl Into<u8>) -> Self {
self.per_page = Some(per_page.into());
self
}

/// Page number of the results to fetch.
pub fn page(mut self, page: impl Into<u32>) -> Self {
self.page = Some(page.into());
self
}

/// Send the actual request.
pub async fn send(self) -> Result<crate::Page<models::timelines::TimelineEvent>> {
let route = format!(
"/repos/{owner}/{repo}/issues/{issue}/timeline",
owner = self.handler.owner,
repo = self.handler.repo,
issue = self.issue_number,
);

self.handler.crab.get(route, Some(&self)).await
}
}

// Timeline
impl<'octo> IssueHandler<'octo> {
/// Lists events in the issue timeline.
/// ```no_run
/// # async fn run() -> octocrab::Result<()> {
/// let timeline = octocrab::instance()
/// .issues("owner", "repo")
/// .list_timeline_events(21u64.into())
/// .per_page(100)
/// .page(2u32)
/// .send()
/// .await?;
/// # Ok(())
/// # }
/// ```
pub fn list_timeline_events(&self, issue_number: u64) -> ListTimelineEventsBuilder<'_, '_> {
ListTimelineEventsBuilder::new(self, issue_number)
}
}

impl<'octo> IssueHandler<'octo> {
/// Lists reactions for an issue.
/// ```no_run
Expand Down
79 changes: 78 additions & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod pulls;
pub mod reactions;
pub mod repos;
pub mod teams;
pub mod timelines;
pub mod workflows;

pub use apps::App;
Expand Down Expand Up @@ -119,6 +120,7 @@ id_type!(
RunId,
StatusId,
TeamId,
TimelineEventId,
ThreadId,
UploaderId,
UserId,
Expand Down Expand Up @@ -160,38 +162,112 @@ pub struct Contents {
pub download_url: Url,
}

/// Issue events are triggered by activity in issues and pull requests.
/// https://docs.github.com/en/webhooks-and-events/events/issue-event-types
#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Event {
/// The issue or pull request was added to a project board.
AddedToProject,
/// The issue or pull request was assigned to a user.
Assigned,
/// GitHub unsuccessfully attempted to automatically change the base branch of the pull request.
AutomaticBaseChangeFailed,
/// GitHub successfully attempted to automatically change the base branch of the pull request.
AutomaticBaseChangeSucceeded,
/// The base reference branch of the pull request changed.
BaseRefChanged,
/// Not documented in the Github issue events documentation.
BaseRefForcePushed,
/// The issue or pull request was closed. When the commit_id is present, it identifies the commit that closed the issue using "closes / fixes" syntax.
Closed,
/// A comment was added to the issue or pull request.
Commented,
/// A comment that was removed from the issue or pull request.
/// This isn't documented as part of the issues-event-types API but returned by the API.
CommentDeleted,
/// A commit was added to the pull request's HEAD branch.
Committed,
/// The issue or pull request was linked to another issue or pull request.
Connected,
/// The pull request was converted to draft mode.
ConvertToDraft,
/// The issue was created by converting a note in a project board to an issue.
ConvertedNoteToIssue,
/// The issue was closed and converted to a discussion.
ConvertedToDiscussion,
/// The issue or pull request was referenced from another issue or pull request.
#[serde(rename = "cross-referenced")]
CrossReferenced,
/// The issue or pull request was removed from a milestone.
Demilestoned,
/// The pull request was deployed.
Deployed,
/// The pull request deployment environment was changed.
DeploymentEnvironmentChanged,
/// The issue or pull request was unlinked from another issue or pull request.
Disconnected,
/// The pull request's HEAD branch was deleted.
HeadRefDeleted,
/// The pull request's HEAD branch was force pushed.
HeadRefForcePushed,
/// The pull request's HEAD branch was restored to the last known commit.
HeadRefRestored,
/// A label was added to the issue or pull request.
Labeled,
/// A comment on a line of source in a pull request. Not documented in the issue and events documentation.
#[serde(rename = "line-commented")]
LineCommented,
/// The issue or pull request was locked.
Locked,
/// The actor was @mentioned in an issue or pull request body.
Mentioned,
/// A user with write permissions marked an issue as a duplicate of another issue, or a pull request as a duplicate of another pull request.
MarkedAsDuplicate,
/// The pull request was merged. The commit_id attribute is the SHA1 of the HEAD commit that was merged. The commit_repository is always the same as the main repository.
Merged,
/// The issue or pull request was added to a milestone.
Milestoned,
/// The issue or pull request was moved between columns in a project board.
MovedColumnsInProject,
/// The issue was pinned.
Pinned,
/// A draft pull request was marked as ready for review.
ReadyForReview,
/// The issue was referenced from a commit message. The commit_id attribute is the commit SHA1 of where that happened and the commit_repository is where that commit was pushed.
Referenced,
/// The issue or pull request was removed from a project board.
RemovedFromProject,
/// The issue or pull request title was changed.
Renamed,
/// The issue or pull request was reopened.
Reopened,
/// The pull request review was dismissed.
ReviewDismissed,
/// A pull request review was requested.
ReviewRequested,
/// A pull request review request was removed.
ReviewRequestRemoved,
/// The pull request was reviewed.
Reviewed,
/// Someone subscribed to receive notifications for an issue or pull request.
Subscribed,
/// The issue was transferred to another repository.
Transferred,
/// A user was unassigned from the issue.
Unassigned,
/// A label was removed from the issue.
Unlabeled,
/// The issue was unlocked.
Unlocked,
/// An issue that a user had previously marked as a duplicate of another issue is no longer considered a duplicate, or a pull request that a user had previously marked as a duplicate of another pull request is no longer considered a duplicate.
UnmarkedAsDuplicate,
/// The issue was unpinned.
Unpinned,
/// Someone unsubscribed from receiving notifications for an issue or pull request.
Unsubscribed,
/// An organization owner blocked a user from the organization.
UserBlocked,
}

Expand Down Expand Up @@ -245,7 +321,8 @@ pub struct ProjectCard {
pub column_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_column_name: Option<String>,
pub column_url: Url,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_url: Option<Url>,
}

#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
Expand Down
130 changes: 130 additions & 0 deletions src/models/timelines.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use super::*;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TimelineEvent {
/// Identifies the actual type of event that occurred.
pub event: Event,
/// The unique identifier of the event.
pub id: Option<TimelineEventId>,
/// The Global Node ID of the event.
pub node_id: Option<String>,
/// The REST API URL for fetching the event.
pub url: Option<Url>,
/// The person who generated the event.
pub actor: Option<Author>,
/// The SHA of the commit that referenced this issue.
pub commit_id: Option<String>,
/// The GitHub REST API link to the commit that referenced this issue.
pub commit_url: Option<String>,
/// The timestamp indicating when the event occurred.
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_card: Option<ProjectCard>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<ProjectId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<Author>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assigner: Option<Author>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_association: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<Author>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issue_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tree: Option<repos::CommitObject>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification: Option<repos::Verification>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parents: Option<Vec<repos::Commit>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub committer: Option<CommitAuthor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<CommitAuthor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<Source>,
#[serde(skip_serializing_if = "Option::is_none")]
pub milestone: Option<Milestone>, // differs from other milestones the API returns. Has only a title.
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<Label>, // differs from other labels the API returns. Has only a name and a color.
#[serde(skip_serializing_if = "Option::is_none")]
pub lock_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_column_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rename: Option<Rename>,
#[serde(skip_serializing_if = "Option::is_none")]
pub submitted_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<pulls::ReviewState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dismissed_review: Option<DismissedReview>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pull_request_url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requested_reviewers: Option<Vec<Author>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub review_requester: Option<Author>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<Author>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DismissedReview {
state: pulls::ReviewState,
review_id: ReviewId,
dismissal_message: String,
dismissal_commit_id: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Source {
issue: issues::Issue,
r#type: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Rename {
from: String,
to: String,
}

#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Label {
pub name: String,
pub color: String,
}

#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Milestone {
pub title: String,
}

/// The author of a commit, identified by its name and email.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommitAuthor {
pub name: String,
pub email: String,
pub date: Option<chrono::DateTime<chrono::Utc>>,
}
7 changes: 7 additions & 0 deletions tests/issues_timeline_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use octocrab::models::timelines::TimelineEvent;

#[tokio::test]
async fn should_deserialize() {
let _: Vec<TimelineEvent> =
serde_json::from_str(include_str!("resources/issues_list_timeline_events.json")).unwrap();
}
Loading

0 comments on commit bda2651

Please sign in to comment.