use bon::Builder;
use serde::Deserialize;
use crate::client::LinearClient;
use crate::error::{Error, Result};
use crate::filter::IssueFilter;
use crate::ids::{
CycleId, IssueId, IssueRef, LabelId, ProjectId, ProjectMilestoneId, TeamId, TemplateId, UserId,
WorkflowStateId,
};
use crate::pagination::{Page, PageInfo};
use crate::types::{
IssueRelationType, IssueStub, LabelRef, MilestoneRef, PaginationOrderBy, Priority, ProjectRef,
StateRef, TeamRef, TimelessDate, Undefinable, UserRef,
};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Issue {
pub id: IssueId,
pub identifier: String,
pub number: f64,
pub title: String,
pub description: Option<String>,
pub url: String,
pub branch_name: String,
pub priority: Priority,
pub priority_label: String,
pub estimate: Option<f64>,
pub due_date: Option<TimelessDate>,
pub sort_order: f64,
pub state: StateRef,
pub team: TeamRef,
pub assignee: Option<UserRef>,
pub creator: Option<UserRef>,
#[serde(deserialize_with = "crate::types::nodes")]
pub labels: Vec<LabelRef>,
pub project: Option<ProjectRef>,
pub project_milestone: Option<MilestoneRef>,
pub parent: Option<IssueStub>,
#[serde(deserialize_with = "crate::types::nodes")]
pub relations: Vec<OutgoingRelation>,
#[serde(deserialize_with = "crate::types::nodes")]
pub inverse_relations: Vec<IncomingRelation>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option", default)]
pub completed_at: Option<time::OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option", default)]
pub canceled_at: Option<time::OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option", default)]
pub started_at: Option<time::OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option", default)]
pub archived_at: Option<time::OffsetDateTime>,
}
impl Issue {
pub fn blocks(&self) -> Vec<&IssueStub> {
self.relations
.iter()
.filter(|relation| relation.relation_type == IssueRelationType::Blocks)
.map(|relation| &relation.related_issue)
.collect()
}
pub fn blocked_by(&self) -> Vec<&IssueStub> {
self.inverse_relations
.iter()
.filter(|relation| relation.relation_type == IssueRelationType::Blocks)
.map(|relation| &relation.issue)
.collect()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OutgoingRelation {
#[serde(rename = "type")]
pub relation_type: IssueRelationType,
pub related_issue: IssueStub,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IncomingRelation {
#[serde(rename = "type")]
pub relation_type: IssueRelationType,
pub issue: IssueStub,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssueSearchResult {
pub id: IssueId,
pub identifier: String,
pub title: String,
pub description: Option<String>,
pub url: String,
pub priority: Priority,
pub estimate: Option<f64>,
pub state: StateRef,
pub team: TeamRef,
pub assignee: Option<UserRef>,
#[serde(deserialize_with = "crate::types::nodes")]
pub labels: Vec<LabelRef>,
pub metadata: serde_json::Value,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: time::OffsetDateTime,
}
#[derive(Debug, Clone, Default, serde::Serialize, Builder)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ListIssuesRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<IssueFilter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_archived: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order_by: Option<PaginationOrderBy>,
}
#[derive(Debug, Clone, serde::Serialize, Builder)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SearchIssuesRequest {
#[builder(into)]
pub term: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<IssueFilter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub after: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, Builder)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssueCreateInput {
#[builder(into)]
pub team_id: TeamId,
#[builder(into)]
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee_id: Option<UserId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state_id: Option<WorkflowStateId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<Priority>,
#[serde(skip_serializing_if = "Option::is_none")]
pub estimate: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label_ids: Option<Vec<LabelId>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<ProjectId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_milestone_id: Option<ProjectMilestoneId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cycle_id: Option<CycleId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<IssueId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_date: Option<TimelessDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_order: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscriber_ids: Option<Vec<UserId>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_id: Option<TemplateId>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub create_as_user: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, Builder)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssueUpdateInput {
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state_id: Option<WorkflowStateId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<Priority>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_order: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label_ids: Option<Vec<LabelId>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub added_label_ids: Option<Vec<LabelId>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub removed_label_ids: Option<Vec<LabelId>>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub description: Undefinable<String>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub assignee_id: Undefinable<UserId>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub estimate: Undefinable<i64>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub project_id: Undefinable<ProjectId>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub project_milestone_id: Undefinable<ProjectMilestoneId>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub cycle_id: Undefinable<CycleId>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub parent_id: Undefinable<IssueId>,
#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]
#[builder(default, into)]
pub due_date: Undefinable<TimelessDate>,
}
macro_rules! issue_fields_fragments {
() => {
" fragment IssueFields on Issue { id identifier number title description url branchName \
priority priorityLabel estimate dueDate sortOrder createdAt updatedAt completedAt \
canceledAt startedAt archivedAt state { ...StateRefFields } team { ...TeamRefFields } \
assignee { ...UserRefFields } creator { ...UserRefFields } \
labels(first: 50) { nodes { ...LabelRefFields } } project { ...ProjectRefFields } \
projectMilestone { ...MilestoneRefFields } parent { ...IssueStubFields } \
relations(first: 10) { nodes { type relatedIssue { ...IssueStubFields } } } \
inverseRelations(first: 10) { nodes { type issue { ...IssueStubFields } } } } \
fragment UserRefFields on User { id name displayName } \
fragment TeamRefFields on Team { id key name } \
fragment ProjectRefFields on Project { id name } \
fragment IssueStubFields on Issue { id identifier title } \
fragment LabelRefFields on IssueLabel { id name color } \
fragment StateRefFields on WorkflowState { id name type color } \
fragment MilestoneRefFields on ProjectMilestone { id name }"
};
}
const ISSUE_GET: &str = concat!(
"query IssueGet($id: String!) { issue(id: $id) { ...IssueFields } }",
issue_fields_fragments!()
);
const ISSUE_LIST: &str = concat!(
"query IssueList($filter: IssueFilter, $first: Int, $after: String, \
$includeArchived: Boolean, $orderBy: PaginationOrderBy) { \
issues(filter: $filter, first: $first, after: $after, \
includeArchived: $includeArchived, orderBy: $orderBy) { \
nodes { ...IssueFields } \
pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }",
issue_fields_fragments!()
);
const ISSUE_SEARCH: &str = "query IssueSearch($term: String!, $filter: IssueFilter, $first: Int, $after: String) { \
searchIssues(term: $term, filter: $filter, first: $first, after: $after) { \
nodes { id identifier title description url priority estimate metadata createdAt updatedAt \
state { id name type color } team { id key name } assignee { id name displayName } \
labels(first: 50) { nodes { id name color } } } \
pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }";
const ISSUE_CREATE: &str = concat!(
"mutation IssueCreate($input: IssueCreateInput!) { \
issueCreate(input: $input) { success issue { ...IssueFields } } }",
issue_fields_fragments!()
);
const ISSUE_BATCH_CREATE: &str = concat!(
"mutation IssueBatchCreate($input: IssueBatchCreateInput!) { \
issueBatchCreate(input: $input) { success issues { ...IssueFields } } }",
issue_fields_fragments!()
);
const ISSUE_UPDATE: &str = concat!(
"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { \
issueUpdate(id: $id, input: $input) { success issue { ...IssueFields } } }",
issue_fields_fragments!()
);
const ISSUE_ARCHIVE: &str =
"mutation IssueArchive($id: String!) { issueArchive(id: $id) { success } }";
const ISSUE_DELETE: &str =
"mutation IssueDelete($id: String!) { issueDelete(id: $id) { success } }";
const ISSUE_ADD_LABEL: &str = concat!(
"mutation IssueAddLabel($id: String!, $labelId: String!) { \
issueAddLabel(id: $id, labelId: $labelId) { success issue { ...IssueFields } } }",
issue_fields_fragments!()
);
const ISSUE_REMOVE_LABEL: &str = concat!(
"mutation IssueRemoveLabel($id: String!, $labelId: String!) { \
issueRemoveLabel(id: $id, labelId: $labelId) { success issue { ...IssueFields } } }",
issue_fields_fragments!()
);
pub(crate) const DOCUMENTS: &[(&str, &str)] = &[
("IssueGet", ISSUE_GET),
("IssueList", ISSUE_LIST),
("IssueSearch", ISSUE_SEARCH),
("IssueCreate", ISSUE_CREATE),
("IssueBatchCreate", ISSUE_BATCH_CREATE),
("IssueUpdate", ISSUE_UPDATE),
("IssueArchive", ISSUE_ARCHIVE),
("IssueDelete", ISSUE_DELETE),
("IssueAddLabel", ISSUE_ADD_LABEL),
("IssueRemoveLabel", ISSUE_REMOVE_LABEL),
];
#[derive(Deserialize)]
struct IssuePayload {
success: bool,
issue: Option<Issue>,
}
fn payload_issue(operation: &'static str, payload: IssuePayload) -> Result<Issue> {
crate::types::ensure_success(operation, payload.success)?;
payload.issue.ok_or(Error::MissingData { operation })
}
#[derive(Deserialize)]
struct SuccessPayload {
success: bool,
}
#[derive(Clone, Copy)]
pub struct IssuesService<'a> {
client: &'a LinearClient,
}
impl LinearClient {
pub fn issues(&self) -> IssuesService<'_> {
IssuesService { client: self }
}
}
impl<'a> IssuesService<'a> {
pub async fn get(&self, issue: impl Into<IssueRef>) -> Result<Issue> {
#[derive(Deserialize)]
struct Data {
issue: Issue,
}
let issue = issue.into();
let data: Data = self
.client
.query(
"IssueGet",
ISSUE_GET,
serde_json::json!({ "id": issue.api_string() }),
)
.await?;
Ok(data.issue)
}
pub async fn list(&self, req: ListIssuesRequest) -> Result<Page<Issue>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Connection {
nodes: Vec<Issue>,
page_info: PageInfo,
}
#[derive(Deserialize)]
struct Data {
issues: Connection,
}
let data: Data = self.client.query("IssueList", ISSUE_LIST, req).await?;
Ok(Page {
nodes: data.issues.nodes,
page_info: data.issues.page_info,
})
}
pub fn list_stream(
&self,
req: ListIssuesRequest,
) -> impl futures::Stream<Item = Result<Issue>> + 'a {
let service = *self;
crate::pagination::paginate(move |cursor| {
let mut req = req.clone();
if cursor.is_some() {
req.after = cursor;
}
async move { service.list(req).await }
})
}
pub async fn search(&self, req: SearchIssuesRequest) -> Result<Page<IssueSearchResult>> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Connection {
nodes: Vec<IssueSearchResult>,
page_info: PageInfo,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
search_issues: Connection,
}
let data: Data = self.client.query("IssueSearch", ISSUE_SEARCH, req).await?;
Ok(Page {
nodes: data.search_issues.nodes,
page_info: data.search_issues.page_info,
})
}
pub async fn create(&self, input: IssueCreateInput) -> Result<Issue> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_create: IssuePayload,
}
let data: Data = self
.client
.mutation(
"IssueCreate",
ISSUE_CREATE,
serde_json::json!({ "input": input }),
)
.await?;
payload_issue("IssueCreate", data.issue_create)
}
pub async fn batch_create(&self, issues: Vec<IssueCreateInput>) -> Result<Vec<Issue>> {
#[derive(Deserialize)]
struct Payload {
success: bool,
issues: Vec<Issue>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_batch_create: Payload,
}
let data: Data = self
.client
.mutation(
"IssueBatchCreate",
ISSUE_BATCH_CREATE,
serde_json::json!({ "input": { "issues": issues } }),
)
.await?;
crate::types::ensure_success("IssueBatchCreate", data.issue_batch_create.success)?;
Ok(data.issue_batch_create.issues)
}
pub async fn update(
&self,
issue: impl Into<IssueRef>,
input: IssueUpdateInput,
) -> Result<Issue> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_update: IssuePayload,
}
let issue = issue.into();
let data: Data = self
.client
.mutation(
"IssueUpdate",
ISSUE_UPDATE,
serde_json::json!({ "id": issue.api_string(), "input": input }),
)
.await?;
payload_issue("IssueUpdate", data.issue_update)
}
pub async fn archive(&self, issue: impl Into<IssueRef>) -> Result<()> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_archive: SuccessPayload,
}
let issue = issue.into();
let data: Data = self
.client
.mutation(
"IssueArchive",
ISSUE_ARCHIVE,
serde_json::json!({ "id": issue.api_string() }),
)
.await?;
crate::types::ensure_success("IssueArchive", data.issue_archive.success)
}
pub async fn delete(&self, issue: impl Into<IssueRef>) -> Result<()> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_delete: SuccessPayload,
}
let issue = issue.into();
let data: Data = self
.client
.mutation(
"IssueDelete",
ISSUE_DELETE,
serde_json::json!({ "id": issue.api_string() }),
)
.await?;
crate::types::ensure_success("IssueDelete", data.issue_delete.success)
}
pub async fn add_label(&self, issue: impl Into<IssueRef>, label: &LabelId) -> Result<Issue> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_add_label: IssuePayload,
}
let issue = issue.into();
let data: Data = self
.client
.mutation(
"IssueAddLabel",
ISSUE_ADD_LABEL,
serde_json::json!({ "id": issue.api_string(), "labelId": label.as_str() }),
)
.await?;
payload_issue("IssueAddLabel", data.issue_add_label)
}
pub async fn remove_label(&self, issue: impl Into<IssueRef>, label: &LabelId) -> Result<Issue> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
issue_remove_label: IssuePayload,
}
let issue = issue.into();
let data: Data = self
.client
.mutation(
"IssueRemoveLabel",
ISSUE_REMOVE_LABEL,
serde_json::json!({ "id": issue.api_string(), "labelId": label.as_str() }),
)
.await?;
payload_issue("IssueRemoveLabel", data.issue_remove_label)
}
}