feat: ask a teammate to review a ticket

«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>
This commit is contained in:
2026-10-20 20:38:30 +02:00
parent 172c53d21d
commit 93d028e870
53 changed files with 2836 additions and 54 deletions

View File

@@ -837,3 +837,92 @@ a.ts-key {
color: var(--ts-ink);
}
}
/* Reviews asked on the ticket. */
.ts-reviews {
ul {
display: grid;
gap: 0.625rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 0.625rem 0.875rem;
border-left: 3px solid var(--ts-c-amber);
border-radius: 0 0.5rem 0.5rem 0;
background: color-mix(in srgb, var(--ts-c-amber) 8%, var(--ts-surface));
&.is-approved {
border-left-color: var(--ts-c-green);
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
}
&.is-changes {
border-left-color: var(--ts-c-red);
background: color-mix(in srgb, var(--ts-c-red) 6%, var(--ts-surface));
}
}
p {
margin: 0;
font-size: 0.875rem;
}
}
.ts-reviews__note {
margin-top: 0.25rem !important;
color: var(--ts-ink-soft);
}
.ts-reviews__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.5rem;
&:empty {
display: none;
}
}
/* Small buttons, not @pi/ui's sized ones. */
.ts-reviews__button {
height: auto !important;
min-width: 0 !important;
padding: 0.25rem 0.75rem !important;
border: 1px solid var(--ts-line-strong) !important;
border-radius: 999px !important;
background: var(--ts-surface) !important;
color: var(--ts-ink);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
&.is-ok {
border-color: transparent !important;
background: var(--ts-c-green) !important;
color: #fff;
}
&:hover,
&:focus-visible {
filter: brightness(1.05);
}
}
.ts-reviews__link {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}

View File

@@ -52,11 +52,13 @@ import {
IssuePriority,
isTyping,
POINT_SCALE,
ProjectRole,
IssuesGateway,
localDay,
ReleaseStatus,
ReminderChoice,
reminderChoices,
ReviewVerdict,
StatusCategory,
whenLabel,
} from '@task-stream/web/tracker/domain';
@@ -211,6 +213,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (project.canContribute()) {
<pi-icon-button
icon="eye"
color="grey"
appearance="outline"
text="Pedir revisión"
title="Pedir a alguien del equipo que lo revise"
(execute)="openReviewRequest()"
/>
}
@if (project.myRole() && inMyDay() !== null) {
<pi-icon-button
icon="sun"
@@ -337,6 +349,91 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
}
@if (issue.reviews.length) {
<article
class="ts-block ts-reviews"
aria-labelledby="reviews-title"
>
<h2 class="ts-section" id="reviews-title">Revisiones</h2>
<ul>
@for (review of issue.reviews; track review.id) {
<li
[class.is-approved]="review.verdict === approved"
[class.is-changes]="review.verdict === changes"
>
@if (!review.verdict) {
<p class="ts-reviews__what">
<strong>{{ review.requester.name }}</strong> pidió a
<strong>{{
review.reviewer.id === facade.me()
? 'ti'
: review.reviewer.name
}}</strong>
que lo revise
<span class="ts-muted"
>· {{ ago(review.requestedAt) }}</span
>
</p>
@if (review.note) {
<p class="ts-reviews__note">«{{ review.note }}»</p>
}
<div class="ts-reviews__actions">
@if (review.reviewer.id === facade.me()) {
<button
type="button"
class="ts-reviews__button is-ok"
(click)="openReviewAnswer(review.id, approved)"
>
✓ Todo bien
</button>
<button
type="button"
class="ts-reviews__button"
(click)="openReviewAnswer(review.id, changes)"
>
Pedir cambios
</button>
}
@if (
review.requester.id === facade.me() ||
project.canManage()
) {
<button
type="button"
class="ts-reviews__link"
(click)="facade.cancelReview(review.id)"
>
Retirar
</button>
}
</div>
} @else {
<p class="ts-reviews__what">
<span aria-hidden="true">{{
review.verdict === approved ? '✓' : '✗'
}}</span>
<strong>{{ review.reviewer.name }}</strong>
{{
review.verdict === approved
? 'lo dio por bueno'
: 'pide cambios'
}}
<span class="ts-muted"
>·
{{ ago(review.answeredAt ?? review.requestedAt) }} ·
lo pidió {{ review.requester.name }}</span
>
</p>
@if (review.answer) {
<p class="ts-reviews__note">«{{ review.answer }}»</p>
}
}
</li>
}
</ul>
</article>
}
@if (issue.decisions.length) {
<article
class="ts-block ts-decisions"
@@ -1218,6 +1315,85 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-skeleton shape="text" [lines]="8" />
}
<ng-template #reviewRequestForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); sendReviewRequest()"
novalidate
>
<p class="ts-muted">
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.
</p>
<label class="ts-reminder__field">
Quién
<select
class="ts-input"
[value]="reviewer()"
(change)="reviewer.set($any($event.target).value)"
>
<option value="">Elige a alguien…</option>
@for (member of reviewers(); track member.id) {
<option [value]="member.id">{{ member.name }}</option>
}
</select>
</label>
<label class="ts-reminder__field">
Qué mirar (opcional)
<textarea
class="ts-input ts-resolve__text"
rows="3"
maxlength="1000"
placeholder="Revisa el flujo en móvil, sobre todo el pago"
[value]="reviewNote()"
(input)="reviewNote.set($any($event.target).value)"
></textarea>
</label>
<pi-action-button
size="w100"
color="primary"
text="Pedir revisión"
[disabled]="!reviewer()"
(execute)="sendReviewRequest()"
/>
</form>
</ng-template>
<ng-template #reviewAnswerForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); sendReviewAnswer()"
novalidate
>
<label class="ts-reminder__field">
{{
answering()?.verdict === approved
? 'Un comentario (opcional)'
: 'Qué hay que cambiar'
}}
<textarea
class="ts-input ts-resolve__text"
rows="4"
maxlength="1000"
[value]="reviewAnswer()"
(input)="reviewAnswer.set($any($event.target).value)"
></textarea>
</label>
<pi-action-button
size="w100"
color="primary"
[text]="
answering()?.verdict === approved
? 'Dar por bueno'
: 'Pedir cambios'
"
[disabled]="
answering()?.verdict === changes && !reviewAnswer().trim()
"
(execute)="sendReviewAnswer()"
/>
</form>
</ng-template>
<ng-template #reminderForm>
<form
class="ts-form"
@@ -1373,6 +1549,30 @@ export class IssuePage {
viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly reminderForm =
viewChild.required<TemplateRef<unknown>>('reminderForm');
private readonly reviewRequestForm =
viewChild.required<TemplateRef<unknown>>('reviewRequestForm');
private readonly reviewAnswerForm =
viewChild.required<TemplateRef<unknown>>('reviewAnswerForm');
protected readonly approved = ReviewVerdict.APPROVED;
protected readonly changes = ReviewVerdict.CHANGES;
/** The review being asked: to whom, and what to look at. */
protected readonly reviewer = signal('');
protected readonly reviewNote = signal('');
/** The review being answered, and the answer. */
protected readonly answering = signal<{
reviewId: string;
verdict: ReviewVerdict;
} | null>(null);
protected readonly reviewAnswer = signal('');
/** Who can be asked: the members, not me. */
protected readonly reviewers = computed(() =>
this.project
.members()
.filter(
(m) => m.user.id !== this.facade.me() && m.role !== ProjectRole.GUEST,
)
.map((m) => ({ id: m.user.id, name: m.user.name })),
);
/** The reminder being written: `datetime-local` value and note. */
protected readonly reminderAt = signal('');
protected readonly reminderNote = signal('');
@@ -1792,6 +1992,44 @@ export class IssuePage {
});
}
protected openReviewRequest(): void {
this.reviewer.set('');
this.reviewNote.set('');
this.drawer.open('Pedir revisión', this.reviewRequestForm());
}
protected sendReviewRequest(): void {
if (!this.reviewer()) return;
this.facade.requestReview(this.reviewer(), this.reviewNote());
this.drawer.closeAll();
}
protected openReviewAnswer(reviewId: string, verdict: ReviewVerdict): void {
this.answering.set({ reviewId, verdict });
this.reviewAnswer.set('');
this.drawer.open(
verdict === ReviewVerdict.APPROVED ? 'Todo bien' : 'Pedir cambios',
this.reviewAnswerForm(),
);
}
protected sendReviewAnswer(): void {
const answering = this.answering();
if (!answering) return;
if (
answering.verdict === ReviewVerdict.CHANGES &&
!this.reviewAnswer().trim()
)
return;
this.facade.answerReview(
answering.reviewId,
answering.verdict,
this.reviewAnswer(),
);
this.answering.set(null);
this.drawer.closeAll();
}
protected openReminder(): void {
const current = this.facade.issue()?.myReminder;
this.reminderChoices.set(reminderChoices());

View File

@@ -145,6 +145,31 @@ const VISIBLE = 12;
}
</section>
} @else {
@if (home.reviewing().length) {
<section
class="ts-panel ts-work__panel"
aria-labelledby="reviewing-title"
>
<header class="ts-work__head">
<h2 id="reviewing-title">Te piden revisión</h2>
<span class="ts-muted ts-mono">{{ home.reviewing().length }}</span>
</header>
<ul class="ts-work__list">
@for (issue of home.reviewing().slice(0, 6); track issue.id) {
<li>
<a
class="ts-work__row ts-work__row--qa"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="statusTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-work__title">{{ issue.title }}</span>
</a>
</li>
}
</ul>
</section>
}
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
<header class="ts-work__head">
<h2 id="qa-title">Esperando QA</h2>

View File

@@ -32,6 +32,8 @@ const ICON: Record<NotificationKind, string> = {
[NotificationKind.WATCHED]: 'focus',
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
[NotificationKind.REMINDER]: 'calendar',
[NotificationKind.REVIEW_REQUESTED]: 'eye',
[NotificationKind.REVIEW_ANSWERED]: 'check',
};
/**