Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions cloud/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,11 +881,13 @@ export type SharedApp = {
lost?: true;
/**
* Its production is on GitHub (lib/hosted-apps.ts AppHosting.github): Publish sends what its Preview serves to this repo
* and branch, whose own deploy puts it live. At Write also its folder, the paths left alone, the workflow it runs, and
* where it was last the same as GitHub (`linked`: Link, Publish or Bring in; none: it isn't linked yet), so an Admin, or
* a Member with App secrets, links it and brings GitHub's changes in (SharedWsSlice.appGithub).
* and branch, whose own deploy puts it live. `at`: the changeset last sent there, which is its Live (null: not linked
* yet), at every level (none from a Bops Cloud before it: Live is then read from `linked`, or from Published as before).
* At Write also its folder, the paths left alone, the workflow it runs, and where it was last the same as GitHub
* (`linked`: Link, Publish or Bring in; none: it isn't linked yet), so an Admin, or a Member with App secrets, links it
* and brings GitHub's changes in (SharedWsSlice.appGithub).
*/
github?: { repo: string; branch: string; path?: string; keep?: string[]; dispatch?: { workflow: string; inputs?: Record<string, string> }; linked?: { sha: string; ingot: string; when: number } };
github?: { repo: string; branch: string; at?: string | null; path?: string; keep?: string[]; dispatch?: { workflow: string; inputs?: Record<string, string> }; linked?: { sha: string; ingot: string; when: number } };
head?: { id: string; message: string; at: number } | null;
/**
* `url`: Published's address (none: no domain yet). Its Preview, where members deploy, from an owner's Bops that sends
Expand Down
17 changes: 12 additions & 5 deletions cloud/test/core-members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1953,16 +1953,22 @@ test("Your Apps at Read, in a workspace of Orgo's team: the apps by name, their
assert.deepEqual(s.workspace, { id: "ws_apps", name: "Apps" }, "the workspace's own fields stay as they were");
});

test("Your Apps says where an app whose production is on GitHub publishes to (its repo and branch), never its folder, kept files or point; nothing for a malformed setting", () => {
test("Your Apps says where an app whose production is on GitHub publishes to (its repo and branch) and the changeset last sent there, never its folder, kept files or GitHub commit; nothing for a malformed setting", () => {
const github = { repo: "acme/shop-site", branch: "main", path: "web", keep: [".env.example"], dispatch: { workflow: "deploy.yml", inputs: { client: "shop" } }, last: { sha: "abc123", ingot: "cs-2", when: 9 } };
const blob = appsBlob({ apps: "read" });
const ws = (blob.workspaces as Record<string, Record<string, Record<string, Record<string, unknown>>>>[])[0];
ws.hostedApps.shop.github = github;
ws.hostedApps.notes.github = { repo: 7, branch: "main" };
const s = sharedSlice(blob, "ws_apps", TOM_VIEW);
assert.deepEqual(s.apps?.list[0].github, { repo: "acme/shop-site", branch: "main" });
// (`at`: what its Live is, so a member's review compares the Preview with GitHub, not with Orgo's Published.)
assert.deepEqual(s.apps?.list[0].github, { repo: "acme/shop-site", branch: "main", at: "cs-2" });
assert.equal(s.apps?.list[1].github, undefined, "a malformed setting shows nothing");
for (const hidden of ["web", ".env.example", "deploy.yml", "abc123"]) assert.ok(!JSON.stringify(s.apps).includes(`"${hidden}"`), `"${hidden}" isn't shown`);
// Not linked yet (no `last`), or a `last` with no changeset: null, said as such.
ws.hostedApps.shop.github = { repo: "acme/shop-site", branch: "main", path: "" };
assert.deepEqual(sharedSlice(blob, "ws_apps", TOM_VIEW).apps?.list[0].github, { repo: "acme/shop-site", branch: "main", at: null });
ws.hostedApps.shop.github = { repo: "acme/shop-site", branch: "main", path: "", last: { sha: "abc123", when: 9 } };
assert.deepEqual(sharedSlice(blob, "ws_apps", TOM_VIEW).apps?.list[0].github, { repo: "acme/shop-site", branch: "main", at: null });
});

test("Your Apps at Write adds each site's settings and the workspace's domains, whatever the member's email; nothing for None, an older entry, the owner, a workspace whose owner isn't Orgo's team, or no apps", () => {
Expand Down Expand Up @@ -2419,17 +2425,18 @@ test("the slice: GitHub's folder, paths left alone, workflow and point at Write
assert.deepEqual(site.github, {
repo: "example-org/web",
branch: "main",
at: "b".repeat(64),
path: "formula",
keep: ["proxy.ts"],
dispatch: { workflow: "deploy.yml", inputs: { client: "acme" } },
linked: { sha: "a".repeat(40), ingot: "b".repeat(64), when: 7 },
});
assert.deepEqual(sam.apps!.list.find((a) => a.name === "blog")!.github, { repo: "example-org/web", branch: "main", path: "blog" }, "set, not linked");
assert.deepEqual(sam.apps!.list.find((a) => a.name === "blog")!.github, { repo: "example-org/web", branch: "main", at: null, path: "blog" }, "set, not linked");
assert.equal(sam.appGithub, true);
assert.deepEqual(site.acts?.map((a) => [a.act, a.going]), [["githubPull", true], ["deploy", undefined]]);
// At Read: where Publish goes, nothing more, and no GitHub buttons.
// At Read: where Publish goes and what its Live is (the changeset last sent there), nothing more, and no GitHub buttons.
const rob = s(ghViewer("member", "rob@example.com", { githubActs: true, githubSeen: true }));
assert.deepEqual(rob.apps!.list.find((a) => a.name === "site")!.github, { repo: "example-org/web", branch: "main" });
assert.deepEqual(rob.apps!.list.find((a) => a.name === "site")!.github, { repo: "example-org/web", branch: "main", at: "b".repeat(64) });
assert.equal(rob.appGithub, undefined);
// A Member at Write without App secrets, or anyone while the owner's last Mac is older: none.
assert.equal(s(ghViewer("member", "ivy@example.com", { githubActs: true })).appGithub, undefined);
Expand Down
13 changes: 8 additions & 5 deletions cloud/ws-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,21 +434,24 @@ const GITHUB_ACT = new Set(["github", "githubLink", "githubPull"]);
const SETTINGS_SEEN: Spec = { port: true, install: true, build: true, start: true, health: true };
/**
* An app's GitHub production as shown (repo and branch, as the owner's Bops or Bops Cloud checked them when set), or none
* when malformed. At Write (`write`) also its folder, the paths left alone, the workflow it runs and where it was last the
* same as GitHub (`linked`, of its point: never more than its commits' ids and when), so Your Apps links it and brings
* GitHub's changes in.
* when malformed; at every level `at`, the changeset last sent there (github.last.ingot: a Link, a Publish or a Bring in,
* by the owner's Mac or a runner), which is what its Live is, or null when it isn't linked yet. At Write (`write`) also its
* folder, the paths left alone, the workflow it runs and where it was last the same as GitHub (`linked`, of its point:
* never more than its commits' ids and when), so Your Apps links it and brings GitHub's changes in.
*/
const githubOf = (g: unknown, write = false): SharedApp["github"] | undefined => {
if (!isObject(g) || typeof g.repo !== "string" || typeof g.branch !== "string" || g.repo.length > 201 || g.branch.length > 200) return undefined;
if (!write) return { repo: g.repo, branch: g.branch };
const words = (v: unknown, max: number): v is string => typeof v === "string" && v.length <= max;
const last = isObject(g.last) && words(g.last.sha, 64) && words(g.last.ingot, 64) && typeof g.last.when === "number" ? g.last : undefined;
const at = last ? (last.ingot as string) : null;
if (!write) return { repo: g.repo, branch: g.branch, at };
const keep = Array.isArray(g.keep) ? g.keep.filter((k): k is string => words(k, 300)).slice(0, 50) : [];
const d = isObject(g.dispatch) && words(g.dispatch.workflow, 120) ? g.dispatch : undefined;
const inputs = d && isObject(d.inputs) ? Object.fromEntries(Object.entries(d.inputs).filter((e): e is [string, string] => e[0].length <= 50 && words(e[1], 200)).slice(0, 10)) : undefined;
const last = isObject(g.last) && words(g.last.sha, 64) && words(g.last.ingot, 64) && typeof g.last.when === "number" ? g.last : undefined;
return {
repo: g.repo,
branch: g.branch,
at,
...(words(g.path, 300) ? { path: g.path } : {}),
...(keep.length ? { keep } : {}),
...(d ? { dispatch: { workflow: d.workflow as string, ...(inputs && Object.keys(inputs).length ? { inputs } : {}) } } : {}),
Expand Down
28 changes: 28 additions & 0 deletions components/app/app-controls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Fold } from "./fold";

// Recipes shared with CRM. Keep the app's existing type, radius, and material.
export const appIcon = { size: 16, strokeWidth: 1.5, "aria-hidden": true as const };
const appAction = "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-[12.5px] leading-4 text-ink disabled:text-pencil";
export const appOutline = `${appAction} font-medium shadow-[0_0_0_1px_#E6E6E3] hover:bg-desk`;
export const appAttention = `${appAction} bg-highlighter font-semibold hover:bg-highlighter/80 disabled:bg-desk`;
export const appPrimary = "inline-flex shrink-0 items-center justify-center rounded-full bg-ink px-3.5 py-1.5 text-[12.5px] font-medium leading-4 text-paper disabled:bg-desk disabled:text-pencil";
export const appGhost = "inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-[12.5px] font-medium leading-4 text-pencil hover:bg-desk hover:text-ink disabled:opacity-50";
export const appList = "flex flex-col overflow-hidden rounded-2xl shadow-[0_0_0_1px_#ECECEA]";
export const appRow = "flex items-center gap-3 border-b border-rule px-3.5 py-3 hover:bg-desk";
const appTagBase = "shrink-0 rounded-full px-2.5 py-1 text-[12px] leading-4 text-ink";
export const appTag = `${appTagBase} bg-desk font-medium`;
export const appAttentionTag = `${appTagBase} bg-highlighter font-semibold`;
export const segmented = "flex shrink-0 gap-0.5 rounded-full bg-black/[0.05] p-[3px]";
export const segment = (on: boolean, blocked = false) => `rounded-full px-2.5 py-1 text-[12px] leading-4 ${on ? "bg-paper text-ink shadow-[0_0_0_1px_#0000000F]" : blocked ? "cursor-default text-[#C9C9C6]" : "text-pencil hover:text-ink"}`;

export function AppDisclosure({ title, children, initiallyOpen = false }: { title: string; children: ReactNode; initiallyOpen?: boolean }) {
const [open, setOpen] = useState(initiallyOpen);
return <div className="flex flex-col gap-2">
<button className={`${appGhost} self-start`} aria-expanded={open} onClick={() => setOpen(!open)}>{open ? <ChevronDown {...appIcon}/> : <ChevronRight {...appIcon}/>}{title}</button>
<Fold lazy open={open}>{children}</Fold>
</div>;
}
Loading
Loading