Public: anyone can read this; the crew can change it.

`forge tell "words"` tells the crew from your shell, about the repository of the folder you are in #7859

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7352-forge-tell-words-tells-the-crew-from-you → main 9 commits fork 3 threads · 3 unresolved

  • Merged. scout/7352-forge-tell-words-tells-the-crew-from-you was merged into main as 5977590.

Review

Sign in to review this change.
17 files changed +361 −33
docs/CLI.md +17
@@ -312,6 +312,23 @@ look alice/shop Pull #3 is green — look and merge https://forge.example.
312312 parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9
313313 ```
314314
315 ## forge tell
316
317 Tell the crew something about a repository; it becomes an issue. The
318 repository is that of this folder's `origin` when it points at the workbench,
319 or `--repo`. Words are 1 to 2000 characters; start them with a dash after `--`.
320
321 Flags:
322
323 - `--repo owner/name` — the repository, instead of this folder's origin.
324 - `--variants 1-4` — how many attempts the crew makes.
325 - `--from /path` — the workbench page you were looking at.
326
327 ```console
328 $ forge tell "make the header smaller"
329 told alice/shop: issue #12 https://forge.example.com/alice/shop/issues/12
330 ```
331
315332 ## forge new
316333
317334 Makes an app from the shell, as `POST /api/v1/apps` does, then clones its
docs/ON-YOUR-MACHINE.md +9
@@ -36,11 +36,20 @@ forge asks --repo owner/name # only that repository's; --json for the API'
3636 forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N]
outdated

Move this out of the asks block and shorten it. Put the MCP/tty differences in a sentence below the block.

3737 forge asks --close 2 --reason "words" # also --dismiss 4; --yes skips the question (needed without a TTY)
3838 forge asks --undo <id> # take back a decision by the receipt id it printed
39 forge tell "make the header smaller" # tell the crew, about this folder's repo (--repo owner/name overrides)
3940 forge map # the plan of this clone: working, ready, waiting
4041 forge search retry budget # code, issues, pulls and repos you can read; exits 6 on "not now"
4142 forge search x --kind pulls --limit 5 # one kind, five hits; --json marks truncated.issues when more matched
4243 ```
4344
45 `forge tell` makes an issue of your words (1 to 2000 characters) and prints its
46 address. `--variants 1-4` asks for that many attempts and `--from /path` names
47 the workbench page you were looking at, as the API takes them. Words that start
48 with a dash go after `--`: `forge tell -- "--fast"`.
49
50 The MCP `tell` tool takes `from` but not `variants`. The terminal (tty) takes
51 neither.
52
4453 `forge ship <number>` reads the repository from `--repo owner/name` or from
4554 the folder's `origin` when it points at your workbench, shows the pull's
4655 title, check and whether it merges, and asks `Ship #12? [y/N]`. `--yes` skips
src/actions/tell.ts +1 −23
@@ -17,7 +17,7 @@ import { routeIssue, type Routed } from "../crew/route.ts";
1717 import { err, ok, type Result } from "../kernel/result.ts";
1818 import { requireParticipant } from "./access.ts";
1919 import { assignIssue, openIssue, type MirrorError } from "./issues.ts";
20 import { scrubQuery } from "./tell_context.ts";
20 import { parseFrom } from "./tell_from.ts";
outdated

Export-from plus import of the same module is noise. Import once, and point other callers at tell_from.ts.

2121 import { requireVerified } from "./verify.ts";
2222 import { VERIFY_FIRST } from "./verify_words.ts";
2323
@@ -45,28 +45,6 @@ export function parseVariants(raw: unknown): number | undefined {
4545 return raw >= 1 && raw <= VARIANTS_MAX ? raw : undefined;
4646 }
4747
48 /** The longest `from` a tell may carry. */
49 export const FROM_MAX = 512;
50
51 /**
52 * `from` as the doors take it: absent is none; otherwise a same-forge path
53 * (leading single `/`, no scheme, no backslash, no control or space) of at
54 * most 512 characters. Undefined for no. A query parameter that carries a
55 * secret (`scrubQuery`) is dropped; the rest of the path is kept as given.
56 */
57 export function parseFrom(raw: unknown): string | null | undefined {
58 if (raw === undefined || raw === null) return null;
59 if (typeof raw !== "string" || raw.length === 0 || raw.length > FROM_MAX)
60 return undefined;
61 if (!raw.startsWith("/") || raw.startsWith("//")) return undefined;
62 if (/[\\\s\x00-\x1f\x7f<>()\[\]`"'*]/.test(raw)) return undefined;
63 const q = raw.indexOf("?");
64 const h = raw.indexOf("#");
65 if (q === -1 || (h !== -1 && h < q)) return raw;
66 const end = h === -1 ? raw.length : h;
67 return `${raw.slice(0, q)}${scrubQuery(raw.slice(q, end))}${raw.slice(end)}`;
68 }
69
7048 /**
7149 * An app's own address as a place a tell was said: scheme and host (and
7250 * port) only. Only the app terminal's door builds one and says `fromApp`; every other
src/actions/tell_from.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * Where a tell was said: the one rule for `from`, pure, so the CLI refuses
3 * what the server would before it spends a round trip.
4 */
5
6 import { scrubQuery } from "./tell_context.ts";
7
8 /** The most characters a tell's words may have; every door refuses more. */
9 export const TELL_TEXT_MAX = 2000;
10
11 /** The longest `from` a tell may carry. */
12 export const FROM_MAX = 512;
13
14 /**
15 * `from` as the doors take it: absent is none; otherwise a same-forge path
16 * (leading single `/`, no scheme, no backslash, no control or space) of at
17 * most 512 characters. Undefined for no. A query parameter that carries a
18 * secret (`scrubQuery`) is dropped; the rest of the path is kept as given.
19 */
20 export function parseFrom(raw: unknown): string | null | undefined {
21 if (raw === undefined || raw === null) return null;
22 if (typeof raw !== "string" || raw.length === 0 || raw.length > FROM_MAX)
23 return undefined;
24 if (!raw.startsWith("/") || raw.startsWith("//")) return undefined;
25 if (/[\\\s\x00-\x1f\x7f<>()\[\]`"'*]/.test(raw)) return undefined;
26 const q = raw.indexOf("?");
27 const h = raw.indexOf("#");
28 if (q === -1 || (h !== -1 && h < q)) return raw;
29 const end = h === -1 ? raw.length : h;
30 return `${raw.slice(0, q)}${scrubQuery(raw.slice(q, end))}${raw.slice(end)}`;
31 }
src/cli/api.ts +30
@@ -433,6 +433,36 @@ export class ForgeApi {
433433 return ok({ raw: out.value, asks });
434434 }
435435
436 /**
437 * Tell the crew something about `owner/name` (`POST /api/v1/feed/tells`).
438 * Answers the issue it became, with its address on the workbench.
439 */
440 async tell(
441 owner: string,
442 name: string,
443 text: string,
444 more: { variants?: number | undefined; from?: string | undefined } = {},
445 ): Promise<ApiResult<{ issue: number; url: string }>> {
446 const out = await this.call<{ issue?: unknown }>("/api/v1/feed/tells", {
447 method: "POST",
448 body: JSON.stringify({
449 app: `${owner}/${name}`,
450 text,
451 ...(more.variants === undefined ? {} : { variants: more.variants }),
452 ...(more.from === undefined ? {} : { from: more.from }),
453 }),
454 });
455 if (!out.ok) return out;
456 const issue = out.value.issue;
457 if (typeof issue !== "number")
458 return err({
459 code: "bad_response",
460 message: "the forge did not say which issue the tell became",
461 status: 0,
462 });
463 return ok({ issue, url: `${this.webUrl(owner, name)}/issues/${issue}` });
464 }
465
436466 /**
437467 * `GET /api/v1/search`: code, issues, pulls and repositories the token can
438468 * read. The answer is handed back as the forge gave it — the CLI prints
src/cli/commands/index.ts +2
@@ -17,6 +17,7 @@ import { command as runs } from "./runs.ts";
1717 import { command as asks } from "./asks.ts";
1818 import { command as map } from "./map.ts";
1919 import { command as search } from "./search.ts";
20 import { command as tell } from "./tell.ts";
2021 import { command as newApp } from "./new.ts";
2122 import { command as ship } from "./ship.ts";
2223 import type { Command } from "./types.ts";
@@ -37,6 +38,7 @@ export const commands: readonly Command[] = [
3738 asks,
3839 map,
3940 search,
41 tell,
4042 newApp,
4143 ship,
4244 ];
src/cli/commands/tell.ts added +74
@@ -0,0 +1,74 @@
1 import { parseFrom, TELL_TEXT_MAX } from "../../actions/tell_from.ts";
2 import { ForgeApi } from "../api.ts";
3 import type { Args } from "../args.ts";
4 import { requireConfig } from "../context.ts";
5 import { EXIT } from "../exit.ts";
6 import { resolveRepo } from "../here.ts";
7 import { inert, type Out } from "../out.ts";
8 import type { Command } from "./types.ts";
9
10 /**
11 * `forge tell "words"`: tell the crew something about a repository; it
12 * becomes an issue. Every rule here is the server's, checked early.
13 */
14 async function tell(out: Out, args: Args): Promise<number> {
15 const text = args.positional.join(" ").trim();
16 if (text === "") {
17 out.err(
18 "forge tell: say something — " +
19 'forge tell "words" [--repo owner/name] [--variants 1-4] [--from /path]',
20 );
21 return EXIT.usage;
22 }
23 if (text.length > TELL_TEXT_MAX) {
24 out.err(`forge tell: the words are 1 to ${TELL_TEXT_MAX} characters`);
outdated

The literal 2000 repeats the server rule. Share the constant so the CLI cannot drift.

25 return EXIT.usage;
26 }
27 const only = args.value("repo");
28 const rawVariants = args.value("variants");
29 const variants = rawVariants === undefined ? undefined : Number(rawVariants);
30 if (
31 variants !== undefined &&
32 !(Number.isInteger(variants) && variants >= 1 && variants <= 4)
33 ) {
34 out.err("forge tell: --variants is a whole number from 1 to 4");
35 return EXIT.usage;
36 }
37 const from = args.value("from");
38 if (from !== undefined && parseFrom(from) === undefined) {
39 out.err(
40 "forge tell: --from is a path on the workbench, like /alice/notes/pulls/3",
41 );
42 return EXIT.usage;
43 }
44 const loaded = await requireConfig(out);
45 if (typeof loaded === "number") return loaded;
46 const ref = await resolveRepo(only, loaded.url);
47 if (typeof ref === "string") {
48 out.err(`forge tell: ${ref}`);
49 return EXIT.usage;
50 }
51 const told = await new ForgeApi(loaded.url, loaded.token).tell(
52 ref.owner,
53 ref.name,
54 text,
55 { variants, from },
56 );
57 if (!told.ok) {
58 out.err(`forge tell: ${inert(told.error.message)}`);
59 return told.error.status === 401 ? EXIT.notConfigured : EXIT.failed;
60 }
61 out.line(
62 `told ${ref.owner}/${ref.name}: issue #${told.value.issue} ${told.value.url}`,
63 );
64 return EXIT.ok;
65 }
66
67 export const command: Command = {
68 name: "tell",
69 usage: {
70 synopsis: "forge tell <words…>",
71 flags: "[--repo owner/name] [--variants 1-4] [--from /path] [--]",
72 },
73 run: tell,
74 };
src/cli/main.ts +2 −1
@@ -7,6 +7,7 @@
77 * forge sync clone what is missing, fetch the rest, report
88 * forge status the same report without touching the network
99 * forge asks what waits on you, most important first
10 * forge tell "words" tell the crew about this folder's repository
1011 * forge search <words> code, issues, pulls and repos you can read
1112 * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N
1213 * forge asks --undo <id> take back what that decided
@@ -35,7 +36,7 @@ export { EXIT };
3536 /** Every command by name, built once from `commands/index.ts`. */
3637 const TABLE = new Map(commands.map((c) => [c.name, c]));
3738
38 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason", "owner"];
39 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason", "owner", "variants", "from"];
3940
4041 export async function main(
4142 argv: readonly string[],
src/http/feed.ts +3 −3
@@ -5,12 +5,12 @@
55
66 import {
77 parseVariants,
8 parseFrom,
98 FROM_WORDS,
109 tell,
1110 UNVERIFIED_WORDS,
1211 VARIANTS_MAX,
1312 } from "../actions/tell.ts";
13 import { parseFrom, TELL_TEXT_MAX } from "../actions/tell_from.ts";
1414 import { DIRECTION_NOTE } from "../crew/direct.ts";
1515 import { readInteraction } from "../feed/interact.ts";
1616 import {
@@ -253,8 +253,8 @@ export function feedRoutes(router: Router<Handler>): void {
253253 const viewer = ctx.auth.user!;
254254 const input = await body(ctx.request);
255255 const text = str(input.text).trim();
256 if (text === "" || text.length > 2000)
257 return apiError("invalid_text", 422, "text is 1 to 2000 characters");
256 if (text === "" || text.length > TELL_TEXT_MAX)
257 return apiError("invalid_text", 422, `text is 1 to ${TELL_TEXT_MAX} characters`);
258258 const variants = parseVariants(input.variants);
259259 if (variants === undefined)
260260 return apiError(
src/http/mcp/tools/tell.ts +2 −1
@@ -1,6 +1,7 @@
11 import { contextNote, parseContext } from "../../../actions/tell_context.ts";
22 import { keepThought } from "../../../actions/thought.ts";
3 import { FROM_WORDS, parseFrom, tell, toldWords, UNVERIFIED_WORDS } from "../../../actions/tell.ts";
3 import { parseFrom } from "../../../actions/tell_from.ts";
4 import { FROM_WORDS, tell, toldWords, UNVERIFIED_WORDS } from "../../../actions/tell.ts";
45 import { DIRECTION_NOTE } from "../../../crew/direct.ts";
56 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
67
src/web/scope.ts +1 −1
@@ -22,7 +22,7 @@
2222 * looking at a clone or at somebody else's server.
2323 */
2424
25 import { parseFrom } from "../actions/tell.ts";
25 import { parseFrom } from "../actions/tell_from.ts";
2626 import { getAppByRepo } from "../core/apps.ts";
2727 import { mayDirectCrew } from "../crew/direct.ts";
2828 import { isPinned } from "../core/pins.ts";
src/web/studio/actions.ts +2 −1
@@ -11,7 +11,8 @@
1111 import { mayView } from "../../core/sealed.ts";
1212 import { holdPull, releasePull } from "../../actions/holds.ts";
1313 import { addComment } from "../../actions/issues.ts";
14 import { parseFrom, SENT_WORDS, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts";
14 import { parseFrom } from "../../actions/tell_from.ts";
15 import { SENT_WORDS, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts";
1516 import { contextNote, parseContext } from "../../actions/tell_context.ts";
1617 import { NOT_TOLD_FROM_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts";
1718 import { keepThought } from "../../actions/thought.ts";
src/web/studio/tell_here.ts +1 −1
@@ -7,7 +7,7 @@
77 */
88
99 import { parseContext } from "../../actions/tell_context.ts";
10 import { parseFrom } from "../../actions/tell.ts";
10 import { parseFrom } from "../../actions/tell_from.ts";
1111 import { mayDirectCrew } from "../../crew/direct.ts";
1212 import type { Ctx } from "../../http/context.ts";
1313 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts";
test/cli-tell.test.ts added +155
@@ -0,0 +1,155 @@
1 /** `forge tell` against a fake `POST /api/v1/feed/tells`, run as a shell runs it. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { EXIT } from "../src/cli/main.ts";
8
9 const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname;
10
11 let server: ReturnType<typeof Bun.serve>;
12 let base = "";
13 let dir = "";
14 let reply: () => Response = () => Response.json({ issue: 12 }, { status: 201 });
15 const seen: { auth: string | null; body: unknown }[] = [];
16
17 beforeAll(async () => {
18 dir = await mkdtemp(join(tmpdir(), "forge-tell-"));
19 server = Bun.serve({
20 port: 0,
21 async fetch(req) {
22 const url = new URL(req.url);
23 if (url.pathname !== "/api/v1/feed/tells" || req.method !== "POST")
24 return Response.json({ error: "not_found", message: "no" }, { status: 404 });
25 seen.push({ auth: req.headers.get("authorization"), body: await req.json() });
26 return reply();
27 },
28 });
29 base = `http://127.0.0.1:${server.port}`;
30 for (const [name, remote] of [
31 ["clone", `${base}/alice/shop.git`],
32 ["other", "https://example.com/a/b.git"],
33 ] as const) {
34 Bun.spawnSync(["git", "init", "-q", join(dir, name)]);
35 Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: join(dir, name) });
36 }
37 });
38
39 afterAll(async () => {
40 server.stop(true);
41 await rm(dir, { recursive: true, force: true });
42 });
43
44 async function forge(args: string[], cwd = join(dir, "clone")) {
45 const proc = Bun.spawn(["bun", CLI, ...args], {
46 cwd,
47 env: {
48 PATH: process.env["PATH"] ?? "",
49 HOME: process.env["HOME"] ?? "",
50 FORGE_URL: base,
51 FORGE_TOKEN: "tok",
52 FORGE_CONFIG_DIR: dir,
53 FORGE_KEYCHAIN: "0",
54 NO_COLOR: "1",
55 },
56 stdout: "pipe",
57 stderr: "pipe",
58 });
59 const [out, err, code] = await Promise.all([
60 new Response(proc.stdout).text(),
61 new Response(proc.stderr).text(),
62 proc.exited,
63 ]);
64 return { code, out, err };
65 }
66
67 test("posts the words about the folder's repository and prints the issue address", async () => {
68 const run = await forge(["tell", "make", "the", "header", "smaller"]);
69 expect(run.code).toBe(EXIT.ok);
70 expect(seen.at(-1)).toEqual({
71 auth: "Bearer tok",
72 body: { app: "alice/shop", text: "make the header smaller" },
73 });
74 expect(run.out).toContain(`issue #12 ${base}/alice/shop/issues/12`);
75 });
76
77 test("`--` ends the options, so words may start with a dash", async () => {
78 const run = await forge(["tell", "--", "--fast", "please"]);
79 expect(run.code).toBe(EXIT.ok);
80 expect((seen.at(-1)!.body as { text: string }).text).toBe("--fast please");
81 });
82
83 test("--variants and --from go to the API as given", async () => {
84 const run = await forge([
85 "tell", "try", "it", "--variants", "3", "--from", "/alice/shop/pulls/3",
86 ]);
87 expect(run.code).toBe(EXIT.ok);
88 expect(seen.at(-1)!.body).toEqual({
89 app: "alice/shop",
90 text: "try it",
91 variants: 3,
92 from: "/alice/shop/pulls/3",
93 });
94 });
95
96 test("a bad --variants or --from exits 2 without a request", async () => {
97 const before = seen.length;
98 const bads = [
99 ["--variants", "9"],
100 ["--variants", "x"],
101 ["--from", "https://evil.example/"],
102 ["--from", "/a b"],
103 ["--from", "/a\"b"],
104 ["--from", "//evil"],
105 ];
106 for (const bad of bads) {
107 expect((await forge(["tell", "hi", ...bad])).code).toBe(EXIT.usage);
108 }
109 expect(seen.length).toBe(before);
110 });
111
112 test("--repo overrides the folder, even one that is not a clone", async () => {
113 const run = await forge(["tell", "hi", "--repo", "bob/lab"], dir);
114 expect(run.code).toBe(EXIT.ok);
115 expect((seen.at(-1)!.body as { app: string }).app).toBe("bob/lab");
116 });
117
118 test("empty words print usage and exit 2 without a request", async () => {
119 const before = seen.length;
120 const run = await forge(["tell", " "]);
121 expect(run.code).toBe(EXIT.usage);
122 expect(run.err).toContain("forge tell");
123 expect(seen.length).toBe(before);
124 });
125
126 test("an origin elsewhere asks for --repo and exits 2", async () => {
127 const run = await forge(["tell", "hi"], join(dir, "other"));
128 expect(run.code).toBe(EXIT.usage);
129 expect(run.err).toContain("--repo");
130 });
131
132 test("a refusal prints the API's words and exits 1; a 401 exits 3", async () => {
133 reply = () =>
134 Response.json({ error: "not_found", message: "no such repository" }, { status: 404 });
135 const gone = await forge(["tell", "hi"]);
136 expect(gone.code).toBe(EXIT.failed);
137 expect(gone.err).toContain("no such repository");
138 reply = () => Response.json({ error: "unauthorized", message: "bad token" }, { status: 401 });
139 expect((await forge(["tell", "hi"])).code).toBe(EXIT.notConfigured);
140 reply = () => Response.json({ issue: 12 }, { status: 201 });
141 });
142
143 test("not logged in exits 3", async () => {
144 const proc = Bun.spawn(["bun", CLI, "tell", "hi"], {
145 cwd: join(dir, "clone"),
146 env: { PATH: process.env["PATH"] ?? "", HOME: dir, FORGE_CONFIG_DIR: join(dir, "empty"), FORGE_KEYCHAIN: "0" },
147 stdout: "pipe",
148 stderr: "pipe",
149 });
150 expect(await proc.exited).toBe(EXIT.notConfigured);
151 });
152
153 test("help lists the command", async () => {
154 expect((await forge(["help"])).out).toContain("forge tell");
155 });
test/tell-context-scrub.test.ts +2 −1
@@ -11,7 +11,8 @@ import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { createApp, type App } from "../src/app.ts";
1313 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { parseFrom, tell } from "../src/actions/tell.ts";
14 import { parseFrom } from "../src/actions/tell_from.ts";
15 import { tell } from "../src/actions/tell.ts";
1516 import { contextNote, parseContext } from "../src/actions/tell_context.ts";
1617 import { listIssues } from "../src/core/issues.ts";
1718 import type { Repo } from "../src/core/repos.ts";
test/tell-from-parse.test.ts added +27
@@ -0,0 +1,27 @@
1 /** `parseFrom` and the words limit, in their own module (src/actions/tell_from.ts). */
2 import { expect, test } from "bun:test";
3 import { FROM_MAX, parseFrom, TELL_TEXT_MAX } from "../src/actions/tell_from.ts";
4
5 test("absent from is none; a same-forge path is kept", () => {
6 expect(parseFrom(undefined)).toBeNull();
7 expect(parseFrom(null)).toBeNull();
8 expect(parseFrom("/alice/notes/pulls/3")).toBe("/alice/notes/pulls/3");
9 });
10
11 test("anything off the forge, odd or too long is refused", () => {
12 for (const bad of ["", "alice", "//evil.test/x", "https://x/y", "/a b", "/a\\b", "/a\nb", "/a<b", 5, "/" + "x".repeat(FROM_MAX)])
13 expect(parseFrom(bad)).toBeUndefined();
14 expect(parseFrom("/" + "x".repeat(FROM_MAX - 1))).toBeDefined();
15 });
16
17 test("a secret in the query is scrubbed, the path and fragment are kept", () => {
18 const out = parseFrom("/alice/notes?token=abc123secret&tab=pulls#top");
19 expect(out).toStartWith("/alice/notes?");
20 expect(out).not.toContain("abc123secret");
21 expect(out).toEndWith("#top");
22 expect(parseFrom("/alice/notes?tab=pulls")).toBe("/alice/notes?tab=pulls");
23 });
24
25 test("the words limit is the one the doors share", () => {
26 expect(TELL_TEXT_MAX).toBe(2000);
27 });
test/tell_from.test.ts +2 −1
@@ -6,7 +6,8 @@ import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { setIssueState } from "../src/actions/issues.ts";
88 import { createRepo } from "../src/actions/repos.ts";
9 import { parseFrom, tell } from "../src/actions/tell.ts";
9 import { parseFrom } from "../src/actions/tell_from.ts";
10 import { tell } from "../src/actions/tell.ts";
1011 import { createApp as insertApp } from "../src/core/apps.ts";
1112 import { getIssue, listIssues } from "../src/core/issues.ts";
1213 import type { Repo } from "../src/core/repos.ts";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs