Public: anyone can read this; the crew can change it.
A tell opens in studio words: an app's issue page #72
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/49-a-tell-opens-in-studio-words-an-app-s-is → main 2 commits fork
4 files
changed +423
−5
docs/STUDIO.md +22
| @@ -102,6 +102,28 @@ A run in flight carries _Stop_ (running) or _Cancel_ (waiting) for anyone | ||
| 102 | 102 | who can write here; its title opens the transcript, where the agent can be |
| 103 | 103 | steered mid-run (see `docs/APPS.md`, _Runs_). |
| 104 | 104 | |
| 105 | ## A tell, on its own page | |
| 106 | ||
| 107 | `/:owner/:name/issues/:number` on an app, in the studio, is a tell — one | |
| 108 | sentence someone said to the crew — not dev's labels, milestones and | |
| 109 | dependency form. It answers three questions: who has it (or that nobody | |
| 110 | does yet), what it is waiting on and what waits on it (`dependenciesOf`, | |
| 111 | `src/core/dependencies.ts`, each a link, read-only here), and what the | |
| 112 | crew has done with it — every agent run, newest first, who ran it, how | |
| 113 | long it went, what it cost and its state in the studio's own word | |
| 114 | (`tellWord`, `src/web/pages/studio.ts`), its title opening the transcript | |
| 115 | — and the pull that closes it, with its check and a link into its | |
| 116 | preview, when one exists. The sentence itself, and the reply, are the | |
| 117 | conversation dev's page shows too (`conversation`/`composer`, | |
| 118 | `src/web/pages/thread.ts`): the same action, the same same-origin check, | |
| 119 | told once. The repository's own standing direction is not an ordinary | |
| 120 | tell — it never gets picked up on its own — and the page says so rather | |
| 121 | than showing it as waiting forever. *Read the details* is one link away, | |
| 122 | `?mode=dev` on the same URL, where labels, the dependency form and the | |
| 123 | assignee control stay the only place those exist; an issue on a | |
| 124 | repository that is not an app, or any issue in dev mode, renders exactly | |
| 125 | what it always has. | |
| 126 | ||
| 105 | 127 | ## Working, on its own page |
| 106 | 128 | |
| 107 | 129 | `/:owner/:name/runs/:id` on an app, in the studio, is a run: who is on it, |
src/web/pages/issues.ts +11 −1
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts"; |
| 8 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 8 | 9 | import { |
| 9 | 10 | issueCounts, |
| 10 | 11 | labelsFor, |
| @@ -62,6 +63,7 @@ import { | ||
| 62 | 63 | threadFragment, |
| 63 | 64 | } from "./thread.ts"; |
| 64 | 65 | import { crewActivityCard } from "./runs.ts"; |
| 66 | import { studioTell } from "./studio.ts"; | |
| 65 | 67 | |
| 66 | 68 | const PER_PAGE = 25; |
| 67 | 69 | |
| @@ -464,10 +466,18 @@ export async function newIssueSubmit(ctx: Ctx): Promise<Response> { | ||
| 464 | 466 | // ---- one issue ---- |
| 465 | 467 | |
| 466 | 468 | // GET /:owner/:name/issues/:number |
| 467 | export function issuePage(ctx: Ctx): Response { | |
| 469 | export async function issuePage(ctx: Ctx): Promise<Response> { | |
| 468 | 470 | const found = threadContext(ctx, "issue"); |
| 469 | 471 | if (found instanceof Response) return found; |
| 470 | 472 | const { repo, issue } = found; |
| 473 | // In the studio a tell is one sentence someone said, not dev's labels and | |
| 474 | // dependency form — the same rule `pullPage` follows for a change. | |
| 475 | // `?mode=dev` is the one-off way to dev's page without switching the | |
| 476 | // browser, same as there. | |
| 477 | const studio = | |
| 478 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 479 | if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined) | |
| 480 | return await studioTell(ctx, repo, issue); | |
| 471 | 481 | const base = repoBase(repo); |
| 472 | 482 | const thread = `${base}/issues/${issue.number}`; |
| 473 | 483 |
src/web/pages/studio.ts +280 −2
| @@ -33,6 +33,8 @@ import { | ||
| 33 | 33 | } from "../../apps/runtime.ts"; |
| 34 | 34 | import { listCrewRoles, type Agent } from "../../core/agents.ts"; |
| 35 | 35 | import { appsForRepos, getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 36 | import { dependenciesOf } from "../../core/dependencies.ts"; | |
| 37 | import { listEvents } from "../../core/events.ts"; | |
| 36 | 38 | import { |
| 37 | 39 | getIssueById, |
| 38 | 40 | listComments, |
| @@ -123,7 +125,13 @@ import { lookCard, lookNow, lookThumb, lookUrl } from "./looks.ts"; | ||
| 123 | 125 | import { onYouCard } from "./site.ts"; |
| 124 | 126 | import { previewCard } from "./apps.ts"; |
| 125 | 127 | import { pullState } from "./pulls.ts"; |
| 126 | import { costText, durationText, logLine, runTitle } from "./runs.ts"; | |
| 128 | import { | |
| 129 | costText, | |
| 130 | durationText, | |
| 131 | logLine, | |
| 132 | reasonWords, | |
| 133 | runTitle, | |
| 134 | } from "./runs.ts"; | |
| 127 | 135 | import { composer, conversation, originGlyph } from "./thread.ts"; |
| 128 | 136 | |
| 129 | 137 | // ---- decisions: what asks a person ---- |
| @@ -582,7 +590,14 @@ function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null { | ||
| 582 | 590 | </form>`; |
| 583 | 591 | } |
| 584 | 592 | |
| 585 | type Word = "working" | "look" | "shipped" | "stopped" | "waiting" | "planned"; | |
| 593 | type Word = | |
| 594 | | "working" | |
| 595 | | "look" | |
| 596 | | "shipped" | |
| 597 | | "stopped" | |
| 598 | | "waiting" | |
| 599 | | "planned" | |
| 600 | | "told"; | |
| 586 | 601 | |
| 587 | 602 | /** |
| 588 | 603 | * Everything the word functions below need to see production: the repo's |
| @@ -718,6 +733,7 @@ const WORD_ICON: Record<Word, Parameters<typeof icon>[0]> = { | ||
| 718 | 733 | stopped: "alert", |
| 719 | 734 | waiting: "clock", |
| 720 | 735 | planned: "map", |
| 736 | told: "clock", | |
| 721 | 737 | }; |
| 722 | 738 | |
| 723 | 739 | /** One of the five (or six) studio words, as a badge. An id names the one a |
| @@ -1591,6 +1607,268 @@ export async function studioPull( | ||
| 1591 | 1607 | }); |
| 1592 | 1608 | } |
| 1593 | 1609 | |
| 1610 | // ---- one tell, in studio words ---- | |
| 1611 | ||
| 1612 | /** The pull whose merge closed this issue, from the event it left behind — | |
| 1613 | * the same record `stateEvents` (thread.ts) reads for the timeline's own | |
| 1614 | * "closed this" line, so this never disagrees with what the page already | |
| 1615 | * shows. Undefined for an issue closed by hand, or still open. */ | |
| 1616 | function shippedBy(db: Database, repo: Repo, issue: Issue): Pull | undefined { | |
| 1617 | if (issue.state !== "closed") return undefined; | |
| 1618 | const closed = listEvents(db, { repoId: repo.id, limit: 200 }).find( | |
| 1619 | (e) => | |
| 1620 | e.kind === "issue.closed" && | |
| 1621 | Number(e.payload.number) === issue.number && | |
| 1622 | e.payload.by === "merge", | |
| 1623 | ); | |
| 1624 | if (closed === undefined) return undefined; | |
| 1625 | const number = Number(closed.payload.pull); | |
| 1626 | return Number.isInteger(number) ? getPull(db, repo.id, number) : undefined; | |
| 1627 | } | |
| 1628 | ||
| 1629 | /** | |
| 1630 | * Where one tell stands, in the studio's own words: the pull that closed | |
| 1631 | * or closes it — judged by `pullWord`, the one place that fact is decided, | |
| 1632 | * so a merged tell reads *shipped* only once production is serving it, | |
| 1633 | * same as everywhere else — else a run in flight, else its last run's own | |
| 1634 | * word, else *told*: said, and nobody has picked it up yet. Undefined for | |
| 1635 | * a closed issue nothing merged shut, so the caller falls back to the | |
| 1636 | * plain state badge dev uses. | |
| 1637 | */ | |
| 1638 | async function tellWord( | |
| 1639 | wordCtx: WordCtx, | |
| 1640 | repo: Repo, | |
| 1641 | issue: Issue, | |
| 1642 | closingPull: Pull | undefined, | |
| 1643 | ): Promise<{ word: Word; note: string } | undefined> { | |
| 1644 | const db = wordCtx.db; | |
| 1645 | if (issue.state === "closed") { | |
| 1646 | const pull = shippedBy(db, repo, issue); | |
| 1647 | return pull ? await pullWord(wordCtx, pull) : undefined; | |
| 1648 | } | |
| 1649 | if (closingPull !== undefined) return await pullWord(wordCtx, closingPull); | |
| 1650 | if (activeRunFor(db, "agent", issue.id) !== undefined) | |
| 1651 | return { word: "working", note: "working now" }; | |
| 1652 | const last = listRuns(db, repo.id, { | |
| 1653 | kind: "agent", | |
| 1654 | issueId: issue.id, | |
| 1655 | limit: 1, | |
| 1656 | })[0]; | |
| 1657 | if (last !== undefined) return await runWord(wordCtx, last); | |
| 1658 | return { word: "told", note: "not picked up yet" }; | |
| 1659 | } | |
| 1660 | ||
| 1661 | /** Every open issue this one waits for, and every one that waits for it — | |
| 1662 | * read-only: the dependency form stays dev's alone (`dependenciesCard`, | |
| 1663 | * thread.ts). */ | |
| 1664 | function tellDependencies(base: string, issue: Issue, db: Ctx["app"]["db"]): Html { | |
| 1665 | const { blockedBy, blocks } = dependenciesOf(db, issue.id); | |
| 1666 | const list = (items: readonly Issue[]) => | |
| 1667 | html`<ul class="dep-list"> | |
| 1668 | ${items.map( | |
| 1669 | (i) => | |
| 1670 | html`<li data-state="${i.state}"> | |
| 1671 | ${icon(i.state === "closed" ? "issueClosed" : "issue")} | |
| 1672 | <a href="${base}/issues/${i.number}">${i.title}</a> | |
| 1673 | <span class="muted small">#${i.number}</span> | |
| 1674 | </li>`, | |
| 1675 | )} | |
| 1676 | </ul>`; | |
| 1677 | if (blockedBy.length === 0 && blocks.length === 0) | |
| 1678 | return card( | |
| 1679 | html`<p class="muted small">Waits for nothing, holds up nothing.</p>`, | |
| 1680 | { head: html`${icon("map")} <h2>Depends on</h2>` }, | |
| 1681 | ); | |
| 1682 | return card( | |
| 1683 | html`${blockedBy.length === 0 | |
| 1684 | ? html`<p class="muted small">Waits for nothing.</p>` | |
| 1685 | : html`<p class="small">Waits for ${blockedBy.length}</p> | |
| 1686 | ${list(blockedBy)}`} | |
| 1687 | ${blocks.length === 0 | |
| 1688 | ? null | |
| 1689 | : html`<p class="small mt">Holds up ${blocks.length}</p> | |
| 1690 | ${list(blocks)}`}`, | |
| 1691 | { head: html`${icon("map")} <h2>Depends on</h2>` }, | |
| 1692 | ); | |
| 1693 | } | |
| 1694 | ||
| 1695 | /** Every agent run for this tell, newest first: who, how long, what it | |
| 1696 | * cost, and its state in the studio's own word — the title opens the | |
| 1697 | * transcript. Empty when the crew has not touched it yet. */ | |
| 1698 | async function tellRuns( | |
| 1699 | wordCtx: WordCtx, | |
| 1700 | repo: Repo, | |
| 1701 | issue: Issue, | |
| 1702 | ): Promise<Html | null> { | |
| 1703 | const runs = listRuns(wordCtx.db, repo.id, { | |
| 1704 | kind: "agent", | |
| 1705 | issueId: issue.id, | |
| 1706 | limit: 10, | |
| 1707 | }); | |
| 1708 | if (runs.length === 0) return null; | |
| 1709 | const base = repoBase(repo); | |
| 1710 | const actors = getUsersByIds( | |
| 1711 | wordCtx.db, | |
| 1712 | runs.map((r) => r.actorId).filter((id): id is string => id !== null), | |
| 1713 | ); | |
| 1714 | const rows = await Promise.all( | |
| 1715 | runs.map(async (r) => { | |
| 1716 | const who = | |
| 1717 | r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"); | |
| 1718 | const { word } = await runWord(wordCtx, r); | |
| 1719 | const duration = durationText(r); | |
| 1720 | return row({ | |
| 1721 | href: `${base}/runs/${r.id}`, | |
| 1722 | nav: true, | |
| 1723 | lead: avatarOf(who), | |
| 1724 | title: html`${who} <span class="muted">${reasonWords(r.reason)}</span>`, | |
| 1725 | meta: html`${duration ? html`${duration} · ` : null}${r.summary || | |
| 1726 | "no summary yet"}`, | |
| 1727 | trail: html`${wordBadge(word)}${when( | |
| 1728 | r.costUsd > 0, | |
| 1729 | () => | |
| 1730 | html`<span class="num muted small">${costText(r.costUsd)}</span>`, | |
| 1731 | )}`, | |
| 1732 | }); | |
| 1733 | }), | |
| 1734 | ); | |
| 1735 | return listCard(rows, { | |
| 1736 | head: html`${icon("bot")} | |
| 1737 | <h2>Runs</h2>`, | |
| 1738 | }); | |
| 1739 | } | |
| 1740 | ||
| 1741 | /** The pull that closes this tell, with its check and a way into its | |
| 1742 | * preview — nothing here that `studioPull`/`previewCard` do not already | |
| 1743 | * draw. */ | |
| 1744 | async function tellClosingPull( | |
| 1745 | ctx: Ctx, | |
| 1746 | wordCtx: WordCtx, | |
| 1747 | repo: Repo, | |
| 1748 | pull: Pull, | |
| 1749 | ): Promise<Html> { | |
| 1750 | const base = repoBase(repo); | |
| 1751 | const { word, note } = await pullWord(wordCtx, pull); | |
| 1752 | return html`${card( | |
| 1753 | html`<p> | |
| 1754 | <a href="${base}/pulls/${pull.number}">${pull.title}</a> | |
| 1755 | ${wordBadge(word)} <span class="muted small">${note}</span> | |
| 1756 | </p>`, | |
| 1757 | { head: html`${icon("pull")} <h2>Closes with</h2>` }, | |
| 1758 | )}${previewCard(ctx, repo, pull)}`; | |
| 1759 | } | |
| 1760 | ||
| 1761 | /** | |
| 1762 | * GET /:owner/:name/issues/:number in studio mode: a tell is one sentence | |
| 1763 | * someone said, and the page answers three questions — who has it, what it | |
| 1764 | * is waiting on, and what the crew has done with it — nothing a dev-mode | |
| 1765 | * reader needs (labels, milestones, the dependency form) belongs here. | |
| 1766 | * `?mode=dev` reads that page, unchanged; the conversation and the reply | |
| 1767 | * are the very same action as always (`conversation`/`composer`, | |
| 1768 | * thread.ts). | |
| 1769 | */ | |
| 1770 | export async function studioTell( | |
| 1771 | ctx: Ctx, | |
| 1772 | repo: Repo, | |
| 1773 | issue: Issue, | |
| 1774 | ): Promise<Response> { | |
| 1775 | const db = ctx.app.db; | |
| 1776 | const base = repoBase(repo); | |
| 1777 | const thread = `${base}/issues/${issue.number}`; | |
| 1778 | const direction = repo.directionIssueId === issue.id; | |
| 1779 | const closingPull = openPullsClosing(db, repo.id).get(issue.number); | |
| 1780 | const wordCtx: WordCtx = { | |
| 1781 | db, | |
| 1782 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 1783 | runtime: appsRuntime(ctx.app), | |
| 1784 | record: getAppByRepo(db, repo.id), | |
| 1785 | }; | |
| 1786 | const [wordInfo, runsCard, closingCard] = await Promise.all([ | |
| 1787 | direction ? undefined : tellWord(wordCtx, repo, issue, closingPull), | |
| 1788 | tellRuns(wordCtx, repo, issue), | |
| 1789 | closingPull ? tellClosingPull(ctx, wordCtx, repo, closingPull) : undefined, | |
| 1790 | ]); | |
| 1791 | ||
| 1792 | const head = html`<div class="issue-head"> | |
| 1793 | <h1>${issue.title} <span class="number">#${issue.number}</span></h1> | |
| 1794 | <div class="issue-meta"> | |
| 1795 | ${direction | |
| 1796 | ? html`<span class="muted small" | |
| 1797 | >${icon("map")} the repository's standing direction — always | |
| 1798 | open, read by the planner directly, never picked up like an | |
| 1799 | ordinary tell</span | |
| 1800 | >` | |
| 1801 | : wordInfo | |
| 1802 | ? html`${wordBadge(wordInfo.word)} | |
| 1803 | <span class="muted small">${wordInfo.note}</span>` | |
| 1804 | : stateBadge(issue.state)} | |
| 1805 | <span | |
| 1806 | >${issue.originAuthor | |
| 1807 | ? originTag(issue.originAuthor, originGlyph(repo)) | |
| 1808 | : userLink(issue.authorName)} | |
| 1809 | opened this ${time(issue.createdAt)}</span | |
| 1810 | > | |
| 1811 | ${when( | |
| 1812 | issue.originUrl, | |
| 1813 | (url) => | |
| 1814 | html`<a class="origin-link" href="${url}" rel="noreferrer noopener" | |
| 1815 | >${icon("external")}view on origin</a | |
| 1816 | >`, | |
| 1817 | )} | |
| 1818 | <span | |
| 1819 | >${issue.assigneeName === null | |
| 1820 | ? html`<span class="muted">nobody has it yet</span>` | |
| 1821 | : html`${when(issue.assigneeIsAgent, () => icon("bot"))} | |
| 1822 | ${userLink(issue.assigneeName)} has it`}</span | |
| 1823 | > | |
| 1824 | </div> | |
| 1825 | </div>`; | |
| 1826 | ||
| 1827 | const talk = conversation(ctx, repo, issue, { openVerb: "opened this" }); | |
| 1828 | ||
| 1829 | const body = html`${repoHeader(ctx, repo, "issues", { | |
| 1830 | compact: true, | |
| 1831 | ownTitle: true, | |
| 1832 | })} | |
| 1833 | ${head} | |
| 1834 | <div class="split"> | |
| 1835 | <div> | |
| 1836 | ${talk.node} | |
| 1837 | ${composer(ctx, { | |
| 1838 | action: `${thread}/comments`, | |
| 1839 | stateAction: `${thread}/state`, | |
| 1840 | state: issue.state, | |
| 1841 | kind: "issue", | |
| 1842 | })} | |
| 1843 | </div> | |
| 1844 | <aside> | |
| 1845 | ${runsCard} | |
| 1846 | ${closingCard} | |
| 1847 | ${tellDependencies(base, issue, db)} | |
| 1848 | </aside> | |
| 1849 | </div> | |
| 1850 | <p class="muted small mt"> | |
| 1851 | <a href="${thread}?mode=dev">${icon("code")}Read the details</a> — | |
| 1852 | labels, the dependency form and the assignee, dev's own way. | |
| 1853 | </p> | |
| 1854 | ${liveMount({ | |
| 1855 | repo: base, | |
| 1856 | thread: "issue", | |
| 1857 | number: issue.number, | |
| 1858 | lastCommentId: talk.lastCommentId, | |
| 1859 | })}`; | |
| 1860 | ||
| 1861 | return page({ | |
| 1862 | ctx, | |
| 1863 | title: `${issue.title} · ${repo.name}`, | |
| 1864 | crumbs: crumbsFor(repo), | |
| 1865 | palette: repoPalette(repo), | |
| 1866 | body, | |
| 1867 | styleCss: studioCss, | |
| 1868 | scriptJs: liveClientJs, | |
| 1869 | }); | |
| 1870 | } | |
| 1871 | ||
| 1594 | 1872 | // ---- the studio home ---- |
| 1595 | 1873 | |
| 1596 | 1874 | /** |
test/studio.test.ts +110 −2
| @@ -13,7 +13,12 @@ import { previewEnv, type EnvName } from "../src/apps/data.ts"; | ||
| 13 | 13 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 14 | 14 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 15 | 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 16 | import { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 16 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 17 | import { | |
| 18 | assignIssue, | |
| 19 | openIssue, | |
| 20 | setIssueState, | |
| 21 | } from "../src/actions/issues.ts"; | |
| 17 | 22 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 18 | 23 | import { |
| 19 | 24 | commitToBranch, |
| @@ -24,7 +29,7 @@ import { setCrewRole } from "../src/core/agents.ts"; | ||
| 24 | 29 | import { getIssue, listIssues, type Issue } from "../src/core/issues.ts"; |
| 25 | 30 | import { recordLook } from "../src/core/looks.ts"; |
| 26 | 31 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 27 | import type { Repo } from "../src/core/repos.ts"; | |
| 32 | import { setDirectionIssue, type Repo } from "../src/core/repos.ts"; | |
| 28 | 33 | import { |
| 29 | 34 | claimRun, |
| 30 | 35 | createRun, |
| @@ -337,6 +342,109 @@ describe("the studio", () => { | ||
| 337 | 342 | expect(studioCookie).toContain("Merge pull request"); |
| 338 | 343 | }); |
| 339 | 344 | |
| 345 | test("a tell on an app renders as a studio page — who has it, its runs, what blocks it, and no label form; ?mode=dev still renders today's page", async () => { | |
| 346 | const blocker = openIssue(app, alice, repo, { | |
| 347 | title: "Ground work", | |
| 348 | body: "Do this first.", | |
| 349 | }); | |
| 350 | if (!blocker.ok) throw new Error(String(blocker.error)); | |
| 351 | const told = openIssue(app, alice, repo, { | |
| 352 | title: "Make the export button work", | |
| 353 | body: "It does nothing right now.", | |
| 354 | }); | |
| 355 | if (!told.ok) throw new Error(String(told.error)); | |
| 356 | const linked = linkIssue(app, alice, repo, told.value, blocker.value); | |
| 357 | expect(linked.ok).toBe(true); | |
| 358 | const scout = getUserByName(app.db, "scout")!; | |
| 359 | const assigned = assignIssue(app, alice, repo, told.value, scout); | |
| 360 | expect(assigned.ok).toBe(true); | |
| 361 | const issue = getIssue(app.db, repo.id, told.value.number)!; | |
| 362 | const run = createRun(app.db, { | |
| 363 | repoId: repo.id, | |
| 364 | kind: "agent", | |
| 365 | issueId: issue.id, | |
| 366 | actorId: scout.id, | |
| 367 | reason: "assigned", | |
| 368 | }); | |
| 369 | claimRun(app.db, run.id); | |
| 370 | noteRunProgress(app.db, run.id, { costUsd: 0.12 }); | |
| 371 | ||
| 372 | const studio = await ( | |
| 373 | await get(`/alice/notes/issues/${issue.number}`) | |
| 374 | ).text(); | |
| 375 | expect(studio).toContain("Make the export button work"); | |
| 376 | expect(studio).toContain("scout"); | |
| 377 | expect(studio).toContain("has it"); | |
| 378 | expect(studio).toContain("Ground work"); | |
| 379 | expect(studio).toContain(`href="/alice/notes/issues/${blocker.value.number}"`); | |
| 380 | expect(studio).toContain('data-word="working"'); | |
| 381 | expect(studio).toContain("$0.12"); | |
| 382 | expect(studio).toContain('id="comment-body"'); | |
| 383 | expect(studio).not.toContain("<h2>Labels</h2>"); | |
| 384 | expect(studio).not.toContain("Apply labels"); | |
| 385 | expect(studio).toContain( | |
| 386 | `href="/alice/notes/issues/${issue.number}?mode=dev"`, | |
| 387 | ); | |
| 388 | ||
| 389 | const dev = await ( | |
| 390 | await get(`/alice/notes/issues/${issue.number}`, { | |
| 391 | cookie: `${cookie}; forge_mode=dev`, | |
| 392 | }) | |
| 393 | ).text(); | |
| 394 | expect(dev).toContain("<h2>Labels</h2>"); | |
| 395 | expect(dev).toContain("<h2>Assignee</h2>"); | |
| 396 | expect(dev).toContain("<h2>Depends on</h2>"); | |
| 397 | }); | |
| 398 | ||
| 399 | test("the repository's direction never reads as waiting forever", async () => { | |
| 400 | const direction = openIssue(app, alice, repo, { | |
| 401 | title: "Direction", | |
| 402 | body: "Ship faster.", | |
| 403 | }); | |
| 404 | if (!direction.ok) throw new Error(String(direction.error)); | |
| 405 | setDirectionIssue(app.db, repo.id, direction.value.id); | |
| 406 | ||
| 407 | const studio = await ( | |
| 408 | await get(`/alice/notes/issues/${direction.value.number}`) | |
| 409 | ).text(); | |
| 410 | expect(studio).toContain("standing direction"); | |
| 411 | expect(studio).not.toContain('data-word="told"'); | |
| 412 | ||
| 413 | // Leave the repository as the other tests in this suite found it. | |
| 414 | setDirectionIssue(app.db, repo.id, null); | |
| 415 | }); | |
| 416 | ||
| 417 | test("a tell on a repository that is not an app renders exactly what it renders today, even in studio mode", async () => { | |
| 418 | const plain = await createRepo(app, alice, { | |
| 419 | name: "plaintell", | |
| 420 | visibility: "private", | |
| 421 | description: "no app here", | |
| 422 | }); | |
| 423 | if (!plain.ok) throw new Error(String(plain.error)); | |
| 424 | const told = openIssue(app, alice, plain.value, { | |
| 425 | title: "Plain issue", | |
| 426 | body: "Nothing special.", | |
| 427 | }); | |
| 428 | if (!told.ok) throw new Error(String(told.error)); | |
| 429 | ||
| 430 | const studioCookie = await ( | |
| 431 | await get(`/alice/plaintell/issues/${told.value.number}`) | |
| 432 | ).text(); | |
| 433 | expect(studioCookie).toContain("<h2>Labels</h2>"); | |
| 434 | expect(studioCookie).toContain("<h2>Assignee</h2>"); | |
| 435 | expect(studioCookie).toContain("<h2>Depends on</h2>"); | |
| 436 | }); | |
| 437 | ||
| 438 | test("a reader without access gets the forge's not-found on a tell, exactly as dev", async () => { | |
| 439 | const told = openIssue(app, alice, repo, { title: "Private work" }); | |
| 440 | if (!told.ok) throw new Error(String(told.error)); | |
| 441 | const anon = await fetch( | |
| 442 | `${base}/alice/notes/issues/${told.value.number}`, | |
| 443 | { headers: { cookie: "forge_mode=studio" }, redirect: "manual" }, | |
| 444 | ); | |
| 445 | expect(anon.status).toBe(404); | |
| 446 | }); | |
| 447 | ||
| 340 | 448 | test("several asks, one decision: a box per ask, one bar, one post; in place when asked for JSON", async () => { |
| 341 | 449 | const scout = getUserByName(app.db, "scout")!; |
| 342 | 450 | // The crew proposes: a pull by scout on its own branch. |
new above
offline