first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { inject, Injectable } from '@angular/core';
import {
Activity,
ActivityGateway,
CommandResult,
Comment,
CommentsGateway,
CreateReleaseInput,
Release,
ReleasesGateway,
UserDirectoryEntry,
UsersGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
ActivityDocument,
AddCommentDocument,
ArchiveReleaseDocument,
CreateReleaseDocument,
DeleteCommentDocument,
EditCommentDocument,
IssueCommentsDocument,
PublishReleaseDocument,
ReleasesDocument,
SearchUsersDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toActivity, toCommandResult, toComment, toRelease } from './mappers';
@Injectable()
export class GraphqlCommentsGateway extends CommentsGateway {
private readonly gql = inject(GraphqlOperations);
list(issueId: string): Observable<Comment[]> {
return this.gql
.query(IssueCommentsDocument, { idOrKey: issueId })
.pipe(map((d) => d.issue.comments.items.map(toComment)));
}
add(issueId: string, body: string): Observable<CommandResult> {
return this.gql
.mutate(AddCommentDocument, { issueId, body })
.pipe(map((d) => toCommandResult(d.addComment)));
}
edit(commentId: string, body: string): Observable<CommandResult> {
return this.gql
.mutate(EditCommentDocument, { commentId, body })
.pipe(map((d) => toCommandResult(d.editComment)));
}
remove(commentId: string): Observable<CommandResult> {
return this.gql
.mutate(DeleteCommentDocument, { commentId })
.pipe(map((d) => toCommandResult(d.deleteComment)));
}
}
@Injectable()
export class GraphqlReleasesGateway extends ReleasesGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<Release[]> {
return this.gql
.query(ReleasesDocument, { idOrKey: projectId })
.pipe(map((d) => d.project.releases.map(toRelease)));
}
create(input: CreateReleaseInput): Observable<CommandResult> {
return this.gql
.mutate(CreateReleaseDocument, { input })
.pipe(map((d) => toCommandResult(d.createRelease)));
}
publish(releaseId: string, force: boolean): Observable<CommandResult> {
return this.gql
.mutate(PublishReleaseDocument, { releaseId, force })
.pipe(map((d) => toCommandResult(d.publishRelease)));
}
archive(releaseId: string): Observable<CommandResult> {
return this.gql
.mutate(ArchiveReleaseDocument, { releaseId })
.pipe(map((d) => toCommandResult(d.archiveRelease)));
}
}
@Injectable()
export class GraphqlActivityGateway extends ActivityGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string, issueId?: string): Observable<Activity[]> {
return this.gql
.query(ActivityDocument, { projectId, issueId: issueId ?? null })
.pipe(map((d) => d.activity.map(toActivity)));
}
}
@Injectable()
export class GraphqlUsersGateway extends UsersGateway {
private readonly gql = inject(GraphqlOperations);
search(term: string): Observable<UserDirectoryEntry[]> {
return this.gql
.query(SearchUsersDocument, { search: term })
.pipe(map((d) => d.users));
}
}

View File

@@ -0,0 +1,112 @@
import { inject, Injectable } from '@angular/core';
import {
BoardColumn,
BoardFilter,
ChangeStatusInput,
CommandResult,
CreateIssueInput,
Issue,
IssuesGateway,
IssueSummary,
LinkIssuesInput,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AssignIssueDocument,
BoardDocument,
ChangeIssueStatusDocument,
CreateIssueDocument,
IssueDocument,
LinkIssuesDocument,
SearchIssuesDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toIssue, toIssueSummary } from './mappers';
@Injectable()
export class GraphqlIssuesGateway extends IssuesGateway {
private readonly gql = inject(GraphqlOperations);
board(filter: BoardFilter): Observable<BoardColumn[]> {
return this.gql
.query(BoardDocument, {
idOrKey: filter.projectId,
releaseId: filter.releaseId ?? null,
assigneeId: filter.assigneeId ?? null,
area: filter.area ?? null,
})
.pipe(
map((d) =>
d.project.board.map((column) => ({
status: column.status as BoardColumn['status'],
total: column.total,
issues: column.issues.map(toIssueSummary),
})),
),
);
}
issue(idOrKey: string): Observable<Issue> {
return this.gql
.query(IssueDocument, { idOrKey })
.pipe(map((d) => toIssue(d.issue)));
}
search(projectId: string, text: string): Observable<IssueSummary[]> {
return this.gql
.query(SearchIssuesDocument, { filter: { projectId, search: text } })
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
}
create(input: CreateIssueInput): Observable<CommandResult> {
return this.gql
.mutate(CreateIssueDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.createIssue)));
}
update(input: UpdateIssueInput): Observable<CommandResult> {
return this.gql
.mutate(UpdateIssueDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.updateIssue)));
}
changeStatus(input: ChangeStatusInput): Observable<CommandResult> {
return this.gql
.mutate(ChangeIssueStatusDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.changeIssueStatus)));
}
assign(
issueId: string,
assigneeId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(AssignIssueDocument, { issueId, assigneeId })
.pipe(map((d) => toCommandResult(d.assignIssue)));
}
link(input: LinkIssuesInput): Observable<CommandResult> {
return this.gql
.mutate(LinkIssuesDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.linkIssues)));
}
unlink(input: LinkIssuesInput): Observable<CommandResult> {
return this.gql
.mutate(UnlinkIssuesDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.unlinkIssues)));
}
setRelease(
issueId: string,
releaseId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(SetIssueReleaseDocument, { issueId, releaseId })
.pipe(map((d) => toCommandResult(d.setIssueRelease)));
}
}

View File

@@ -0,0 +1,46 @@
import { inject, Injectable } from '@angular/core';
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { Apollo } from 'apollo-angular';
import { filter, map, Observable } from 'rxjs';
type Vars = Record<string, unknown>;
/** Thin typed wrapper: one-shot queries/mutations and subscriptions as plain data streams. */
@Injectable({ providedIn: 'root' })
export class GraphqlOperations {
private readonly apollo = inject(Apollo);
query<TData, TVars extends Vars>(
document: TypedDocumentNode<TData, TVars>,
variables?: TVars,
): Observable<TData> {
return this.apollo
.query<TData, TVars>({ query: document, variables: variables as TVars })
.pipe(map((result) => result.data as TData));
}
mutate<TData, TVars extends Vars>(
document: TypedDocumentNode<TData, TVars>,
variables: TVars,
): Observable<TData> {
return this.apollo
.mutate<TData, TVars>({ mutation: document, variables })
.pipe(map((result) => result.data as TData));
}
subscribe<TData, TVars extends Vars>(
document: TypedDocumentNode<TData, TVars>,
variables?: TVars,
): Observable<TData> {
return this.apollo
.subscribe<
TData,
TVars
>({ query: document, variables: variables as TVars })
.pipe(
map((result) => result.data),
filter((data): data is NonNullable<typeof data> => !!data),
map((data) => data as TData),
);
}
}

View File

@@ -0,0 +1,68 @@
import { inject, Injectable } from '@angular/core';
import {
CommandResult,
CreateProjectInput,
MemberInput,
Project,
ProjectsGateway,
ProjectStats,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AddProjectMemberDocument,
ChangeProjectMemberRoleDocument,
CreateProjectDocument,
MyProjectsDocument,
ProjectDocument,
ProjectStatsDocument,
RemoveProjectMemberDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toProject, toStats } from './mappers';
@Injectable()
export class GraphqlProjectsGateway extends ProjectsGateway {
private readonly gql = inject(GraphqlOperations);
myProjects(): Observable<Project[]> {
return this.gql
.query(MyProjectsDocument)
.pipe(map((d) => d.myProjects.map(toProject)));
}
project(idOrKey: string): Observable<Project> {
return this.gql
.query(ProjectDocument, { idOrKey })
.pipe(map((d) => toProject(d.project)));
}
stats(projectId: string): Observable<ProjectStats> {
return this.gql
.query(ProjectStatsDocument, { idOrKey: projectId })
.pipe(map((d) => toStats(d.project.stats)));
}
create(input: CreateProjectInput): Observable<CommandResult> {
return this.gql
.mutate(CreateProjectDocument, { input })
.pipe(map((d) => toCommandResult(d.createProject)));
}
addMember(input: MemberInput): Observable<CommandResult> {
return this.gql
.mutate(AddProjectMemberDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.addProjectMember)));
}
changeMemberRole(input: MemberInput): Observable<CommandResult> {
return this.gql
.mutate(ChangeProjectMemberRoleDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.changeProjectMemberRole)));
}
removeMember(projectId: string, userId: string): Observable<CommandResult> {
return this.gql
.mutate(RemoveProjectMemberDocument, { projectId, userId })
.pipe(map((d) => toCommandResult(d.removeProjectMember)));
}
}

View File

@@ -0,0 +1,77 @@
import { inject, Injectable } from '@angular/core';
import {
Activity,
CommentChanged,
IssueChanged,
Project,
Release,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
ActivityAddedDocument,
CommentChangedDocument,
IssueChangedDocument,
MyProjectsChangedDocument,
ProjectChangedDocument,
ReleaseChangedDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import {
toActivity,
toComment,
toIssueSummary,
toProject,
toRelease,
} from './mappers';
/** GraphQL subscriptions (graphql-ws). Each stream lives while it is subscribed. */
@Injectable()
export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
private readonly gql = inject(GraphqlOperations);
issueChanges(projectId: string): Observable<IssueChanged> {
return this.gql.subscribe(IssueChangedDocument, { projectId }).pipe(
map(({ issueChanged }) => ({
...issueChanged,
issue: toIssueSummary(issueChanged.issue),
})),
);
}
commentChanges(issueId: string): Observable<CommentChanged> {
return this.gql.subscribe(CommentChangedDocument, { issueId }).pipe(
map(({ commentChanged }) => ({
action: commentChanged.action,
commentId: commentChanged.commentId,
comment: commentChanged.comment
? toComment(commentChanged.comment)
: null,
})),
);
}
activity(projectId: string): Observable<Activity> {
return this.gql
.subscribe(ActivityAddedDocument, { projectId })
.pipe(map((d) => toActivity(d.activityAdded)));
}
releaseChanges(projectId: string): Observable<Release> {
return this.gql
.subscribe(ReleaseChangedDocument, { projectId })
.pipe(map((d) => toRelease(d.releaseChanged.release)));
}
projectChanges(projectId: string): Observable<Project> {
return this.gql
.subscribe(ProjectChangedDocument, { projectId })
.pipe(map((d) => toProject(d.projectChanged.project)));
}
myProjectsChanges(): Observable<Project> {
return this.gql
.subscribe(MyProjectsChangedDocument)
.pipe(map((d) => toProject(d.myProjectsChanged.project)));
}
}

View File

@@ -0,0 +1,55 @@
import type {
Activity,
Comment,
CommandResult,
Issue,
IssueSummary,
Project,
ProjectStats,
Release,
} from '@task-stream/web/tracker/domain';
import type {
ActivityFieldsFragment,
CommandResultFieldsFragment,
CommentFieldsFragment,
IssueFieldsFragment,
IssueSummaryFieldsFragment,
ProjectFieldsFragment,
ProjectStatsQuery,
ReleaseFieldsFragment,
} from '../graphql/generated';
/*
* GraphQL → domain. The generated types already have the domain's shape;
* enums arrive as string unions with the same values as the domain enums,
* so mapping is a typed re-labelling kept in one place.
*/
const as = <T>(value: unknown): T => value as T;
export const toProject = (p: ProjectFieldsFragment): Project => as<Project>(p);
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
as<IssueSummary>(i);
export const toIssue = (i: IssueFieldsFragment): Issue => as<Issue>(i);
export const toComment = (c: CommentFieldsFragment): Comment => c;
export const toActivity = (a: ActivityFieldsFragment): Activity => a;
export const toCommandResult = (
r: CommandResultFieldsFragment,
): CommandResult => r;
export function toRelease(
r: ReleaseFieldsFragment & {
progress?: { total: number; finished: number; percent: number };
},
): Release {
return as<Release>({
...r,
progress: r.progress ?? { total: 0, finished: 0, percent: 0 },
});
}
export function toStats(
stats: NonNullable<ProjectStatsQuery['project']>['stats'],
): ProjectStats {
return as<ProjectStats>(stats);
}