mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Move issue and patch timeline building into lib
Both views built their activity feed inline, with the shared part written twice. It now lives in tested modules.
This commit is contained in:
@@ -1,14 +1,7 @@
|
||||
<script lang="ts" module>
|
||||
export interface ActivityItem<T = unknown> {
|
||||
key: string;
|
||||
timestamp: number;
|
||||
data: T;
|
||||
/// Renders as a card or a filled band rather than a bare row of text.
|
||||
/// Grouped rows are packed tight against each other, which reads as one
|
||||
/// element when the things being packed have their own edges, so these are
|
||||
/// never folded into a run.
|
||||
standalone?: boolean;
|
||||
}
|
||||
import type { ActivityItem } from "@app/lib/cobActivity";
|
||||
|
||||
export type { ActivityItem };
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="A">
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script lang="ts" module>
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Action } from "@bindings/cob/issue/Action";
|
||||
|
||||
export type FlattenedIssueOperation =
|
||||
| (Action & {
|
||||
id: string;
|
||||
author: Author;
|
||||
timestamp: number;
|
||||
previous?: Action;
|
||||
})
|
||||
| { type: "opened"; id: string; author: Author; timestamp: number };
|
||||
import type { FlattenedIssueOperation } from "@app/lib/issueTimeline";
|
||||
|
||||
export type { FlattenedIssueOperation };
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { itemDiff } from "@app/lib/cobActivity";
|
||||
import {
|
||||
absoluteTimestamp,
|
||||
authorForNodeId,
|
||||
@@ -33,12 +28,6 @@
|
||||
}
|
||||
|
||||
const { op, hideAuthor }: Props = $props();
|
||||
|
||||
function itemDiff<A>(previousState: A[], newState: A[]) {
|
||||
const removed = previousState.filter(x => !newState.includes(x));
|
||||
const added = newState.filter(x => !previousState.includes(x));
|
||||
return { removed, added };
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts" module>
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Action } from "@bindings/cob/patch/Action";
|
||||
|
||||
export type FlattenedPatchOperation = Action & {
|
||||
id: string;
|
||||
author: Author;
|
||||
timestamp: number;
|
||||
previous?: Action;
|
||||
};
|
||||
import type { FlattenedPatchOperation } from "@app/lib/patchTimeline";
|
||||
|
||||
export type { FlattenedPatchOperation };
|
||||
|
||||
export function splitDescription(text: string): {
|
||||
subject?: string;
|
||||
@@ -29,6 +25,7 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
import { itemDiff } from "@app/lib/cobActivity";
|
||||
import { cachedConfig, writeToClipboard } from "@app/lib/invoke";
|
||||
import {
|
||||
absoluteTimestamp,
|
||||
@@ -81,12 +78,6 @@
|
||||
openedAsDraft = false,
|
||||
}: Props = $props();
|
||||
|
||||
function itemDiff<A>(previousState: A[], newState: A[]) {
|
||||
const removed = previousState.filter(x => !newState.includes(x));
|
||||
const added = newState.filter(x => !previousState.includes(x));
|
||||
return { removed, added };
|
||||
}
|
||||
|
||||
let copyLinkIcon: "link" | "checkmark" = $state("link");
|
||||
async function copyRevisionLink() {
|
||||
if (!rid || !patchId) return;
|
||||
|
||||
+14
-321
@@ -30,6 +30,8 @@
|
||||
cachedListCommits,
|
||||
invoke,
|
||||
} from "@app/lib/invoke";
|
||||
import type { PatchActivityData } from "@app/lib/patchTimeline";
|
||||
import { patchTimeline } from "@app/lib/patchTimeline";
|
||||
import * as roles from "@app/lib/roles";
|
||||
import { push } from "@app/lib/router";
|
||||
import {
|
||||
@@ -43,16 +45,13 @@
|
||||
import { announce } from "@app/components/AnnounceSwitch.svelte";
|
||||
import Changes from "@app/components/Changes.svelte";
|
||||
import CommitActivityItem from "@app/components/CommitActivityItem.svelte";
|
||||
import Discussion, {
|
||||
type ActivityItem,
|
||||
} from "@app/components/Discussion.svelte";
|
||||
import Discussion from "@app/components/Discussion.svelte";
|
||||
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
|
||||
import FileBlock from "@app/components/FileBlock.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Markdown from "@app/components/Markdown.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
import PatchActivityItem, {
|
||||
type FlattenedPatchOperation,
|
||||
splitDescription,
|
||||
} from "@app/components/PatchActivityItem.svelte";
|
||||
import Path from "@app/components/Path.svelte";
|
||||
@@ -64,27 +63,7 @@
|
||||
import ReviewItem from "@app/components/ReviewItem.svelte";
|
||||
import ThreadComponent from "@app/components/Thread.svelte";
|
||||
|
||||
type ActivityData =
|
||||
| {
|
||||
kind: "op";
|
||||
op: FlattenedPatchOperation;
|
||||
commits?: Commit[];
|
||||
reviewThreads?: Thread<CodeLocation>[];
|
||||
reviewComments?: Thread<CodeLocation>[];
|
||||
}
|
||||
| {
|
||||
kind: "opened";
|
||||
op: FlattenedPatchOperation & { type: "revision" };
|
||||
openedAsDraft: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "olderRevisions";
|
||||
groupKey: string;
|
||||
revisionIds: string[];
|
||||
count: number;
|
||||
author?: Author;
|
||||
expanded: boolean;
|
||||
};
|
||||
type ActivityData = PatchActivityData;
|
||||
|
||||
interface Props {
|
||||
rid: string;
|
||||
@@ -172,21 +151,6 @@
|
||||
// Timeline order, as delivered — see `revisionPosition`.
|
||||
const latestRevisionId = $derived(revisions.at(-1)?.id);
|
||||
const firstRevisionId = $derived(revisions[0]?.id);
|
||||
// The patch was opened as a draft when its first lifecycle change is a draft
|
||||
// happening right at creation (the first or second operation), as opposed to
|
||||
// being converted to draft later in its life.
|
||||
const openingDraftOpId = $derived.by(() => {
|
||||
const ops = [...activity].sort((a, b) => a.timestamp - b.timestamp);
|
||||
const firstLifecycleIdx = ops.findIndex(op =>
|
||||
op.actions.some(a => a.type === "lifecycle"),
|
||||
);
|
||||
if (firstLifecycleIdx === -1 || firstLifecycleIdx > 1) return undefined;
|
||||
const op = ops[firstLifecycleIdx];
|
||||
const lifecycle = op.actions.find(a => a.type === "lifecycle");
|
||||
return lifecycle?.type === "lifecycle" && lifecycle.state.status === "draft"
|
||||
? op.id
|
||||
: undefined;
|
||||
});
|
||||
const targetBranch = $derived(
|
||||
patchTargetBranch === undefined
|
||||
? undefined
|
||||
@@ -675,25 +639,6 @@
|
||||
commitsByRevision = next;
|
||||
});
|
||||
});
|
||||
const skippedActivityTypes = new Set<Action["type"]>([
|
||||
"revision.comment",
|
||||
"revision.comment.edit",
|
||||
"revision.comment.redact",
|
||||
"revision.comment.react",
|
||||
"revision.react",
|
||||
"revision.edit",
|
||||
"revision.redact",
|
||||
"review.comment",
|
||||
"review.comment.edit",
|
||||
"review.comment.redact",
|
||||
"review.comment.react",
|
||||
"review.comment.resolve",
|
||||
"review.comment.unresolve",
|
||||
"review.edit",
|
||||
"review.redact",
|
||||
"review.react",
|
||||
]);
|
||||
|
||||
const olderRevisionIds = $derived(
|
||||
new Set(revisions.filter(r => r.id !== latestRevisionId).map(r => r.id)),
|
||||
);
|
||||
@@ -718,268 +663,16 @@
|
||||
return map;
|
||||
});
|
||||
|
||||
const activityItems: ActivityItem<ActivityData>[] = $derived.by(() => {
|
||||
const tracker: Partial<Record<Action["type"], Action>> = {};
|
||||
const items: ActivityItem<ActivityData>[] = [];
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const reviewOpsByReviewId = new Map<
|
||||
string,
|
||||
FlattenedPatchOperation & { type: "review" }
|
||||
>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const revisionOpsByRevisionId = new Map<
|
||||
string,
|
||||
FlattenedPatchOperation & { type: "revision" }
|
||||
>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const redactedRevisionIds = new Set<string>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const redactedReviewIds = new Set<string>();
|
||||
activity.forEach(operation => {
|
||||
operation.actions.forEach((action, actionIndex) => {
|
||||
if (skippedActivityTypes.has(action.type)) {
|
||||
if (action.type === "review.edit") {
|
||||
const reviewOp = reviewOpsByReviewId.get(action.review);
|
||||
if (reviewOp) {
|
||||
if ("verdict" in action) reviewOp.verdict = action.verdict;
|
||||
if ("summary" in action) reviewOp.summary = action.summary;
|
||||
if ("labels" in action) reviewOp.labels = action.labels;
|
||||
}
|
||||
} else if (action.type === "revision.edit") {
|
||||
const revisionOp = revisionOpsByRevisionId.get(action.revision);
|
||||
if (revisionOp) {
|
||||
revisionOp.description = action.description;
|
||||
}
|
||||
} else if (action.type === "revision.redact") {
|
||||
redactedRevisionIds.add(action.revision);
|
||||
} else if (action.type === "review.redact") {
|
||||
redactedReviewIds.add(action.review);
|
||||
}
|
||||
tracker[action.type] = action;
|
||||
return;
|
||||
}
|
||||
const previous = tracker[action.type];
|
||||
// The first `edit` action has nothing to diff against, so the
|
||||
// renderer skips it. Skip it here too so we don't leave a gap.
|
||||
if (action.type === "edit" && !previous) {
|
||||
tracker[action.type] = action;
|
||||
return;
|
||||
}
|
||||
if (action.type === "label") {
|
||||
const prev =
|
||||
previous && previous.type === "label" ? previous.labels : [];
|
||||
const added = action.labels.filter(l => !prev.includes(l));
|
||||
const removed = prev.filter(l => !action.labels.includes(l));
|
||||
if (added.length === 0 && removed.length === 0) {
|
||||
tracker[action.type] = action;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const op: FlattenedPatchOperation = {
|
||||
...action,
|
||||
id: operation.id,
|
||||
author: operation.author,
|
||||
timestamp: operation.timestamp,
|
||||
previous,
|
||||
};
|
||||
tracker[action.type] = action;
|
||||
const commits =
|
||||
action.type === "revision"
|
||||
? commitsByRevision[operation.id]
|
||||
: undefined;
|
||||
const reviewThreads =
|
||||
action.type === "review"
|
||||
? threadsByReview.get(operation.id)
|
||||
: undefined;
|
||||
const reviewComments =
|
||||
action.type === "review"
|
||||
? discussionThreadsByReview.get(operation.id)
|
||||
: undefined;
|
||||
if (action.type === "review") {
|
||||
reviewOpsByReviewId.set(
|
||||
operation.id,
|
||||
op as FlattenedPatchOperation & { type: "review" },
|
||||
);
|
||||
}
|
||||
if (action.type === "revision") {
|
||||
revisionOpsByRevisionId.set(
|
||||
operation.id,
|
||||
op as FlattenedPatchOperation & { type: "revision" },
|
||||
);
|
||||
}
|
||||
items.push({
|
||||
key: `${operation.id}:${actionIndex}`,
|
||||
timestamp: operation.timestamp,
|
||||
data: { kind: "op", op, commits, reviewThreads, reviewComments },
|
||||
// A merge draws a filled band and a review draws a card; both need
|
||||
// the space around them that a run of bare rows deliberately drops.
|
||||
standalone: op.type === "merge" || op.type === "review",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const filtered = items.filter(item => {
|
||||
if (item.data.kind !== "op") return true;
|
||||
if (
|
||||
item.data.op.type === "revision" &&
|
||||
redactedRevisionIds.has(item.data.op.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
item.data.op.type === "review" &&
|
||||
redactedReviewIds.has(item.data.op.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// The opening-draft lifecycle is folded into the "opened a draft patch"
|
||||
// label on the first revision, so drop the standalone item.
|
||||
if (
|
||||
item.data.op.type === "lifecycle" &&
|
||||
item.data.op.state.status === "draft" &&
|
||||
item.data.op.id === openingDraftOpId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
filtered.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Place each review immediately after the revision it belongs to, so it
|
||||
// reads as the next timeline item under that revision rather than floating
|
||||
// wherever its own timestamp lands.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const reviewsByRevision = new Map<string, ActivityItem<ActivityData>[]>();
|
||||
for (const item of filtered) {
|
||||
if (item.data.kind === "op" && item.data.op.type === "review") {
|
||||
const revId = item.data.op.revision;
|
||||
const list = reviewsByRevision.get(revId) ?? [];
|
||||
list.push(item);
|
||||
reviewsByRevision.set(revId, list);
|
||||
}
|
||||
}
|
||||
const reordered: ActivityItem<ActivityData>[] = [];
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const placedReviews = new Set<string>();
|
||||
for (const item of filtered) {
|
||||
if (item.data.kind === "op" && item.data.op.type === "review") continue;
|
||||
reordered.push(item);
|
||||
if (item.data.kind === "op" && item.data.op.type === "revision") {
|
||||
const reviews = reviewsByRevision.get(item.data.op.id);
|
||||
if (reviews) {
|
||||
reordered.push(...reviews);
|
||||
reviews.forEach(r => placedReviews.add(r.key));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reviews whose revision is gone (e.g. redacted) keep their original order.
|
||||
for (const item of filtered) {
|
||||
if (
|
||||
item.data.kind === "op" &&
|
||||
item.data.op.type === "review" &&
|
||||
!placedReviews.has(item.key)
|
||||
) {
|
||||
reordered.push(item);
|
||||
}
|
||||
}
|
||||
items.length = 0;
|
||||
items.push(...reordered);
|
||||
|
||||
// The patch creation is shown as a standalone "opened patch" marker; the
|
||||
// first revision itself stays in the timeline below (and folds with other
|
||||
// revisions). Synthesize the marker from the first revision operation.
|
||||
const firstRevisionOp = revisionOpsByRevisionId.get(firstRevisionId);
|
||||
const opened: ActivityItem<ActivityData>[] = firstRevisionOp
|
||||
? [
|
||||
{
|
||||
key: `opened:${firstRevisionId}`,
|
||||
timestamp: firstRevisionOp.timestamp,
|
||||
data: {
|
||||
kind: "opened",
|
||||
op: firstRevisionOp,
|
||||
openedAsDraft: openingDraftOpId !== undefined,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const isOlderRevisionItem = (item: ActivityItem<ActivityData>) =>
|
||||
item.data.kind === "op" &&
|
||||
item.data.op.type === "revision" &&
|
||||
olderRevisionIds.has(item.data.op.id);
|
||||
// A review belonging to an older revision folds together with that revision.
|
||||
const isFoldableReview = (item: ActivityItem<ActivityData>) =>
|
||||
item.data.kind === "op" &&
|
||||
item.data.op.type === "review" &&
|
||||
olderRevisionIds.has(item.data.op.revision);
|
||||
const isFoldable = (item: ActivityItem<ActivityData>) =>
|
||||
isOlderRevisionItem(item) || isFoldableReview(item);
|
||||
const itemOpAuthorDid = (item: ActivityItem<ActivityData>) =>
|
||||
item.data.kind === "op" ? item.data.op.author.did : undefined;
|
||||
|
||||
// Fold each maximal run of *consecutive* older revisions by the *same
|
||||
// author* (and the reviews nested under them) into one "<author> created N
|
||||
// revisions" toggle. A lifecycle change, comment, or a switch to another
|
||||
// author breaks the run, so a fold is always attributed to one person.
|
||||
const folded: ActivityItem<ActivityData>[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
if (!isFoldable(items[i])) {
|
||||
folded.push(items[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
const runAuthorDid = itemOpAuthorDid(items[i]);
|
||||
while (
|
||||
j < items.length &&
|
||||
isFoldable(items[j]) &&
|
||||
itemOpAuthorDid(items[j]) === runAuthorDid
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
const run = items.slice(i, j);
|
||||
const revisionItems = run.filter(isOlderRevisionItem);
|
||||
if (revisionItems.length < 2) {
|
||||
// A lone older revision (with its reviews) isn't worth folding.
|
||||
folded.push(...run);
|
||||
} else {
|
||||
const head = run[0];
|
||||
const groupKey = `older:${head.data.kind === "op" ? head.data.op.id : head.key}`;
|
||||
const runExpanded = expandedRevisionRuns[groupKey] ?? false;
|
||||
const revisionIds = revisionItems
|
||||
.map(item => (item.data.kind === "op" ? item.data.op.id : undefined))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
// Only attribute the fold to an author when every folded revision is by
|
||||
// the same person; a mixed-author run stays unattributed so it isn't
|
||||
// wrongly labelled "<first author> created N revisions".
|
||||
const runAuthors = revisionItems
|
||||
.map(item =>
|
||||
item.data.kind === "op" ? item.data.op.author : undefined,
|
||||
)
|
||||
.filter((a): a is Author => a !== undefined);
|
||||
const uniqueDids = new Set(runAuthors.map(a => a.did));
|
||||
const commonAuthor = uniqueDids.size === 1 ? runAuthors[0] : undefined;
|
||||
folded.push({
|
||||
key: groupKey,
|
||||
timestamp: head.timestamp,
|
||||
data: {
|
||||
kind: "olderRevisions",
|
||||
groupKey,
|
||||
revisionIds,
|
||||
count: revisionItems.length,
|
||||
author: commonAuthor,
|
||||
expanded: runExpanded,
|
||||
},
|
||||
});
|
||||
if (runExpanded) {
|
||||
folded.push(...run);
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return [...opened, ...folded];
|
||||
});
|
||||
const activityItems = $derived(
|
||||
patchTimeline({
|
||||
activity,
|
||||
revisions,
|
||||
commitsByRevision,
|
||||
threadsByReview,
|
||||
discussionThreadsByReview,
|
||||
expandedRevisionRuns,
|
||||
}),
|
||||
);
|
||||
const reviewSummaryFingerprints = $derived(
|
||||
new Set(
|
||||
revisions
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Operation } from "@bindings/cob/Operation";
|
||||
|
||||
export interface ActivityItem<T = unknown> {
|
||||
key: string;
|
||||
timestamp: number;
|
||||
data: T;
|
||||
/// Renders as a card or a filled band rather than a bare row of text.
|
||||
/// Grouped rows are packed tight against each other, which reads as one
|
||||
/// element when the things being packed have their own edges, so these are
|
||||
/// never folded into a run.
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
/// One action of an operation, with the operation's metadata and the previous
|
||||
/// action of the same type, which the timeline diffs against.
|
||||
export type FlattenedAction<A> = A & {
|
||||
id: string;
|
||||
author: Author;
|
||||
timestamp: number;
|
||||
previous?: A;
|
||||
};
|
||||
|
||||
/// What was added and removed between two lists.
|
||||
export function itemDiff<T>(previous: T[], next: T[]) {
|
||||
return {
|
||||
removed: previous.filter(x => !next.includes(x)),
|
||||
added: next.filter(x => !previous.includes(x)),
|
||||
};
|
||||
}
|
||||
|
||||
function labelsOf(action: { type: string }): string[] | undefined {
|
||||
if (action.type !== "label" || !("labels" in action)) return undefined;
|
||||
return action.labels as string[];
|
||||
}
|
||||
|
||||
/// Turn a COB's operations into one timeline entry per action, dropping the
|
||||
/// actions that would render nothing:
|
||||
///
|
||||
/// - types in `skip`, which the timeline shows elsewhere or not at all;
|
||||
/// - the first `edit`, since there is nothing to diff it against;
|
||||
/// - a `label` action that neither adds nor removes a label.
|
||||
///
|
||||
/// Skipped actions still count as the previous action of their type.
|
||||
/// `onSkipped` sees each action in `skip` and `onKept` each entry as it is
|
||||
/// made, both in operation order, so a caller can fold edits into the entries
|
||||
/// they belong to.
|
||||
export function flattenActivity<A extends { type: string }>(
|
||||
activity: Operation<A>[],
|
||||
options: {
|
||||
skip: ReadonlySet<A["type"]>;
|
||||
onSkipped?: (action: A) => void;
|
||||
onKept?: (op: FlattenedAction<A>) => void;
|
||||
},
|
||||
): ActivityItem<FlattenedAction<A>>[] {
|
||||
const previousByType = new Map<A["type"], A>();
|
||||
const items: ActivityItem<FlattenedAction<A>>[] = [];
|
||||
|
||||
for (const operation of activity) {
|
||||
operation.actions.forEach((action, actionIndex) => {
|
||||
const previous = previousByType.get(action.type);
|
||||
previousByType.set(action.type, action);
|
||||
|
||||
if (options.skip.has(action.type)) {
|
||||
options.onSkipped?.(action);
|
||||
return;
|
||||
}
|
||||
if (action.type === "edit" && !previous) {
|
||||
return;
|
||||
}
|
||||
const labels = labelsOf(action);
|
||||
if (labels) {
|
||||
const { added, removed } = itemDiff(
|
||||
(previous && labelsOf(previous)) ?? [],
|
||||
labels,
|
||||
);
|
||||
if (added.length === 0 && removed.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const op: FlattenedAction<A> = {
|
||||
...action,
|
||||
id: operation.id,
|
||||
author: operation.author,
|
||||
timestamp: operation.timestamp,
|
||||
previous,
|
||||
};
|
||||
options.onKept?.(op);
|
||||
items.push({
|
||||
key: `${operation.id}:${actionIndex}`,
|
||||
timestamp: operation.timestamp,
|
||||
data: op,
|
||||
});
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Action } from "@bindings/cob/issue/Action";
|
||||
import type { Issue } from "@bindings/cob/issue/Issue";
|
||||
import type { Operation } from "@bindings/cob/Operation";
|
||||
|
||||
import type { ActivityItem, FlattenedAction } from "@app/lib/cobActivity";
|
||||
import { flattenActivity } from "@app/lib/cobActivity";
|
||||
|
||||
export type FlattenedIssueOperation =
|
||||
| FlattenedAction<Action>
|
||||
| { type: "opened"; id: string; author: Author; timestamp: number };
|
||||
|
||||
// Comments render as threads beside the timeline, not as entries in it.
|
||||
const skip = new Set<Action["type"]>([
|
||||
"comment",
|
||||
"comment.edit",
|
||||
"comment.react",
|
||||
"comment.redact",
|
||||
]);
|
||||
|
||||
/// The issue's timeline: an "opened" entry, then one entry per action worth
|
||||
/// showing.
|
||||
export function issueTimeline(
|
||||
issue: Issue,
|
||||
activity: Operation<Action>[],
|
||||
): ActivityItem<FlattenedIssueOperation>[] {
|
||||
const opened = issue.body?.edits[0]?.timestamp ?? issue.timestamp;
|
||||
return [
|
||||
{
|
||||
key: `${issue.id}:opened`,
|
||||
timestamp: opened,
|
||||
data: {
|
||||
type: "opened",
|
||||
id: issue.id,
|
||||
author: issue.author,
|
||||
timestamp: opened,
|
||||
},
|
||||
},
|
||||
...flattenActivity(activity, { skip }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Operation } from "@bindings/cob/Operation";
|
||||
import type { Action } from "@bindings/cob/patch/Action";
|
||||
import type { Revision } from "@bindings/cob/patch/Revision";
|
||||
import type { CodeLocation } from "@bindings/cob/thread/CodeLocation";
|
||||
import type { Thread } from "@bindings/cob/thread/Thread";
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
|
||||
import type { ActivityItem, FlattenedAction } from "@app/lib/cobActivity";
|
||||
import { flattenActivity } from "@app/lib/cobActivity";
|
||||
|
||||
export type FlattenedPatchOperation = FlattenedAction<Action>;
|
||||
|
||||
export type PatchActivityData =
|
||||
| {
|
||||
kind: "op";
|
||||
op: FlattenedPatchOperation;
|
||||
commits?: Commit[];
|
||||
reviewThreads?: Thread<CodeLocation>[];
|
||||
reviewComments?: Thread<CodeLocation>[];
|
||||
}
|
||||
| {
|
||||
kind: "opened";
|
||||
op: FlattenedPatchOperation & { type: "revision" };
|
||||
openedAsDraft: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "olderRevisions";
|
||||
groupKey: string;
|
||||
revisionIds: string[];
|
||||
count: number;
|
||||
author?: Author;
|
||||
expanded: boolean;
|
||||
};
|
||||
|
||||
// Shown elsewhere in the patch view, or folded into the entry they change.
|
||||
const skip = new Set<Action["type"]>([
|
||||
"revision.comment",
|
||||
"revision.comment.edit",
|
||||
"revision.comment.redact",
|
||||
"revision.comment.react",
|
||||
"revision.react",
|
||||
"revision.edit",
|
||||
"revision.redact",
|
||||
"review.comment",
|
||||
"review.comment.edit",
|
||||
"review.comment.redact",
|
||||
"review.comment.react",
|
||||
"review.comment.resolve",
|
||||
"review.comment.unresolve",
|
||||
"review.edit",
|
||||
"review.redact",
|
||||
"review.react",
|
||||
]);
|
||||
|
||||
// The patch was opened as a draft when its first lifecycle change is a draft
|
||||
// happening right at creation (the first or second operation), as opposed to
|
||||
// being converted to draft later in its life.
|
||||
export function openingDraftOpId(
|
||||
activity: Operation<Action>[],
|
||||
): string | undefined {
|
||||
const ops = [...activity].sort((a, b) => a.timestamp - b.timestamp);
|
||||
const firstLifecycleIdx = ops.findIndex(op =>
|
||||
op.actions.some(a => a.type === "lifecycle"),
|
||||
);
|
||||
if (firstLifecycleIdx === -1 || firstLifecycleIdx > 1) return undefined;
|
||||
const op = ops[firstLifecycleIdx];
|
||||
const lifecycle = op.actions.find(a => a.type === "lifecycle");
|
||||
return lifecycle?.type === "lifecycle" && lifecycle.state.status === "draft"
|
||||
? op.id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export interface PatchTimelineInput {
|
||||
activity: Operation<Action>[];
|
||||
/// In timeline order.
|
||||
revisions: Revision[];
|
||||
commitsByRevision: Record<string, Commit[]>;
|
||||
threadsByReview: Map<string, Thread<CodeLocation>[]>;
|
||||
discussionThreadsByReview: Map<string, Thread<CodeLocation>[]>;
|
||||
/// Which folded runs of older revisions the reader expanded, by group key.
|
||||
expandedRevisionRuns: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/// The patch's activity timeline, built from its operation log.
|
||||
export function patchTimeline({
|
||||
activity,
|
||||
revisions,
|
||||
commitsByRevision,
|
||||
threadsByReview,
|
||||
discussionThreadsByReview,
|
||||
expandedRevisionRuns,
|
||||
}: PatchTimelineInput): ActivityItem<PatchActivityData>[] {
|
||||
const latestRevisionId = revisions.at(-1)?.id;
|
||||
const firstRevisionId = revisions[0]?.id;
|
||||
const olderRevisionIds = new Set(
|
||||
revisions.filter(r => r.id !== latestRevisionId).map(r => r.id),
|
||||
);
|
||||
const draftOpId = openingDraftOpId(activity);
|
||||
|
||||
const reviewOpsByReviewId = new Map<
|
||||
string,
|
||||
FlattenedPatchOperation & { type: "review" }
|
||||
>();
|
||||
const revisionOpsByRevisionId = new Map<
|
||||
string,
|
||||
FlattenedPatchOperation & { type: "revision" }
|
||||
>();
|
||||
const redactedRevisionIds = new Set<string>();
|
||||
const redactedReviewIds = new Set<string>();
|
||||
|
||||
const flattened = flattenActivity(activity, {
|
||||
skip,
|
||||
onSkipped: action => {
|
||||
if (action.type === "review.edit") {
|
||||
const reviewOp = reviewOpsByReviewId.get(action.review);
|
||||
if (reviewOp) {
|
||||
if ("verdict" in action) reviewOp.verdict = action.verdict;
|
||||
if ("summary" in action) reviewOp.summary = action.summary;
|
||||
if ("labels" in action) reviewOp.labels = action.labels;
|
||||
}
|
||||
} else if (action.type === "revision.edit") {
|
||||
const revisionOp = revisionOpsByRevisionId.get(action.revision);
|
||||
if (revisionOp) {
|
||||
revisionOp.description = action.description;
|
||||
}
|
||||
} else if (action.type === "revision.redact") {
|
||||
redactedRevisionIds.add(action.revision);
|
||||
} else if (action.type === "review.redact") {
|
||||
redactedReviewIds.add(action.review);
|
||||
}
|
||||
},
|
||||
onKept: op => {
|
||||
if (op.type === "review") {
|
||||
reviewOpsByReviewId.set(op.id, op);
|
||||
} else if (op.type === "revision") {
|
||||
revisionOpsByRevisionId.set(op.id, op);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const items: ActivityItem<PatchActivityData>[] = flattened.map(item => {
|
||||
const op = item.data;
|
||||
return {
|
||||
key: item.key,
|
||||
timestamp: item.timestamp,
|
||||
data: {
|
||||
kind: "op",
|
||||
op,
|
||||
commits: op.type === "revision" ? commitsByRevision[op.id] : undefined,
|
||||
reviewThreads:
|
||||
op.type === "review" ? threadsByReview.get(op.id) : undefined,
|
||||
reviewComments:
|
||||
op.type === "review"
|
||||
? discussionThreadsByReview.get(op.id)
|
||||
: undefined,
|
||||
},
|
||||
// A merge draws a filled band and a review draws a card; both need
|
||||
// the space around them that a run of bare rows deliberately drops.
|
||||
standalone: op.type === "merge" || op.type === "review",
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = items.filter(item => {
|
||||
if (item.data.kind !== "op") return true;
|
||||
if (
|
||||
item.data.op.type === "revision" &&
|
||||
redactedRevisionIds.has(item.data.op.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
item.data.op.type === "review" &&
|
||||
redactedReviewIds.has(item.data.op.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// The opening-draft lifecycle is folded into the "opened a draft patch"
|
||||
// label on the first revision, so drop the standalone item.
|
||||
if (
|
||||
item.data.op.type === "lifecycle" &&
|
||||
item.data.op.state.status === "draft" &&
|
||||
item.data.op.id === draftOpId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
filtered.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Place each review immediately after the revision it belongs to, so it
|
||||
// reads as the next timeline item under that revision rather than floating
|
||||
// wherever its own timestamp lands.
|
||||
const reviewsByRevision = new Map<
|
||||
string,
|
||||
ActivityItem<PatchActivityData>[]
|
||||
>();
|
||||
for (const item of filtered) {
|
||||
if (item.data.kind === "op" && item.data.op.type === "review") {
|
||||
const revId = item.data.op.revision;
|
||||
const list = reviewsByRevision.get(revId) ?? [];
|
||||
list.push(item);
|
||||
reviewsByRevision.set(revId, list);
|
||||
}
|
||||
}
|
||||
const reordered: ActivityItem<PatchActivityData>[] = [];
|
||||
const placedReviews = new Set<string>();
|
||||
for (const item of filtered) {
|
||||
if (item.data.kind === "op" && item.data.op.type === "review") continue;
|
||||
reordered.push(item);
|
||||
if (item.data.kind === "op" && item.data.op.type === "revision") {
|
||||
const reviews = reviewsByRevision.get(item.data.op.id);
|
||||
if (reviews) {
|
||||
reordered.push(...reviews);
|
||||
reviews.forEach(r => placedReviews.add(r.key));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reviews whose revision is gone (e.g. redacted) keep their original order.
|
||||
for (const item of filtered) {
|
||||
if (
|
||||
item.data.kind === "op" &&
|
||||
item.data.op.type === "review" &&
|
||||
!placedReviews.has(item.key)
|
||||
) {
|
||||
reordered.push(item);
|
||||
}
|
||||
}
|
||||
items.length = 0;
|
||||
items.push(...reordered);
|
||||
|
||||
// The patch creation is shown as a standalone "opened patch" marker; the
|
||||
// first revision itself stays in the timeline below (and folds with other
|
||||
// revisions). Synthesize the marker from the first revision operation.
|
||||
const firstRevisionOp = revisionOpsByRevisionId.get(firstRevisionId);
|
||||
const opened: ActivityItem<PatchActivityData>[] = firstRevisionOp
|
||||
? [
|
||||
{
|
||||
key: `opened:${firstRevisionId}`,
|
||||
timestamp: firstRevisionOp.timestamp,
|
||||
data: {
|
||||
kind: "opened",
|
||||
op: firstRevisionOp,
|
||||
openedAsDraft: draftOpId !== undefined,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const isOlderRevisionItem = (item: ActivityItem<PatchActivityData>) =>
|
||||
item.data.kind === "op" &&
|
||||
item.data.op.type === "revision" &&
|
||||
olderRevisionIds.has(item.data.op.id);
|
||||
// A review belonging to an older revision folds together with that revision.
|
||||
const isFoldableReview = (item: ActivityItem<PatchActivityData>) =>
|
||||
item.data.kind === "op" &&
|
||||
item.data.op.type === "review" &&
|
||||
olderRevisionIds.has(item.data.op.revision);
|
||||
const isFoldable = (item: ActivityItem<PatchActivityData>) =>
|
||||
isOlderRevisionItem(item) || isFoldableReview(item);
|
||||
const itemOpAuthorDid = (item: ActivityItem<PatchActivityData>) =>
|
||||
item.data.kind === "op" ? item.data.op.author.did : undefined;
|
||||
|
||||
// Fold each maximal run of *consecutive* older revisions by the *same
|
||||
// author* (and the reviews nested under them) into one "<author> created N
|
||||
// revisions" toggle. A lifecycle change, comment, or a switch to another
|
||||
// author breaks the run, so a fold is always attributed to one person.
|
||||
const folded: ActivityItem<PatchActivityData>[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
if (!isFoldable(items[i])) {
|
||||
folded.push(items[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
const runAuthorDid = itemOpAuthorDid(items[i]);
|
||||
while (
|
||||
j < items.length &&
|
||||
isFoldable(items[j]) &&
|
||||
itemOpAuthorDid(items[j]) === runAuthorDid
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
const run = items.slice(i, j);
|
||||
const revisionItems = run.filter(isOlderRevisionItem);
|
||||
if (revisionItems.length < 2) {
|
||||
// A lone older revision (with its reviews) isn't worth folding.
|
||||
folded.push(...run);
|
||||
} else {
|
||||
const head = run[0];
|
||||
const groupKey = `older:${head.data.kind === "op" ? head.data.op.id : head.key}`;
|
||||
const runExpanded = expandedRevisionRuns[groupKey] ?? false;
|
||||
const revisionIds = revisionItems
|
||||
.map(item => (item.data.kind === "op" ? item.data.op.id : undefined))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
// Only attribute the fold to an author when every folded revision is by
|
||||
// the same person; a mixed-author run stays unattributed so it isn't
|
||||
// wrongly labelled "<first author> created N revisions".
|
||||
const runAuthors = revisionItems
|
||||
.map(item =>
|
||||
item.data.kind === "op" ? item.data.op.author : undefined,
|
||||
)
|
||||
.filter((a): a is Author => a !== undefined);
|
||||
const uniqueDids = new Set(runAuthors.map(a => a.did));
|
||||
const commonAuthor = uniqueDids.size === 1 ? runAuthors[0] : undefined;
|
||||
folded.push({
|
||||
key: groupKey,
|
||||
timestamp: head.timestamp,
|
||||
data: {
|
||||
kind: "olderRevisions",
|
||||
groupKey,
|
||||
revisionIds,
|
||||
count: revisionItems.length,
|
||||
author: commonAuthor,
|
||||
expanded: runExpanded,
|
||||
},
|
||||
});
|
||||
if (runExpanded) {
|
||||
folded.push(...run);
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return [...opened, ...folded];
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { nodeRunning } from "@app/lib/events";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import { issueTimeline } from "@app/lib/issueTimeline";
|
||||
import { show } from "@app/lib/modal";
|
||||
import * as roles from "@app/lib/roles";
|
||||
import * as router from "@app/lib/router";
|
||||
@@ -22,9 +23,7 @@
|
||||
import { announce } from "@app/components/AnnounceSwitch.svelte";
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import ConfirmDeleteButton from "@app/components/ConfirmDeleteButton.svelte";
|
||||
import Discussion, {
|
||||
type ActivityItem,
|
||||
} from "@app/components/Discussion.svelte";
|
||||
import Discussion from "@app/components/Discussion.svelte";
|
||||
import EditableTitle from "@app/components/EditableTitle.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import IssueActivityItem, {
|
||||
@@ -64,73 +63,7 @@
|
||||
publicKeyFromDid(issue.author.did) === config.publicKey,
|
||||
);
|
||||
|
||||
const activityItems: ActivityItem<FlattenedIssueOperation>[] = $derived.by(
|
||||
() => {
|
||||
// Actions `IssueActivityItem` has no branch for. Left in, they still
|
||||
// group under an author, so the timeline grows a heading with nothing
|
||||
// beneath it.
|
||||
const skipped = new Set<Action["type"]>([
|
||||
"comment",
|
||||
"comment.edit",
|
||||
"comment.react",
|
||||
"comment.redact",
|
||||
]);
|
||||
const tracker: Partial<Record<Action["type"], Action>> = {};
|
||||
const items: ActivityItem<FlattenedIssueOperation>[] = [];
|
||||
const openedTimestamp =
|
||||
issue.body?.edits[0]?.timestamp ?? issue.timestamp;
|
||||
items.push({
|
||||
key: `${issue.id}:opened`,
|
||||
timestamp: openedTimestamp,
|
||||
data: {
|
||||
type: "opened",
|
||||
id: issue.id,
|
||||
author: issue.author,
|
||||
timestamp: openedTimestamp,
|
||||
},
|
||||
});
|
||||
activity.forEach(operation => {
|
||||
operation.actions.forEach((action, actionIndex) => {
|
||||
if (skipped.has(action.type)) {
|
||||
tracker[action.type] = action;
|
||||
return;
|
||||
}
|
||||
const previous = tracker[action.type];
|
||||
// The first `edit` action has nothing to diff against, so the
|
||||
// renderer skips it. Skip it here too so we don't leave a gap.
|
||||
if (action.type === "edit" && !previous) {
|
||||
tracker[action.type] = action;
|
||||
return;
|
||||
}
|
||||
// A label action that neither adds nor removes renders nothing.
|
||||
if (action.type === "label") {
|
||||
const prev =
|
||||
previous && previous.type === "label" ? previous.labels : [];
|
||||
const added = action.labels.filter(l => !prev.includes(l));
|
||||
const removed = prev.filter(l => !action.labels.includes(l));
|
||||
if (added.length === 0 && removed.length === 0) {
|
||||
tracker[action.type] = action;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const op: FlattenedIssueOperation = {
|
||||
...action,
|
||||
id: operation.id,
|
||||
author: operation.author,
|
||||
timestamp: operation.timestamp,
|
||||
previous,
|
||||
};
|
||||
tracker[action.type] = action;
|
||||
items.push({
|
||||
key: `${operation.id}:${actionIndex}`,
|
||||
timestamp: operation.timestamp,
|
||||
data: op,
|
||||
});
|
||||
});
|
||||
});
|
||||
return items;
|
||||
},
|
||||
);
|
||||
const activityItems = $derived(issueTimeline(issue, activity));
|
||||
|
||||
async function reload() {
|
||||
[issue, activity, threads] = await Promise.all([
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Action } from "@bindings/cob/issue/Action";
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { flattenActivity, itemDiff } from "@app/lib/cobActivity";
|
||||
|
||||
import { author, operation } from "./support/cobs";
|
||||
|
||||
const alice = author("alice");
|
||||
|
||||
function op(id: string, timestamp: number, actions: Action[]) {
|
||||
return { ...operation(alice, timestamp, []), id, actions };
|
||||
}
|
||||
|
||||
test("itemDiff lists added and removed items", () => {
|
||||
expect(itemDiff(["a", "b"], ["b", "c"])).toEqual({
|
||||
removed: ["a"],
|
||||
added: ["c"],
|
||||
});
|
||||
expect(itemDiff([], [])).toEqual({ removed: [], added: [] });
|
||||
});
|
||||
|
||||
describe("flattenActivity", () => {
|
||||
const none = new Set<Action["type"]>();
|
||||
|
||||
test("makes one entry per action with its operation's metadata", () => {
|
||||
const items = flattenActivity(
|
||||
[
|
||||
op("o1", 10, [
|
||||
{ type: "lifecycle", state: { status: "closed", reason: "solved" } },
|
||||
{ type: "assign", assignees: [alice] },
|
||||
]),
|
||||
],
|
||||
{ skip: none },
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
key: "o1:0",
|
||||
timestamp: 10,
|
||||
data: {
|
||||
type: "lifecycle",
|
||||
state: { status: "closed", reason: "solved" },
|
||||
id: "o1",
|
||||
author: alice,
|
||||
timestamp: 10,
|
||||
previous: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "o1:1",
|
||||
timestamp: 10,
|
||||
data: {
|
||||
type: "assign",
|
||||
assignees: [alice],
|
||||
id: "o1",
|
||||
author: alice,
|
||||
timestamp: 10,
|
||||
previous: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("gives each entry the previous action of its type", () => {
|
||||
const first = {
|
||||
type: "lifecycle" as const,
|
||||
state: { status: "open" as const },
|
||||
};
|
||||
const items = flattenActivity(
|
||||
[
|
||||
op("o1", 1, [first]),
|
||||
op("o2", 2, [
|
||||
{ type: "lifecycle", state: { status: "closed", reason: "other" } },
|
||||
]),
|
||||
],
|
||||
{ skip: none },
|
||||
);
|
||||
|
||||
expect(items[1].data.previous).toEqual(first);
|
||||
});
|
||||
|
||||
test("drops the first edit but diffs later ones against it", () => {
|
||||
const opening = { type: "edit" as const, title: "Opening title" };
|
||||
const items = flattenActivity(
|
||||
[
|
||||
op("o1", 1, [opening]),
|
||||
op("o2", 2, [{ type: "edit", title: "Renamed" }]),
|
||||
],
|
||||
{ skip: none },
|
||||
);
|
||||
|
||||
expect(items.map(i => i.key)).toEqual(["o2:0"]);
|
||||
expect(items[0].data.previous).toEqual(opening);
|
||||
});
|
||||
|
||||
test("drops label actions that change nothing", () => {
|
||||
const items = flattenActivity(
|
||||
[
|
||||
op("o1", 1, [{ type: "label", labels: ["bug"] }]),
|
||||
op("o2", 2, [{ type: "label", labels: ["bug"] }]),
|
||||
op("o3", 3, [{ type: "label", labels: ["bug", "ux"] }]),
|
||||
op("o4", 4, [{ type: "label", labels: [] }]),
|
||||
],
|
||||
{ skip: none },
|
||||
);
|
||||
|
||||
expect(items.map(i => i.key)).toEqual(["o1:0", "o3:0", "o4:0"]);
|
||||
});
|
||||
|
||||
test("drops skipped types, which still count as the previous action", () => {
|
||||
const onSkipped = vi.fn();
|
||||
const skipped = { type: "comment" as const, body: "hello" };
|
||||
const items = flattenActivity(
|
||||
[
|
||||
op("o1", 1, [skipped]),
|
||||
op("o2", 2, [{ type: "comment", body: "again" }]),
|
||||
],
|
||||
{ skip: new Set(["comment"]), onSkipped },
|
||||
);
|
||||
|
||||
expect(items).toEqual([]);
|
||||
expect(onSkipped.mock.calls).toEqual([
|
||||
[skipped],
|
||||
[{ type: "comment", body: "again" }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes each entry to onKept as it is made", () => {
|
||||
const kept: string[] = [];
|
||||
const items = flattenActivity(
|
||||
[
|
||||
op("o1", 1, [
|
||||
{ type: "assign", assignees: [] },
|
||||
{ type: "label", labels: ["x"] },
|
||||
]),
|
||||
],
|
||||
{
|
||||
skip: none,
|
||||
onKept: entry => {
|
||||
kept.push(entry.type);
|
||||
if (entry.type === "label") entry.labels = ["changed"];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(kept).toEqual(["assign", "label"]);
|
||||
// Callers fold later edits into the entry they were handed.
|
||||
expect(items[1].data).toMatchObject({ labels: ["changed"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Issue } from "@bindings/cob/issue/Issue";
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { issueTimeline } from "@app/lib/issueTimeline";
|
||||
|
||||
import { author, operation } from "./support/cobs";
|
||||
|
||||
const alice = author("alice");
|
||||
const bob = author("bob");
|
||||
|
||||
function issue(props: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue",
|
||||
author: alice,
|
||||
title: "Title",
|
||||
state: { status: "open" },
|
||||
assignees: [],
|
||||
commentCount: 0,
|
||||
labels: [],
|
||||
timestamp: 50,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
test("starts with the opening, timed by the first edit of the body", () => {
|
||||
const [opened] = issueTimeline(
|
||||
issue({
|
||||
body: {
|
||||
id: "body",
|
||||
author: alice,
|
||||
edits: [{ author: alice, timestamp: 7, body: "Description" }],
|
||||
reactions: [],
|
||||
replyTo: null,
|
||||
location: null,
|
||||
resolved: false,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(opened).toEqual({
|
||||
key: "issue:opened",
|
||||
timestamp: 7,
|
||||
data: { type: "opened", id: "issue", author: alice, timestamp: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to the issue's timestamp without a body", () => {
|
||||
expect(issueTimeline(issue(), [])[0].timestamp).toBe(50);
|
||||
});
|
||||
|
||||
test("leaves comments out of the timeline", () => {
|
||||
const items = issueTimeline(issue(), [
|
||||
operation(bob, 60, [
|
||||
{ type: "comment", body: "Hi" },
|
||||
{ type: "comment.edit", id: "c", body: "Hello" },
|
||||
{ type: "comment.react", id: "c", reaction: "👍", active: true },
|
||||
{ type: "comment.redact", id: "c" },
|
||||
{ type: "label", labels: ["bug"] },
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(items.map(i => i.data.type)).toEqual(["opened", "label"]);
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Operation } from "@bindings/cob/Operation";
|
||||
import type { Action } from "@bindings/cob/patch/Action";
|
||||
import type { Commit } from "@bindings/repo/Commit";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ActivityItem } from "@app/lib/cobActivity";
|
||||
import type { PatchActivityData } from "@app/lib/patchTimeline";
|
||||
import { openingDraftOpId, patchTimeline } from "@app/lib/patchTimeline";
|
||||
|
||||
import { author, comment, location, revision, thread } from "./support/cobs";
|
||||
|
||||
const alice = author("alice");
|
||||
const bob = author("bob");
|
||||
|
||||
function op(
|
||||
id: string,
|
||||
timestamp: number,
|
||||
actions: Action[],
|
||||
by: Author = alice,
|
||||
): Operation<Action> {
|
||||
return { id, author: by, timestamp, actions };
|
||||
}
|
||||
|
||||
function revisionOp(id: string, timestamp: number, by: Author = alice) {
|
||||
return op(
|
||||
id,
|
||||
timestamp,
|
||||
[{ type: "revision", description: `Revision ${id}`, base: "b", oid: "o" }],
|
||||
by,
|
||||
);
|
||||
}
|
||||
|
||||
function reviewOp(id: string, timestamp: number, of: string, by = bob) {
|
||||
return op(id, timestamp, [{ type: "review", revision: of }], by);
|
||||
}
|
||||
|
||||
function timeline(
|
||||
activity: Operation<Action>[],
|
||||
props: {
|
||||
revisionIds?: string[];
|
||||
expanded?: Record<string, boolean>;
|
||||
commits?: Record<string, Commit[]>;
|
||||
} = {},
|
||||
) {
|
||||
const revisionIds =
|
||||
props.revisionIds ??
|
||||
activity
|
||||
.filter(o => o.actions.some(a => a.type === "revision"))
|
||||
.map(o => o.id);
|
||||
return patchTimeline({
|
||||
activity,
|
||||
revisions: revisionIds.map(id => revision({ id })),
|
||||
commitsByRevision: props.commits ?? {},
|
||||
threadsByReview: new Map(),
|
||||
discussionThreadsByReview: new Map(),
|
||||
expandedRevisionRuns: props.expanded ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
// A compact view of the timeline: `opened`, `older(n)` for a fold, and the
|
||||
// operation type and id for everything else.
|
||||
function shape(items: ActivityItem<PatchActivityData>[]): string[] {
|
||||
return items.map(item => {
|
||||
const data = item.data;
|
||||
if (data.kind === "opened") {
|
||||
return data.openedAsDraft ? "opened-draft" : "opened";
|
||||
}
|
||||
if (data.kind === "olderRevisions") return `older(${data.count})`;
|
||||
return `${data.op.type}:${data.op.id}`;
|
||||
});
|
||||
}
|
||||
|
||||
describe("openingDraftOpId", () => {
|
||||
const draft = {
|
||||
type: "lifecycle" as const,
|
||||
state: { status: "draft" as const },
|
||||
};
|
||||
|
||||
test("finds a draft set in the first or second operation", () => {
|
||||
expect(openingDraftOpId([op("d", 1, [draft])])).toBe("d");
|
||||
expect(openingDraftOpId([revisionOp("r", 1), op("d", 2, [draft])])).toBe(
|
||||
"d",
|
||||
);
|
||||
});
|
||||
|
||||
test("goes by timestamp, not array order", () => {
|
||||
expect(
|
||||
openingDraftOpId([
|
||||
op("d", 3, [draft]),
|
||||
revisionOp("r", 1),
|
||||
op("x", 2, [{ type: "label", labels: ["a"] }]),
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("ignores a draft set later in the patch's life", () => {
|
||||
expect(
|
||||
openingDraftOpId([
|
||||
revisionOp("r", 1),
|
||||
op("x", 2, [{ type: "label", labels: ["a"] }]),
|
||||
op("d", 3, [draft]),
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("ignores an opening lifecycle change that isn't to draft", () => {
|
||||
expect(
|
||||
openingDraftOpId([
|
||||
op("o", 1, [{ type: "lifecycle", state: { status: "open" } }]),
|
||||
]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
openingDraftOpId([
|
||||
op("a", 1, [{ type: "lifecycle", state: { status: "archived" } }]),
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchTimeline", () => {
|
||||
test("starts with the opening, then the first revision", () => {
|
||||
expect(shape(timeline([revisionOp("r1", 1)]))).toEqual([
|
||||
"opened",
|
||||
"revision:r1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("folds a draft at creation into the opening", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline([
|
||||
revisionOp("r1", 1),
|
||||
op("d", 2, [{ type: "lifecycle", state: { status: "draft" } }]),
|
||||
]),
|
||||
),
|
||||
).toEqual(["opened-draft", "revision:r1"]);
|
||||
});
|
||||
|
||||
test("keeps a later switch to draft as its own entry", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline([
|
||||
revisionOp("r1", 1),
|
||||
op("l", 2, [{ type: "label", labels: ["x"] }]),
|
||||
op("d", 3, [{ type: "lifecycle", state: { status: "draft" } }]),
|
||||
]),
|
||||
),
|
||||
).toEqual(["opened", "revision:r1", "label:l", "lifecycle:d"]);
|
||||
});
|
||||
|
||||
test("places each review right after its revision", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline([
|
||||
revisionOp("r1", 1),
|
||||
revisionOp("r2", 2),
|
||||
reviewOp("v1", 3, "r1"),
|
||||
]),
|
||||
),
|
||||
).toEqual(["opened", "revision:r1", "review:v1", "revision:r2"]);
|
||||
});
|
||||
|
||||
test("orders entries by timestamp", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline([
|
||||
op("l", 5, [{ type: "label", labels: ["x"] }]),
|
||||
revisionOp("r1", 1),
|
||||
]),
|
||||
),
|
||||
).toEqual(["opened", "revision:r1", "label:l"]);
|
||||
});
|
||||
|
||||
test("folds review and revision edits into the entries they change", () => {
|
||||
const items = timeline([
|
||||
revisionOp("r1", 1),
|
||||
reviewOp("v1", 2, "r1"),
|
||||
op("e1", 3, [
|
||||
{
|
||||
type: "review.edit",
|
||||
review: "v1",
|
||||
verdict: "accept",
|
||||
summary: "LGTM",
|
||||
},
|
||||
{ type: "revision.edit", revision: "r1", description: "Edited" },
|
||||
]),
|
||||
]);
|
||||
|
||||
expect(shape(items)).toEqual(["opened", "revision:r1", "review:v1"]);
|
||||
expect(items[1].data).toMatchObject({
|
||||
op: { description: "Edited" },
|
||||
});
|
||||
expect(items[2].data).toMatchObject({
|
||||
op: { verdict: "accept", summary: "LGTM" },
|
||||
});
|
||||
});
|
||||
|
||||
test("drops redacted revisions and reviews", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline(
|
||||
[
|
||||
revisionOp("r1", 1),
|
||||
revisionOp("r2", 2),
|
||||
reviewOp("v1", 3, "r1"),
|
||||
op("x", 4, [
|
||||
{ type: "revision.redact", revision: "r2" },
|
||||
{ type: "review.redact", review: "v1" },
|
||||
]),
|
||||
],
|
||||
{ revisionIds: ["r1"] },
|
||||
),
|
||||
),
|
||||
).toEqual(["opened", "revision:r1"]);
|
||||
});
|
||||
|
||||
test("keeps a review of a redacted revision, at the end", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline(
|
||||
[
|
||||
revisionOp("r1", 1),
|
||||
revisionOp("r2", 2),
|
||||
reviewOp("v2", 3, "r2"),
|
||||
op("l", 4, [{ type: "label", labels: ["x"] }]),
|
||||
op("x", 5, [{ type: "revision.redact", revision: "r2" }]),
|
||||
],
|
||||
{ revisionIds: ["r1"] },
|
||||
),
|
||||
),
|
||||
).toEqual(["opened", "revision:r1", "label:l", "review:v2"]);
|
||||
});
|
||||
|
||||
test("attaches commits and marks merges and reviews as standalone", () => {
|
||||
const commits = [{ id: "c1" } as Commit];
|
||||
const items = timeline(
|
||||
[
|
||||
revisionOp("r1", 1),
|
||||
reviewOp("v1", 2, "r1"),
|
||||
op("m", 3, [{ type: "merge", revision: "r1", commit: "c1" }]),
|
||||
],
|
||||
{ commits: { r1: commits } },
|
||||
);
|
||||
|
||||
expect(items[1].data).toMatchObject({ kind: "op", commits });
|
||||
expect(items.map(i => i.standalone)).toEqual([
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
test("attaches a review's code and discussion threads", () => {
|
||||
const code = [thread(comment({ location: location("a.ts", "new", 1) }))];
|
||||
const discussion = [thread(comment())];
|
||||
const [, , review] = patchTimeline({
|
||||
activity: [revisionOp("r1", 1), reviewOp("v1", 2, "r1")],
|
||||
revisions: [revision({ id: "r1" })],
|
||||
commitsByRevision: {},
|
||||
threadsByReview: new Map([["v1", code]]),
|
||||
discussionThreadsByReview: new Map([["v1", discussion]]),
|
||||
expandedRevisionRuns: {},
|
||||
});
|
||||
|
||||
expect(review.data).toMatchObject({
|
||||
reviewThreads: code,
|
||||
reviewComments: discussion,
|
||||
});
|
||||
});
|
||||
|
||||
describe("older revisions", () => {
|
||||
const activity = [
|
||||
revisionOp("r1", 1),
|
||||
reviewOp("v1", 2, "r1", alice),
|
||||
revisionOp("r2", 3),
|
||||
revisionOp("r3", 4),
|
||||
];
|
||||
|
||||
test("folds a run of older revisions, with their reviews, by one author", () => {
|
||||
const items = timeline(activity);
|
||||
|
||||
expect(shape(items)).toEqual(["opened", "older(2)", "revision:r3"]);
|
||||
expect(items[1].data).toEqual({
|
||||
kind: "olderRevisions",
|
||||
groupKey: "older:r1",
|
||||
revisionIds: ["r1", "r2"],
|
||||
count: 2,
|
||||
author: alice,
|
||||
expanded: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows the folded entries when the run is expanded", () => {
|
||||
expect(
|
||||
shape(timeline(activity, { expanded: { "older:r1": true } })),
|
||||
).toEqual([
|
||||
"opened",
|
||||
"older(2)",
|
||||
"revision:r1",
|
||||
"review:v1",
|
||||
"revision:r2",
|
||||
"revision:r3",
|
||||
]);
|
||||
});
|
||||
|
||||
// Current behaviour, which may not be intended: a review by someone else
|
||||
// breaks the run, so reviewed revisions rarely fold.
|
||||
test("a review by another author breaks the run", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline([
|
||||
revisionOp("r1", 1),
|
||||
reviewOp("v1", 2, "r1", bob),
|
||||
revisionOp("r2", 3),
|
||||
revisionOp("r3", 4),
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
"opened",
|
||||
"revision:r1",
|
||||
"review:v1",
|
||||
"revision:r2",
|
||||
"revision:r3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("doesn't fold a lone older revision", () => {
|
||||
expect(
|
||||
shape(timeline([revisionOp("r1", 1), revisionOp("r2", 2)])),
|
||||
).toEqual(["opened", "revision:r1", "revision:r2"]);
|
||||
});
|
||||
|
||||
test("a different author breaks the run", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline([
|
||||
revisionOp("r1", 1),
|
||||
revisionOp("r2", 2, bob),
|
||||
revisionOp("r3", 3),
|
||||
revisionOp("r4", 4),
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
"opened",
|
||||
"revision:r1",
|
||||
"revision:r2",
|
||||
"revision:r3",
|
||||
"revision:r4",
|
||||
]);
|
||||
});
|
||||
|
||||
test("another entry between revisions breaks the run", () => {
|
||||
expect(
|
||||
shape(
|
||||
timeline([
|
||||
revisionOp("r1", 1),
|
||||
op("l", 2, [{ type: "label", labels: ["x"] }]),
|
||||
revisionOp("r2", 3),
|
||||
revisionOp("r3", 4),
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
"opened",
|
||||
"revision:r1",
|
||||
"label:l",
|
||||
"revision:r2",
|
||||
"revision:r3",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,10 +72,10 @@ export function revision(props: Partial<Revision> = {}): Revision {
|
||||
};
|
||||
}
|
||||
|
||||
export function operation(
|
||||
export function operation<A = Action>(
|
||||
by: Author,
|
||||
timestamp: number,
|
||||
actions: Action[],
|
||||
): Operation<Action> {
|
||||
actions: A[],
|
||||
): Operation<A> {
|
||||
return { id: `op-${timestamp}`, author: by, timestamp, actions };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user