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

/asks renders what needs you as its own page, and every test reads asks from /asks instead of the home #2804

Merged opened this

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

Merged

scout/forge:scout/2799-asks-renders-what-needs-you-as-its-own-p → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/2799-asks-renders-what-needs-you-as-its-own-p was merged into main as 3b4eb0a.

Review

Sign in to review this change.
12 files changed +74 −60
src/web/pages/board.ts +2 −2
@@ -394,7 +394,7 @@ function reviewStrip(ctx: Ctx, scope: BoardScope): Html | null {
394394 if (!none && waits.length === 0) return [];
395395 const oldest = waits.map((w) => w.since).sort()[0];
396396 return [
397 html`<a class="board-asks" href="/#needs-you"
397 html`<a class="board-asks" href="/asks"
398398 ><b>${repo.name}</b>: ${none ? "No reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${waits.length === 0
399399 ? null
400400 : html` · <b>${waits.length}</b> waiting since ${oldest!.slice(0, 10)}`}</a
@@ -421,7 +421,7 @@ function asksStrip(ctx: Ctx): Html | null {
421421 const since = new Date(Date.now() - ASKS_WINDOW_DAYS * 86_400_000).toISOString();
422422 const m = askMeasure(ctx.app.db, user.id, since);
423423 if (m.raised + m.acted + m.dismissed + m.expired === 0) return null;
424 return html`<a class="board-asks" id="asks-measure" href="/#needs-you"
424 return html`<a class="board-asks" id="asks-measure" href="/asks"
425425 >Asks, ${ASKS_WINDOW_DAYS} days: <b>${m.raised}</b> raised · <b>${m.acted}</b> acted
426426 on · <b>${m.dismissed}</b> dismissed · <b>${m.expired}</b> went by themselves</a
427427 >`;
src/web/studio/asks_page.ts +30 −23
@@ -27,7 +27,9 @@ import { needLine } from "../../actions/health.ts";
2727 import type { Degraded } from "../../kernel/vitals.ts";
2828 import type { Ctx } from "../../http/context.ts";
2929 import type { User } from "../../core/users.ts";
30 import { onYouAnswer, studioHome } from "./home.ts";
30 import { onYouAnswer, onYouRegion } from "./home.ts";
31 import { liveRegionsJs } from "../client.ts";
32 import { liveClientJs } from "../live.ts";
3133 import { notFoundPage, repoBase, signInFirst } from "../chrome.ts";
3234 import { backTo, field, readForm } from "../form.ts";
3335 import { MAX_BODY } from "../../core/issues.ts";
@@ -40,7 +42,7 @@ import {
4042 ownerFactRepoIds,
4143 } from "../../core/repos.ts";
4244 import { html, type Html } from "../html.ts";
43 import { redirect } from "../layout.ts";
45 import { page, redirect } from "../layout.ts";
4446 import { btn, card, icon, row } from "../ui.ts";
4547 import {
4648 askAnchor,
@@ -332,15 +334,23 @@ export interface AsksTop {
332334 readonly reason?: string;
333335 }
334336
335 /** GET /asks — the home, scrolled to what needs you; `?op=<id>` (a bulk
336 * action's receipt) rides along. */
337 /** GET /asks — what needs you as its own page: the home's section and its
338 * live script; `?op=<id>` (a bulk action's receipt) rides along. */
337339 export function asksPage(ctx: Ctx): Response {
338 const op = ctx.url.searchParams.get("op");
339 return redirect(
340 op === null
341 ? "/#needs-you"
342 : `/?op=${encodeURIComponent(op)}#asks-bulk-result`,
343 );
340 return renderAsks(ctx, homeTop(ctx));
341 }
342
343 /** The asks page, with a bulk step's preview or receipt on top. */
344 function renderAsks(ctx: Ctx, top: AsksTop | undefined): Response {
345 const user = ctx.auth.user;
346 if (!user) return signInFirst(ctx);
347 const section = needsYouSection(ctx, user, top);
348 return page({
349 ctx,
350 title: "Asks",
351 body: html`<main class="stack"><h1 class="sr-only">Asks</h1>${onYouRegion(ctx, user, undefined, section.body)}</main>`,
352 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${section.scriptJs}`,
353 });

The asks heading is sr-only on a page whose only content is a card list. That is fine for screen readers, but check on the phone screenshot that the sentence (asks-sentence) is the visible heading and not duplicated.

344354 }
345355
346356 /** The receipt `?op=<id>` names on the home, when it is the viewer's own. */
@@ -487,12 +497,9 @@ export function needsYouSection(
487497 return { body, scriptJs: SELECT_JS };
488498 }
489499
490 /** The home with a bulk step's preview or receipt on top of what needs
491 * you — where every bulk answer lands. */
500 /** The asks page with a bulk step's preview or receipt on top — where every bulk answer lands. */
492501 async function asksScreen(ctx: Ctx, top: AsksTop): Promise<Response> {
493 const user = ctx.auth.user;
494 if (!user) return signInFirst(ctx);
495 return await studioHome(ctx, user, top);
502 return renderAsks(ctx, top);
496503 }
497504
498505 // ---- many at once: select, preview, do it, undo — all on this screen ----
@@ -641,7 +648,7 @@ export async function asksBulkSubmit(ctx: Ctx): Promise<Response> {
641648 : `${why.length} not done: ${[...new Set(why.filter((w) => w !== ""))].join("; ") || "refused"}`;
642649 return onYouAnswer(ctx, ctx.auth.user, note);
643650 }
644 return redirect(`/?op=${op.value.id}#asks-bulk-result`);
651 return redirect(`/asks?op=${op.value.id}#asks-bulk-result`);
645652 }
646653 const plan = previewAsksBulk(ctx.app, ctx.auth.user, action, items, reason);
647654 if (!plan.ok) return await asksScreen(ctx, nothingDone(plan.error.message));
@@ -675,7 +682,7 @@ export async function asksBulkUndo(ctx: Ctx): Promise<Response> {
675682 return op.error.code === "not_found"
676683 ? notFoundPage(ctx)
677684 : await asksScreen(ctx, nothingDone(op.error.message));
678 return redirect(`/?op=${op.value.id}#asks-bulk-result`);
685 return redirect(`/asks?op=${op.value.id}#asks-bulk-result`);
679686 }
680687
681688 /** POST /asks/decide — hand one hand-over card to the decider; the ledger keeps what it saw. */
@@ -685,7 +692,7 @@ export async function asksDecide(ctx: Ctx): Promise<Response> {
685692 if (form instanceof Response) return form;
686693 const done = await decideAsk(ctx.app, ctx.auth.user, field(form, "ask"));
687694 if (!done.ok) return await asksScreen(ctx, nothingDone(done.error.message));
688 return redirect(`/?decided=${done.value.id}#asks-bulk-result`);
695 return redirect(`/asks?decided=${done.value.id}#asks-bulk-result`);
689696 }
690697
691698 /** POST /asks/proposal/:id/(approve|redirect|later|again) — one wave proposal, then back to the page the person came from. */
@@ -701,7 +708,7 @@ export function asksProposal(verb: ProposalVerb) {
701708 : done.error.code === "forbidden" || done.error.code === "unauthorized"
702709 ? new Response("forbidden", { status: 403 })
703710 : await asksScreen(ctx, nothingDone(done.error.message));
704 return redirect(backTo(form, "/"));
711 return redirect(backTo(form, "/asks"));
705712 };
706713 }
707714
@@ -719,7 +726,7 @@ export async function asksSetting(ctx: Ctx): Promise<Response> {
719726 };
720727 return await asksScreen(ctx, nothingDone(why[done.error] ?? "it no longer asks you"));
721728 }
722 return redirect("/");
729 return redirect("/asks");
723730 }
724731
725732 /** POST /asks/decide/:id/undo — put a set-aside card back on the board, within a day. */
@@ -732,7 +739,7 @@ export async function asksDecideUndo(ctx: Ctx): Promise<Response> {
732739 return undone.error.code === "not_found"
733740 ? notFoundPage(ctx)
734741 : await asksScreen(ctx, nothingDone(undone.error.message));
735 return redirect(`/?decided=${undone.value.id}#asks-bulk-result`);
742 return redirect(`/asks?decided=${undone.value.id}#asks-bulk-result`);
736743 }
737744
738745 /** POST /asks/listen — take back the viewer's own mute of the repositories named. */
@@ -745,7 +752,7 @@ export async function asksListen(ctx: Ctx): Promise<Response> {
745752 .filter((r): r is string => typeof r === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(r))
746753 .slice(0, BULK_MAX);
747754 removeMutes(ctx.app.db, ctx.auth.user.id, repos.map((r) => `repo:${r}`));
748 return redirect("/");
755 return redirect("/asks");
749756 }
750757
751758 /** POST /asks/mute — the viewer mutes one repository they can read: its asks leave their home, inbox and phone. */
@@ -759,5 +766,5 @@ export async function asksMute(ctx: Ctx): Promise<Response> {
759766 const repo = getRepoById(ctx.app.db, id);
760767 if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFoundPage(ctx);
761768 muteRepo(ctx.app.db, ctx.auth.user.id, repo.id);
762 return redirect("/");
769 return redirect("/asks");
763770 }
src/web/studio/home.ts +1 −1
@@ -155,7 +155,7 @@ const appsRegion = (ctx: Ctx, user: User): Html =>
155155 ${yourApps(ctx, user)}
156156 </div>`;
157157
158 const onYouRegion = (
158 export const onYouRegion = (
159159 ctx: Ctx,
160160 user: User,
161161 note?: string,
test/asks-bulk.test.ts +1 −1
@@ -76,7 +76,7 @@ describe("acting on many asks at once", () => {
7676 });
7777 const location = res.headers.get("location");
7878 if (res.status === 303 && location !== null) {
79 expect(location).toStartWith("/?op=");
79 expect(location).toStartWith("/asks?op=");
8080 const next = await fetch(`${servers.url}${location}`, { headers, redirect: "manual" });
8181 expect(next.status).toBe(200);
8282 return (await next.text()).replace(/\s+/g, " ");
test/asks-expiry.test.ts +7 −7
@@ -126,7 +126,7 @@ describe("asks that expire, hold and collapse", () => {
126126 pears = await botPull("pears", [{ path: "PEARS.md", content: "pears\n" }]);
127127 green(apples);
128128 green(pears);
129 const page = await get("/");
129 const page = await get("/asks");
130130 expect(sentence(page)).toBe("1 thing needs you");
131131 expect(headerCount(page)).toBe(1);
132132 expect(page).toContain("2 green changes wait for your look");
@@ -152,7 +152,7 @@ describe("asks that expire, hold and collapse", () => {
152152 // card at all, so there is nothing to mute.
153153 const fragment = await get("/live/feed");
154154 expect(fragment).not.toContain('data-kind="ask"');
155 const home = await get("/");
155 const home = await get("/asks");
156156 expect(home).toContain('<section id="needs-you"');
157157 const at = home.indexOf('<section id="needs-you"');
158158 expect(home.slice(at, home.indexOf("</section>", at))).not.toContain(
@@ -169,7 +169,7 @@ describe("asks that expire, hold and collapse", () => {
169169 repoId: repo.id,
170170 actorId: null,
171171 });
172 let page = await get("/");
172 let page = await get("/asks");
173173 expect(sentence(page)).toBe("All fine");
174174 expect(headerCount(page)).toBe(0);
175175 expect(askLinesFor(app.db, aliceUser)).toHaveLength(0);
@@ -179,7 +179,7 @@ describe("asks that expire, hold and collapse", () => {
179179 // A new check on one of them: that one asks again, on its own.
180180 await tick();
181181 green(apples);
182 page = await get("/");
182 page = await get("/asks");
183183 expect(sentence(page)).toBe("1 thing needs you");
184184 const pinned = page.slice(
185185 page.indexOf('<section id="needs-you"'),
@@ -203,7 +203,7 @@ describe("asks that expire, hold and collapse", () => {
203203 repoId: repo.id,
204204 actorId: null,
205205 });
206 expect(sentence(await get("/"))).toBe("All fine");
206 expect(sentence(await get("/asks"))).toBe("All fine");
207207 // The merge box is the change's Files step (#628).
208208 const change = await get(
209209 `/${repo.ownerName}/${repo.name}/pulls/${apples.number}/files`,
@@ -231,7 +231,7 @@ describe("asks that expire, hold and collapse", () => {
231231 });
232232 await sweepStaleAsks(app);
233233 expect(askLinesFor(app.db, aliceUser)).toHaveLength(0);
234 expect(sentence(await get("/"))).toBe("All fine");
234 expect(sentence(await get("/asks"))).toBe("All fine");
235235 // The feed and the MCP's asks carry no green line for it either.
236236 expect(
237237 readFeed(app.db, aliceUser, { limit: 50 }).items.some(
@@ -241,7 +241,7 @@ describe("asks that expire, hold and collapse", () => {
241241 expect(JSON.stringify(asksAnswer({ app, auth: { user: aliceUser } } as never, aliceUser))).not.toContain("is green");
242242 // Assigned to alice, the home's one list says it conflicts, never ready.
243243 setAssignee(app.db, plums.id, aliceUser.id);
244 const home = await get("/");
244 const home = await get("/asks");
245245 expect(home).toContain("conflicts with main");
246246 expect(askLinesFor(app.db, aliceUser).map((l) => l.waitingFor.word)).toEqual(["conflicts with main"]);
247247 // Its own page still says why it cannot ship.
test/asks-mute.test.ts +2 −1
@@ -60,7 +60,7 @@ describe("muting a repository's asks", () => {
6060 return ((await res.json()) as { rows: unknown[] }).rows;
6161 };
6262 const home = async () =>
63 await fetch(`${servers.url}/`, { headers: headers(cookie) }).then((r) => r.text());
63 await fetch(`${servers.url}/asks`, { headers: headers(cookie) }).then((r) => r.text());
6464
6565 const signIn = async (username: string) => {
6666 const res = await fetch(`${servers.url}/signin`, {
@@ -141,6 +141,7 @@ describe("muting a repository's asks", () => {
141141
142142 const res = await post(cookie, "/asks/mute", [["repo", repo.id]]);
143143 expect(res.status).toBe(303);
144 expect(res.headers.get("location")).toBe("/asks");
144145 expect(mutedRepos(app.db, alice.id).has(repo.id)).toBe(true);
145146 // Idempotent: twice is the same as once.
146147 await post(cookie, "/asks/mute", [["repo", repo.id]]);
test/asks-rank.test.ts +4 −4
@@ -204,7 +204,7 @@ describe("asks that earn their place", () => {
204204 expect(row.words).toEqual(ASK_WORDS.look);
205205 expect(row.words.options.length).toBeGreaterThan(0);
206206 }
207 const asks = await page("/");
207 const asks = await page("/asks");
208208 expect(asks).toContain("If nobody acts, it waits; nothing ships.");
209209 expect(asks).toContain("<b>security</b>");
210210 // The long sentence the card used to repeat is gone.
@@ -236,7 +236,7 @@ describe("asks that earn their place", () => {
236236 for (const name of ["r4", "r5", "r6"])
237237 await greenPull(name, `Work in ${name}`);
238238 // What needs you is pinned on the home (#634): count its own cards.
239 const home = await page("/");
239 const home = await page("/asks");
240240 const asks = home.slice(home.indexOf('<section id="needs-you"'));
241241 const cards = (s: string) => (s.match(/class="card[" ]/g) ?? []).length;
242242 // Two before the phone's "more", three more inside it before the wide
@@ -340,7 +340,7 @@ describe("asks that earn their place", () => {
340340 expect((await mcpAsks()).measure.raised).toBe(6);
341341 });
342342 test("a phone's ask is a full-width row: kind and repo on one line, the decider apart, the rest behind More", async () => {
343 const home = await page("/");
343 const home = await page("/asks");
344344 const asks = home.slice(home.indexOf('<section id="needs-you"'));
345345 expect(asks).toContain("ask-card");
346346 expect(asks).toContain('<b class="ask-kind">');
@@ -357,7 +357,7 @@ describe("asks that earn their place", () => {
357357 // The security pull went red above; a new green one joins it in a row of two.
358358 check(security, "ok");
359359 await greenPull("security", "Another change here");
360 const asks = await page("/");
360 const asks = await page("/asks");
361361 expect(asks).toContain("2 green changes wait for your look");
362362 expect(asks).toContain("<b>security</b>: \u201cWiden the board id\u201d");
363363 // On a phone the row is its head line, its two asks one tap away; a
test/asks.test.ts +21 −15
@@ -104,16 +104,22 @@ describe("the asks page", () => {
104104 });
105105
106106 test("when nothing asks, it says All fine, and so does the header", async () => {
107 const page = await get(alice, "/");
107 const page = await get(alice, "/asks");
108108 expect(sentence(page)).toBe("All fine");
109109 expect(headerCount(page)).toBe(0);
110 expect(await get(alice, "/")).not.toContain('<section id="needs-you"');
110 expect(await get(alice, "/asks")).not.toContain('<section id="needs-you"');
111111 });
112112
113 test("/asks lands on the home's pinned needs-you, and a signed-out reader there gets the door, no asks", async () => {
113 test("/asks is its own page: the needs-you section in its live region, and a signed-out reader is sent to sign in, no asks", async () => {
114114 const response = await fetch(`${servers.url}/asks`, { redirect: "manual" });
115115 expect(response.status).toBe(303);
116 expect(response.headers.get("location")).toBe("/#needs-you");
116 expect(response.headers.get("location")).toContain("/signin");
117 const page = await get(alice, "/asks");
118 expect(page).toContain('id="live-on-you"');
119 expect(page).toContain('data-live-src="/live/on-you"');
120 expect(page).toContain("<title>");
121 expect(page).toContain('<h1 class="sr-only">Asks</h1>');
122 expect(page).toContain('id="asks-sentence"');
117123 const door = await (await fetch(`${servers.url}/`)).text();
118124 expect(door).not.toContain('id="needs-you"');
119125 expect(door).not.toContain('id="asks-sentence"');
@@ -146,7 +152,7 @@ describe("the asks page", () => {
146152 claimRun(app.db, run.id);
147153 finishRun(app.db, run.id, { state: "failed", summary: "red" });
148154 // Failed but the crew is still trying: that is working, not an ask.
149 expect(sentence(await get(alice, "/"))).toBe("All fine");
155 expect(sentence(await get(alice, "/asks"))).toBe("All fine");
150156 recordDecision(app.db, {
151157 kind: "retry",
152158 repoId: repo.id,
@@ -157,16 +163,16 @@ describe("the asks page", () => {
157163 by: "rule",
158164 ms: 1,
159165 });
160 const page = await get(alice, "/");
166 const page = await get(alice, "/asks");
161167 expect(sentence(page)).toBe("1 thing needs you");
162168 expect(page).toContain("Bot did a thing");
163169 expect(headerCount(page)).toBe(1);
164170 // The home counts it in the header's Asks link; the dev dashboard's
165171 // own "needs you" line went with the dashboard (#625).
166 const home = await get(alice, "/");
172 const home = await get(alice, "/asks");
167173 expect(headerCount(home)).toBe(1);
168174 // Bob can read the public repository but cannot write it: not his ask.
169 expect(sentence(await get(bob, "/"))).toBe("All fine");
175 expect(sentence(await get(bob, "/asks"))).toBe("All fine");
170176 });
171177
172178 test("a forge health problem is a card for an admin and for no one else", async () => {
@@ -174,11 +180,11 @@ describe("the asks page", () => {
174180 app.db
175181 .query("UPDATE run SET created_at = ?1 WHERE state = 'queued'")
176182 .run(new Date(Date.now() - 3 * 3_600_000).toISOString());
177 const admin = await get(alice, "/");
183 const admin = await get(alice, "/asks");
178184 expect(admin).toContain("behind one lane");
179185 expect(sentence(admin)).toBe("2 things need you");
180186 expect(headerCount(admin)).toBe(2);
181 const other = await get(bob, "/");
187 const other = await get(bob, "/asks");
182188 expect(other).not.toContain("behind one lane");
183189 expect(sentence(other)).toBe("All fine");
184190 });
@@ -224,17 +230,17 @@ describe("the asks page", () => {
224230 ms: 1,
225231 });
226232 const card = `id="ask-stopped:${pull.id}"`;
227 expect(await get(alice, "/")).toContain(card);
233 expect(await get(alice, "/asks")).toContain(card);
228234 // The re-run passes: the stop is answered, and the pull is green.
229235 check("ok");
230236 resolveOpen(app.db, pull.id, "retry", "green");
231 const green = await get(alice, "/");
237 const green = await get(alice, "/asks");
232238 expect(green).not.toContain(card);
233239 expect(green).toContain(`id="ask-look:${pull.id}"`);
234240 // Red again, before anything new is decided: the old stop is spent, and
235241 // the crew's tries are what the card is counted from.
236242 check("failed");
237 expect(await get(alice, "/")).not.toContain(card);
243 expect(await get(alice, "/asks")).not.toContain(card);
238244 });
239245
240246 test("a green change's card says what its tests did", async () => {
@@ -309,11 +315,11 @@ describe("the asks page", () => {
309315 expect(card.says).toContain("alice");
310316 expect(card.says).toContain("checks.concurrency");
311317 expect(ASK_WORDS.setting.options).toEqual(["Keep", "Put back"]);
312 const page = await get(alice, "/");
318 const page = await get(alice, "/asks");
313319 expect(page).toContain("Keep");
314320 expect(page).toContain("Put back");
315321 // not for a person who cannot put it back
316 expect(await get(bob, "/")).not.toContain("Put back");
322 expect(await get(bob, "/asks")).not.toContain("Put back");
317323 expect(listEvents(app.db, { kind: "issue.assigned" }).length).toBe(0);
318324 });
319325
test/holds.test.ts +1 −1
@@ -741,7 +741,7 @@ describe("a hold is set, shown and lifted", () => {
741741 // What needs you, pinned on the home (#634), is the part to read: the
742742 // feed below it may well mention the change as news.
743743 const asks = async (cookie: string) => {
744 const home = await page("/", cookie);
744 const home = await page("/asks", cookie);
745745 const at = home.indexOf('<section id="needs-you"');
746746 return at === -1 ? "" : home.slice(at, home.indexOf("</section>", at));
747747 };
test/planner-declined.test.ts +1 −1
@@ -183,7 +183,7 @@ describe("the planner declined, waiting for a person", () => {
183183 expect(board).toContain(
184184 `action="/alice/garden/issues/${direction.number}/comments"`,
185185 );
186 const asks = await page(aliceCookie, "/");
186 const asks = await page(aliceCookie, "/asks");
187187 expect(asks).toContain("The planner declined the direction");
188188 expect(asks).toContain(
189189 `/alice/garden/issues/${direction.number}#comment-body`,
test/review-wait.test.ts +1 −1
@@ -34,7 +34,7 @@ describe("a green agent pull and its reviewer", () => {
3434
3535 const home = async (): Promise<string> =>
3636 (
37 await (await fetch(`${servers.url}/`, { headers: { cookie } })).text()
37 await (await fetch(`${servers.url}/asks`, { headers: { cookie } })).text()
3838 ).replace(/\s+/g, " ");
3939 const wordCtx = (): WordCtx => ({ db: app.db }) as unknown as WordCtx;
4040
test/web-apps.test.ts +3 −3
@@ -824,11 +824,11 @@ describe("apps, runs and the crew on the web", () => {
824824 form: { assignee: "alice" },
825825 });
826826 expect(assigned.status).toBe(303);
827 const dash = await bodyOf(alice, "/");
827 const dash = await bodyOf(alice, "/asks");
828828 expect(dash).toContain('<section id="needs-you"');
829829 expect(dash).toMatch(/<h2 id="asks-sentence">\d+ things? needs? you<\/h2>/);
830830 expect(dash).toContain("Say hello");
831 expect(await bodyOf(bob, "/")).not.toContain('<section id="needs-you"');
831 expect(await bodyOf(bob, "/asks")).not.toContain('<section id="needs-you"');
832832
833833 // Needs you: a pull the crew opened, in a repository alice can write.
834834 expect(addToCrew(app, aliceUser, hello, "bot").ok).toBe(true);
@@ -849,7 +849,7 @@ describe("apps, runs and the crew on the web", () => {
849849 // racing to finish here: when it does, the dashboard links to /asks
850850 // rather than listing it (test/asks.test.ts holds that page to account).
851851 // Bob can read the public repo but not write it: not his inbox.
852 expect(await bodyOf(bob, "/")).not.toContain('<section id="needs-you"');
852 expect(await bodyOf(bob, "/asks")).not.toContain('<section id="needs-you"');
853853 });
854854
855855 test("the assignee select offers the people who can read the repository", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs