commit 68d918221826c5615baf78e1b2acb3579bae7121 from: Murilo Ijanc date: Sat Apr 25 22:27:49 2026 UTC Initial import. commit - /dev/null commit + 68d918221826c5615baf78e1b2acb3579bae7121 blob - /dev/null blob + 567609b1234a9b8806c5a05da6c866e480aa148d (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1 @@ +build/ blob - /dev/null blob + 32f3292e31bbd5acf75b7671b679e9017828078c (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2026 Murilo Ijanc' + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. blob - /dev/null blob + 34a0d2e46c69be74f746f0e0a22808f60e3d2f52 (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,114 @@ +# +# Copyright (c) 2026 Murilo Ijanc' +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +RUSTC ?= $(shell rustup which rustc 2>/dev/null || which rustc) +RUSTFLAGS ?= -C opt-level=2 +VERSION = 0.1.0 +PREFIX ?= /usr/local + +BUILD = build +SRC = shortcut_sdk.rs +LIB = $(BUILD)/libshortcut_sdk.rlib +TEST = $(BUILD)/shortcut_sdk-test + +EXAMPLE_SRCS = $(wildcard examples/*.rs) +EXAMPLES = $(EXAMPLE_SRCS:examples/%.rs=$(BUILD)/ex-%) + +# Vendored deps managed by marmita(1). Adding vendor/foo.rs is enough +# for the pattern rule below to build it as an rlib; if the upstream +# ships link.mk, marmita copies it to vendor/foo.mk and the -include +# below appends to LINK_FLAGS / BUILD_ENV. +VENDOR_RS = $(wildcard vendor/*.rs) +VENDOR_NAMES = $(VENDOR_RS:vendor/%.rs=%) +VENDOR_LIBS = $(VENDOR_NAMES:%=$(BUILD)/lib%.rlib) +VENDOR_EXT = $(foreach n,$(VENDOR_NAMES),--extern $(n)=$(BUILD)/lib$(n).rlib) +LINK_FLAGS = +BUILD_ENV = +-include $(wildcard vendor/*.mk) +LINK_ARGS = $(addprefix -C link-arg=,$(LINK_FLAGS)) + +CLIPPY ?= $(shell rustup which clippy-driver 2>/dev/null) +RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null) +RUSTDOC ?= $(shell rustup which rustdoc 2>/dev/null || which rustdoc) + +DOC = $(BUILD)/doc/shortcut_sdk/index.html + +.PHONY: all clean test fmt-check clippy ci doc examples install + +all: $(LIB) + +$(BUILD)/lib%.rlib: vendor/%.rs + mkdir -p $(BUILD) + $(BUILD_ENV) $(RUSTC) --edition 2024 --crate-type rlib \ + --crate-name $* $(RUSTFLAGS) -o $@ $< + +$(LIB): $(SRC) $(VENDOR_LIBS) + mkdir -p $(BUILD) + SHORTCUT_SDK_VERSION=$(VERSION) $(RUSTC) --edition 2024 \ + --crate-type rlib --crate-name shortcut_sdk $(RUSTFLAGS) \ + $(VENDOR_EXT) \ + $(LINK_ARGS) \ + -o $@ $< + +$(TEST): $(SRC) $(VENDOR_LIBS) + mkdir -p $(BUILD) + SHORTCUT_SDK_VERSION=$(VERSION) $(RUSTC) --edition 2024 \ + --test --crate-name shortcut_sdk \ + $(VENDOR_EXT) \ + $(LINK_ARGS) \ + -o $@ $< + +test: $(TEST) + $(TEST) + +fmt-check: + $(RUSTFMT) --edition 2024 --check $(SRC) + +clippy: $(VENDOR_LIBS) + mkdir -p $(BUILD) + SHORTCUT_SDK_VERSION=$(VERSION) $(CLIPPY) --edition 2024 \ + --crate-type rlib --crate-name shortcut_sdk \ + $(VENDOR_EXT) \ + $(LINK_ARGS) \ + -W clippy::all -o $(BUILD)/shortcut_sdk.clippy $(SRC) + @rm -f $(BUILD)/shortcut_sdk.clippy + +$(DOC): $(SRC) $(VENDOR_LIBS) + mkdir -p $(BUILD) + SHORTCUT_SDK_VERSION=$(VERSION) $(RUSTDOC) --edition 2024 \ + --crate-name shortcut_sdk \ + $(VENDOR_EXT) \ + $(LINK_ARGS) \ + -o $(BUILD)/doc $(SRC) + +doc: $(DOC) + +$(BUILD)/ex-%: examples/%.rs $(LIB) + mkdir -p $(BUILD) + SHORTCUT_SDK_VERSION=$(VERSION) $(RUSTC) --edition 2024 \ + --crate-name $* --extern shortcut_sdk=$(LIB) -L $(BUILD) \ + $(RUSTFLAGS) -o $@ $< + +examples: $(EXAMPLES) + +install: $(LIB) + install -d $(PREFIX)/lib + install -m 644 $(LIB) $(PREFIX)/lib/libshortcut_sdk.rlib + +clean: + rm -rf $(BUILD) + +ci: fmt-check clippy $(LIB) test blob - /dev/null blob + bd001e8158c65a43c8054dc15bf35df83bc56789 (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,111 @@ +shortcut_sdk - Shortcut REST API v3 client +========================================== +shortcut_sdk is a minimal Rust library distributed as a single source file +(`shortcut_sdk.rs`). It wraps the Shortcut REST API v3 with typed +request and response structs and a thin `Client` over HTTP. + + +Requirements +------------ +rustc with the 2024 edition, plus libtls and libcrypto for the vendored +http crate. No cargo, no procedural macros, no async runtime. + + +Building +-------- +Build the rlib: + + make + +Run the unit tests: + + make test + +Generate browsable rustdoc under `build/doc/shortcut_sdk/index.html`: + + make doc + +Format and lint everything (used in CI): + + make ci + + +Vendoring into your project +--------------------------- +shortcut_sdk depends on two vendored single-file crates: `http` and +`jackson` (both shipped under `vendor/`). Copy `shortcut_sdk.rs` and +the contents of `vendor/` into your project, then build them as rlibs +and link your binary against all three: + + rustc --edition 2024 --crate-type rlib --crate-name http \ + -o build/libhttp.rlib vendor/http.rs + rustc --edition 2024 --crate-type rlib --crate-name jackson \ + -o build/libjackson.rlib vendor/jackson.rs + SHORTCUT_SDK_VERSION=0.1.0 \ + rustc --edition 2024 --crate-type rlib --crate-name shortcut_sdk \ + --extern http=build/libhttp.rlib \ + --extern jackson=build/libjackson.rlib \ + -C link-arg=-ltls -C link-arg=-lcrypto \ + -o build/libshortcut_sdk.rlib shortcut_sdk.rs + +Your binary then links against shortcut_sdk: + + rustc --edition 2024 --crate-type bin \ + --extern shortcut_sdk=build/libshortcut_sdk.rlib \ + --extern http=build/libhttp.rlib \ + --extern jackson=build/libjackson.rlib \ + -L build -C link-arg=-ltls -C link-arg=-lcrypto \ + -o build/myapp main.rs + +The Makefile in this repository follows the same pattern; reading it is +the shortest path to wiring up your own. + + +Quick tour +---------- +Authenticate with a Shortcut API token (set the `SHORTCUT_TOKEN` +environment variable in production code): + + use shortcut_sdk::{Client, CreateStoryParams, SearchQuery}; + + let c = Client::new(std::env::var("SHORTCUT_TOKEN")?); + +Read a story, list stories from a group, search across the workspace: + + let s = c.get_story(1234)?; + let inbox = c.list_group_stories("uuid-platform", Some(50), None)?; + let hits = c.search_stories( + &SearchQuery::new("owner:ijanc state:in-progress").page_size(25), + )?; + +Create a story with the fluent params builder (optional fields stay +unset and are not sent on the wire): + + let s = c.create_story( + &CreateStoryParams::default() + .name("Write SDK docs") + .workflow_state_id(500_000_123) + .owner_ids(vec!["uuid-1".into()]), + )?; + +Upload a PRD as an attachment to a story: + + use shortcut_sdk::FileUpload; + + c.upload_files( + &[FileUpload::new("PRD.pdf", "application/pdf")], + Some(s.id), + )?; + + +Download +-------- + got clone ssh://ijanc@ijanc.org/shortcut_sdk + git clone https://git.ijanc.org/shortcut_sdk.git + git clone https://git.sr.ht/~ijanc/shortcut_sdk + git clone https://github.com/shortcut_sdk.git + + +License +------- +ISC -- see LICENSE. blob - /dev/null blob + 450307cba41d678fd5dc8c231a18f64e1d9d26b3 (mode 644) --- /dev/null +++ shortcut_sdk.rs @@ -0,0 +1,3745 @@ +// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et : +// +// Copyright (c) 2026 Murilo Ijanc' +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +//! Shortcut REST API v3 client. + +use std::fmt; + +use jackson::{FromJson, ToJson, Value, json_struct}; + +pub fn version() -> &'static str { + env!("SHORTCUT_SDK_VERSION") +} + +const BASE_URL: &str = "https://api.app.shortcut.com"; +const TOKEN_HEADER: &str = "Shortcut-Token"; + +////////////////////////////////////////////////////////////////////////////// +// Error +////////////////////////////////////////////////////////////////////////////// + +pub enum Error { + Http(String), + Status { code: u16, body: String }, + Decode(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Http(m) => write!(f, "http: {m}"), + Error::Status { code, body } => { + write!(f, "status {code}: {}", body.trim()) + } + Error::Decode(m) => write!(f, "decode: {m}"), + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +////////////////////////////////////////////////////////////////////////////// +// Client +////////////////////////////////////////////////////////////////////////////// + +pub struct Client { + token: String, + base: String, +} + +impl Client { + pub fn new(token: impl Into) -> Self { + Self { + token: token.into(), + base: BASE_URL.to_string(), + } + } + + pub fn with_base(token: impl Into, base: impl Into) -> Self { + Self { + token: token.into(), + base: base.into(), + } + } + + fn request(&self, method: http::Method, path: &str) -> http::RequestBuilder { + http::request(method, format!("{}{}", self.base, path)) + .header(TOKEN_HEADER, self.token.clone()) + .header("Accept", "application/json") + .header("User-Agent", format!("shortcut_sdk/{}", version())) + } + + fn json_call( + &self, + method: http::Method, + path: &str, + body: &B, + ) -> Result { + let json = body + .to_json() + .stringify() + .map_err(|e| Error::Decode(e.to_string()))?; + let rb = self + .request(method, path) + .header("Content-Type", "application/json") + .body(json); + send_json(rb) + } +} + +fn send_json(rb: http::RequestBuilder) -> Result { + let resp = rb.send().map_err(|e| Error::Http(e.to_string()))?; + let code = resp.status.code(); + let body = resp.body_string().map_err(|e| Error::Http(e.to_string()))?; + if !(200..300).contains(&code) { + return Err(Error::Status { code, body }); + } + let v: Value = body + .parse() + .map_err(|e: jackson::Error| Error::Decode(e.to_string()))?; + T::from_json(&v).map_err(|e| Error::Decode(e.to_string())) +} + +fn send_empty(rb: http::RequestBuilder) -> Result<()> { + let resp = rb.send().map_err(|e| Error::Http(e.to_string()))?; + let code = resp.status.code(); + if !(200..300).contains(&code) { + let body = resp.body_string().unwrap_or_default(); + return Err(Error::Status { code, body }); + } + Ok(()) +} + +// Append non-None query params to `path`, percent-encoding values. +fn with_query(path: &str, params: &[(&str, Option)]) -> String { + let mut out = String::from(path); + let mut sep = '?'; + for (k, v) in params { + if let Some(val) = v { + out.push(sep); + sep = '&'; + out.push_str(k); + out.push('='); + out.push_str(&http::percent_encode(val)); + } + } + out +} + +////////////////////////////////////////////////////////////////////////////// +// params_struct! - struct + Default + ToJson(skip None) + fluent setters +// +// Each field is tagged `required` (typed as T, always emitted) or `opt` +// (typed as Option, emitted only when Some). Setters consume self and +// take `impl Into` so the call site does not handle the Option. +////////////////////////////////////////////////////////////////////////////// + +macro_rules! params_struct { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( $kind:ident $field:ident : $ty:ty ),* $(,)? + } + ) => { + $(#[$meta])* + $vis struct $name { + $( pub $field: params_struct!(@ty $kind $ty), )* + } + + impl ::std::default::Default for $name { + fn default() -> Self { + Self { + $( $field: params_struct!(@default $kind $ty), )* + } + } + } + + impl $crate::ToJson for $name { + #[allow(clippy::vec_init_then_push)] + fn to_json(&self) -> $crate::Value { + let mut __out: ::std::vec::Vec< + (::std::string::String, $crate::Value) + > = ::std::vec::Vec::new(); + $( params_struct!(@emit self, __out, $kind $field); )* + $crate::Value::Object(__out) + } + } + + impl $name { + $( params_struct!(@setter $kind $field $ty); )* + } + }; + + (@ty required $ty:ty) => { $ty }; + (@ty opt $ty:ty) => { ::std::option::Option<$ty> }; + + (@default required $ty:ty) => { + <$ty as ::std::default::Default>::default() + }; + (@default opt $ty:ty) => { ::std::option::Option::None }; + + (@emit $self:ident, $out:ident, required $field:ident) => { + $out.push(( + ::std::stringify!($field).to_string(), + $crate::ToJson::to_json(&$self.$field), + )); + }; + (@emit $self:ident, $out:ident, opt $field:ident) => { + if let ::std::option::Option::Some(__v) = &$self.$field { + $out.push(( + ::std::stringify!($field).to_string(), + $crate::ToJson::to_json(__v), + )); + } + }; + + (@setter required $field:ident $ty:ty) => { + pub fn $field( + mut self, + value: impl ::std::convert::Into<$ty>, + ) -> Self { + self.$field = value.into(); + self + } + }; + (@setter opt $field:ident $ty:ty) => { + pub fn $field( + mut self, + value: impl ::std::convert::Into<$ty>, + ) -> Self { + self.$field = ::std::option::Option::Some(value.into()); + self + } + }; +} + +////////////////////////////////////////////////////////////////////////////// +// Icon - shared between Member.profile.display_icon and Group.display_icon +////////////////////////////////////////////////////////////////////////////// + +json_struct! { + pub struct Icon { + pub id: String, + pub entity_type: String, + pub url: String, + pub created_at: String, + pub updated_at: String, + } +} + +////////////////////////////////////////////////////////////////////////////// +// Epic +// +// Feature-level grouping of stories. Epic.health is the only place this +// commit consumes Health; the health endpoints land in their own commit. +// +// EpicState carries a wire field "type" (Rust keyword); same workaround +// as WorkflowState -- hand-written FromJson/ToJson, Rust field is `kind`. +// +// ThreadedComment is recursive (a comment can have child comments). +// Vec on the field is enough to break the cycle since +// Vec is heap-allocated. +////////////////////////////////////////////////////////////////////////////// + +json_struct! { + pub struct Health { + pub id: Option, + pub entity_type: String, + pub status: String, + pub text: Option, + pub author_id: Option, + pub epic_id: Option, + pub objective_id: Option, + pub created_at: Option, + pub updated_at: Option, + } +} + +json_struct! { + pub struct EpicAssociatedGroup { + pub group_id: String, + pub associated_stories_count: Option, + } +} + +json_struct! { + pub struct EpicStats { + pub num_points: i64, + pub num_points_done: i64, + pub num_points_started: i64, + pub num_points_unstarted: i64, + pub num_points_backlog: i64, + pub num_stories_total: i64, + pub num_stories_done: i64, + pub num_stories_started: i64, + pub num_stories_unstarted: i64, + pub num_stories_backlog: i64, + pub num_stories_unestimated: i64, + pub num_related_documents: i64, + pub last_story_update: Option, + } +} + +pub struct EpicState { + pub id: i64, + pub global_id: String, + pub entity_type: String, + pub name: String, + pub description: String, + pub color: Option, + pub kind: String, + pub position: i64, + pub created_at: String, + pub updated_at: String, +} + +impl ToJson for EpicState { + fn to_json(&self) -> Value { + Value::Object(vec![ + ("id".into(), self.id.to_json()), + ("global_id".into(), self.global_id.to_json()), + ("entity_type".into(), self.entity_type.to_json()), + ("name".into(), self.name.to_json()), + ("description".into(), self.description.to_json()), + ("color".into(), self.color.to_json()), + ("type".into(), self.kind.to_json()), + ("position".into(), self.position.to_json()), + ("created_at".into(), self.created_at.to_json()), + ("updated_at".into(), self.updated_at.to_json()), + ]) + } +} + +impl FromJson for EpicState { + fn from_json(v: &Value) -> std::result::Result { + let obj = v + .as_object() + .ok_or_else(|| jackson::Error::new("expected object", 0))?; + let mut id = None; + let mut global_id = None; + let mut entity_type = None; + let mut name = None; + let mut description = None; + let mut color = None; + let mut kind = None; + let mut position = None; + let mut created_at = None; + let mut updated_at = None; + for (k, val) in obj { + match k.as_str() { + "id" => id = Some(val), + "global_id" => global_id = Some(val), + "entity_type" => entity_type = Some(val), + "name" => name = Some(val), + "description" => description = Some(val), + "color" => color = Some(val), + "type" => kind = Some(val), + "position" => position = Some(val), + "created_at" => created_at = Some(val), + "updated_at" => updated_at = Some(val), + _ => {} + } + } + macro_rules! req { + ($opt:ident, $key:expr) => { + $opt.ok_or_else(|| jackson::Error::new(concat!("missing field: ", $key), 0)) + .and_then(FromJson::from_json)? + }; + } + Ok(EpicState { + id: req!(id, "id"), + global_id: req!(global_id, "global_id"), + entity_type: req!(entity_type, "entity_type"), + name: req!(name, "name"), + description: req!(description, "description"), + color: match color { + Some(v) => Option::::from_json(v)?, + None => None, + }, + kind: req!(kind, "type"), + position: req!(position, "position"), + created_at: req!(created_at, "created_at"), + updated_at: req!(updated_at, "updated_at"), + }) + } +} + +json_struct! { + pub struct EpicWorkflow { + pub id: i64, + pub entity_type: String, + pub default_epic_state_id: i64, + pub epic_states: Vec, + pub created_at: String, + pub updated_at: String, + } +} + +json_struct! { + pub struct ThreadedComment { + pub id: i64, + pub app_url: String, + pub entity_type: String, + pub text: String, + pub author_id: String, + pub deleted: bool, + pub mention_ids: Vec, + pub member_mention_ids: Vec, + pub group_mention_ids: Vec, + pub external_id: Option, + pub comments: Vec, + pub created_at: String, + pub updated_at: String, + } +} + +json_struct! { + pub struct Epic { + pub id: i64, + pub global_id: String, + pub entity_type: String, + pub app_url: String, + pub name: String, + pub description: String, + pub state: String, + pub epic_state_id: i64, + pub milestone_id: Option, + pub group_id: Option, + pub group_ids: Vec, + pub project_ids: Vec, + pub objective_ids: Vec, + pub label_ids: Vec, + pub labels: Vec, + pub follower_ids: Vec, + pub owner_ids: Vec, + pub requested_by_id: String, + pub mention_ids: Vec, + pub member_mention_ids: Vec, + pub group_mention_ids: Vec, + pub associated_groups: Vec, + pub external_id: Option, + pub position: i64, + pub stories_without_projects: i64, + pub started: bool, + pub started_at: Option, + pub started_at_override: Option, + pub completed: bool, + pub completed_at: Option, + pub completed_at_override: Option, + pub planned_start_date: Option, + pub deadline: Option, + pub archived: bool, + pub stats: EpicStats, + pub health: Option, + pub comments: Vec, + pub productboard_id: Option, + pub productboard_plugin_id: Option, + pub productboard_url: Option, + pub productboard_name: Option, + pub created_at: Option, + pub updated_at: Option, + } +} + +json_struct! { + pub struct EpicSlim { + pub id: i64, + pub global_id: String, + pub entity_type: String, + pub app_url: String, + pub name: String, + pub description: Option, + pub state: String, + pub epic_state_id: i64, + pub milestone_id: Option, + pub group_id: Option, + pub group_ids: Vec, + pub project_ids: Vec, + pub objective_ids: Vec, + pub label_ids: Vec, + pub labels: Vec, + pub follower_ids: Vec, + pub owner_ids: Vec, + pub requested_by_id: String, + pub mention_ids: Vec, + pub member_mention_ids: Vec, + pub group_mention_ids: Vec, + pub associated_groups: Vec, + pub external_id: Option, + pub position: i64, + pub stories_without_projects: i64, + pub started: bool, + pub started_at: Option, + pub started_at_override: Option, + pub completed: bool, + pub completed_at: Option, + pub completed_at_override: Option, + pub planned_start_date: Option, + pub deadline: Option, + pub archived: bool, + pub stats: EpicStats, + pub productboard_id: Option, + pub productboard_plugin_id: Option, + pub productboard_url: Option, + pub productboard_name: Option, + pub created_at: Option, + pub updated_at: Option, + } +} + +json_struct! { + pub struct EpicPaginatedResults { + pub total: i64, + pub data: Vec, + pub next: Option, + } +} + +params_struct! { + pub struct CreateEpicParams { + required name: String, + opt description: String, + opt state: String, + opt epic_state_id: i64, + opt milestone_id: i64, + opt group_id: String, + opt group_ids: Vec, + opt objective_ids: Vec, + opt labels: Vec, + opt follower_ids: Vec, + opt owner_ids: Vec, + opt requested_by_id: String, + opt external_id: String, + opt converted_from_story_id: i64, + opt planned_start_date: String, + opt deadline: String, + opt started_at_override: String, + opt completed_at_override: String, + opt created_at: String, + opt updated_at: String, + } +} + +params_struct! { + pub struct UpdateEpicParams { + opt name: String, + opt description: String, + opt state: String, + opt epic_state_id: i64, + opt milestone_id: i64, + opt group_id: String, + opt group_ids: Vec, + opt objective_ids: Vec, + opt labels: Vec, + opt follower_ids: Vec, + opt owner_ids: Vec, + opt requested_by_id: String, + opt external_id: String, + opt archived: bool, + opt before_id: i64, + opt after_id: i64, + opt planned_start_date: String, + opt deadline: String, + opt started_at_override: String, + opt completed_at_override: String, + } +} + +impl Client { + pub fn list_epics(&self, includes_description: Option) -> Result> { + let path = with_query( + "/api/v3/epics", + &[( + "includes_description", + includes_description.map(|b| b.to_string()), + )], + ); + send_json(self.request(http::Method::Get, &path)) + } + + pub fn list_epics_paginated( + &self, + includes_description: Option, + page: Option, + page_size: Option, + ) -> Result { + let path = with_query( + "/api/v3/epics/paginated", + &[ + ( + "includes_description", + includes_description.map(|b| b.to_string()), + ), + ("page", page.map(|n| n.to_string())), + ("page_size", page_size.map(|n| n.to_string())), + ], + ); + send_json(self.request(http::Method::Get, &path)) + } + + pub fn get_epic(&self, id: i64) -> Result { + send_json(self.request(http::Method::Get, &format!("/api/v3/epics/{id}"))) + } + + pub fn create_epic(&self, params: &CreateEpicParams) -> Result { + self.json_call(http::Method::Post, "/api/v3/epics", params) + } + + pub fn update_epic(&self, id: i64, params: &UpdateEpicParams) -> Result { + self.json_call(http::Method::Put, &format!("/api/v3/epics/{id}"), params) + } + + pub fn delete_epic(&self, id: i64) -> Result<()> { + send_empty(self.request(http::Method::Delete, &format!("/api/v3/epics/{id}"))) + } + + pub fn list_epic_stories( + &self, + id: i64, + includes_description: Option, + ) -> Result> { + let path = with_query( + &format!("/api/v3/epics/{id}/stories"), + &[( + "includes_description", + includes_description.map(|b| b.to_string()), + )], + ); + send_json(self.request(http::Method::Get, &path)) + } + + pub fn get_epic_workflow(&self) -> Result { + send_json(self.request(http::Method::Get, "/api/v3/epic-workflow")) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Category +// +// Tag for grouping objectives (and the legacy "milestones" view). +// Category.type is a Rust keyword -- hand-written FromJson and ToJson, +// Rust field is `kind`. Subresources /categories/{id}/objectives and +// /categories/{id}/milestones return the legacy Milestone payload and +// stay deferred (Milestone is out of v1). +////////////////////////////////////////////////////////////////////////////// + +pub struct Category { + pub id: i64, + pub global_id: String, + pub entity_type: String, + pub name: String, + pub kind: String, + pub color: Option, + pub external_id: Option, + pub archived: bool, + pub created_at: String, + pub updated_at: String, +} + +impl ToJson for Category { + fn to_json(&self) -> Value { + Value::Object(vec![ + ("id".into(), self.id.to_json()), + ("global_id".into(), self.global_id.to_json()), + ("entity_type".into(), self.entity_type.to_json()), + ("name".into(), self.name.to_json()), + ("type".into(), self.kind.to_json()), + ("color".into(), self.color.to_json()), + ("external_id".into(), self.external_id.to_json()), + ("archived".into(), self.archived.to_json()), + ("created_at".into(), self.created_at.to_json()), + ("updated_at".into(), self.updated_at.to_json()), + ]) + } +} + +impl FromJson for Category { + fn from_json(v: &Value) -> std::result::Result { + let obj = v + .as_object() + .ok_or_else(|| jackson::Error::new("expected object", 0))?; + let mut id = None; + let mut global_id = None; + let mut entity_type = None; + let mut name = None; + let mut kind = None; + let mut color = None; + let mut external_id = None; + let mut archived = None; + let mut created_at = None; + let mut updated_at = None; + for (k, val) in obj { + match k.as_str() { + "id" => id = Some(val), + "global_id" => global_id = Some(val), + "entity_type" => entity_type = Some(val), + "name" => name = Some(val), + "type" => kind = Some(val), + "color" => color = Some(val), + "external_id" => external_id = Some(val), + "archived" => archived = Some(val), + "created_at" => created_at = Some(val), + "updated_at" => updated_at = Some(val), + _ => {} + } + } + macro_rules! req { + ($opt:ident, $key:expr) => { + $opt.ok_or_else(|| jackson::Error::new(concat!("missing field: ", $key), 0)) + .and_then(FromJson::from_json)? + }; + } + Ok(Category { + id: req!(id, "id"), + global_id: req!(global_id, "global_id"), + entity_type: req!(entity_type, "entity_type"), + name: req!(name, "name"), + kind: req!(kind, "type"), + color: match color { + Some(v) => Option::::from_json(v)?, + None => None, + }, + external_id: match external_id { + Some(v) => Option::::from_json(v)?, + None => None, + }, + archived: req!(archived, "archived"), + created_at: req!(created_at, "created_at"), + updated_at: req!(updated_at, "updated_at"), + }) + } +} + +params_struct! { + pub struct CreateCategoryParams { + required name: String, + opt color: String, + opt external_id: String, + } +} + +params_struct! { + pub struct UpdateCategoryParams { + opt name: String, + opt color: String, + opt archived: bool, + } +} + +impl Client { + pub fn list_categories(&self) -> Result> { + send_json(self.request(http::Method::Get, "/api/v3/categories")) + } + + pub fn get_category(&self, id: i64) -> Result { + send_json(self.request(http::Method::Get, &format!("/api/v3/categories/{id}"))) + } + + pub fn create_category(&self, params: &CreateCategoryParams) -> Result { + self.json_call(http::Method::Post, "/api/v3/categories", params) + } + + pub fn update_category(&self, id: i64, params: &UpdateCategoryParams) -> Result { + self.json_call( + http::Method::Put, + &format!("/api/v3/categories/{id}"), + params, + ) + } + + pub fn delete_category(&self, id: i64) -> Result<()> { + send_empty(self.request(http::Method::Delete, &format!("/api/v3/categories/{id}"))) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Key Result +// +// Key-results sit under objectives. The wire `type` field clashes with +// Rust; same `kind` rename pattern. KeyResultValue is a tagged union of +// numeric vs boolean readings; both fields are optional and only one is +// populated at a time. +////////////////////////////////////////////////////////////////////////////// + +json_struct! { + pub struct KeyResultValue { + pub numeric_value: Option, + pub boolean_value: Option, + } +} + +pub struct KeyResult { + pub id: String, + pub name: String, + pub objective_id: i64, + pub kind: String, + pub progress: i64, + pub initial_observed_value: KeyResultValue, + pub current_observed_value: KeyResultValue, + pub current_target_value: KeyResultValue, +} + +impl ToJson for KeyResult { + fn to_json(&self) -> Value { + Value::Object(vec![ + ("id".into(), self.id.to_json()), + ("name".into(), self.name.to_json()), + ("objective_id".into(), self.objective_id.to_json()), + ("type".into(), self.kind.to_json()), + ("progress".into(), self.progress.to_json()), + ( + "initial_observed_value".into(), + self.initial_observed_value.to_json(), + ), + ( + "current_observed_value".into(), + self.current_observed_value.to_json(), + ), + ( + "current_target_value".into(), + self.current_target_value.to_json(), + ), + ]) + } +} + +impl FromJson for KeyResult { + fn from_json(v: &Value) -> std::result::Result { + let obj = v + .as_object() + .ok_or_else(|| jackson::Error::new("expected object", 0))?; + let mut id = None; + let mut name = None; + let mut objective_id = None; + let mut kind = None; + let mut progress = None; + let mut initial = None; + let mut current_observed = None; + let mut current_target = None; + for (k, val) in obj { + match k.as_str() { + "id" => id = Some(val), + "name" => name = Some(val), + "objective_id" => objective_id = Some(val), + "type" => kind = Some(val), + "progress" => progress = Some(val), + "initial_observed_value" => initial = Some(val), + "current_observed_value" => current_observed = Some(val), + "current_target_value" => current_target = Some(val), + _ => {} + } + } + macro_rules! req { + ($opt:ident, $key:expr) => { + $opt.ok_or_else(|| jackson::Error::new(concat!("missing field: ", $key), 0)) + .and_then(FromJson::from_json)? + }; + } + Ok(KeyResult { + id: req!(id, "id"), + name: req!(name, "name"), + objective_id: req!(objective_id, "objective_id"), + kind: req!(kind, "type"), + progress: req!(progress, "progress"), + initial_observed_value: req!(initial, "initial_observed_value"), + current_observed_value: req!(current_observed, "current_observed_value"), + current_target_value: req!(current_target, "current_target_value"), + }) + } +} + +params_struct! { + pub struct UpdateKeyResultParams { + opt name: String, + opt initial_observed_value: KeyResultValue, + opt observed_value: KeyResultValue, + opt target_value: KeyResultValue, + } +} + +impl Client { + pub fn get_key_result(&self, id: &str) -> Result { + send_json(self.request(http::Method::Get, &format!("/api/v3/key-results/{id}"))) + } + + pub fn update_key_result(&self, id: &str, params: &UpdateKeyResultParams) -> Result { + self.json_call( + http::Method::Put, + &format!("/api/v3/key-results/{id}"), + params, + ) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Objective +// +// Strategic OKR-level container. Embeds Vec and exposes its +// own ObjectiveStats (lead and cycle time aggregates). +////////////////////////////////////////////////////////////////////////////// + +json_struct! { + pub struct ObjectiveStats { + pub num_related_documents: i64, + pub average_cycle_time: Option, + pub average_lead_time: Option, + } +} + +json_struct! { + pub struct Objective { + pub id: i64, + pub global_id: String, + pub entity_type: String, + pub app_url: String, + pub name: String, + pub description: String, + pub state: String, + pub categories: Vec, + pub key_result_ids: Vec, + pub position: i64, + pub started: bool, + pub started_at: Option, + pub started_at_override: Option, + pub completed: bool, + pub completed_at: Option, + pub completed_at_override: Option, + pub archived: bool, + pub stats: ObjectiveStats, + pub created_at: String, + pub updated_at: String, + } +} + +params_struct! { + pub struct CreateObjectiveParams { + required name: String, + opt description: String, + opt state: String, + opt categories: Vec, + opt started_at_override: String, + opt completed_at_override: String, + } +} + +params_struct! { + pub struct UpdateObjectiveParams { + opt name: String, + opt description: String, + opt state: String, + opt archived: bool, + opt categories: Vec, + opt before_id: i64, + opt after_id: i64, + opt started_at_override: String, + opt completed_at_override: String, + } +} + +impl Client { + pub fn list_objectives(&self) -> Result> { + send_json(self.request(http::Method::Get, "/api/v3/objectives")) + } + + pub fn get_objective(&self, id: i64) -> Result { + send_json(self.request(http::Method::Get, &format!("/api/v3/objectives/{id}"))) + } + + pub fn create_objective(&self, params: &CreateObjectiveParams) -> Result { + self.json_call(http::Method::Post, "/api/v3/objectives", params) + } + + pub fn update_objective(&self, id: i64, params: &UpdateObjectiveParams) -> Result { + self.json_call( + http::Method::Put, + &format!("/api/v3/objectives/{id}"), + params, + ) + } + + pub fn delete_objective(&self, id: i64) -> Result<()> { + send_empty(self.request(http::Method::Delete, &format!("/api/v3/objectives/{id}"))) + } + + pub fn list_objective_epics(&self, id: i64) -> Result> { + send_json(self.request(http::Method::Get, &format!("/api/v3/objectives/{id}/epics"))) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Health endpoints +// +// Health updates rate the status of an epic or objective ("on track", +// "at risk", "off track"). The Health response struct itself lives +// near Epic since Epic embeds it; this section is just the endpoints +// and the create/update parameter shapes (which are identical between +// epics and objectives, so a single CreateHealthParams covers both). +////////////////////////////////////////////////////////////////////////////// + +params_struct! { + pub struct CreateHealthParams { + required status: String, + opt text: String, + } +} + +params_struct! { + pub struct UpdateHealthParams { + opt status: String, + opt text: String, + } +} + +impl Client { + pub fn get_epic_health(&self, epic_id: i64) -> Result { + send_json(self.request( + http::Method::Get, + &format!("/api/v3/epics/{epic_id}/health"), + )) + } + + pub fn create_epic_health(&self, epic_id: i64, params: &CreateHealthParams) -> Result { + self.json_call( + http::Method::Post, + &format!("/api/v3/epics/{epic_id}/health"), + params, + ) + } + + pub fn list_epic_health_history(&self, epic_id: i64) -> Result> { + send_json(self.request( + http::Method::Get, + &format!("/api/v3/epics/{epic_id}/health-history"), + )) + } + + pub fn get_objective_health(&self, objective_id: i64) -> Result { + send_json(self.request( + http::Method::Get, + &format!("/api/v3/objectives/{objective_id}/health"), + )) + } + + pub fn create_objective_health( + &self, + objective_id: i64, + params: &CreateHealthParams, + ) -> Result { + self.json_call( + http::Method::Post, + &format!("/api/v3/objectives/{objective_id}/health"), + params, + ) + } + + pub fn list_objective_health_history(&self, objective_id: i64) -> Result> { + send_json(self.request( + http::Method::Get, + &format!("/api/v3/objectives/{objective_id}/health-history"), + )) + } + + pub fn update_health(&self, id: &str, params: &UpdateHealthParams) -> Result { + self.json_call(http::Method::Put, &format!("/api/v3/health/{id}"), params) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Document +// +// Shortcut's first-class wiki page (PRDs, ADRs, postmortems, design +// notes). Doc carries the full content_markdown / content_html; +// DocSlim is the listing payload (id + title + app_url). +////////////////////////////////////////////////////////////////////////////// + +json_struct! { + pub struct Doc { + pub id: String, + pub title: Option, + pub content_markdown: Option, + pub content_html: Option, + pub app_url: String, + pub archived: bool, + pub created_at: String, + pub updated_at: String, + } +} + +json_struct! { + pub struct DocSlim { + pub id: String, + pub title: Option, + pub app_url: String, + } +} + +json_struct! { + pub struct DocSearchResults { + pub total: i64, + pub data: Vec, + pub next: Option, + } +} + +params_struct! { + pub struct CreateDocParams { + required title: String, + required content: String, + opt content_format: String, + } +} + +params_struct! { + pub struct UpdateDocParams { + opt title: String, + opt content: String, + opt content_format: String, + } +} + +impl Client { + pub fn list_docs(&self) -> Result> { + send_json(self.request(http::Method::Get, "/api/v3/documents")) + } + + pub fn get_doc(&self, id: &str) -> Result { + send_json(self.request(http::Method::Get, &format!("/api/v3/documents/{id}"))) + } + + pub fn create_doc(&self, params: &CreateDocParams) -> Result { + self.json_call(http::Method::Post, "/api/v3/documents", params) + } + + pub fn update_doc(&self, id: &str, params: &UpdateDocParams) -> Result { + self.json_call( + http::Method::Put, + &format!("/api/v3/documents/{id}"), + params, + ) + } + + pub fn delete_doc(&self, id: &str) -> Result<()> { + send_empty(self.request(http::Method::Delete, &format!("/api/v3/documents/{id}"))) + } + + pub fn list_doc_epics(&self, id: &str) -> Result> { + send_json(self.request(http::Method::Get, &format!("/api/v3/documents/{id}/epics"))) + } + + pub fn link_doc_to_epic(&self, doc_id: &str, epic_id: i64) -> Result<()> { + send_empty(self.request( + http::Method::Put, + &format!("/api/v3/documents/{doc_id}/epics/{epic_id}"), + )) + } + + pub fn unlink_doc_from_epic(&self, doc_id: &str, epic_id: i64) -> Result<()> { + send_empty(self.request( + http::Method::Delete, + &format!("/api/v3/documents/{doc_id}/epics/{epic_id}"), + )) + } + + pub fn list_epic_documents(&self, epic_id: i64) -> Result> { + send_json(self.request( + http::Method::Get, + &format!("/api/v3/epics/{epic_id}/documents"), + )) + } + + pub fn search_documents(&self, q: &SearchQuery) -> Result { + let path = q.append_to("/api/v3/search/documents"); + send_json(self.request(http::Method::Get, &path)) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Linked-file endpoints +// +// LinkedFile struct lives near Story (Story.linked_files embeds it). +// Both create and update params carry a "type" wire field (Rust keyword); +// hand-written ToJson for both keeps the call site builder-style while +// the wire stays "type". +////////////////////////////////////////////////////////////////////////////// + +pub struct CreateLinkedFileParams { + pub name: String, + pub url: String, + pub kind: String, + pub story_id: Option, + pub description: Option, + pub size: Option, + pub uploader_id: Option, + pub content_type: Option, + pub thumbnail_url: Option, +} + +impl CreateLinkedFileParams { + pub fn new(name: impl Into, url: impl Into, kind: impl Into) -> Self { + Self { + name: name.into(), + url: url.into(), + kind: kind.into(), + story_id: None, + description: None, + size: None, + uploader_id: None, + content_type: None, + thumbnail_url: None, + } + } + + pub fn story_id(mut self, v: i64) -> Self { + self.story_id = Some(v); + self + } + pub fn description(mut self, v: impl Into) -> Self { + self.description = Some(v.into()); + self + } + pub fn size(mut self, v: i64) -> Self { + self.size = Some(v); + self + } + pub fn uploader_id(mut self, v: impl Into) -> Self { + self.uploader_id = Some(v.into()); + self + } + pub fn content_type(mut self, v: impl Into) -> Self { + self.content_type = Some(v.into()); + self + } + pub fn thumbnail_url(mut self, v: impl Into) -> Self { + self.thumbnail_url = Some(v.into()); + self + } +} + +impl ToJson for CreateLinkedFileParams { + fn to_json(&self) -> Value { + let mut out: Vec<(String, Value)> = Vec::new(); + out.push(("name".into(), self.name.to_json())); + out.push(("url".into(), self.url.to_json())); + out.push(("type".into(), self.kind.to_json())); + if let Some(v) = self.story_id { + out.push(("story_id".into(), v.to_json())); + } + if let Some(v) = &self.description { + out.push(("description".into(), v.to_json())); + } + if let Some(v) = self.size { + out.push(("size".into(), v.to_json())); + } + if let Some(v) = &self.uploader_id { + out.push(("uploader_id".into(), v.to_json())); + } + if let Some(v) = &self.content_type { + out.push(("content_type".into(), v.to_json())); + } + if let Some(v) = &self.thumbnail_url { + out.push(("thumbnail_url".into(), v.to_json())); + } + Value::Object(out) + } +} + +#[derive(Default)] +pub struct UpdateLinkedFileParams { + pub name: Option, + pub url: Option, + pub kind: Option, + pub story_id: Option, + pub description: Option, + pub size: Option, + pub uploader_id: Option, + pub thumbnail_url: Option, +} + +impl UpdateLinkedFileParams { + pub fn name(mut self, v: impl Into) -> Self { + self.name = Some(v.into()); + self + } + pub fn url(mut self, v: impl Into) -> Self { + self.url = Some(v.into()); + self + } + pub fn kind(mut self, v: impl Into) -> Self { + self.kind = Some(v.into()); + self + } + pub fn story_id(mut self, v: i64) -> Self { + self.story_id = Some(v); + self + } + pub fn description(mut self, v: impl Into) -> Self { + self.description = Some(v.into()); + self + } + pub fn size(mut self, v: i64) -> Self { + self.size = Some(v); + self + } + pub fn uploader_id(mut self, v: impl Into) -> Self { + self.uploader_id = Some(v.into()); + self + } + pub fn thumbnail_url(mut self, v: impl Into) -> Self { + self.thumbnail_url = Some(v.into()); + self + } +} + +impl ToJson for UpdateLinkedFileParams { + fn to_json(&self) -> Value { + let mut out: Vec<(String, Value)> = Vec::new(); + if let Some(v) = &self.name { + out.push(("name".into(), v.to_json())); + } + if let Some(v) = &self.url { + out.push(("url".into(), v.to_json())); + } + if let Some(v) = &self.kind { + out.push(("type".into(), v.to_json())); + } + if let Some(v) = self.story_id { + out.push(("story_id".into(), v.to_json())); + } + if let Some(v) = &self.description { + out.push(("description".into(), v.to_json())); + } + if let Some(v) = self.size { + out.push(("size".into(), v.to_json())); + } + if let Some(v) = &self.uploader_id { + out.push(("uploader_id".into(), v.to_json())); + } + if let Some(v) = &self.thumbnail_url { + out.push(("thumbnail_url".into(), v.to_json())); + } + Value::Object(out) + } +} + +impl Client { + pub fn list_linked_files(&self) -> Result> { + send_json(self.request(http::Method::Get, "/api/v3/linked-files")) + } + + pub fn get_linked_file(&self, id: i64) -> Result { + send_json(self.request(http::Method::Get, &format!("/api/v3/linked-files/{id}"))) + } + + pub fn create_linked_file(&self, params: &CreateLinkedFileParams) -> Result { + self.json_call(http::Method::Post, "/api/v3/linked-files", params) + } + + pub fn update_linked_file( + &self, + id: i64, + params: &UpdateLinkedFileParams, + ) -> Result { + self.json_call( + http::Method::Put, + &format!("/api/v3/linked-files/{id}"), + params, + ) + } + + pub fn delete_linked_file(&self, id: i64) -> Result<()> { + send_empty(self.request(http::Method::Delete, &format!("/api/v3/linked-files/{id}"))) + } +} + +////////////////////////////////////////////////////////////////////////////// +// Label endpoints +// +// Label and LabelStats live near Iteration (iterations carry Vec