Files
task-stream/libs/web/tracker/domain/src/lib/ports/gateways.ts
Josep Piguillem 4148335457 feat: bulk edit tickets
Select several tickets in the backlog and change their status, assignee,
sprint, priority or labels at once. Each ticket goes through the normal
rules and may refuse on its own; the result says which did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:26:53 +02:00

322 lines
11 KiB
TypeScript

import { Observable } from 'rxjs';
import {
Activity,
Attachment,
BoardColumn,
Comment,
Epic,
GitIntegration,
Automation,
AutomationInput,
ImportReport,
Webhook,
WebhookDelivery,
SavedFilter,
Sprint,
SprintBurndown,
SavedFilterCriteria,
CommandResult,
Issue,
IssuePage,
IssueSummary,
Notification,
NotificationSettings,
Project,
ProjectAnalytics,
ProjectStats,
Release,
ReleaseBurnup,
UserDirectoryEntry,
} from '../models/models';
import { ProjectSettings } from '../models/workflow';
import {
BoardFilter,
ChangeStatusInput,
CreateIssueInput,
CreateProjectInput,
CreateReleaseInput,
LinkIssuesInput,
MemberInput,
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>;
/** 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[]>;
/** Tickets across all my projects (most recently updated first). */
abstract mine(filter: MyIssuesFilter): Observable<IssuePage>;
abstract create(input: CreateIssueInput): 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>;
/** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
/** 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[]>;
/** 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). */
export abstract class GitGateway {
abstract integration(projectId: string): Observable<GitIntegration>;
/** On with a new secret (also to rotate it). */
abstract enable(projectId: string): Observable<GitIntegration>;
abstract disable(projectId: 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>;
}
export abstract class CommentsGateway {
abstract list(issueId: string): Observable<Comment[]>;
abstract add(issueId: string, body: string): Observable<CommandResult>;
abstract edit(commentId: string, body: string): Observable<CommandResult>;
abstract remove(commentId: string): Observable<CommandResult>;
}
/** A ticket's files. Uploads and downloads go through the REST API. */
export abstract class AttachmentsGateway {
abstract upload(
issueId: string,
file: File,
commentId?: string | null,
): Observable<CommandResult>;
/** 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>;
abstract archive(releaseId: string): 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[]>;
}
export abstract class UsersGateway {
abstract search(term: string): Observable<UserDirectoryEntry[]>;
}
/** The signed-in person's notifications. */
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>;
}
/** 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>;
}