omnitrack 0.3.0

Universal issue-tracker provider contracts and clients (Linear, Jira, ...) for Rust, in one crate.
Documentation
# Contracts

## Capability traits

The named-entity capabilities follow the same read shape:

```rust
pub trait Projects: Send + Sync {
    fn get(&self, id: ProjectId) -> BoxFuture<'_, IssueResult<Project>>;
    fn list(&self, page: Option<PageRequest>) -> BoxFuture<'_, IssueResult<Page<Project>>>;
}
```

Same for `Milestones`, `Cycles`, `Teams`, `Users`, `Labels` over their respective entity + id.

`Viewer` is a single-method capability for the authenticated account, used to validate credentials:

```rust
pub trait Viewer: Send + Sync {
    fn current_user(&self) -> BoxFuture<'_, IssueResult<User>>;
}
```

`Comments` operates on an issue's discussion: `list_comments(issue, page)`, `post_comment(issue, body)`, `delete_comment(id)`. `Comment` is `{ id, body, author, created_at }`, built via `comment().id(..).body(..).build()`.

`Issues` takes a filter on `list` and adds mutation verbs on top of the read pair:

```rust
pub trait Issues: Send + Sync {
    fn get(&self, id: IssueId) -> BoxFuture<'_, IssueResult<Issue>>;
    fn list(&self, filter: IssueFilter, page: Option<PageRequest>) -> BoxFuture<'_, IssueResult<Page<Issue>>>;
    fn create(&self, draft: IssueDraft) -> BoxFuture<'_, IssueResult<Issue>>;
    fn update(&self, id: IssueId, patch: IssuePatch) -> BoxFuture<'_, IssueResult<Issue>>;
    fn delete(&self, id: IssueId) -> BoxFuture<'_, IssueResult<()>>;
    fn close(&self, id: IssueId) -> BoxFuture<'_, IssueResult<Issue>> { /* default: update(category = Completed) */ }
}
```

`list` takes an `IssueFilter` (`issue_filter().team(..).project(..).assignee(..).category(..).build()`; `IssueFilter::default()` = all). `close` has a default impl (sugar over `update` setting `StatusCategory::Completed`), so a provider only implements `get`/`list`/`create`/`update`/`delete`. Each capability has a `TransportNotConfigured*` default impl returning `ErrorKind::TransportNotConfigured`.

`create` takes an `IssueDraft` (server assigns the id, so `Issue` is not reused). Required: `team` + `title` (typestate-enforced). `update` takes an `IssuePatch` (all fields optional; an empty patch is a no-op fetch):

```rust
let draft = issue_draft().team("TEAM-1").title("New issue")
    .category(StatusCategory::Unstarted).build();
let patch = issue_patch().title("Renamed").category(StatusCategory::Started).build();
```

## Value objects

```rust
let it = issue()
    .id("ISS-1")                  // required
    .title("Title")               // required
    .status("open")               // required (raw provider status)
    .category(StatusCategory::Started)  // normalized lifecycle
    .project("PRJ-1")
    .milestone("MIL-1")
    .assignee("USR-1")
    .priority(2)
    .updated_at("2026-05-25T00:00:00Z")
    .build();
```

`issue()` is a typestate builder: `.build()` only exists once `id`, `title`, and `status` are set (compile-time enforced, like `vcs-providers-rs`). Optional fields default to `None` / empty.

Beyond the fields above, `Issue` also carries `identifier` (human key, e.g. `ENG-123`), `description`, `url`, `created_at`, `author` (`UserId`), `team` (`TeamId`), and `labels` (`Vec<LabelId>`, set via `.labels([...])` or repeated `.label(...)`). All optional/empty by default; providers populate what they expose.

Id setters (`id`, `project`, `milestone`, `assignee`) accept either a raw string or the newtype — both compile, since the id newtypes implement `From<&str>` / `From<String>`:

```rust
issue().id("ISS-1")                  // raw string
issue().id(IssueId::make("ISS-1"))   // newtype — both work
```

The built `Issue` always stores strong newtypes; the string form is convenience sugar.

`status` is the raw provider status string (varies per tracker). `category` is the normalized lifecycle for cross-provider filtering:

```rust
pub enum StatusCategory { Backlog, Unstarted, Started, Completed, Canceled }
```

Linear `state.type`, Jira `statusCategory`, and GitHub open/closed all map onto it. `category` is optional until a provider maps it.

Named entities (`Project`, `Milestone`, `Cycle`, `Team`, `User`, `Label`) are `make(id, name)` with `id()` / `name()` accessors. All ids are newtypes.

## Pagination

```rust
let req = pagination().after(PageCursor::make("c1")).limit(100).build();
let page: Page<Issue> = /* provider.list(Some(req)) */;
for issue in page.items() { /* … */ }
let more = page.next(); // Option<&PageCursor>
```

## Errors

```rust
pub enum ErrorKind { TransportNotConfigured, Transport, Decode, NotFound, Unauthorized, RateLimited, Provider }
let err = error().of(ErrorKind::NotFound, "issue missing");
```

---

Prev: [Architecture](./01-architecture.md) · [Index](./README.md) · Next: [Providers](./03-providers.md)