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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | import { Box, CircleAlert, MailOpen } from "lucide-react"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 2 | import { Form, data, redirect } from "react-router"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 3 | |
| 4 | import { REPO_ROLE_LABELS, REPO_ROLE_SUMMARIES } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import type { Route } from "./+types/invitations"; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 7 | import { Avatar, ButtonLink, ErrorText, SubmitButton, usePending } from "../../components/ui"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 8 | import { page } from "../../lib/meta"; |
| 9 | import { identity } from "../../lib/services.server"; | |
| 10 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 11 | ||
| 12 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 13 | return page(args, { title: `Invitation · ${params.owner}/${params.repo} · g1t` }); | |
| 14 | } | |
| 15 | ||
| 16 | /** The viewer's pending invitation to this repository, if they have one. */ | |
| 17 | async function invitationTo(user: Parameters<typeof identity.myRepoInvitations>[0], owner: string, repo: string) { | |
| 18 | const full = `${owner}/${repo}`.toLowerCase(); | |
| 19 | const mine = await identity.myRepoInvitations(user); | |
| 20 | return mine.find((invitation) => invitation.repo.toLowerCase() === full && invitation.status === "pending") ?? null; | |
| 21 | } | |
| 22 | ||
| 23 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 24 | // Someone invited usually cannot see the repository yet, so this reads | |
| 25 | // their own invitations, not the repository. | |
| 26 | const user = requireUser(context, request); | |
| 27 | const invitation = await invitationTo(user, params.owner, params.repo); | |
| 28 | return { invitation, viewer: { username: user.username, avatar: user.avatar ?? null } }; | |
| 29 | } | |
| 30 | ||
| 31 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 32 | assertSameOrigin(request); | |
| 33 | const user = requireUser(context, request); | |
| 34 | const form = await request.formData(); | |
| 35 | const accept = form.get("intent") === "accept"; | |
| 36 | const invitation = await invitationTo(user, params.owner, params.repo); | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 37 | // Refusals are said with a 200, so the page loads again and shows the |
| 38 | // invitation as it now stands beside the reason: after a 4xx answer | |
| 39 | // React Router keeps the page's data as it was. | |
| 40 | if (!invitation) return { error: "This invitation is no longer open." }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 41 | const answered = await identity.respondRepoInvitation(user, invitation.id, accept); |
| Merge branch 'worktree-agent-a985c01f43538af01' | 42 | if (!answered.ok) return { error: answered.error.message }; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 43 | // The role is theirs from the next request on: straight to the repository. |
| 44 | throw redirect(accept ? `/${invitation.repo}` : "/"); | |
| 45 | } | |
| 46 | ||
| 47 | const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }); | |
| 48 | ||
| 49 | export default function RepoInvitation({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 50 | const { invitation, viewer } = loaderData; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 51 | // Either answer: the other button waits for it. |
| 52 | const answering = usePending(); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 53 | const full = `${params.owner}/${params.repo}`; |
| 54 | if (!invitation) { | |
| 55 | return ( | |
| 56 | <div className="mx-auto max-w-lg px-4 py-16"> | |
| 57 | <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight"> | |
| 58 | <CircleAlert size={22} className="text-warn" /> | |
| 59 | No open invitation | |
| 60 | </h1> | |
| 61 | <p className="mt-3 text-sm leading-6 text-muted"> | |
| 62 | There is no invitation to <span className="font-mono text-fg">{full}</span> waiting for{" "} | |
| 63 | <span className="font-mono text-fg">{viewer.username}</span>. It may have been accepted, declined, revoked, or | |
| 64 | it expired; an invitation lasts 7 days. Ask whoever invited you to send another. | |
| 65 | </p> | |
| 66 | <div className="mt-8 flex flex-wrap gap-3"> | |
| 67 | <ButtonLink to={`/${full}`} variant="quiet"> | |
| 68 | Go to {full} | |
| 69 | </ButtonLink> | |
| 70 | <ButtonLink to="/" variant="quiet"> | |
| 71 | Mission control | |
| 72 | </ButtonLink> | |
| 73 | </div> | |
| 74 | </div> | |
| 75 | ); | |
| 76 | } | |
| 77 | return ( | |
| 78 | <div className="mx-auto max-w-lg px-4 py-16"> | |
| 79 | <div className="flex items-center gap-3"> | |
| 80 | {invitation.invited_by ? ( | |
| 81 | <Avatar name={invitation.invited_by} image={invitation.inviter_avatar} size={44} /> | |
| 82 | ) : ( | |
| 83 | <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent"> | |
| 84 | <MailOpen size={20} /> | |
| 85 | </span> | |
| 86 | )} | |
| 87 | <span className="text-faint">→</span> | |
| 88 | <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line"> | |
| 89 | <Box size={18} /> | |
| 90 | </span> | |
| 91 | </div> | |
| 92 | <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance"> | |
| 93 | {invitation.invited_by ?? "Someone"} invited you to <span className="font-mono break-all">{invitation.repo}</span> | |
| 94 | </h1> | |
| 95 | <p className="mt-2 text-sm leading-6 text-muted"> | |
| 96 | Accepting gives you the <span className="text-fg">{REPO_ROLE_LABELS[invitation.role]}</span> role on this | |
| 97 | repository: {REPO_ROLE_SUMMARIES[invitation.role].replace(/^./, (c) => c.toLowerCase())} You see this repository | |
| 98 | only, not the rest of the {params.owner} workspace. | |
| 99 | </p> | |
| 100 | <dl className="mt-5 space-y-1 text-sm"> | |
| 101 | {invitation.invited_by && ( | |
| 102 | <div className="flex gap-2"> | |
| 103 | <dt className="w-24 shrink-0 text-faint">From</dt> | |
| 104 | <dd className="font-mono text-fg-soft">@{invitation.invited_by}</dd> | |
| 105 | </div> | |
| 106 | )} | |
| 107 | <div className="flex gap-2"> | |
| 108 | <dt className="w-24 shrink-0 text-faint">Role</dt> | |
| 109 | <dd className="text-fg-soft">{REPO_ROLE_LABELS[invitation.role]}</dd> | |
| 110 | </div> | |
| 111 | <div className="flex gap-2"> | |
| 112 | <dt className="w-24 shrink-0 text-faint">Works until</dt> | |
| 113 | <dd className="text-fg-soft">{LONG_DATE.format(new Date(invitation.expires_at))}</dd> | |
| 114 | </div> | |
| 115 | </dl> | |
| 116 | <Form method="post" className="mt-8 space-y-3"> | |
| 117 | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 118 | <Avatar name={viewer.username} image={viewer.avatar} size={20} /> | |
| 119 | Signed in as <span className="font-mono text-fg">{viewer.username}</span> | |
| 120 | </p> | |
| 121 | <ErrorText>{actionData?.error}</ErrorText> | |
| 122 | <div className="flex flex-col gap-3 sm:flex-row"> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 123 | <SubmitButton name="intent" value="accept" disabled={answering} pending="Accepting…"> |
| 124 | Accept invitation | |
| 125 | </SubmitButton> | |
| 126 | <SubmitButton name="intent" value="decline" variant="quiet" disabled={answering} pending="Declining…"> | |
| 127 | Decline | |
| 128 | </SubmitButton> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 129 | </div> |
| 130 | </Form> | |
| 131 | </div> | |
| 132 | ); | |
| 133 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.