«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.
API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
545 lines
19 KiB
TypeScript
545 lines
19 KiB
TypeScript
import { IssuePriority, ReviewVerdict } from '../models/enums';
|
|
import { MyDay } from '../models/my-day';
|
|
import { Home } from '../models/home';
|
|
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
|
import {
|
|
DashboardShare,
|
|
SharedBlock,
|
|
SharedDashboard,
|
|
} from '../models/sharing';
|
|
import { Observable } from 'rxjs';
|
|
import {
|
|
Activity,
|
|
Attachment,
|
|
Automation,
|
|
AutomationInput,
|
|
BoardColumn,
|
|
CalendarFeed,
|
|
PushDeviceSubscription,
|
|
CommandResult,
|
|
AlertIntegration,
|
|
Comment,
|
|
Decision,
|
|
Typing,
|
|
Epic,
|
|
GitIntegration,
|
|
GitRepository,
|
|
ImportReport,
|
|
Issue,
|
|
IssuePage,
|
|
IssueSummary,
|
|
Notification,
|
|
NotificationSettings,
|
|
Project,
|
|
ProjectAnalytics,
|
|
ProjectStats,
|
|
Release,
|
|
ReleaseBurnup,
|
|
SavedFilter,
|
|
Presence,
|
|
MyView,
|
|
MyViewCriteria,
|
|
SavedFilterCriteria,
|
|
Sprint,
|
|
SprintBurndown,
|
|
UserDirectoryEntry,
|
|
Webhook,
|
|
WebhookDelivery,
|
|
} from '../models/models';
|
|
import { ProjectSettings } from '../models/workflow';
|
|
import {
|
|
BoardFilter,
|
|
ChangeStatusInput,
|
|
CreateIssueInput,
|
|
CreateProjectInput,
|
|
CreateReleaseInput,
|
|
LinkIssuesInput,
|
|
MemberInput,
|
|
IssueListFilter,
|
|
ProposalInput,
|
|
ProposalResult,
|
|
MyIssuesFilter,
|
|
UpdateIssueInput,
|
|
UpdateProjectInput,
|
|
CreateSprintInput,
|
|
BulkChange,
|
|
BulkResult,
|
|
UpdateSprintInput,
|
|
} from '../models/inputs';
|
|
|
|
/*
|
|
* Outbound ports of the tracker. The application layer depends on these
|
|
* abstract classes; the infrastructure layer implements them over GraphQL.
|
|
*/
|
|
|
|
export abstract class ProjectsGateway {
|
|
abstract myProjects(): Observable<Project[]>;
|
|
/** Projects anyone can read (no sign-in needed). */
|
|
abstract publicProjects(): Observable<Project[]>;
|
|
abstract project(idOrKey: string): Observable<Project>;
|
|
abstract stats(projectId: string): Observable<ProjectStats>;
|
|
/** My active projects at a glance. */
|
|
abstract portfolio(): Observable<PortfolioProject[]>;
|
|
/** The home page beyond «my work». */
|
|
abstract myHome(): Observable<Home>;
|
|
/** The dashboard; with `since`, also what changed after it. */
|
|
abstract insights(
|
|
projectId: string,
|
|
since: string | null,
|
|
): Observable<ProjectInsights>;
|
|
/** Archives the project (read-only) or brings it back. Owner only. */
|
|
abstract setArchived(
|
|
projectId: string,
|
|
archived: boolean,
|
|
): Observable<CommandResult>;
|
|
/** Statistics of the last `days` days. */
|
|
abstract analytics(
|
|
projectId: string,
|
|
days: number,
|
|
): Observable<ProjectAnalytics>;
|
|
/** Replaces statuses, transitions, types and areas (owners and admins). */
|
|
abstract updateSettings(
|
|
projectId: string,
|
|
settings: ProjectSettings,
|
|
): Observable<CommandResult>;
|
|
abstract create(input: CreateProjectInput): Observable<CommandResult>;
|
|
/** Name, description or visibility (owners and admins). */
|
|
abstract update(input: UpdateProjectInput): Observable<CommandResult>;
|
|
/** Owner only; `confirmKey` must be the project key. */
|
|
abstract delete(
|
|
projectId: string,
|
|
confirmKey: string,
|
|
): Observable<CommandResult>;
|
|
abstract addMember(input: MemberInput): Observable<CommandResult>;
|
|
abstract changeMemberRole(input: MemberInput): Observable<CommandResult>;
|
|
abstract removeMember(
|
|
projectId: string,
|
|
userId: string,
|
|
): Observable<CommandResult>;
|
|
}
|
|
|
|
export abstract class IssuesGateway {
|
|
abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
|
|
abstract issue(idOrKey: string): Observable<Issue>;
|
|
abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
|
|
/** A project's tickets for the list view (most recently updated first). */
|
|
abstract list(filter: IssueListFilter): Observable<IssuePage>;
|
|
/** Tickets across all my projects (most recently updated first). */
|
|
abstract mine(filter: MyIssuesFilter): Observable<IssuePage>;
|
|
abstract create(input: CreateIssueInput): Observable<CommandResult>;
|
|
/** Votes for it (`on`) or takes the vote back. */
|
|
abstract vote(issueId: string, on: boolean): Observable<CommandResult>;
|
|
/** A copy of what the ticket says; the result carries its key. */
|
|
abstract duplicate(issueId: string): Observable<CommandResult>;
|
|
abstract update(input: UpdateIssueInput): Observable<CommandResult>;
|
|
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
|
|
abstract assign(
|
|
issueId: string,
|
|
assigneeId: string | null,
|
|
): Observable<CommandResult>;
|
|
abstract link(input: LinkIssuesInput): Observable<CommandResult>;
|
|
abstract unlink(input: LinkIssuesInput): Observable<CommandResult>;
|
|
abstract setRelease(
|
|
issueId: string,
|
|
releaseId: string | null,
|
|
): Observable<CommandResult>;
|
|
/** Asks a member to review the ticket. */
|
|
abstract requestReview(
|
|
issueId: string,
|
|
reviewerId: string,
|
|
note: string | null,
|
|
): Observable<CommandResult>;
|
|
/** Answers a review I was asked for. */
|
|
abstract answerReview(
|
|
issueId: string,
|
|
reviewId: string,
|
|
verdict: ReviewVerdict,
|
|
answer: string | null,
|
|
): Observable<CommandResult>;
|
|
abstract cancelReview(
|
|
issueId: string,
|
|
reviewId: string,
|
|
): Observable<CommandResult>;
|
|
/** The signed-in person follows (or stops following) the ticket. */
|
|
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
|
/** My pinned tickets, most recently pinned first. */
|
|
abstract pins(): Observable<IssueSummary[]>;
|
|
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
|
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
|
/** Follows the ticket with everything under it (or stops). */
|
|
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
|
/** Reminds me of the ticket then (replaces the waiting one). */
|
|
abstract setReminder(
|
|
issueId: string,
|
|
at: string,
|
|
note: string | null,
|
|
): Observable<void>;
|
|
abstract clearReminder(issueId: string): Observable<void>;
|
|
/** «Mi día» on `day` (today, `YYYY-MM-DD` in my time zone). */
|
|
abstract myDay(day: string): Observable<MyDay>;
|
|
abstract addToMyDay(day: string, issueIds: string[]): Observable<void>;
|
|
abstract removeFromMyDay(day: string, issueId: string): Observable<void>;
|
|
/**
|
|
* Decisions (comments marked as such) of all my projects by their text,
|
|
* most recent first: here and not in CommentsGateway, for the palette.
|
|
*/
|
|
abstract searchDecisions(
|
|
search: string,
|
|
limit: number,
|
|
): Observable<Decision[]>;
|
|
/** Tickets hanging from `issueId`. */
|
|
abstract children(
|
|
projectId: string,
|
|
issueId: string,
|
|
): Observable<IssueSummary[]>;
|
|
/** Places the ticket under `parentId` (null: back to the top). */
|
|
abstract setParent(
|
|
issueId: string,
|
|
parentId: string | null,
|
|
): Observable<CommandResult>;
|
|
abstract epics(projectId: string): Observable<Epic[]>;
|
|
/** Tickets due between two days (`YYYY-MM-DD`, both included). */
|
|
abstract due(
|
|
projectId: string,
|
|
from: string,
|
|
to: string,
|
|
): Observable<IssueSummary[]>;
|
|
/** To the trash (whoever opened it, or a manager). */
|
|
abstract trash(issueId: string): Observable<CommandResult>;
|
|
abstract restore(issueId: string): Observable<CommandResult>;
|
|
/** The project's tickets in the trash, newest first. */
|
|
abstract trashed(projectId: string): Observable<IssueSummary[]>;
|
|
/** The same change on several tickets; each may refuse on its own. */
|
|
abstract bulkUpdate(
|
|
issueIds: string[],
|
|
change: BulkChange,
|
|
): Observable<BulkResult>;
|
|
/** Tickets still to start (TODO category), in backlog order. */
|
|
abstract backlog(projectId: string): Observable<IssuePage>;
|
|
/** Moves a ticket between two others of the backlog (null: an end). */
|
|
abstract rank(
|
|
issueId: string,
|
|
aboveIssueId: string | null,
|
|
belowIssueId: string | null,
|
|
): Observable<CommandResult>;
|
|
abstract addChecklistItem(
|
|
issueId: string,
|
|
text: string,
|
|
): Observable<CommandResult>;
|
|
abstract updateChecklistItem(
|
|
issueId: string,
|
|
itemId: string,
|
|
changes: { text?: string; done?: boolean },
|
|
): Observable<CommandResult>;
|
|
abstract removeChecklistItem(
|
|
issueId: string,
|
|
itemId: string,
|
|
): Observable<CommandResult>;
|
|
}
|
|
|
|
/** The project's automation rules (owners and admins). */
|
|
export abstract class AutomationsGateway {
|
|
abstract list(projectId: string): Observable<Automation[]>;
|
|
abstract create(
|
|
projectId: string,
|
|
rule: AutomationInput,
|
|
): Observable<Automation>;
|
|
abstract update(
|
|
automationId: string,
|
|
changes: { name?: string; active?: boolean },
|
|
): Observable<Automation>;
|
|
abstract remove(automationId: string): Observable<void>;
|
|
}
|
|
|
|
/** Tickets in and out as CSV. */
|
|
export abstract class TransferGateway {
|
|
abstract exportCsv(projectId: string): Observable<Blob>;
|
|
/** `dryRun`: only tell what would be created. */
|
|
abstract importCsv(
|
|
projectId: string,
|
|
file: File,
|
|
dryRun: boolean,
|
|
): Observable<ImportReport>;
|
|
}
|
|
|
|
/** The project's outgoing webhooks (owners and admins). */
|
|
export abstract class WebhooksGateway {
|
|
abstract list(projectId: string): Observable<Webhook[]>;
|
|
abstract events(): Observable<string[]>;
|
|
/** The secret comes back only here. */
|
|
abstract create(
|
|
projectId: string,
|
|
url: string,
|
|
events: string[],
|
|
): Observable<{ id: string; secret: string }>;
|
|
abstract update(
|
|
webhookId: string,
|
|
changes: { url?: string; events?: string[]; active?: boolean },
|
|
): Observable<Webhook>;
|
|
abstract remove(webhookId: string): Observable<void>;
|
|
abstract test(webhookId: string): Observable<WebhookDelivery>;
|
|
}
|
|
|
|
/** The project's Git webhook (owners and admins configure it). */
|
|
/** Alerts → bugs (owners and admins). */
|
|
export abstract class AlertsGateway {
|
|
abstract integration(projectId: string): Observable<AlertIntegration>;
|
|
/** Turns it on, or changes what it opens; the URL stays. */
|
|
abstract save(
|
|
projectId: string,
|
|
settings: { typeId: string | null; priority: IssuePriority },
|
|
): Observable<AlertIntegration>;
|
|
/** A new URL; the old one stops working. */
|
|
abstract rotateToken(projectId: string): Observable<AlertIntegration>;
|
|
abstract disable(projectId: string): Observable<void>;
|
|
}
|
|
|
|
export abstract class GitGateway {
|
|
abstract integration(projectId: string): Observable<GitIntegration>;
|
|
/** A repository with its own secret (the first turns the integration on). */
|
|
abstract addRepository(
|
|
projectId: string,
|
|
name: string,
|
|
): Observable<GitRepository>;
|
|
abstract rotateSecret(
|
|
projectId: string,
|
|
repositoryId: string,
|
|
): Observable<GitRepository>;
|
|
abstract removeRepository(
|
|
projectId: string,
|
|
repositoryId: string,
|
|
): Observable<void>;
|
|
}
|
|
|
|
/** Saved views of a project's board (mine and the shared ones). */
|
|
export abstract class SavedFiltersGateway {
|
|
abstract list(projectId: string): Observable<SavedFilter[]>;
|
|
abstract save(input: {
|
|
projectId: string;
|
|
name: string;
|
|
shared: boolean;
|
|
criteria: SavedFilterCriteria;
|
|
}): Observable<string>;
|
|
abstract remove(id: string): Observable<void>;
|
|
/** My views of «my work», by name. */
|
|
abstract myViews(): Observable<MyView[]>;
|
|
abstract saveMyView(input: {
|
|
name: string;
|
|
criteria: MyViewCriteria;
|
|
}): Observable<string>;
|
|
abstract removeMyView(id: string): Observable<void>;
|
|
}
|
|
|
|
export abstract class CommentsGateway {
|
|
abstract list(issueId: string): Observable<Comment[]>;
|
|
abstract add(
|
|
issueId: string,
|
|
body: string,
|
|
replyTo?: string | null,
|
|
): Observable<CommandResult>;
|
|
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
|
/** Adds (`on`) or takes back one's reaction. */
|
|
abstract react(
|
|
commentId: string,
|
|
emoji: string,
|
|
on: boolean,
|
|
): Observable<CommandResult>;
|
|
abstract remove(commentId: string): Observable<CommandResult>;
|
|
/**
|
|
* Settles a conversation (or reopens it); a conclusion is added as a
|
|
* reply marked as a decision.
|
|
*/
|
|
abstract resolveThread(
|
|
commentId: string,
|
|
resolved: boolean,
|
|
conclusion: string | null,
|
|
): Observable<CommandResult>;
|
|
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
|
abstract markDecision(
|
|
commentId: string,
|
|
on: boolean,
|
|
): Observable<CommandResult>;
|
|
}
|
|
|
|
/** A ticket's files. Uploads and downloads go through the REST API. */
|
|
/** The ticket's acknowledgement and the new file's id. */
|
|
export type AttachmentUploaded = CommandResult & { attachmentId: string };
|
|
|
|
/** The public form: no session needed. */
|
|
export abstract class ProposalsGateway {
|
|
abstract submit(
|
|
projectKey: string,
|
|
proposal: ProposalInput,
|
|
): Observable<ProposalResult>;
|
|
}
|
|
|
|
export abstract class AttachmentsGateway {
|
|
abstract upload(
|
|
issueId: string,
|
|
file: File,
|
|
commentId?: string | null,
|
|
): Observable<AttachmentUploaded>;
|
|
/** The file's bytes, to show (images) or save. */
|
|
abstract download(issueId: string, attachment: Attachment): Observable<Blob>;
|
|
abstract remove(
|
|
issueId: string,
|
|
attachmentId: string,
|
|
): Observable<CommandResult>;
|
|
}
|
|
|
|
export abstract class ReleasesGateway {
|
|
abstract list(projectId: string): Observable<Release[]>;
|
|
/** Planned releases of all my projects, soonest target date first. */
|
|
abstract upcoming(limit: number): Observable<Release[]>;
|
|
abstract create(input: CreateReleaseInput): Observable<CommandResult>;
|
|
abstract publish(
|
|
releaseId: string,
|
|
force: boolean,
|
|
): Observable<CommandResult>;
|
|
/** Its unfinished tickets go to `moveTo` (an open release) or leave it. */
|
|
abstract archive(
|
|
releaseId: string,
|
|
moveTo?: string | null,
|
|
): Observable<CommandResult>;
|
|
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
|
|
/** A Markdown draft from the release's finished tickets (not saved). */
|
|
abstract notesDraft(releaseId: string): Observable<string>;
|
|
/** null removes them. */
|
|
abstract writeNotes(
|
|
releaseId: string,
|
|
notes: string | null,
|
|
): Observable<CommandResult>;
|
|
}
|
|
|
|
export abstract class SprintsGateway {
|
|
/** With their progress: under way, planned, then closed. */
|
|
abstract list(projectId: string): Observable<Sprint[]>;
|
|
abstract burndown(sprintId: string): Observable<SprintBurndown>;
|
|
abstract create(input: CreateSprintInput): Observable<CommandResult>;
|
|
abstract update(input: UpdateSprintInput): Observable<CommandResult>;
|
|
abstract start(sprintId: string): Observable<CommandResult>;
|
|
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
|
|
abstract close(
|
|
sprintId: string,
|
|
carryOverTo: string | null,
|
|
): Observable<CommandResult>;
|
|
abstract remove(sprintId: string): Observable<CommandResult>;
|
|
abstract setIssueSprint(
|
|
issueId: string,
|
|
sprintId: string | null,
|
|
): Observable<CommandResult>;
|
|
}
|
|
|
|
export abstract class ActivityGateway {
|
|
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
|
|
/** What happened that day (`YYYY-MM-DD`, UTC), newest first (200 at most). */
|
|
abstract onDay(projectId: string, day: string): Observable<Activity[]>;
|
|
}
|
|
|
|
export abstract class UsersGateway {
|
|
abstract search(term: string): Observable<UserDirectoryEntry[]>;
|
|
}
|
|
|
|
/** The signed-in person's notifications. */
|
|
export abstract class CalendarGateway {
|
|
/** Mine, if I made one. */
|
|
abstract mine(): Observable<CalendarFeed | null>;
|
|
/** A new secret URL; the previous one stops working. */
|
|
abstract create(): Observable<CalendarFeed>;
|
|
abstract revoke(): Observable<void>;
|
|
}
|
|
|
|
/** The project's dashboard for clients, behind a secret link. */
|
|
export abstract class SharingGateway {
|
|
/** The project's link, if it has one (owners and admins). */
|
|
abstract share(projectId: string): Observable<DashboardShare | null>;
|
|
/** Turns it on or changes its blocks; `renew` makes a new secret. */
|
|
abstract save(
|
|
projectId: string,
|
|
blocks: SharedBlock[],
|
|
renew: boolean,
|
|
): Observable<DashboardShare>;
|
|
abstract remove(projectId: string): Observable<void>;
|
|
/** What a link shows, for anyone (null: no such link). */
|
|
abstract shared(token: string): Observable<SharedDashboard | null>;
|
|
}
|
|
|
|
export abstract class NotificationsGateway {
|
|
abstract mine(
|
|
unreadOnly: boolean,
|
|
limit: number,
|
|
): Observable<{ items: Notification[]; unread: number }>;
|
|
/** Without ids: all of them. */
|
|
abstract markRead(ids?: string[]): Observable<void>;
|
|
abstract settings(): Observable<NotificationSettings>;
|
|
abstract updateSettings(email: boolean): Observable<NotificationSettings>;
|
|
/** Push on this device. */
|
|
abstract subscribePush(
|
|
subscription: PushDeviceSubscription,
|
|
): Observable<void>;
|
|
/** Push off on this device. */
|
|
abstract unsubscribePush(endpoint: string): Observable<void>;
|
|
}
|
|
|
|
/**
|
|
* This browser's side of push: its service worker and push subscription.
|
|
* Not every browser has it (and on iPhone only an installed web app does).
|
|
*/
|
|
export abstract class PushDevice {
|
|
abstract get supported(): boolean;
|
|
/** This browser's subscription, if push is on here. */
|
|
abstract current(): Promise<PushDeviceSubscription | null>;
|
|
/** Asks for permission and subscribes; throws when refused. */
|
|
abstract subscribe(publicKey: string): Promise<PushDeviceSubscription>;
|
|
/** Unsubscribes; the endpoint it had, if any. */
|
|
abstract unsubscribe(): Promise<string | null>;
|
|
}
|
|
|
|
/** Realtime (GraphQL subscriptions over WebSocket). */
|
|
export interface IssueChanged {
|
|
action: string;
|
|
version: number;
|
|
actorId: string;
|
|
issue: IssueSummary;
|
|
}
|
|
|
|
export interface CommentChanged {
|
|
action: string;
|
|
commentId: string;
|
|
comment: Comment | null;
|
|
}
|
|
|
|
/** A project changed, or was deleted (then `project` is its last state). */
|
|
export interface ProjectChanged {
|
|
project: Project;
|
|
deleted: boolean;
|
|
}
|
|
|
|
/** Realtime needs a session: without one, these streams stay silent. */
|
|
export abstract class TrackerRealtimeGateway {
|
|
abstract issueChanges(projectId: string): Observable<IssueChanged>;
|
|
abstract commentChanges(issueId: string): Observable<CommentChanged>;
|
|
abstract activity(projectId: string): Observable<Activity>;
|
|
abstract releaseChanges(projectId: string): Observable<Release>;
|
|
/** Something about a sprint of the project changed (or it was deleted). */
|
|
abstract sprintChanges(projectId: string): Observable<{ sprintId: string }>;
|
|
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
|
|
abstract myProjectsChanges(): Observable<ProjectChanged>;
|
|
abstract notifications(): Observable<Notification>;
|
|
/**
|
|
* Each time the live connection comes back after a cut: what happened
|
|
* meanwhile was not received, so views reload.
|
|
*/
|
|
abstract reconnects(): Observable<void>;
|
|
}
|
|
|
|
/** Who is on a ticket's page: a heartbeat answers who else is. */
|
|
export abstract class PresenceGateway {
|
|
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
|
|
abstract leave(issueId: string): Observable<void>;
|
|
/** I am writing (an answer to `threadId`, or a new comment). */
|
|
abstract typing(issueId: string, threadId: string | null): Observable<void>;
|
|
/** Who is writing on the ticket, as they type (me included). */
|
|
abstract typists(issueId: string): Observable<Typing>;
|
|
}
|