Skip to content
2,133 changes: 1,921 additions & 212 deletions packages/api-client/src/schema.ts

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions packages/cli/src/commands/media/comment/_action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ArgosAPIClient, ArgosAPISchema } from "@argos-ci/api-client";
import type { Command } from "commander";
import { createApiClient } from "../../../lib/api";
import { formatComment } from "../../../lib/format";
import { handleCliError, output } from "../../../lib/run";
import { resolveToken } from "../../../lib/target";
import { jsonOption, tokenOption, type JsonOption } from "../../../options";

type Comment = ArgosAPISchema.components["schemas"]["Comment"];

export type MediaCommentActionContext = {
client: ArgosAPIClient;
mediaId: string;
commentId: string;
};

export type MediaCommentOptions = JsonOption & { token?: string | undefined };

/**
* Register a `media comment <name> <mediaId> <commentId>` command that runs
* `perform` and prints the returned comment.
*
* The media equivalent of the build `defineCommentAction`, and simpler than it:
* a media comment is addressed by the media's own id, so there is no project
* path to resolve and no build reference to parse.
*/
export function defineMediaCommentAction(opts: {
name: string;
description: string;
perform: (ctx: MediaCommentActionContext) => Promise<Comment>;
}) {
return (comment: Command) => {
comment
.command(opts.name)
.description(opts.description)
.argument("<mediaId>", "ID of the media")
.argument("<commentId>", "ID of the comment")
.addOption(tokenOption)
.addOption(jsonOption)
.action(
async (
mediaId: string,
commentId: string,
options: MediaCommentOptions,
) => {
try {
const client = createApiClient(await resolveToken(options));
const result = await opts.perform({ client, mediaId, commentId });
output(result, options, formatComment);
} catch (error) {
handleCliError(error, "user");
}
},
);
};
}
61 changes: 61 additions & 0 deletions packages/cli/src/commands/media/comment/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Command } from "commander";
import { parseAnchor } from "../../../lib/anchor";
import { createApiClient, unwrap } from "../../../lib/api";
import { resolveBody } from "../../../lib/body";
import { fail } from "../../../lib/cli-error";
import { formatComment } from "../../../lib/format";
import { handleCliError, output } from "../../../lib/run";
import { resolveToken } from "../../../lib/target";
import { jsonOption, tokenOption, type JsonOption } from "../../../options";

type MediaCommentCreateOptions = JsonOption & {
token?: string | undefined;
body?: string;
bodyFile?: string;
replyTo?: string;
anchorPoint?: string;
};

export function registerMediaCommentCreate(comment: Command) {
comment
.command("create")
.description("Post a comment (or reply) on a media")
.argument("<mediaId>", "ID of the media")
.option("--body <markdown>", "Markdown body of the comment")
.option("--body-file <path>", "Read the comment body from a Markdown file")
.option(
"--reply-to <threadId>",
"Reply to an existing thread (its root comment ID)",
)
.option(
"--anchor-point <x,y>",
"Pin the comment to a spot on the media, in normalized 0-1 coordinates",
)
.addOption(tokenOption)
.addOption(jsonOption)
.action(async (mediaId: string, options: MediaCommentCreateOptions) => {
try {
const body = await resolveBody(options, { required: true });
const anchor = parseAnchor(options);
// A reply inherits the spot its thread already points at, and a line
// range describes a text snapshot rather than an image.
if (anchor && options.replyTo) {
fail("--anchor-point cannot be used with --reply-to.");
}
const client = createApiClient(await resolveToken(options));
const result = unwrap(
await client.POST("/media/{mediaId}/comments", {
params: { path: { mediaId } },
body: {
body: body as string,
threadId: options.replyTo,
anchor,
},
}),
);
output(result, options, formatComment);
} catch (error) {
handleCliError(error, "user");
}
});
}
13 changes: 13 additions & 0 deletions packages/cli/src/commands/media/comment/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { unwrap } from "../../../lib/api";
import { defineMediaCommentAction } from "./_action";

export const registerMediaCommentDelete = defineMediaCommentAction({
name: "delete",
description: "Delete a comment on a media (author only)",
perform: async ({ client, mediaId, commentId }) =>
unwrap(
await client.DELETE("/media/{mediaId}/comments/{commentId}", {
params: { path: { mediaId, commentId } },
}),
),
});
42 changes: 42 additions & 0 deletions packages/cli/src/commands/media/comment/edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Command } from "commander";
import { createApiClient, unwrap } from "../../../lib/api";
import { resolveBody } from "../../../lib/body";
import { formatComment } from "../../../lib/format";
import { handleCliError, output } from "../../../lib/run";
import { resolveToken } from "../../../lib/target";
import { jsonOption, tokenOption, type JsonOption } from "../../../options";

type EditOptions = JsonOption & {
token?: string | undefined;
body?: string;
bodyFile?: string;
};

export function registerMediaCommentEdit(comment: Command) {
comment
.command("edit")
.description("Update the body of a comment on a media (author only)")
.argument("<mediaId>", "ID of the media")
.argument("<commentId>", "ID of the comment")
.option("--body <markdown>", "New Markdown body of the comment")
.option("--body-file <path>", "Read the new body from a Markdown file")
.addOption(tokenOption)
.addOption(jsonOption)
.action(
async (mediaId: string, commentId: string, options: EditOptions) => {
try {
const body = await resolveBody(options, { required: true });
const client = createApiClient(await resolveToken(options));
const result = unwrap(
await client.PATCH("/media/{mediaId}/comments/{commentId}", {
params: { path: { mediaId, commentId } },
body: { body: body as string },
}),
);
output(result, options, formatComment);
} catch (error) {
handleCliError(error, "user");
}
},
);
}
13 changes: 13 additions & 0 deletions packages/cli/src/commands/media/comment/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { unwrap } from "../../../lib/api";
import { defineMediaCommentAction } from "./_action";

export const registerMediaCommentGet = defineMediaCommentAction({
name: "get",
description: "Show a single comment on a media",
perform: async ({ client, mediaId, commentId }) =>
unwrap(
await client.GET("/media/{mediaId}/comments/{commentId}", {
params: { path: { mediaId, commentId } },
}),
),
});
31 changes: 31 additions & 0 deletions packages/cli/src/commands/media/comment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Command } from "commander";
import { registerMediaCommentCreate } from "./create";
import { registerMediaCommentDelete } from "./delete";
import { registerMediaCommentEdit } from "./edit";
import { registerMediaCommentGet } from "./get";
import { registerMediaCommentList } from "./list";
import { registerMediaCommentReact } from "./react";
import { registerMediaCommentResolve } from "./resolve";
import { registerMediaCommentSubscribe } from "./subscribe";
import { registerMediaCommentUnreact } from "./unreact";
import { registerMediaCommentUnresolve } from "./unresolve";
import { registerMediaCommentUnsubscribe } from "./unsubscribe";

export function registerMediaComment(media: Command) {
const comment = media
.command("comment")
.description(
"List, post, and act on the comments left on a media — the feedback a human pinned to a spot on your screenshot",
);
registerMediaCommentList(comment);
registerMediaCommentCreate(comment);
registerMediaCommentGet(comment);
registerMediaCommentEdit(comment);
registerMediaCommentDelete(comment);
registerMediaCommentResolve(comment);
registerMediaCommentUnresolve(comment);
registerMediaCommentReact(comment);
registerMediaCommentUnreact(comment);
registerMediaCommentSubscribe(comment);
registerMediaCommentUnsubscribe(comment);
}
72 changes: 72 additions & 0 deletions packages/cli/src/commands/media/comment/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ArgosAPISchema } from "@argos-ci/api-client";
import type { Command } from "commander";
import { Option } from "commander";
import { createApiClient, unwrap } from "../../../lib/api";
import { formatComments } from "../../../lib/format";
import { handleCliError, output } from "../../../lib/run";
import { resolveToken } from "../../../lib/target";
import { jsonOption, tokenOption, type JsonOption } from "../../../options";

type Comment = ArgosAPISchema.components["schemas"]["Comment"];

type ListOptions = JsonOption & {
token?: string | undefined;
all?: boolean | undefined;
};

export function registerMediaCommentList(comment: Command) {
comment
.command("list")
.description(
"List the open comment threads on a media — the feedback still to act on",
)
.argument("<mediaId>", "ID of the media")
.addOption(
new Option(
"--all",
"Include threads that are already resolved (by default only open ones are listed)",
),
)
.addOption(tokenOption)
.addOption(jsonOption)
.action(async (mediaId: string, options: ListOptions) => {
try {
const client = createApiClient(await resolveToken(options));
const result = unwrap(
await client.GET("/media/{mediaId}/comments", {
params: { path: { mediaId } },
}),
);
// Filtered here rather than in the query: the endpoint returns a media's
// whole history and takes no filter. Work already dealt with is not
// feedback, and an agent told to act on a review must not redo it.
output(
options.all ? result : selectOpenThreads(result),
options,
formatComments,
);
} catch (error) {
handleCliError(error, "user");
}
});
}

/**
* Drop resolved threads, replies included.
*
* `resolvedAt` is only set on a thread's root comment, so a reply has to be judged
* by the thread it belongs to — otherwise answers to settled feedback come back
* without the comment that settled it.
*/
function selectOpenThreads(comments: Comment[]): Comment[] {
const resolved = new Set(
comments
.filter((comment) => comment.resolvedAt !== null)
.map((comment) => comment.id),
);
return comments.filter(
(comment) =>
comment.resolvedAt === null &&
!(comment.threadId !== null && resolved.has(comment.threadId)),
);
}
43 changes: 43 additions & 0 deletions packages/cli/src/commands/media/comment/react.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Command } from "commander";
import { createApiClient, unwrap } from "../../../lib/api";
import { formatComment } from "../../../lib/format";
import { handleCliError, output } from "../../../lib/run";
import { resolveToken } from "../../../lib/target";
import { jsonOption, tokenOption, type JsonOption } from "../../../options";

type ReactOptions = JsonOption & { token?: string | undefined };

export function registerMediaCommentReact(comment: Command) {
comment
.command("react")
.description("Add an emoji reaction to a comment on a media")
.argument("<mediaId>", "ID of the media")
.argument("<commentId>", "ID of the comment")
.argument("<emoji>", "Emoji to react with")
.addOption(tokenOption)
.addOption(jsonOption)
.action(
async (
mediaId: string,
commentId: string,
emoji: string,
options: ReactOptions,
) => {
try {
const client = createApiClient(await resolveToken(options));
const result = unwrap(
await client.POST(
"/media/{mediaId}/comments/{commentId}/reactions",
{
params: { path: { mediaId, commentId } },
body: { emoji },
},
),
);
output(result, options, formatComment);
} catch (error) {
handleCliError(error, "user");
}
},
);
}
14 changes: 14 additions & 0 deletions packages/cli/src/commands/media/comment/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { unwrap } from "../../../lib/api";
import { defineMediaCommentAction } from "./_action";

export const registerMediaCommentResolve = defineMediaCommentAction({
name: "resolve",
description:
"Mark a comment thread as resolved — what to call once you have acted on the feedback",
perform: async ({ client, mediaId, commentId }) =>
unwrap(
await client.POST("/media/{mediaId}/comments/{commentId}/resolve", {
params: { path: { mediaId, commentId } },
}),
),
});
13 changes: 13 additions & 0 deletions packages/cli/src/commands/media/comment/subscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { unwrap } from "../../../lib/api";
import { defineMediaCommentAction } from "./_action";

export const registerMediaCommentSubscribe = defineMediaCommentAction({
name: "subscribe",
description: "Subscribe to a comment thread's notifications",
perform: async ({ client, mediaId, commentId }) =>
unwrap(
await client.POST("/media/{mediaId}/comments/{commentId}/subscription", {
params: { path: { mediaId, commentId } },
}),
),
});
Loading
Loading