Merge branch 'worktree-agent-a6da31375506fc440'
# Conflicts: # apps/web/app/components/shell.tsx
44 files+371−2928 files not counted0/44 viewed
| 87 | 87 | ), | |
| 88 | 88 | ( | |
| 89 | 89 | "Invites", | |
| 90 | − | "Inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you.", | |
| 90 | + | "Inviting a friend to g1t by email, inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you. Anyone can sign up for g1t without an invite.", | |
| 91 | 91 | &[ | |
| 92 | + | Op::ListInvites, | |
| 93 | + | Op::CreateInvite, | |
| 94 | + | Op::RevokeInvite, | |
| 95 | + | Op::ResendInvite, | |
| 92 | 96 | Op::ListWorkspaceInvites, | |
| 93 | 97 | Op::InviteMember, | |
| 94 | 98 | Op::RevokeWorkspaceInvite, | |
| ⋯ | |||
| 633 | 637 | Op::ConfirmEmail => "Confirm an email address", | |
| 634 | 638 | Op::RemoveEmail => "Remove an email address", | |
| 635 | 639 | Op::UpdateEmailSettings => "Change your email settings", | |
| 640 | + | Op::ListInvites => "List your invites to g1t", | |
| 641 | + | Op::CreateInvite => "Invite a friend to g1t", | |
| 642 | + | Op::RevokeInvite => "Revoke an invite to g1t", | |
| 643 | + | Op::ResendInvite => "Send an invite to g1t again", | |
| 636 | 644 | Op::ListWorkspaceInvites => "List a workspace's invites", | |
| 637 | 645 | Op::InviteMember => "Invite someone to a workspace", | |
| 638 | 646 | Op::ListInvitations => "List your workspace invitations", | |
| 127 | 127 | ConfirmEmail, | |
| 128 | 128 | RemoveEmail, | |
| 129 | 129 | UpdateEmailSettings, | |
| 130 | + | ListInvites, | |
| 131 | + | CreateInvite, | |
| 132 | + | RevokeInvite, | |
| 133 | + | ResendInvite, | |
| 130 | 134 | ListWorkspaceInvites, | |
| 131 | 135 | InviteMember, | |
| 132 | 136 | RevokeWorkspaceInvite, | |
| ⋯ | |||
| 700 | 704 | } | |
| 701 | 705 | ||
| 702 | 706 | impl Op { | |
| 703 | − | pub const ALL: [Op; 376] = [ | |
| 707 | + | pub const ALL: [Op; 380] = [ | |
| 704 | 708 | Op::Whoami, | |
| 705 | 709 | Op::GetWorkspace, | |
| 706 | 710 | Op::CreateWorkspace, | |
| ⋯ | |||
| 716 | 720 | Op::ConfirmEmail, | |
| 717 | 721 | Op::RemoveEmail, | |
| 718 | 722 | Op::UpdateEmailSettings, | |
| 723 | + | Op::ListInvites, | |
| 724 | + | Op::CreateInvite, | |
| 725 | + | Op::RevokeInvite, | |
| 726 | + | Op::ResendInvite, | |
| 719 | 727 | Op::ListWorkspaceInvites, | |
| 720 | 728 | Op::InviteMember, | |
| 721 | 729 | Op::RevokeWorkspaceInvite, | |
| ⋯ | |||
| 1101 | 1109 | Op::ConfirmEmail => "confirm_email", | |
| 1102 | 1110 | Op::RemoveEmail => "remove_email", | |
| 1103 | 1111 | Op::UpdateEmailSettings => "update_email_settings", | |
| 1112 | + | Op::ListInvites => "list_invites", | |
| 1113 | + | Op::CreateInvite => "create_invite", | |
| 1114 | + | Op::RevokeInvite => "revoke_invite", | |
| 1115 | + | Op::ResendInvite => "resend_invite", | |
| 1104 | 1116 | Op::ListWorkspaceInvites => "list_workspace_invites", | |
| 1105 | 1117 | Op::InviteMember => "invite_member", | |
| 1106 | 1118 | Op::RevokeWorkspaceInvite => "revoke_workspace_invite", | |
| ⋯ | |||
| 1310 | 1322 | Op::UpdateEmailSettings => { | |
| 1311 | 1323 | "Change what your addresses do; only the fields given change. `primary` is a confirmed address to make primary: account mail and password resets go there. `backup` is a confirmed address that also gets security notices, or an empty string for the primary only. Changing either needs your account `password`, and every confirmed address is told. `private_email` keeps your address off commits g1t makes for you (merges and changes made on the web, and agents' commits for you), which use your noreply address instead; `block_private_pushes` refuses pushes whose commits carry one of your addresses while it is private. People only." | |
| 1312 | 1324 | } | |
| 1325 | + | Op::ListInvites => { | |
| 1326 | + | "Your invites to g1t, newest first, and how many you have left. Anyone can sign up for g1t without an invite; an invite is a note from you with a link that opens sign-up with your friend's address filled in and confirmed. You have 20 for good (`allowance.limit`, more if g1t granted them): pending and used ones count, and one revoked or expired before it was used comes back. A pending invite's `code` is shown to you; the link is https://g1t.sh/invite/<code>. `status` is `pending`; `awaiting_confirmation` (used to make an account that has not confirmed its address yet); `awaiting_answer` (used to make an account that has yet to accept or decline the workspace it was also invited to); `redeemed` (they joined: `redeemed_by` is their username); `declined`; `expired`; or `revoked`. People only." | |
| 1327 | + | } | |
| 1328 | + | Op::CreateInvite => { | |
| 1329 | + | "Invite a friend to g1t by `email`. They get an email from you, with your `message` if you write one, and a link that opens sign-up with their address filled in and confirmed. Their account starts with a workspace of its own. It works for 30 days and uses one of your 20 invites; revoking it before it is used gives it back. Refused with `409` when the address already has a g1t account or you already have a pending invite for it (neither uses one), and `limit` when you have none left. With `workspace`, a workspace you own on a paid plan, they are also invited to it as `role` (`member` unless you give `owner`): once their account is confirmed they accept or decline, and no workspace of their own is made; a free workspace is refused with `payment_required` (402). Returns the invite with its `code`. People only, once your own address is confirmed: an agent's or a workspace's token cannot invite anyone to g1t." | |
| 1330 | + | } | |
| 1331 | + | Op::RevokeInvite => { | |
| 1332 | + | "Revoke a pending invite to g1t you made. It stops working at once, and the invite comes back to you. One already used to make an account stays used." | |
| 1333 | + | } | |
| 1334 | + | Op::ResendInvite => { | |
| 1335 | + | "Send a pending invite to g1t again: the same email, with the same link, to the same address. Refused with `409` when it is no longer pending." | |
| 1336 | + | } | |
| 1313 | 1337 | Op::ListWorkspaceInvites => { | |
| 1314 | 1338 | "The invites made for a workspace, newest first, with each pending one's `code`. Owners only." | |
| 1315 | 1339 | } | |
| ⋯ | |||
| 1881 | 1905 | }), | |
| 1882 | 1906 | &[], | |
| 1883 | 1907 | ), | |
| 1908 | + | Op::ListInvites => object(json!({}), &[]), | |
| 1909 | + | Op::CreateInvite => object( | |
| 1910 | + | json!({ | |
| 1911 | + | "email": { "type": "string", "description": "Your friend's email address. The invite is sent there, and only that address can use it." }, | |
| 1912 | + | "message": { | |
| 1913 | + | "type": "string", | |
| 1914 | + | "description": "A note for the email, quoted under your name. Up to 500 characters.", | |
| 1915 | + | }, | |
| 1916 | + | "workspace": { | |
| 1917 | + | "type": "string", | |
| 1918 | + | "description": "Also invite them to this workspace, by slug: one you own on a paid plan. Left out, the invite is to g1t only.", | |
| 1919 | + | }, | |
| 1920 | + | "role": { | |
| 1921 | + | "type": "string", | |
| 1922 | + | "enum": ["member", "owner"], | |
| 1923 | + | "description": "The role they join workspace with when they accept. member when left out.", | |
| 1924 | + | }, | |
| 1925 | + | }), | |
| 1926 | + | &["email"], | |
| 1927 | + | ), | |
| 1928 | + | Op::RevokeInvite | Op::ResendInvite => object( | |
| 1929 | + | json!({ "id": { "type": "string", "description": "The invite's id, such as inv_01k…" } }), | |
| 1930 | + | &["id"], | |
| 1931 | + | ), | |
| 1884 | 1932 | Op::ListWorkspaceInvites => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 1885 | 1933 | Op::InviteMember => object( | |
| 1886 | 1934 | json!({ | |
| ⋯ | |||
| 3339 | 3387 | | Op::ConfirmEmail | |
| 3340 | 3388 | | Op::RemoveEmail | |
| 3341 | 3389 | | Op::UpdateEmailSettings | |
| 3390 | + | | Op::ListInvites | |
| 3391 | + | | Op::CreateInvite | |
| 3392 | + | | Op::RevokeInvite | |
| 3393 | + | | Op::ResendInvite | |
| 3342 | 3394 | | Op::ListWorkspaceInvites | |
| 3343 | 3395 | | Op::InviteMember | |
| 3344 | 3396 | | Op::RevokeWorkspaceInvite | |
| ⋯ | |||
| 3641 | 3693 | ) | |
| 3642 | 3694 | .await | |
| 3643 | 3695 | } | |
| 3696 | + | // Invites to g1t from Invite a friend: identity refuses anyone | |
| 3697 | + | // but a person with a confirmed address. | |
| 3698 | + | Op::ListInvites => { | |
| 3699 | + | let overview: g1t_contracts::identity::InvitesOverview = | |
| 3700 | + | g1t_kit::call(identity, "list_invites", &json!({ "user": actor() })).await?; | |
| 3701 | + | ok(&overview) | |
| 3702 | + | } | |
| 3703 | + | Op::CreateInvite => { | |
| 3704 | + | let role = optional_text(input, "role"); | |
| 3705 | + | if role.as_deref().is_some_and(|role| role != "member" && role != "owner") { | |
| 3706 | + | return failed(FailureCode::Invalid, "role is member or owner."); | |
| 3707 | + | } | |
| 3708 | + | pass( | |
| 3709 | + | identity, | |
| 3710 | + | "create_invite", | |
| 3711 | + | &json!({ | |
| 3712 | + | "user": actor(), | |
| 3713 | + | "email": text(input, "email"), | |
| 3714 | + | "message": optional_text(input, "message"), | |
| 3715 | + | "join": optional_text(input, "workspace"), | |
| 3716 | + | "join_role": role, | |
| 3717 | + | "surface": services.audit.surface, | |
| 3718 | + | }), | |
| 3719 | + | ) | |
| 3720 | + | .await | |
| 3721 | + | } | |
| 3722 | + | Op::RevokeInvite | Op::ResendInvite => { | |
| 3723 | + | let method = if self == Op::RevokeInvite { "revoke_invite" } else { "resend_invite" }; | |
| 3724 | + | pass(identity, method, &json!({ "user": actor(), "id": text(input, "id") })).await | |
| 3725 | + | } | |
| 3644 | 3726 | Op::ListWorkspaceInvites => { | |
| 3645 | 3727 | pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await | |
| 3646 | 3728 | } | |
| 383 | 383 | }, | |
| 384 | 384 | "notes": "Only the fields given change. `primary` and `backup` need `password`; `private_email` and `block_private_pushes` do not." | |
| 385 | 385 | }, | |
| 386 | + | "list_invites": { | |
| 387 | + | "response": { | |
| 388 | + | "allowance": { | |
| 389 | + | "limit": 20, | |
| 390 | + | "used": 2, | |
| 391 | + | "remaining": 18 | |
| 392 | + | }, | |
| 393 | + | "invites": [ | |
| 394 | + | { | |
| 395 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 396 | + | "code": "g1t-k7m2-q9xd-4hpw-…", | |
| 397 | + | "hint": "g1t-k7m2", | |
| 398 | + | "email": "ada@example.com", | |
| 399 | + | "kind": "account", | |
| 400 | + | "workspace": null, | |
| 401 | + | "status": "pending", | |
| 402 | + | "charged_to": "user", | |
| 403 | + | "invited_by": "syntaqx", | |
| 404 | + | "redeemed_by": null, | |
| 405 | + | "created_at": "2026-10-05T17:00:00.000Z", | |
| 406 | + | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 407 | + | "redeemed_at": null, | |
| 408 | + | "revoked_at": null, | |
| 409 | + | "invitee": null, | |
| 410 | + | "role": null | |
| 411 | + | }, | |
| 412 | + | { | |
| 413 | + | "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n", | |
| 414 | + | "code": null, | |
| 415 | + | "hint": "g1t-p3rt", | |
| 416 | + | "email": "grace@example.com", | |
| 417 | + | "kind": "account", | |
| 418 | + | "workspace": null, | |
| 419 | + | "status": "redeemed", | |
| 420 | + | "charged_to": "user", | |
| 421 | + | "invited_by": "syntaqx", | |
| 422 | + | "redeemed_by": "grace", | |
| 423 | + | "created_at": "2026-10-02T09:12:40.000Z", | |
| 424 | + | "expires_at": "2026-11-01T09:12:40.000Z", | |
| 425 | + | "redeemed_at": "2026-10-02T11:30:05.000Z", | |
| 426 | + | "revoked_at": null, | |
| 427 | + | "invitee": "grace", | |
| 428 | + | "role": null | |
| 429 | + | } | |
| 430 | + | ] | |
| 431 | + | }, | |
| 432 | + | "notes": "Anyone can sign up for g1t without an invite. A revoked or expired invite that was never used is not counted, so it comes back; a used one stays spent. `status` is `pending`, `awaiting_confirmation`, `awaiting_answer` (the account it made has yet to accept or decline the workspace in `workspace`), `redeemed` (they joined, as `redeemed_by`), `declined`, `expired` or `revoked`. See [Invite a friend](/guides/authentication/#invite-a-friend)." | |
| 433 | + | }, | |
| 434 | + | "create_invite": { | |
| 435 | + | "request": { | |
| 436 | + | "email": "ada@example.com", | |
| 437 | + | "message": "This is where we keep the compiler now. Come and look." | |
| 438 | + | }, | |
| 439 | + | "response": { | |
| 440 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 441 | + | "code": "g1t-k7m2-q9xd-4hpw-…", | |
| 442 | + | "hint": "g1t-k7m2", | |
| 443 | + | "email": "ada@example.com", | |
| 444 | + | "kind": "account", | |
| 445 | + | "workspace": null, | |
| 446 | + | "status": "pending", | |
| 447 | + | "charged_to": "user", | |
| 448 | + | "invited_by": "syntaqx", | |
| 449 | + | "redeemed_by": null, | |
| 450 | + | "created_at": "2026-10-05T17:00:00.000Z", | |
| 451 | + | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 452 | + | "redeemed_at": null, | |
| 453 | + | "revoked_at": null, | |
| 454 | + | "invitee": null, | |
| 455 | + | "role": null | |
| 456 | + | }, | |
| 457 | + | "notes": "The code is returned in full; the email g1t sends carries a link to `https://g1t.sh/invite/<code>` that opens sign-up with the address filled in and confirmed. Add `workspace` (and `role`) to also invite them to a workspace you own on a paid plan: once their account is confirmed they accept or decline it, and no workspace of their own is made. A free workspace is refused with `402` (`payment_required`). Refused with `409` when the address already has a g1t account or you already have a pending invite for it, neither of which uses an invite; with `402` (`limit`) when you have none left; and with `403` for an agent's or a workspace's token, or before your own address is confirmed." | |
| 458 | + | }, | |
| 459 | + | "revoke_invite": { | |
| 460 | + | "response": { | |
| 461 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 462 | + | "code": null, | |
| 463 | + | "hint": "g1t-k7m2", | |
| 464 | + | "email": "ada@example.com", | |
| 465 | + | "kind": "account", | |
| 466 | + | "workspace": null, | |
| 467 | + | "status": "revoked", | |
| 468 | + | "charged_to": "user", | |
| 469 | + | "invited_by": "syntaqx", | |
| 470 | + | "redeemed_by": null, | |
| 471 | + | "created_at": "2026-10-05T17:00:00.000Z", | |
| 472 | + | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 473 | + | "redeemed_at": null, | |
| 474 | + | "revoked_at": "2026-10-06T08:00:00.000Z", | |
| 475 | + | "invitee": null, | |
| 476 | + | "role": null | |
| 477 | + | }, | |
| 478 | + | "notes": "The invite comes back to you. One already used to make an account cannot be revoked and stays spent." | |
| 479 | + | }, | |
| 480 | + | "resend_invite": { | |
| 481 | + | "response": { | |
| 482 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 483 | + | "code": "g1t-k7m2-q9xd-4hpw-…", | |
| 484 | + | "hint": "g1t-k7m2", | |
| 485 | + | "email": "ada@example.com", | |
| 486 | + | "kind": "account", | |
| 487 | + | "workspace": null, | |
| 488 | + | "status": "pending", | |
| 489 | + | "charged_to": "user", | |
| 490 | + | "invited_by": "syntaqx", | |
| 491 | + | "redeemed_by": null, | |
| 492 | + | "created_at": "2026-10-05T17:00:00.000Z", | |
| 493 | + | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 494 | + | "redeemed_at": null, | |
| 495 | + | "revoked_at": null, | |
| 496 | + | "invitee": null, | |
| 497 | + | "role": null | |
| 498 | + | }, | |
| 499 | + | "notes": "Sends the same email again, with the same link; when it stops working does not change. Refused with `409` unless it is pending." | |
| 500 | + | }, | |
| 386 | 501 | "list_workspace_invites": { | |
| 387 | 502 | "response": [ | |
| 388 | 503 | { | |
| ⋯ | |||
| 393 | 508 | "kind": "account", | |
| 394 | 509 | "workspace": "acme-labs", | |
| 395 | 510 | "status": "pending", | |
| 511 | + | "charged_to": "none", | |
| 396 | 512 | "invited_by": "syntaqx", | |
| 397 | 513 | "redeemed_by": null, | |
| 398 | 514 | "created_at": "2026-10-05T17:00:00.000Z", | |
| ⋯ | |||
| 410 | 526 | "kind": "workspace", | |
| 411 | 527 | "workspace": "acme-labs", | |
| 412 | 528 | "status": "pending", | |
| 529 | + | "charged_to": "none", | |
| 413 | 530 | "invited_by": "syntaqx", | |
| 414 | 531 | "redeemed_by": null, | |
| 415 | 532 | "created_at": "2026-10-05T17:00:00.000Z", | |
| ⋯ | |||
| 434 | 551 | "kind": "workspace", | |
| 435 | 552 | "workspace": "acme-labs", | |
| 436 | 553 | "status": "pending", | |
| 554 | + | "charged_to": "none", | |
| 437 | 555 | "invited_by": "syntaqx", | |
| 438 | 556 | "redeemed_by": null, | |
| 439 | 557 | "created_at": "2026-10-05T17:00:00.000Z", | |
| ⋯ | |||
| 454 | 572 | "kind": "account", | |
| 455 | 573 | "workspace": "acme-labs", | |
| 456 | 574 | "status": "revoked", | |
| 575 | + | "charged_to": "none", | |
| 457 | 576 | "invited_by": "syntaqx", | |
| 458 | 577 | "redeemed_by": null, | |
| 459 | 578 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 216 | 216 | through::<g1t_contracts::accounts::AccountEmails>(op, sent) | |
| 217 | 217 | } | |
| 218 | 218 | Op::ConfirmEmail => through::<g1t_contracts::accounts::EmailConfirmed>(op, sent), | |
| 219 | − | Op::InviteMember | Op::RevokeWorkspaceInvite => { | |
| 219 | + | Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent), | |
| 220 | + | Op::CreateInvite | Op::RevokeInvite | Op::ResendInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => { | |
| 220 | 221 | through::<g1t_contracts::identity::Invite>(op, sent) | |
| 221 | 222 | } | |
| 222 | 223 | Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent), |
| 52 | 52 | route("POST", "/user/emails/confirm", Op::ConfirmEmail, &[]), | |
| 53 | 53 | route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]), | |
| 54 | 54 | route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]), | |
| 55 | + | // Your invites to g1t (Invite a friend). | |
| 56 | + | route("GET", "/user/invites", Op::ListInvites, &[]), | |
| 57 | + | route("POST", "/user/invites", Op::CreateInvite, &[]), | |
| 58 | + | route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]), | |
| 59 | + | route("POST", "/user/invites/:id/resend", Op::ResendInvite, &[]), | |
| 55 | 60 | // Invitations to workspaces waiting for your answer. | |
| 56 | 61 | route("GET", "/user/invitations", Op::ListInvitations, &[]), | |
| 57 | 62 | route("POST", "/user/invitations/:id/accept", Op::AcceptInvitation, &[]), |
| 533 | 533 | Tool { | |
| 534 | 534 | name: "account", | |
| 535 | 535 | title: "Your account", | |
| 536 | − | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, and invitations to workspaces and repositories waiting for you.", | |
| 536 | + | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites to g1t, and invitations to workspaces and repositories waiting for you.", | |
| 537 | 537 | default_action: Some("whoami"), | |
| 538 | 538 | actions: &[ | |
| 539 | 539 | a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"), | |
| ⋯ | |||
| 542 | 542 | a("confirm_email", Op::ConfirmEmail, "Confirm an address with the code from its email"), | |
| 543 | 543 | a("remove_email", Op::RemoveEmail, "Remove an address"), | |
| 544 | 544 | a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"), | |
| 545 | + | a("list_invites", Op::ListInvites, "Your invites to g1t, and how many are left"), | |
| 546 | + | a("create_invite", Op::CreateInvite, "Invite a friend to g1t by email"), | |
| 547 | + | a("revoke_invite", Op::RevokeInvite, "Revoke one; it comes back"), | |
| 548 | + | a("resend_invite", Op::ResendInvite, "Send a pending one again"), | |
| 545 | 549 | a("list_workspace_invitations", Op::ListInvitations, "Invitations to workspaces for you"), | |
| 546 | 550 | a("accept_workspace_invitation", Op::AcceptInvitation, "Accept one and join"), | |
| 547 | 551 | a("decline_workspace_invitation", Op::DeclineInvitation, "Decline one"), | |
| 216 | 216 | | Someone else on g1t | They are sent an [invitation](#invitations) to accept. Typing the confirmed email address of someone on g1t does the same. | | |
| 217 | 217 | | An email address with no g1t account | g1t emails an invite code that only that address can use. Signing up with it makes their account and accepts the invitation in one step. | | |
| 218 | 218 | ||
| 219 | − | An invite code to someone without an account uses one of the workspace's | |
| 220 | − | granted invites, or else one of yours (see | |
| 221 | − | [invites](/guides/authentication/#invites)), and works for 30 days. | |
| 219 | + | An invite code to someone without an account costs nothing (it never | |
| 220 | + | uses one of your [invites to g1t](/guides/authentication/#invite-a-friend)) | |
| 221 | + | and works for 30 days. | |
| 222 | 222 | ||
| 223 | 223 | On a free workspace, only the first row works: its members can be given a | |
| 224 | 224 | role, but nobody else can be invited until the workspace starts the g1t |
| 45 | 45 | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location, website and time zone. | | |
| 46 | 46 | | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). | | |
| 47 | 47 | | Notifications | [`/settings/notifications`](https://g1t.sh/settings/notifications) | [Sounds and do not disturb](/guides/chat/#sounds-and-do-not-disturb), [pop-ups and browser notifications](/guides/chat/#notifications), and [what you are emailed for and how you watch repositories](/guides/notifications/#settings). | | |
| 48 | + | | Invites to g1t | [`/settings/invites`](https://g1t.sh/settings/invites) | [Invite a friend](#invite-a-friend): the invites you sent, who joined, and how many you have left. | | |
| 48 | 49 | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. | | |
| 49 | 50 | | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): their permissions, where they reach, and when they expire. | | |
| 50 | 51 | | Integrations | [`/settings/integrations`](https://g1t.sh/settings/integrations) | [Your own integrations](/guides/integrations/#workspace-and-personal): what you have connected for yourself, in every workspace, and what is coming. | | |
| ⋯ | |||
| 63 | 64 | unlink GitHub in [Settings → GitHub](https://g1t.sh/settings/github). See | |
| 64 | 65 | [GitHub](/guides/github/#sign-in-with-github). | |
| 65 | 66 | ||
| 66 | − | Making an account with GitHub needs an invite too: start from your invite | |
| 67 | − | link, or enter the code when g1t asks for it after GitHub. | |
| 67 | + | Making an account with GitHub needs no invite. If a friend invited you, | |
| 68 | + | you can still start from the link in their email: when GitHub has | |
| 69 | + | confirmed the same address, the account is made with it. | |
| 68 | 70 | ||
| 69 | 71 | With [two-factor authentication](#two-factor-authentication) on, signing in | |
| 70 | 72 | with GitHub asks for a code from your app as well. | |
| ⋯ | |||
| 132 | 134 | Anyone can sign up without an invite. Invites are how people already on | |
| 133 | 135 | g1t bring someone in: | |
| 134 | 136 | ||
| 137 | + | - [Invite a friend](#invite-a-friend): anyone on g1t invites someone to | |
| 138 | + | g1t by email address, with no workspace attached; | |
| 135 | 139 | - an owner's [invitation into a workspace](#inviting-someone-into-a-workspace), | |
| 136 | 140 | by username or by email address; | |
| 137 | 141 | - an [invitation to collaborate on a repository](/guides/access-and-roles/), | |
| ⋯ | |||
| 197 | 201 | can ask them for a new one; or [sign up](https://g1t.sh/register) without | |
| 198 | 202 | it. | |
| 199 | 203 | ||
| 204 | + | ### Invite a friend | |
| 205 | + | ||
| 206 | + | Anyone on g1t can invite a friend, by email, from **Invite a friend** in | |
| 207 | + | the account menu at the bottom of the sidebar, or from | |
| 208 | + | [Settings → Invites to g1t](https://g1t.sh/settings/invites). It is word | |
| 209 | + | of mouth, not a key: your friend could sign up at | |
| 210 | + | [g1t.sh/register](https://g1t.sh/register) without it. What the invite | |
| 211 | + | adds: | |
| 212 | + | ||
| 213 | + | - an email from you, with your name and, if you write one, your message; | |
| 214 | + | - a link that opens sign-up with their address filled in and, because they | |
| 215 | + | came from that email, already confirmed (see | |
| 216 | + | [invites from your inbox](#invites-from-your-inbox)); | |
| 217 | + | - on your Invites to g1t page, the invite's status: **Pending**, | |
| 218 | + | **@ada is confirming their email**, then **Joined as @ada**. | |
| 219 | + | ||
| 220 | + | Their account starts with a workspace of its own, on the free plan, the | |
| 221 | + | same as any other. An invite to g1t adds them to no workspace of yours. | |
| 222 | + | ||
| 223 | + | Each person has **20 invites to g1t**, for good. A pending invite counts | |
| 224 | + | until it is used, revoked or expires (after 30 days); revoking one that | |
| 225 | + | was never used, or letting it expire, gives it back. One that was used | |
| 226 | + | to make an account stays spent. The page shows how many you have left, | |
| 227 | + | and the account menu shows the same number beside **Invite a friend**. If | |
| 228 | + | you run out, your friends can still sign up without one; g1t staff can | |
| 229 | + | grant more. | |
| 230 | + | ||
| 231 | + | Some invites are refused, and cost nothing: | |
| 232 | + | ||
| 233 | + | - to an address that already has a g1t account: to work with them, | |
| 234 | + | [invite them into a workspace](#inviting-someone-into-a-workspace) by | |
| 235 | + | username instead; | |
| 236 | + | - to an address you already have a pending invite for: send that one | |
| 237 | + | again from its **⋯** menu, or revoke it first. | |
| 238 | + | ||
| 239 | + | From the **⋯** menu beside each pending invite you can copy its link, | |
| 240 | + | send the email again, or revoke it. Only people can invite friends, once | |
| 241 | + | their own address is confirmed: an agent's or a workspace's token cannot. | |
| 242 | + | ||
| 243 | + | **Also invite them to a workspace** appears in the dialog only when you | |
| 244 | + | own a workspace on a paid plan. Ticked, your friend also gets a | |
| 245 | + | [workspace invitation](#workspace-invitations) to that workspace, with | |
| 246 | + | the role you choose, to accept or decline once their account is | |
| 247 | + | confirmed; they then get no workspace of their own. A workspace on the | |
| 248 | + | free plan cannot invite anyone, so it is never offered. An invitation | |
| 249 | + | into a workspace never uses one of your 20 invites. | |
| 250 | + | ||
| 251 | + | On a self-hosted installation, identity's `INVITES_PER_USER` sets the | |
| 252 | + | number (20 when unset; 0 turns Invite a friend off). | |
| 253 | + | ||
| 200 | 254 | ### Invites from your inbox | |
| 201 | 255 | ||
| 202 | − | When g1t emails an invite to an address (an owner's invitation into a | |
| 203 | − | workspace, or an invitation onto a repository), the link in that email carries a | |
| 256 | + | When g1t emails an invite to an address (a friend's invite to g1t, an | |
| 257 | + | owner's invitation into a workspace, or an invitation onto a repository), | |
| 258 | + | the link in that email carries a | |
| 204 | 259 | `proof` that only the email has: `g1t.sh/invite/<code>?proof=…`. Opening | |
| 205 | 260 | the link shows that you can read that inbox, so: | |
| 206 | 261 | ||
| ⋯ | |||
| 252 | 307 | - An account whose invite brings it into a workspace gets the invitation | |
| 253 | 308 | to it, and no workspace of its own. | |
| 254 | 309 | - Every other account (signed up with a password or with GitHub, or with | |
| 255 | − | an invite that names no workspace, such as one onto a repository) gets a | |
| 310 | + | an invite that names no workspace, such as a friend's invite to g1t or | |
| 311 | + | one onto a repository) gets a | |
| 256 | 312 | workspace of its own, named for its username, on the free plan. Rename it or start the plan on it whenever you like. | |
| 257 | 313 | ||
| 258 | 314 | Signed in without any workspace (you declined an invitation, or left the | |
| ⋯ | |||
| 265 | 321 | ||
| 266 | 322 | | Route | MCP tool and action | What it does | | |
| 267 | 323 | | --- | --- | --- | | |
| 324 | + | | [`GET /user/invites`](/reference/api/invites/list-invites/) | `account` `list_invites` | Your invites to g1t, newest first, and `allowance` (`limit`, `used`, `remaining`) | | |
| 325 | + | | [`POST /user/invites`](/reference/api/invites/create-invite/) | `account` `create_invite` | Invite a friend to g1t by `email`, with an optional `message`; optionally also to a paid `workspace` you own, as `role` | | |
| 326 | + | | [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | `account` `revoke_invite` | Revoke a pending invite; it comes back to you | | |
| 327 | + | | [`POST /user/invites/{id}/resend`](/reference/api/invites/resend-invite/) | `account` `resend_invite` | Send a pending invite's email again | | |
| 268 | 328 | | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | `workspace` `invite_member` | Invite a `username` or an `email` into a workspace, with a `role`. Owners only. | | |
| 269 | 329 | | [`GET /user/invitations`](/reference/api/invites/list-invitations/) | `account` `list_workspace_invitations` | The workspace invitations waiting for your answer | | |
| 270 | 330 | | [`POST /user/invitations/{id}/accept`](/reference/api/invites/accept-invitation/) | `account` `accept_workspace_invitation` | Join the invitation's workspace with its role | | |
| 168 | 168 | | `AWS_CLI_IMAGE` | `amazon/aws-cli:2.37.10` | The image `storage-setup` makes the buckets and the packs' lifecycle rule with. | | |
| 169 | 169 | | `BACKUP_S3_BUCKET` | `g1t-backups` | The bucket on the same store that nightly repository backups (a `git bundle` of each repository whose branches or tags changed) are kept in. The bundles are cut by g1t's runner, which this installation does not run yet, so the bucket stays empty for now: copy the volumes, as below. | | |
| 170 | 170 | | `STATUS_PORT` | `8788` | The port the status page is published on | | |
| 171 | + | | `INVITES_PER_USER` | `20` | How many friends each person may invite by email with [Invite a friend](/guides/authentication/#invite-a-friend). `0` turns it off. Anyone may register with or without an invite. | | |
| 171 | 172 | | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. | | |
| 172 | 173 | | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET` | (none) | A [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) widget's site key and secret key, to check that a person fills in the sign-up form. With either unset, sign-up has no such check. | | |
| 173 | 174 | | `PHONE_PUSH` | `on` | `off`: send [the phone app](#the-phone-app) no notifications, so their words never pass through Expo's push service. Browser and desktop notifications are not affected. | |
| 368 | 368 | ||
| 369 | 369 | Anyone can make a g1t account without an invite, so to bring someone in | |
| 370 | 370 | you invite them to the workspace; someone without an account signs up from | |
| 371 | − | the invitation. | |
| 371 | + | the invitation. To tell a friend about g1t without adding them to a | |
| 372 | + | workspace, use [Invite a friend](/guides/authentication/#invite-a-friend) | |
| 373 | + | in your account menu instead. | |
| 372 | 374 | ||
| 373 | 375 | On the workspace's **Members** page, `g1t.sh/<workspace>/-/members` | |
| 374 | 376 | (**Workspace → Access → Members** in the sidebar), or on **Invitations** |
| 230 | 230 | | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. | | |
| 231 | 231 | | [Notifications](/reference/api/notifications/list-notifications/) | Your notifications: their threads, why you were told of each, marking them read, done, saved or snoozed, and what you subscribe to and watch. See [notifications](/guides/notifications/). | | |
| 232 | 232 | | [Billing](/reference/api/billing/get-usage/) | A workspace's usage by product, project and day, its budget and its invoices. See [usage and billing](/guides/usage-and-billing/). | | |
| 233 | − | | [Invites](/reference/api/invites/list-workspace-invites/) | Inviting people into a workspace by username or email, and answering the invitations sent to you. | | |
| 233 | + | | [Invites](/reference/api/invites/list-invites/) | Inviting a friend to g1t by email, inviting people into a workspace by username or email, and answering the invitations sent to you. Anyone can sign up without an invite. See [invite a friend](/guides/authentication/#invite-a-friend). | | |
| 234 | 234 | | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. | | |
| 235 | 235 | | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. | | |
| 236 | 236 | | [Secret scanning](/reference/api/secret-scanning/list-secret-scanning-alerts/) | Secrets found in pushes and history, where each one is, bypassing push protection and reviewing bypass requests, validity checks, and custom patterns. See [secret protection](/guides/security/secret-protection/). | |
| 773 | 773 | ||
| 774 | 774 | ## `account` | |
| 775 | 775 | ||
| 776 | − | Who the token acts as and its workspaces, your email addresses, and | |
| 776 | + | Who the token acts as and its workspaces, your email addresses, your | |
| 777 | + | [invites to g1t](/guides/authentication/#invite-a-friend), and | |
| 777 | 778 | [invitations](/guides/authentication/#invites) to workspaces and | |
| 778 | 779 | repositories waiting for you. `whoami` is the default | |
| 779 | 780 | action, and needs no scope. A workspace's token cannot | |
| 780 | − | use the email and invitation actions. An account that has not confirmed its | |
| 781 | + | use the email, invite and invitation actions. An account that has not confirmed its | |
| 781 | 782 | email address gets `403` from every tool until it does; see | |
| 782 | 783 | [until you confirm](/guides/authentication/#until-you-confirm). | |
| 783 | 784 | ||
| ⋯ | |||
| 789 | 790 | | [`confirm_email`](/reference/api/accounts/confirm-email/) | Confirm an address with the six-digit code from its email. See [confirming your email address](/guides/authentication/#confirming-your-email-address). | `code` | `account:write` | | |
| 790 | 791 | | [`remove_email`](/reference/api/accounts/remove-email/) | Remove an address; never the primary or the last confirmed one. | `email`, `password` | `account:write` | | |
| 791 | 792 | | [`update_email_settings`](/reference/api/accounts/update-email-settings/) | Change `primary` or `backup` (with `password`), `private_email` or `block_private_pushes`. See [email addresses](/guides/authentication/#email-addresses). | None | `account:write` | | |
| 793 | + | | [`list_invites`](/reference/api/invites/list-invites/) | Your invites to g1t, with who joined, and how many of your 20 are left. | None | `account:read` | | |
| 794 | + | | [`create_invite`](/reference/api/invites/create-invite/) | Invite a friend to g1t by email, with an optional `message`. With `workspace` (one you own on a paid plan) and `role`, also invite them to it. Uses one of your invites; refused with `409` when the address has an account or you already have a pending invite for it. | `email` | `account:write` | | |
| 795 | + | | [`revoke_invite`](/reference/api/invites/revoke-invite/) | Revoke a pending invite to g1t; it comes back to you. | `id` | `account:write` | | |
| 796 | + | | [`resend_invite`](/reference/api/invites/resend-invite/) | Send a pending invite to g1t again. | `id` | `account:write` | | |
| 792 | 797 | | [`list_workspace_invitations`](/reference/api/invites/list-invitations/) | The invitations to workspaces waiting for your answer, each with its `workspace`, the `role` it gives and who sent it. | None | `account:read` | | |
| 793 | 798 | | [`accept_workspace_invitation`](/reference/api/invites/accept-invitation/) | Accept one; you join the workspace at once with its role. | `id` | `account:write` | | |
| 794 | 799 | | [`decline_workspace_invitation`](/reference/api/invites/decline-invitation/) | Decline one; whoever sent it is told. | `id` | `account:write` | | |
Binary or large file; its contents are not shown.
| 149 | 149 | (`account_restored`, `account_purged`), naming the staff member. There | |
| 150 | 150 | is no API route for deleting an account; only the site and sudo can. | |
| 151 | 151 | - **Invites** (`/invites`): anyone may register on g1t.sh, so there is | |
| 152 | − | no waitlist, allowance or shared link to manage. The page lists every | |
| 153 | − | invite code sent with an invitation into a workspace or onto a | |
| 154 | − | repository, searchable by a code's start, an email or a username, and | |
| 155 | − | revokes a pending one (`admin_invites`, `admin_revoke_invite`). The | |
| 156 | − | old waitlist, grants and shared links stay in identity's database, | |
| 157 | − | untouched. | |
| 152 | + | no waitlist or shared link to manage, and an invite is never needed. | |
| 153 | + | The page lists every invite code: those sent with an invitation into a | |
| 154 | + | workspace or onto a repository, and invites to g1t that people send | |
| 155 | + | their friends with Invite a friend (20 each), on their own tab. | |
| 156 | + | Searchable by a code's start, an email or a username; a pending one can | |
| 157 | + | be revoked (`admin_invites`, `admin_revoke_invite`). The Invites to g1t | |
| 158 | + | tab also grants a person more of them, or takes some back, with why | |
| 159 | + | (`admin_grant_invites`, kept in `invite_grants`). The old waitlist and | |
| 160 | + | shared links stay in identity's database, untouched. | |
| 158 | 161 | - **Aliases** (`/aliases`, under Customers): names that lead to a | |
| 159 | 162 | workspace, set by staff only; there is no way for a customer to make | |
| 160 | 163 | one, and nothing user-facing mentions them. `g1t`, the product's name, |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { INVITES_DONE, doneMessage, invitesHref, joinedThrough } from "./invites.ts"; | |
| 4 | + | import { INVITES_DONE, MAX_GRANT, doneMessage, invitesHref, invitesTab, joinedThrough, parseGrant } from "./invites.ts"; | |
| 5 | 5 | ||
| 6 | − | test("the page has one address, with its search", () => { | |
| 6 | + | test("the page has one address, with its search and tab", () => { | |
| 7 | 7 | assert.equal(invitesHref(), "/invites"); | |
| 8 | 8 | assert.equal(invitesHref({ q: "g1t-k7m2", empty: "" }), "/invites?q=g1t-k7m2"); | |
| 9 | + | assert.equal(invitesHref({ tab: "g1t" }), "/invites?tab=g1t"); | |
| 10 | + | assert.equal(invitesTab("g1t"), "g1t"); | |
| 11 | + | assert.equal(invitesTab("waitlist"), "all"); | |
| 12 | + | assert.equal(invitesTab(null), "all"); | |
| 9 | 13 | }); | |
| 10 | 14 | ||
| 11 | − | test("revoking says so, and nothing else is flashed", () => { | |
| 15 | + | test("revoking and granting say so, and nothing else is flashed", () => { | |
| 12 | 16 | assert.equal(doneMessage("revoked"), INVITES_DONE.revoked); | |
| 17 | + | assert.equal(doneMessage("granted"), INVITES_DONE.granted); | |
| 13 | 18 | assert.equal(doneMessage("approved"), null); | |
| 14 | 19 | assert.equal(doneMessage(null), null); | |
| 15 | 20 | }); | |
| ⋯ | |||
| 17 | 22 | test("an account made with a shared link says where it came from", () => { | |
| 18 | 23 | assert.equal(joinedThrough("Cloudflare judges"), "Joined through Cloudflare judges"); | |
| 19 | 24 | }); | |
| 25 | + | ||
| 26 | + | test("a grant names a person, a whole number of invites and why", () => { | |
| 27 | + | const form = (fields: Record<string, string>) => new Map(Object.entries(fields)); | |
| 28 | + | assert.deepEqual(parseGrant(form({ username: "@Ada", amount: "10", note: "Bringing her team" })), { | |
| 29 | + | ok: true, | |
| 30 | + | value: { username: "ada", amount: 10, note: "Bringing her team" }, | |
| 31 | + | }); | |
| 32 | + | assert.equal(parseGrant(form({ username: "ada", amount: "-5", note: "Too many" })).ok, true); | |
| 33 | + | assert.equal(parseGrant(form({ username: "not a name", amount: "10", note: "why" })).ok, false); | |
| 34 | + | assert.equal(parseGrant(form({ username: "ada", amount: "0", note: "why" })).ok, false); | |
| 35 | + | assert.equal(parseGrant(form({ username: "ada", amount: String(MAX_GRANT + 1), note: "why" })).ok, false); | |
| 36 | + | assert.equal(parseGrant(form({ username: "ada", amount: "2.5", note: "why" })).ok, false); | |
| 37 | + | assert.equal(parseGrant(form({ username: "ada", amount: "10", note: "" })).ok, false); | |
| 38 | + | }); | |
| 1 | 1 | /** | |
| 2 | − | * The Invites page: every invitation sent to an address, to find and | |
| 3 | − | * revoke one. Anyone may register, so there is no waitlist, no allowance | |
| 4 | − | * and no shared link to manage. No Workers imports, so it can be tested | |
| 5 | − | * under Node. Identity checks everything again. | |
| 2 | + | * The Invites page: every invite, to find and revoke one, with invites to | |
| 3 | + | * g1t from Invite a friend on their own tab, and more of those for a | |
| 4 | + | * person. Anyone may register, so there is no waitlist and no shared link | |
| 5 | + | * to manage; an invite is never needed to sign up. No Workers imports, so | |
| 6 | + | * it can be tested under Node. Identity checks everything again. | |
| 6 | 7 | */ | |
| 7 | 8 | ||
| 9 | + | /** The page's tabs: every invite, or only invites to g1t (Invite a friend). */ | |
| 10 | + | export type InvitesTab = "all" | "g1t"; | |
| 11 | + | ||
| 12 | + | export function invitesTab(raw: string | null | undefined): InvitesTab { | |
| 13 | + | return raw === "g1t" ? "g1t" : "all"; | |
| 14 | + | } | |
| 15 | + | ||
| 8 | 16 | /** The page's address, with its search. */ | |
| 9 | 17 | export function invitesHref(params: Record<string, string | null | undefined> = {}): string { | |
| 10 | 18 | const search = new URLSearchParams(); | |
| ⋯ | |||
| 16 | 24 | /** The flash messages the page redirects back with (`?done=`). */ | |
| 17 | 25 | export const INVITES_DONE: Record<string, string> = { | |
| 18 | 26 | revoked: "Invite revoked. It stops working at once.", | |
| 27 | + | granted: "Granted. It applies at once.", | |
| 19 | 28 | }; | |
| 20 | 29 | ||
| 21 | 30 | /** The flash for `?done=`. */ | |
| ⋯ | |||
| 27 | 36 | export function joinedThrough(label: string): string { | |
| 28 | 37 | return `Joined through ${label}`; | |
| 29 | 38 | } | |
| 39 | + | ||
| 40 | + | /** The most invites one grant gives or takes back (`MAX_INVITE_GRANT` in the contracts). */ | |
| 41 | + | export const MAX_GRANT = 1000; | |
| 42 | + | ||
| 43 | + | const USERNAME = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 44 | + | ||
| 45 | + | export type Grant = { username: string; amount: number; note: string }; | |
| 46 | + | ||
| 47 | + | /** A grant of invites to g1t, as typed: who, how many (negative takes back), and why. */ | |
| 48 | + | export function parseGrant(form: { get(name: string): unknown }): { ok: true; value: Grant } | { ok: false; error: string } { | |
| 49 | + | const read = (name: string) => { | |
| 50 | + | const value = form.get(name); | |
| 51 | + | return typeof value === "string" ? value.trim() : ""; | |
| 52 | + | }; | |
| 53 | + | const username = read("username").replace(/^@/, "").toLowerCase(); | |
| 54 | + | if (!USERNAME.test(username)) return { ok: false, error: `“${read("username")}” is not a username.` }; | |
| 55 | + | const raw = read("amount"); | |
| 56 | + | if (!/^-?\d+$/.test(raw)) return { ok: false, error: "Give a whole number of invites, such as 10, or -5 to take some back." }; | |
| 57 | + | const amount = Number(raw); | |
| 58 | + | if (amount === 0 || Math.abs(amount) > MAX_GRANT) return { ok: false, error: `Grant between 1 and ${MAX_GRANT} invites.` }; | |
| 59 | + | const note = read("note"); | |
| 60 | + | if (note.length < 3) return { ok: false, error: "Say why, for the record." }; | |
| 61 | + | return { ok: true, value: { username, amount, note: note.slice(0, 500) } }; | |
| 62 | + | } | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…