Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
275 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import {
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.5 filterInvitations,
6 filterMembers,
7 invitationCounts,
8 invitationFilter,
9 invitationFilterOf,
10 invitationTone,
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)11 inviteKind,
Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one.12 G1T_INVITES,
13 bringIntoChoices,
14 inviteActions,
15 inviteDraft,
16 leftBadge,
17 remainingLine,
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.18 memberFilters,
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)19 invitePageCopy,
20 workspaceInviteCopy,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running21 cleanCode,
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm22 cleanProof,
23 invitePath,
24 inviteSignUpCopy,
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running25 inviteFor,
26 inviteLink,
27 inviteState,
28 landingFor,
29 looksAutomated,
30 signUpCopy,
31 suggestUsername,
32 welcomeCookie,
33 clearWelcome,
34 welcomes,
35} from "./invites.ts";
36
37const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk";
38
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.39test("the Invitations page sorts every state under one filter, and counts them", () => {
40 const statuses = ["pending", "awaiting_confirmation", "awaiting_answer", "redeemed", "declined", "expired", "revoked"] as const;
41 const invites = statuses.map((status) => ({ status }));
42 assert.equal(invitationFilterOf("pending"), "pending");
43 assert.equal(invitationFilterOf("awaiting_confirmation"), "pending");
44 assert.equal(invitationFilterOf("awaiting_answer"), "pending");
45 assert.equal(invitationFilterOf("redeemed"), "accepted");
46 assert.equal(invitationFilterOf("declined"), "declined");
47 assert.equal(invitationFilterOf("expired"), "expired");
48 assert.equal(invitationFilterOf("revoked"), "revoked");
49 assert.deepEqual(invitationCounts(invites), { all: 7, pending: 3, accepted: 1, declined: 1, expired: 1, revoked: 1 });
50 assert.equal(filterInvitations(invites, "all").length, 7);
51 assert.deepEqual(filterInvitations(invites, "pending").map((invite) => invite.status), ["pending", "awaiting_confirmation", "awaiting_answer"]);
52 assert.deepEqual(filterInvitations(invites, "accepted").map((invite) => invite.status), ["redeemed"]);
53 // The query names a filter, or means All.
54 assert.equal(invitationFilter("pending"), "pending");
55 assert.equal(invitationFilter("failed"), "all");
56 assert.equal(invitationFilter(null), "all");
57 // Waiting is amber, joined green, a no red, and over is quiet.
58 assert.equal(invitationTone("awaiting_answer"), "warn");
59 assert.equal(invitationTone("redeemed"), "success");
60 assert.equal(invitationTone("revoked"), "danger");
61 assert.equal(invitationTone("expired"), "neutral");
62});
63
64test("the Members page filters by role, two-factor, team and words", () => {
65 const members = [
66 { username: "ada", name: "Ada Lovelace", role: "owner" as const, two_factor: true },
67 { username: "grace", name: "Grace Hopper", role: "member" as const, two_factor: false },
68 { username: "linus", name: null, role: "member" as const, two_factor: true },
69 ];
70 const teams = { ada: [{ slug: "core" }], grace: [{ slug: "web" }, { slug: "core" }] };
71 assert.deepEqual(memberFilters(""), { role: "all", twoFactor: "all", team: null, query: "" });
72 assert.deepEqual(memberFilters("?role=owner&2fa=off&team=Core&q=%20ada%20"), { role: "owner", twoFactor: "off", team: "core", query: "ada" });
73 assert.deepEqual(memberFilters("?role=admin&2fa=maybe"), { role: "all", twoFactor: "all", team: null, query: "" });
74 const names = (filters: string) => filterMembers(members, memberFilters(filters), teams).map((member) => member.username);
75 assert.deepEqual(names(""), ["ada", "grace", "linus"]);
76 assert.deepEqual(names("?role=member"), ["grace", "linus"]);
77 assert.deepEqual(names("?2fa=off"), ["grace"]);
78 assert.deepEqual(names("?team=core"), ["ada", "grace"]);
79 assert.deepEqual(names("?q=hopper"), ["grace"]);
80 assert.deepEqual(names("?q=@LIN"), ["linus"]);
81 assert.deepEqual(names("?role=owner&team=web"), []);
82});
83
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.84test("everyone is offered a plain sign-up", () => {
Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite85 assert.deepEqual(signUpCopy(), { primary: "Sign up", secondary: null });
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running86});
87
88test("an invite link is on g1t.sh unless told otherwise", () => {
89 assert.equal(inviteLink(CODE), `https://g1t.sh/invite/${CODE}`);
90 assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`);
91});
92
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm93const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8";
94
95test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => {
96 assert.equal(cleanProof(PROOF), PROOF);
97 assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF);
98 assert.equal(cleanProof("not-a-proof"), null);
99 assert.equal(cleanProof("abc"), null);
100 assert.equal(cleanProof("a".repeat(500)), null);
101 assert.equal(cleanProof(null), null);
102 assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`);
103 assert.equal(invitePath(CODE, null), `/invite/${CODE}`);
104 assert.equal(invitePath(CODE), `/invite/${CODE}`);
105});
106
107test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => {
108 const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null };
109 const proven = inviteSignUpCopy({ ...base, emailProven: true });
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)110 assert.equal(proven.intro, "You can join Flagon, Inc. as soon as you create it: accept the invitation then.");
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm111 assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/);
112 assert.match(proven.hint, /confirmed already/);
113 assert.doesNotMatch(proven.hint, /code/);
114
115 // No proof (a code typed in, or a link passed on): nothing new is said.
116 const plain = inviteSignUpCopy(base);
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)117 assert.equal(plain.intro, "You can join Flagon, Inc. as soon as you confirm your email: accept the invitation then.");
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm118 assert.equal(plain.confirmed, null);
119 assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start.");
120
121 // An invite for anyone with the code has no address to prove.
122 const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null });
123 assert.equal(open.confirmed, null);
124 assert.equal(open.intro, "It takes a minute.");
125 assert.equal(open.hint, "We email it a code to confirm it before you start.");
126
127 const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true });
128 assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it.");
129});
130
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running131test("a pasted link or code is tidied to the code", () => {
132 assert.equal(cleanCode(CODE), CODE);
133 assert.equal(cleanCode(` ${CODE} `), CODE);
134 assert.equal(cleanCode(`https://g1t.sh/invite/${CODE}`), CODE);
135 assert.equal(cleanCode(`https://g1t.sh/register?invite=${CODE}&next=%2F`), CODE);
136 assert.equal(cleanCode("g1t-k7m2 q9xd"), "g1t-k7m2q9xd");
137 assert.equal(cleanCode(null), "");
138 assert.equal(cleanCode("x".repeat(500)).length, 80);
139});
140
141test("each invite says where it stands and whom it is for", () => {
142 const base = { redeemedBy: null, email: null, workspace: null };
143 assert.deepEqual(inviteState({ ...base, status: "pending" }), { label: "Pending", tone: "pending" });
144 assert.deepEqual(inviteState({ ...base, status: "redeemed", redeemedBy: "ada" }), { label: "Joined as @ada", tone: "done" });
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)145 assert.deepEqual(inviteState({ ...base, status: "awaiting_confirmation", redeemedBy: "ada" }), {
146 label: "@ada is confirming their email",
147 tone: "pending",
148 });
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running149 assert.deepEqual(inviteState({ ...base, status: "expired" }), { label: "Expired", tone: "dead" });
150 assert.deepEqual(inviteState({ ...base, status: "revoked" }), { label: "Revoked", tone: "dead" });
151 assert.equal(inviteFor({ ...base, status: "pending" }), "Anyone with the link");
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)152 assert.equal(inviteFor({ ...base, status: "pending", email: "ada@example.com", workspace: "acme" }), "ada@example.com");
153 assert.equal(inviteFor({ ...base, status: "pending", invitee: "daweazl", workspace: "flagon-io" }), "@daweazl");
154 // Which kind each is, beside whom it is for.
155 assert.deepEqual(inviteKind({ workspace: null }), { kind: "g1t", label: "Invite to g1t" });
156 assert.deepEqual(inviteKind({ workspace: "flagon-io" }), { kind: "workspace", label: "Invite to join flagon-io" });
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)157 assert.deepEqual(inviteState({ ...base, status: "awaiting_answer", redeemedBy: "daweazl" }), { label: "Waiting for @daweazl to accept", tone: "pending" });
158 assert.deepEqual(inviteState({ ...base, status: "declined", invitee: "daweazl" }), { label: "@daweazl declined", tone: "dead" });
Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running159});
160
161test("bots that fill the hidden field or answer instantly are turned away", () => {
162 const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
163 const now = 1_000_000;
164 assert.equal(looksAutomated(form({ website: "http://spam.example" }), now), true);
165 assert.equal(looksAutomated(form({ started: String(now - 200) }), now), true);
166 assert.equal(looksAutomated(form({ started: String(now - 10_000) }), now), false);
167 assert.equal(looksAutomated(form({}), now), false);
168 assert.equal(looksAutomated(form({ website: " " }), now), false);
169});
170
171test("a username is suggested from the invited address", () => {
172 assert.equal(suggestUsername("ada.lovelace@example.com"), "ada-lovelace");
173 assert.equal(suggestUsername("Margaret_Hamilton+g1t@example.com"), "margaret-hamilton");
174 assert.equal(suggestUsername("--x--@example.com"), "x");
175 assert.equal(suggestUsername(`${"a".repeat(38)}.b@example.com`), "a".repeat(38));
176 assert.equal(suggestUsername("...@example.com"), "");
177 assert.equal(suggestUsername(null), "");
178});
179
180test("an invite lands in its workspace, else its repository", () => {
181 assert.equal(landingFor({ workspace: { slug: "Flagon-IO" }, repository: null }), "flagon-io");
182 assert.equal(landingFor({ workspace: null, repository: { name: "flagon-io/g1t" } }), "flagon-io/g1t");
183 assert.equal(landingFor({ workspace: null, repository: null }), null);
184});
185
186test("the welcome is for one place, and ends", () => {
187 const set = welcomeCookie("flagon-io/g1t", true);
188 assert.match(set, /^g1t_welcome=flagon-io%2Fg1t; Path=\/; Max-Age=300; HttpOnly; SameSite=Lax; Secure$/);
189 const header = `a=1; ${set.split(";")[0]}; b=2`;
190 assert.equal(welcomes(header, "flagon-io/g1t"), true);
191 assert.equal(welcomes(header, "flagon-io"), false);
192 assert.equal(welcomes("g1t_welcome=flagon-io", "Flagon-IO"), true);
193 assert.equal(welcomes("g1t_welcome=%E0%A4%A", "flagon-io"), false);
194 assert.equal(welcomes("g1t_welcome=..%2F..%2Fx", "../../x"), false);
195 assert.equal(welcomes(null, "flagon-io"), false);
196 assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/);
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)197});
198
199test("the two invites say which they are", () => {
200 // A workspace's People page: an invitation to accept or decline, which signs up whoever has no account.
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.201 const copy = workspaceInviteCopy("Flagon, Inc.");
202 assert.equal(copy.heading, "Invite to Flagon, Inc.");
203 assert.match(copy.hint, /invitation to join Flagon, Inc\..*join only if they accept/);
204 assert.match(copy.hint, /If they do not have a g1t account yet, they sign up from the invitation first/);
205 assert.doesNotMatch(copy.hint, /one of yours|shared invites/);
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)206});
207
208test("an invite's page names the invite it is", () => {
209 const base = { kind: "account" as const, invitedBy: { username: "syntaqx" }, workspace: null, repository: null, hasAccount: false };
210 const g1t = invitePageCopy(base, false);
211 assert.equal(`${g1t.before}${g1t.place ?? ""}${g1t.after}`, "@syntaqx invited you to g1t");
Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one.212 assert.match(g1t.about, /Sign up from this invite and your address is filled in for you\. It does not add you to anyone's workspace/);
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)213 const join = invitePageCopy({ ...base, workspace: { name: "Flagon, Inc." } }, false);
214 assert.equal(`${join.before}${join.place}${join.after}`, "@syntaqx invited you to join Flagon, Inc. on g1t");
215 assert.equal(join.place, "Flagon, Inc.");
216 assert.match(join.about, /invitation to join Flagon, Inc\., which you accept or decline/);
217 assert.match(join.about, /You do not have a g1t account yet, so it also lets you make one/);
218 // Someone with an account, or signed in, just accepts.
219 assert.match(invitePageCopy({ ...base, kind: "workspace", workspace: { name: "Flagon, Inc." }, hasAccount: true }, false).about, /Accepting joins you to Flagon, Inc\./);
220 assert.equal(invitePageCopy({ ...base, invitedBy: null }, false).before, "The g1t team invited you to g1t");
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)221});
Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one.222
223test("Invite a friend never reads as a gate on signing up", () => {
224 for (const words of Object.values(G1T_INVITES)) {
225 assert.doesNotMatch(words, /invite-only|waitlist|request access|need an invite|held for/i, words);
226 }
227 assert.match(G1T_INVITES.about, /Anyone can sign up for g1t/);
228 assert.match(G1T_INVITES.out, /can still sign up/);
229});
230
231test("a friend can also be invited only to a paid workspace the person owns", () => {
232 const memberships = [
233 { slug: "Acme", name: "Acme Labs", role: "owner" as const },
234 { slug: "hobby", name: null, role: "owner" as const },
235 { slug: "flagon-io", name: "Flagon, Inc.", role: "member" as const },
236 ];
237 assert.deepEqual(bringIntoChoices(memberships, ["hobby"]), [{ slug: "acme", name: "Acme Labs" }]);
238 // Nothing paid that they own: no box at all.
239 assert.deepEqual(bringIntoChoices(memberships, ["acme", "hobby"]), []);
240});
241
242test("the form sends a workspace only when its box is ticked and it was offered", () => {
243 const form = (fields: Record<string, string>) => new Map(Object.entries(fields));
244 const offered = [{ slug: "acme", name: "Acme Labs" }];
245 assert.deepEqual(inviteDraft(form({ email: " ada@example.com ", message: " " }), offered), { email: "ada@example.com", message: null });
246 assert.deepEqual(inviteDraft(form({ email: "ada@example.com", join: "acme", join_role: "owner" }), offered), { email: "ada@example.com", message: null });
247 assert.deepEqual(inviteDraft(form({ email: "ada@example.com", message: "Come build", also_join: "on", join: "acme", join_role: "owner" }), offered), {
248 email: "ada@example.com",
249 message: "Come build",
250 join: "acme",
251 joinRole: "owner",
252 });
253 // A workspace not offered (free, or not theirs) is dropped.
254 assert.deepEqual(inviteDraft(form({ email: "ada@example.com", also_join: "on", join: "hobby" }), offered), { email: "ada@example.com", message: null });
255});
256
257test("how many invites are left reads plainly, and the menu shows the count", () => {
258 assert.equal(remainingLine({ limit: 20, remaining: 18 }), "18 of 20 invites left");
259 assert.equal(remainingLine({ limit: 20, remaining: 0 }), "You have used all 20 of your invites");
260 assert.equal(remainingLine({ limit: 1, remaining: 1 }), "1 of 1 invite left");
261 assert.equal(remainingLine({ limit: 0, remaining: 0 }), "Invites to g1t are turned off on this installation");
262 assert.equal(leftBadge(18), "18 left");
263 assert.equal(leftBadge(0), "0 left");
264 assert.equal(leftBadge(null), null);
265 assert.equal(leftBadge(undefined), null);
266});
267
268test("only a pending invite is sent again or copied; one waiting on its friend can still be revoked", () => {
269 assert.deepEqual(inviteActions({ status: "pending", code: "g1t-abcd" }), { revoke: true, resend: true, copy: true });
270 assert.deepEqual(inviteActions({ status: "pending", code: null }), { revoke: true, resend: true, copy: false });
271 assert.deepEqual(inviteActions({ status: "awaiting_confirmation", code: null }), { revoke: true, resend: false, copy: false });
272 for (const status of ["redeemed", "expired", "revoked", "declined"] as const) {
273 assert.deepEqual(inviteActions({ status, code: null }), { revoke: false, resend: false, copy: false }, status);
274 }
275});

This file's history is long; its oldest lines are credited to the oldest commit read.