From 9e07f49aa8ce320b06bfd6d82b172da660343197 Mon Sep 17 00:00:00 2001 From: Joseph Kong Date: Tue, 13 Oct 2026 00:07:54 +0800 Subject: [PATCH] GitLab projects can be floors, with merge request boards A GitLab project (gitlab.com or a company's own, through the glab CLI) is now a floor like a GitHub repo: the elevator lists and clones it, its issues and merge requests hang on the boards, MR windows show pipeline jobs, line threads and the diff, and merging, commenting, closing, labels, claiming issues and opening an MR from a desk all work. Workers' prompts and the office's wording use glab, MR and !12 on a GitLab floor. With accounts, everyone can sign in to their own GitLab with a token. A checkout whose origin is on another host (GitHub Enterprise) stays with gh, as before: only a host named gitlab or one glab is signed in to counts as a GitLab. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 26 +- docs/configuration.md | 8 +- docs/features.md | 8 +- docs/gitlab.md | 54 ++++ src/client/features/boards/index.ts | 3 +- src/client/features/boards/world.ts | 7 +- src/client/features/bookshelf/index.ts | 11 +- src/client/features/bookshelf/ui.ts | 10 +- src/client/features/gong/index.ts | 3 +- src/client/features/hud/index.ts | 3 +- src/client/features/palette/index.ts | 10 +- src/client/features/workers/actions.ts | 16 +- src/client/features/workers/views.ts | 11 +- src/client/lite.ts | 5 +- src/client/ui/boards.ts | 26 +- src/client/ui/changes.ts | 20 +- src/client/ui/elevator.ts | 22 +- src/client/ui/forge.ts | 16 + src/client/ui/github/close.ts | 13 +- src/client/ui/github/comment-box.ts | 8 +- src/client/ui/github/issue-window.ts | 5 +- src/client/ui/github/labels.ts | 17 +- src/client/ui/github/merge.ts | 15 +- src/client/ui/github/pieces.ts | 3 +- src/client/ui/github/prompts.ts | 6 +- src/client/ui/github/pull-window.ts | 27 +- src/client/ui/help.ts | 13 +- src/client/ui/markdown.ts | 41 ++- src/client/ui/meeting.ts | 19 +- src/client/ui/palette.ts | 3 +- src/client/ui/prompt.ts | 9 +- src/client/ui/prompts.ts | 7 +- src/client/ui/queue.ts | 7 +- src/client/ui/repos.ts | 27 +- src/client/ui/settings.ts | 13 +- src/client/ui/signins.ts | 47 ++- src/client/ui/workers-panel.ts | 3 +- src/client/world/character/worker-badges.ts | 7 +- src/client/world/character/worker.ts | 8 +- src/server/building.ts | 98 ++---- src/server/changes.ts | 11 +- src/server/clone.ts | 18 +- src/server/floor.ts | 71 ++-- src/server/forge.ts | 148 +++++++++ src/server/github.ts | 4 +- src/server/gitlab/api.ts | 268 +++++++++++++++ src/server/gitlab/boards.ts | 342 ++++++++++++++++++++ src/server/gitlab/repos.ts | 87 +++++ src/server/hooks/office-workers.ts | 27 +- src/server/http/routes/github.ts | 6 +- src/server/meetings.ts | 8 +- src/server/office-workers.ts | 9 +- src/server/office/context.ts | 5 +- src/server/office/floors.ts | 2 +- src/server/office/gates.ts | 21 +- src/server/office/services.ts | 3 +- src/server/prompts.ts | 14 +- src/server/providers/claude.ts | 2 +- src/server/repos.ts | 55 ++++ src/server/setup.ts | 19 +- src/server/signins-gitlab.ts | 62 ++++ src/server/signins-run.ts | 63 ++++ src/server/signins.ts | 153 ++++----- src/server/workers/pr.ts | 61 ++-- src/server/workers/worktree.ts | 2 +- src/server/ws/handlers/changes.ts | 4 +- src/server/ws/handlers/floors.ts | 4 +- src/server/ws/handlers/github.ts | 45 +-- src/server/ws/handlers/signins.ts | 7 +- src/server/ws/handlers/workers.ts | 20 +- src/shared/actions.ts | 2 +- src/shared/floors.ts | 74 ++++- src/shared/prompts-gitlab.ts | 63 ++++ src/shared/prompts.ts | 25 +- src/shared/protocol/accounts.ts | 17 +- src/shared/protocol/floors.ts | 7 +- tests/gitlab.test.ts | 206 ++++++++++++ tests/office-workers.test.ts | 8 + tests/prompts.test.ts | 22 +- 79 files changed, 2113 insertions(+), 507 deletions(-) create mode 100644 docs/gitlab.md create mode 100644 src/client/ui/forge.ts create mode 100644 src/server/forge.ts create mode 100644 src/server/gitlab/api.ts create mode 100644 src/server/gitlab/boards.ts create mode 100644 src/server/gitlab/repos.ts create mode 100644 src/server/repos.ts create mode 100644 src/server/signins-gitlab.ts create mode 100644 src/server/signins-run.ts create mode 100644 src/shared/prompts-gitlab.ts create mode 100644 tests/gitlab.test.ts diff --git a/README.md b/README.md index fac9441a5..5643833dc 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ **A 3D office your team shares with its coding agents.** Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, -and jump into any of them together. Every GitHub repo is a floor of the building. +and jump into any of them together. Every GitHub repo (or GitLab project) is a floor of the building. [![Release](https://img.shields.io/github/v/release/AgentSystemLabs/agent-office?style=flat-square&color=e8c547&label=release)](https://github.com/AgentSystemLabs/agent-office/releases) [![Build](https://img.shields.io/github/actions/workflow/status/AgentSystemLabs/agent-office/release.yml?style=flat-square&label=build)](https://github.com/AgentSystemLabs/agent-office/actions) @@ -20,7 +20,7 @@ and jump into any of them together. Every GitHub repo is a floor of the building [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square)](#run-locally) [![Built with TypeScript](https://img.shields.io/badge/built%20with-TypeScript-3178c6?style=flat-square)](https://www.typescriptlang.org) -[**Run locally**](#run-locally) · [**Deploy to AWS**](#deploy-to-aws-ec2) · [**Azure**](#deploy-to-azure) · [**Railway**](#deploy-to-railway) · [**Fly.io**](#deploy-to-flyio) · [**Dokploy**](#deploy-to-dokploy) · [**Coolify**](#deploy-to-coolify) · [**Any server**](#deploy-to-any-ubuntu-or-debian-server) · [**Add users**](#add-users) · [**Controls**](#controls) · [**Features**](docs/features.md) · [**How it works**](docs/how-it-works.md) +[**Run locally**](#run-locally) · [**GitLab**](#gitlab) · [**Deploy to AWS**](#deploy-to-aws-ec2) · [**Azure**](#deploy-to-azure) · [**Railway**](#deploy-to-railway) · [**Fly.io**](#deploy-to-flyio) · [**Dokploy**](#deploy-to-dokploy) · [**Coolify**](#deploy-to-coolify) · [**Any server**](#deploy-to-any-ubuntu-or-debian-server) · [**Add users**](#add-users) · [**Controls**](#controls) · [**Features**](docs/features.md) · [**How it works**](docs/how-it-works.md) ```sh curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/install.sh | bash @@ -32,12 +32,12 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is -- **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. +- **A floor per project.** Ride the elevator, pick one of your GitHub repos or GitLab projects, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. - **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎤**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install. - **You can't miss who needs you.** A worker that stops to ask you something lights a red beacon over its desk, puts a banner on your screen saying who and what for, and sounds an alarm. One that has finished jumps up and down and dings. Press **N** to go straight to whoever is waiting. - **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its terminal with the keys a phone keyboard lacks, and the boards. The 3D office offers it on a phone or a slow computer. -- **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. +- **GitHub or GitLab on the walls.** Issues and pull requests (merge requests on a GitLab floor) hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. @@ -52,6 +52,7 @@ On the machine that runs the office: - **Node.js 20+** - At least one agent CLI, signed in as the user that runs the office: **Claude Code** (`claude`), **Codex** (`codex`), **OpenCode** (`opencode`), **Grok** (`grok`), **Muse** (`muse`), **DeepSeek Harness** (`dsh`), **Pi** (`pi`, 0.87.1+) or the **Cursor** CLI (`cursor-agent`). With [accounts](#add-users), everyone can sign in to their own Claude from the office instead. - **git**, and the **GitHub CLI** (`gh auth login`) for cloning repos and the issue and PR boards +- For GitLab projects (gitlab.com or your company's own), the **GitLab CLI** (`glab auth login --hostname `): see [GitLab](#gitlab) ## Run locally @@ -72,8 +73,8 @@ This puts an `agent-office` command on your PATH, so next time just run `agent-o The first time it starts, it walks you through setting up, right in the terminal: 1. **Where to clone your projects.** It suggests a code folder you already have (`~/Workspace`, `~/code`…), else `~/agent-office`. Each project goes in `//`. -2. **GitHub.** If the GitHub CLI isn't signed in, it offers to run `gh auth login` for you. -3. **Your first project.** Pick one of your repos by number, or type `owner/name`, and the office clones it as the first floor. +2. **GitHub.** If the GitHub CLI isn't signed in, it offers to run `gh auth login` for you. If `glab` is signed in to a GitLab instead, it carries on with that. +3. **Your first project.** Pick one of your repos by number, or type `owner/name` (or `host/group/project` for a GitLab), and the office clones it as the first floor. Press Enter to skip a step: the elevator in the office asks for your first project too. Then the office opens in your browser, **already signed in**, with a link that works once. The terminal also prints the office password, for signing in from another browser (it's saved in `~/agent-office/.agent-office/config.json`). @@ -103,6 +104,16 @@ agent-office > Only your computer can reach the office: it listens on `127.0.0.1`. `--host 0.0.0.0` lets your network in, but over plain http, where voice and screen sharing don't work. To share the office with a team, put it on a server: [AWS](#deploy-to-aws-ec2), [Azure](#deploy-to-azure), [Railway](#deploy-to-railway), [Fly.io](#deploy-to-flyio), [Dokploy](#deploy-to-dokploy), [Coolify](#deploy-to-coolify) or [any Ubuntu or Debian machine](#deploy-to-any-ubuntu-or-debian-server). +## GitLab + +A GitLab project can be a floor too, on gitlab.com or your company's own GitLab (one behind a VPN works). Install the [GitLab CLI](https://gitlab.com/gitlab-org/cli#installation) on the office's machine and sign it in with a token that has the `api` and `write_repository` scopes: + +```bash +glab auth login --hostname gitlab.example.com +``` + +The elevator then lists that GitLab's projects next to your GitHub repos, or you can type `gitlab.example.com/group/project` (or paste its URL). A GitLab floor has everything a GitHub one has, in GitLab's words: an issues board and a **merge requests** board, MR windows with their pipeline jobs, line threads and diff, merge, comment, close and labels, opening an MR from a desk with **O**, and worker prompts that use `glab`. With accounts, everyone can sign in to their own GitLab in **🔐 Your sign-ins**. A checkout you already have becomes a floor when you start the office in it, or when you add it to `floors.json`. All of it is in [docs/gitlab.md](docs/gitlab.md). + ## Deploy to AWS (EC2) One script, using only the AWS CLI. You need the **AWS CLI signed in** (`aws configure` or `aws sso login`), `ssh`, `curl` and a clone of this repo: @@ -354,7 +365,7 @@ deploy/dokploy.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invit deploy/coolify.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invite ada' # on Coolify ``` -**Their own Claude and GitHub.** With accounts, everyone's workers run on their own Claude plan, and the office acts on GitHub as them: comments, merges, labels, pushes and pull requests show up under their name. The first time someone comes in, **🔐 Your sign-ins** opens (it's in the **☰** menu too). *Sign in with Claude* gives them Claude's sign-in page and takes back the code it shows. *Sign in with GitHub* shows a one-time code for github.com/login/device. They can paste a token from `claude setup-token`, or a GitHub token, instead. A 🐚 shell they open at a desk runs as them, so `claude auth login` and `gh auth login` typed there work too. Admins can use the office machine's own sign-ins instead. Each account's sign-ins live in `.agent-office/homes//`, and revoking the account deletes them. The boards are read with the machine's own `gh`, so that account needs read access to the repos. Running it just for yourself, with no accounts, none of this applies. +**Their own Claude and GitHub.** With accounts, everyone's workers run on their own Claude plan, and the office acts on GitHub as them: comments, merges, labels, pushes and pull requests show up under their name. The first time someone comes in, **🔐 Your sign-ins** opens (it's in the **☰** menu too). *Sign in with Claude* gives them Claude's sign-in page and takes back the code it shows. *Sign in with GitHub* shows a one-time code for github.com/login/device. They can paste a token from `claude setup-token`, or a GitHub token, instead. On a building with GitLab floors, a GitLab card takes a GitLab personal access token the same way ([docs/gitlab.md](docs/gitlab.md#everyones-own-gitlab)). A 🐚 shell they open at a desk runs as them, so `claude auth login` and `gh auth login` typed there work too. Admins can use the office machine's own sign-ins instead. Each account's sign-ins live in `.agent-office/homes//`, and revoking the account deletes them. The boards are read with the machine's own `gh`, so that account needs read access to the repos. Running it just for yourself, with no accounts, none of this applies. **3. Turn off the shared password.** Until you do, anyone who knows the office password can get in, as an admin. Once everyone has an account, switch it off in **🔑 Accounts** (signed in with your own admin account), or `agent-office accounts password off`. @@ -405,6 +416,7 @@ Every change to the app that lands on `main` is published as a GitHub release by - [Features](docs/features.md): everything in the office, room by room - [Agents](docs/agents.md): Claude Code, Codex and OpenCode, models and effort, and the office's prompts - [Configuration](docs/configuration.md): every command-line option, and where the office keeps its data +- [GitLab](docs/gitlab.md): GitLab projects as floors, merge requests, `glab`, and everyone's own GitLab sign-in - [Maps](docs/maps.md): the castle, the space station, and making a map of your own - [Workers' servers on your own computer](docs/tunnel.md): `agent-office tunnel`, which opens every worker's web server on your computer by itself - [AWS reference](docs/aws.md): Tailscale, service tunnels, upgrades, and everything `deploy/aws.sh` does diff --git a/docs/configuration.md b/docs/configuration.md index 611e23f0d..7785a594e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,9 +4,9 @@ Back to the [README](../README.md). ## Where the office keeps things -The office keeps its data in `~/agent-office` (`--home` or `AGENT_OFFICE_HOME` to move it) and clones projects next to it, as `~/agent-office//`. To clone them somewhere else, like `~/Workspace`, an admin picks the **Workspace folder** in ⚙️ Settings → **🏢 Building** (or start with `--projects` or `AGENT_OFFICE_PROJECTS`). Floors you already have stay where they are, and a checkout of the same repository that's already in the new folder is used as it is. The building's map is in `~/agent-office/.agent-office/map.json`, and maps of your own go in `~/agent-office/.agent-office/maps/` (see [Maps](maps.md)). The list of floors is `~/agent-office/.agent-office/floors.json`, and each account's own Claude and GitHub sign-ins are in `~/agent-office/.agent-office/homes//` (revoking the account deletes them). Each floor keeps its workers, queue, pictures and worktrees in its own checkout's `.agent-office/`. +The office keeps its data in `~/agent-office` (`--home` or `AGENT_OFFICE_HOME` to move it) and clones projects next to it, as `~/agent-office//` (a GitLab project as `~/agent-office///`). To clone them somewhere else, like `~/Workspace`, an admin picks the **Workspace folder** in ⚙️ Settings → **🏢 Building** (or start with `--projects` or `AGENT_OFFICE_PROJECTS`). Floors you already have stay where they are, and a checkout of the same repository that's already in the new folder is used as it is. The building's map is in `~/agent-office/.agent-office/map.json`, and maps of your own go in `~/agent-office/.agent-office/maps/` (see [Maps](maps.md)). The list of floors is `~/agent-office/.agent-office/floors.json`, and each account's own Claude, GitHub and GitLab sign-ins are in `~/agent-office/.agent-office/homes//` (revoking the account deletes them). Each floor keeps its workers, queue, pictures and worktrees in its own checkout's `.agent-office/`. -Already have a checkout? Pick its repository anyway: a checkout of it that's already where the workspace folder would clone it is used as it is. You can still start the office in a project, `agent-office ~/code/my-project`: that project becomes a floor, and the office keeps its data in `~/code/my-project/.agent-office` as it did before there were floors. An office that already ran in a project carries on in it when you start `agent-office` there again. An admin can take that project off the building in the elevator like any other floor. +Already have a checkout? Pick its repository anyway: a checkout of it that's already where the workspace folder would clone it is used as it is. You can still start the office in a project, `agent-office ~/code/my-project`: that project becomes a floor, and the office keeps its data in `~/code/my-project/.agent-office` as it did before there were floors. An office that already ran in a project carries on in it when you start `agent-office` there again. An admin can take that project off the building in the elevator like any other floor. Or, with the office stopped, add checkouts you already have to `floors.json` yourself: each needs only an `id` (lowercase letters, digits and dashes) and its absolute `dir`, as in `[{ "id": "my-project", "dir": "/Users/me/code/my-project" }]`. The office reads which GitHub repo or GitLab project it is from the checkout's `origin`. ## Command line @@ -39,8 +39,8 @@ agent-office [dir] [options] agent-office setup [--projects ] [--project ]... [--home ] - The first-start walkthrough again: the workspace folder, GitHub sign-in and - repositories to clone as floors. With --projects / --project it asks nothing. + The first-start walkthrough again: the workspace folder, GitHub sign-in (or a + GitLab glab is signed in to) and repositories to clone as floors. With --projects / --project it asks nothing. Run it while the office is stopped. agent-office prune [dir] [-n|--dry-run] [-f|--force] diff --git a/docs/features.md b/docs/features.md index f320d9b99..bf5317c7b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,7 +2,7 @@ Everything in the office, room by room. Back to the [README](../README.md). -- **A floor per project.** The first time the office starts in a terminal it walks you through picking your first project (see [Run locally](../README.md#run-locally)). Otherwise you start inside the elevator, and it asks for one: pick one of the repositories your `gh` login can see (or type `owner/name`) and the office clones it into the workspace folder, `~/agent-office//` unless you pick another (**📁 Change folder** right there, for admins). While it clones, its floor on the panel says how far along it is (*Downloading 64% · 231 MiB · 1.5 MiB/s*), and you can close the panel and carry on: everyone hears when the floor opens. Whoever added it, or an admin, can stop it with the **⏹️** beside it. A clone that hears nothing from GitHub for 3 minutes is stopped as stalled, one that would have to ask something (an ssh host key, a password) fails straight away and says what to run on the office's machine, and restarting the office doesn't stop one. To add another project, or go to one, walk into the elevator on the north wall and press **E**. Or click the project name in the top-left corner for the list of floors: pick one and you're there in a blink, standing on the same spot in the office. From outside it (the balcony, the street, the garage) it takes you in, and you step out of that floor's elevator. Each floor has its own desks, workers, issues and PR boards, task queue, services and pictures, and its own wall and floor colors, so you always know where you are. You only see and hear the people on your floor. The elevator panel shows how many workers are busy or waiting on someone on each floor, and you get a heads-up when a worker on another floor starts waiting. An admin can take a project off the building with the **🗑** beside its floor in the elevator: its workers stop, and everyone on it rides the elevator to another floor. Nothing is deleted. The checkout stays on disk with its workers, queue and pictures, and adding the same repository again moves back into it (as long as the workspace folder hasn't moved since). That goes for the project the office was started in (`agent-office `) too: it stays off after a restart, and the office keeps its own data in that folder as before. +- **A floor per project.** The first time the office starts in a terminal it walks you through picking your first project (see [Run locally](../README.md#run-locally)). Otherwise you start inside the elevator, and it asks for one: pick one of the repositories your `gh` login can see, or the GitLab projects `glab` can (or type `owner/name`, or `host/group/project` for a GitLab; see [GitLab](gitlab.md)), and the office clones it into the workspace folder, `~/agent-office//` (`//` for a GitLab) unless you pick another (**📁 Change folder** right there, for admins). While it clones, its floor on the panel says how far along it is (*Downloading 64% · 231 MiB · 1.5 MiB/s*), and you can close the panel and carry on: everyone hears when the floor opens. Whoever added it, or an admin, can stop it with the **⏹️** beside it. A clone that hears nothing from GitHub for 3 minutes is stopped as stalled, one that would have to ask something (an ssh host key, a password) fails straight away and says what to run on the office's machine, and restarting the office doesn't stop one. To add another project, or go to one, walk into the elevator on the north wall and press **E**. Or click the project name in the top-left corner for the list of floors: pick one and you're there in a blink, standing on the same spot in the office. From outside it (the balcony, the street, the garage) it takes you in, and you step out of that floor's elevator. Each floor has its own desks, workers, issues and PR boards, task queue, services and pictures, and its own wall and floor colors, so you always know where you are. You only see and hear the people on your floor. The elevator panel shows how many workers are busy or waiting on someone on each floor, and you get a heads-up when a worker on another floor starts waiting. An admin can take a project off the building with the **🗑** beside its floor in the elevator: its workers stop, and everyone on it rides the elevator to another floor. Nothing is deleted. The checkout stays on disk with its workers, queue and pictures, and adding the same repository again moves back into it (as long as the workspace folder hasn't moved since). That goes for the project the office was started in (`agent-office `) too: it stays off after a restart, and the office keeps its own data in that folder as before. - **Walk around.** Use WASD, Space to jump, and drag the mouse to orbit the camera. Everyone in the office sees everyone else move in real time. - **A quiet screen.** The office stays in view: the top bar holds just the floor you're on, **🤖 Workers** and the **☰** menu (or **Tab**). The menu opens every window (issues, PRs, the queue, services, the whiteboard, search, voice, screen sharing, settings) and lists what can show on screen: the workers, who's in the office, spend, Claude limits, the chat and the floor details. Turn each one on or off there, or hide a panel with its **✕**. Pin anything you use a lot to the top bar. The choices are kept in your browser. Chat lines fade after a few seconds; hover the chat or press **T** to read back. What needs you shows up on the top bar by itself: the workers waiting on someone (click to go to them, like **N**), the mute button while you're in voice, **Sharing** while you share your screen, and **⬆️ Update** when there's a new version. - **Coffee.** Press **E** at the coffee machine in the kitchen for a mug and a minute of quicker walking and higher jumps. A little meter under the project name shows how much buzz is left. Drink a third cup before the last one wears off and you get the jitters for a few seconds. @@ -46,8 +46,8 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Cost per worker.** Claude Code workers show tracked session cost and tokens. OpenCode workers show tokens and model/provider-reported cost estimates, including child-session usage, with a cache/reasoning breakdown in tooltips. These estimates are not billing; missing cost is shown as unavailable. Codex workers show the root session’s recorded tokens, including cache and reasoning breakdowns; cost and API-call count are unavailable. DeepSeek Harness workers show what is in each session's context window (and the window's size) from ACP's usage updates, plus a cost only when the harness reports one. Today's and all-time totals, `--budget` warnings and `--budget-pause` use tracked Claude Code spend only; they cannot cap OpenCode, Codex or DeepSeek Harness spending. Once the tracked budget is spent, the hiring pause applies to all new workers. OpenCode and Codex session snapshots and Claude totals survive restarts. Current-desk totals disappear when their workers are sent home. - **Machine monitor and worker limit.** A monitor on the west wall, between the windows, shows how busy the office's machine is: CPU and memory right now, the last five minutes of each, and how many workers the office runs. Start with `--max-workers `, or have an admin set a limit under **⚙️** → *Worker limit*, and the office runs at most that many workers at once across every floor (shells and board agents count too). Hiring past it is refused with a message saying why, and the task queue waits for room instead of failing its tasks. In the office, the limit can be lowered but never raised past `--max-workers`. When memory is 90% used, or the CPU has been 90% busy for half a minute, the monitor says the machine is under pressure, the hint at an empty desk says so, and the hire dialog warns you before you hire. - **Plan limits.** Turn on **⏳ Claude limits** in the **☰** menu and a meter shows how much of your Claude plan's 5-hour session and week is used, plus any per-model weekly limit (like Fable's), and when each one starts over. These are the same numbers Claude Code's `/usage` shows, on every floor: for your own Claude sign-in once you have one (see **Your own Claude and GitHub** below), else for the one the office's machine is signed in to. While anyone is in the office, it asks `claude` for them every two minutes, which starts no conversation and costs nothing. Click the meter to read it again. It stays hidden when Claude runs on an API key instead of a plan, and it doesn't count OpenCode, Codex, Grok, Muse, DeepSeek Harness or Pi. -- **Issues board.** A tack board shows GitHub issues in *Open*, *In progress* and *Closed*. The notes pinned to the board on the wall are the *Open* ones, the issues nobody has started on: one that moves to *In progress* comes off the wall, and you find it in the board's window. Click a column's header to filter it by label: pick one or more and the column shows only the cards with any of them. Your filters stay put for each floor, in your browser. Or type in the box under a column's header to narrow it to the cards whose titles have every word you type; it works along with the label filter, **✕** clears it, and the boxes start empty each time you open the board. Click an issue to read it and its comments, or add a comment of your own (it posts as the GitHub account you signed in to in **🔐 Your sign-ins**, or on the office password as the one `gh` is signed in to on the server; the window names it), then choose **Hand to a worker** to seat a worker with a ready-made prompt (or give it to one who's already there), or **Add to queue**. Or take its card straight off the board, without opening it: point at the note (the crosshair in first person, the mouse in third) and press **E** or click (**O** opens it instead). **✋ Pick it up** in an issue's window does the same. You carry the card across the office in your hands, where everyone on the floor sees it (and sees it missing from the board): press **E** at an empty desk to hire a worker for it, at a worker's desk to hand it to that worker, or at the task queue to queue it. However a worker gets an issue (**Hand to a worker**, its card, or the queue seating one for it), the issue moves to *In progress* and its note comes off the wall the moment the worker has it, for everyone on the floor. The office assigns it on GitHub behind that, which is what keeps it there; if GitHub won't assign it, the card goes back and you're told why. **Q** puts the card back. **Close issue…** closes it as completed or not planned, with an optional comment, and takes it off the queue if it was waiting there. **🏷️ Edit** next to an issue's labels (**Add labels** when it has none), or the 🏷️ that shows when you hover its card on the board, picks its labels from the repo's own: tick them on and off, filter by typing (**Enter** ticks the first match), then **Save labels**. An *in progress* (or *doing*, *wip*) label moves the issue to *In progress*. -- **PR board.** A second tack board shows pull requests in *Draft*, *In review*, *Approved*, *Merged* and *Closed*, with CI status and diff size. Its column headers filter by label too, and the box under each header by title. Click a PR to read its description, comments, reviews and checks, and comment on it. **Files changed** shows the diff, with the files listed on the left as a folder tree or a flat list, and review comments under the lines they're on. Tick a file **Reviewed** (or press **V**; **J**/**K** step through files) and it folds away. Your ticks are kept in your browser, and a file you ticked that changes again is flagged. **Merge…** squashes, merges or rebases it, and can have GitHub merge it once its checks pass. **Close PR…** closes it without merging, with an optional comment, and can delete its branch. **Review** and **Fix comments & merge** hand the PR to a worker; the second one addresses the review comments, gets the checks green and merges. When the PR conflicts with its base, that button becomes **Fix conflicts & merge**: a new worker is picked by default, merges the base in, resolves the conflicts and merges. Its labels change the same way as an issue's: **🏷️ Edit** in the PR window, or the 🏷️ on its card. +- **Issues board.** A tack board shows GitHub (or GitLab) issues in *Open*, *In progress* and *Closed*. The notes pinned to the board on the wall are the *Open* ones, the issues nobody has started on: one that moves to *In progress* comes off the wall, and you find it in the board's window. Click a column's header to filter it by label: pick one or more and the column shows only the cards with any of them. Your filters stay put for each floor, in your browser. Or type in the box under a column's header to narrow it to the cards whose titles have every word you type; it works along with the label filter, **✕** clears it, and the boxes start empty each time you open the board. Click an issue to read it and its comments, or add a comment of your own (it posts as the GitHub account you signed in to in **🔐 Your sign-ins**, or on the office password as the one `gh` is signed in to on the server; the window names it), then choose **Hand to a worker** to seat a worker with a ready-made prompt (or give it to one who's already there), or **Add to queue**. Or take its card straight off the board, without opening it: point at the note (the crosshair in first person, the mouse in third) and press **E** or click (**O** opens it instead). **✋ Pick it up** in an issue's window does the same. You carry the card across the office in your hands, where everyone on the floor sees it (and sees it missing from the board): press **E** at an empty desk to hire a worker for it, at a worker's desk to hand it to that worker, or at the task queue to queue it. However a worker gets an issue (**Hand to a worker**, its card, or the queue seating one for it), the issue moves to *In progress* and its note comes off the wall the moment the worker has it, for everyone on the floor. The office assigns it on GitHub behind that, which is what keeps it there; if GitHub won't assign it, the card goes back and you're told why. **Q** puts the card back. **Close issue…** closes it as completed or not planned, with an optional comment, and takes it off the queue if it was waiting there. **🏷️ Edit** next to an issue's labels (**Add labels** when it has none), or the 🏷️ that shows when you hover its card on the board, picks its labels from the repo's own: tick them on and off, filter by typing (**Enter** ticks the first match), then **Save labels**. An *in progress* (or *doing*, *wip*) label moves the issue to *In progress*. +- **PR board.** A second tack board shows pull requests in *Draft*, *In review*, *Approved*, *Merged* and *Closed*, with CI status and diff size. On a GitLab floor it's the **merge requests** board: the same columns and windows, with MRs (`!12`), their pipeline jobs and GitLab's merge options (see [GitLab](gitlab.md)). Its column headers filter by label too, and the box under each header by title. Click a PR to read its description, comments, reviews and checks, and comment on it. **Files changed** shows the diff, with the files listed on the left as a folder tree or a flat list, and review comments under the lines they're on. Tick a file **Reviewed** (or press **V**; **J**/**K** step through files) and it folds away. Your ticks are kept in your browser, and a file you ticked that changes again is flagged. **Merge…** squashes, merges or rebases it, and can have GitHub merge it once its checks pass. **Close PR…** closes it without merging, with an optional comment, and can delete its branch. **Review** and **Fix comments & merge** hand the PR to a worker; the second one addresses the review comments, gets the checks green and merges. When the PR conflicts with its base, that button becomes **Fix conflicts & merge**: a new worker is picked by default, merges the base in, resolves the conflicts and merges. Its labels change the same way as an issue's: **🏷️ Edit** in the PR window, or the 🏷️ on its card. - **The merge gong.** A brass gong stands by the elevator, on the far side from the PR board. When a pull request merges, it rings for everyone on the floor, confetti rains down from the ceiling all over the floor (the loft too) and bursts over the desk the PR came from (with no worker there, over the gong), and every worker that's awake climbs up on its desk for a quick dance, its light flashing like a disco ball, before it hops back into its seat. Workers on bean bags dance on the bag, and the board agents on their kiosks. It rings however the PR merged: from the PR window, by a worker's `gh pr merge`, by auto-merge or on GitHub itself. When the last task on the task queue gets done, the gong rings three times and confetti flies over every desk. Walk up and press **E** to bang it yourself. - **Ask a worker about anything on a board.** Every issue and PR has **✍️ Ask a worker…**: type your own prompt, and the worker gets it along with which issue or PR it's about. Send it to a new worker at a free desk, or to one already sitting at a desk. - **Board agents.** An agent stands at a little kiosk beside each of the issues board, the PR board and the task queue. Walk up and press **E**, type what you want, and it runs as a Claude Code (or your configured) agent that already knows its job. The issues agent files, finds, triages, labels and closes issues with `gh`. The PR agent sums up, reviews, comments on and merges pull requests. The queue agent turns what you ask for into tasks on the queue, one per piece of work, and can tell you what's queued or take tasks off. It never does the work itself, not even a one-line fix: it queues it and tells you what it queued. The first prompt hires it; after that your prompts go into the same session, so you can follow up, and a sleeping one wakes up with your prompt. Press **O** there to watch its terminal and **X** to send it home, and the next prompt starts a fresh one. The agents work in the floor's main checkout, so they're told not to change code there: work that needs code changed goes on the queue, where a fresh worker does it in its own worktree. @@ -70,4 +70,4 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Jukebox.** A jukebox glows in the corner of the lounge. Walk up and press **E** to put on one of its four lo-fi tunes, skip to the next, turn it off, or paste a link to internet radio or an audio file. Everyone on the floor hears the same song from the same bar, coming from the jukebox: loud on the couch, faint at the desks. The tunes are synthesized in the browser like the office sounds, and it keeps playing when you switch tabs. The jukebox has its own volume under **⚙️**, just for you. - **Screen sharing.** Your screen appears on the lounge TV for everyone, and there's a full-screen viewer. - **An account for everyone.** Open **🔑 Accounts** from the **☰** menu and make an invite link. Whoever opens it picks a password and gets an account in their own name. That name is the one on their character, in chat and on every terminal they type into (**⌨️** in the terminal header shows who typed last), and nobody else can take it. Admins see everyone's accounts there, can make someone an admin, and can revoke an account, which signs that person out at once. The shared office password keeps working alongside the accounts until an admin switches it off. Sessions are signed cookies, and login attempts are rate limited. -- **Your own Claude and GitHub.** With an account, the workers you hire run on your own Claude plan, and the office acts on GitHub as you: comments, merges, labels, claimed issues, commits, pushes and pull requests show up under your name, with your git name and GitHub noreply email. The first time you come in, **🔐 Your sign-ins** opens (it's in the **☰** menu too). **Sign in with Claude** gives you Claude's sign-in page and takes back the code it shows; **Sign in with GitHub** shows a one-time code for github.com/login/device. Or paste a token from `claude setup-token` (or an API key), or a GitHub token. A 🐚 shell you open at a desk runs as you, so `claude auth login` and `gh auth login` typed there sign you in too. Hiring a Claude worker, queueing a task, calling a meeting or asking a board agent needs your own Claude sign-in first; without one, the window opens and says why. A worker keeps running as whoever hired it: a queued task as whoever queued it, a meeting's workers as whoever called it. Admins can pick the office machine's own sign-ins instead. Everyone's sign-ins are kept apart, so any number of people can be signed in to different accounts at once. Revoking an account signs it out and deletes its sign-ins. On the office password, with no account, everything runs on the machine's own `claude` and `gh` as before. +- **Your own Claude and GitHub.** With an account, the workers you hire run on your own Claude plan, and the office acts on GitHub as you: comments, merges, labels, claimed issues, commits, pushes and pull requests show up under your name, with your git name and GitHub noreply email. The first time you come in, **🔐 Your sign-ins** opens (it's in the **☰** menu too). **Sign in with Claude** gives you Claude's sign-in page and takes back the code it shows; **Sign in with GitHub** shows a one-time code for github.com/login/device. Or paste a token from `claude setup-token` (or an API key), or a GitHub token. On a building with GitLab floors, a **GitLab** card takes the GitLab's address and a personal access token, and the office acts on GitLab as you too (see [GitLab](gitlab.md#everyones-own-gitlab)). A 🐚 shell you open at a desk runs as you, so `claude auth login` and `gh auth login` typed there sign you in too. Hiring a Claude worker, queueing a task, calling a meeting or asking a board agent needs your own Claude sign-in first; without one, the window opens and says why. A worker keeps running as whoever hired it: a queued task as whoever queued it, a meeting's workers as whoever called it. Admins can pick the office machine's own sign-ins instead. Everyone's sign-ins are kept apart, so any number of people can be signed in to different accounts at once. Revoking an account signs it out and deletes its sign-ins. On the office password, with no account, everything runs on the machine's own `claude` and `gh` as before. diff --git a/docs/gitlab.md b/docs/gitlab.md new file mode 100644 index 000000000..5d90dbb05 --- /dev/null +++ b/docs/gitlab.md @@ -0,0 +1,54 @@ +# GitLab + +Back to the [README](../README.md). + +A GitLab project can be a floor just like a GitHub repository: gitlab.com, or a company's own GitLab (one reached over a VPN works too). The office talks to it through the GitLab CLI, `glab`, the way it talks to GitHub through `gh`. A building can mix GitHub and GitLab floors. + +## Set up + +On the machine that runs the office: + +```bash +brew install glab # or see https://gitlab.com/gitlab-org/cli#installation +glab auth login --hostname gitlab.example.com +``` + +Sign in with a personal access token that has the `api` and `write_repository` scopes, and the office lists every project that token is a member of in the elevator, on every GitLab `glab` is signed in to, next to your GitHub repositories. Or type the project yourself: `gitlab.example.com/group/subgroup/project`, its https or ssh URL, or the link to one of its merge requests. `gh` doesn't have to be installed for a building of GitLab floors, and the first-start walkthrough (`agent-office setup`) carries on with `glab` alone. + +A GitLab project is cloned into the workspace folder as `///…/`, over ssh unless `glab config set git_protocol https --host ` says otherwise. The clone uses the machine's own git credentials (its ssh key, or `glab`'s for https), so check that a plain `git clone` of the project works there first. + +### A checkout you already have + +Start the office in it, `agent-office ~/code/my-project`, or, with the office stopped, add it to `.agent-office/floors.json` in the office's home: + +```json +[{ "id": "my-project", "dir": "/Users/me/code/my-project" }] +``` + +The office reads which GitLab project it is from the checkout's `origin` remote, so there's no need to say. Set `"repo": "gitlab.example.com/group/my-project"` only when `origin` points somewhere else. + +## What a GitLab floor has + +Everything a GitHub floor has, in GitLab's words: the boards, buttons and prompts say *merge request*, *MR* and `!12` instead of *pull request*, *PR* and `#12`, and the ☰ menu's board is **Merge requests**. + +- **The issues board** shows the project's issues. Handing one to a worker assigns it to you on GitLab. GitLab has no close reason, so **Close issue… → not planned** adds a comment saying so. +- **The merge requests board** shows drafts, MRs in review, approved, merged and closed, with their pipeline's status and size. An MR's window has its description, comments, line threads, approvals, pipeline jobs and **Files changed**. **Merge…** squashes or makes a merge commit, as the project allows, and can leave it to merge when its pipeline passes. **Close MR…** can delete its branch. +- **Labels**, comments, the meeting room's reviews and the board agents work as they do on GitHub. +- **Opening an MR from a desk** (**O** at a worktree worker's desk) pushes the branch and opens a merge request with `glab`. A worker that opens one itself with `glab mr create` shows it at its desk, and `office-workers pr !12` (or the MR's URL) tells the office which one is its own. +- **Workers' prompts** use `glab` (`glab mr view`, `glab mr merge`…) instead of `gh`. Your own prompts in **⚙️ Settings → Prompts** are used on both kinds of floor. +- **Markdown** in descriptions and comments links `!12`, `#12` and `@someone` to that GitLab. + +## Everyone's own GitLab + +With [accounts](../README.md#add-users), **🔐 Your sign-ins** has a GitLab card once the building has a GitLab floor. Type the GitLab's address and paste a personal access token (GitLab: **Preferences → Access tokens**, with the `api` and `write_repository` scopes), and the office acts on GitLab as you: comments, merges, labels, claimed issues and merge requests show up under your name, and commits carry your GitLab name. Or type `glab auth login` in a 🐚 shell at a desk, which runs as you. + +The token is kept by `glab` in your account's own folder, `.agent-office/homes//glab/`, never in the machine's keychain, which every account would share. Revoking the account deletes it. Admins can use the office machine's own `glab` instead. The boards are read with the machine's own `glab`, so its sign-in needs access to the projects. + +Pushes go wherever the checkout's `origin` points. Over https they go out as you; over ssh they use the machine's ssh key, as they do on a GitHub floor. + +## When something's wrong + +- **"glab isn't signed in to this GitLab"**: run `glab auth login --hostname ` on the office's machine. +- **"The office can't reach this GitLab"**: check the network or VPN. The office runs `glab` with Go's own DNS resolver (`GODEBUG=netdns=go`), because on a Mac the system resolver times out when many lookups go over a VPN at once. Set `GODEBUG` yourself to override it. +- **A GitLab project is missing from the elevator**: the elevator lists projects you're a member of, not archived ones. Type its path instead. +- **An older GitLab**: fields its GraphQL doesn't have (approvals, reviewer states on Community Edition) are left out of the boards, not treated as errors. diff --git a/src/client/features/boards/index.ts b/src/client/features/boards/index.ts index 49f80548d..c55c8d0b2 100644 --- a/src/client/features/boards/index.ts +++ b/src/client/features/boards/index.ts @@ -12,6 +12,7 @@ import { openBoard } from '../../ui/boards'; import { inProgress } from '../../ui/github/progress'; import type { BoardActions } from '../../ui/github/prompts'; import { clip } from '../../ui/dom'; +import { words } from '../../ui/forge'; import { openIssue } from '../../ui/pull'; import { openServices } from '../../ui/services'; import { BoardTexture, QueueBoardTexture, ServicesBoardTexture } from './world'; @@ -116,7 +117,7 @@ export function installBoards(ctx: Ctx, deps: BoardsDeps) { }); ctx.interactions.define('pulls', { reach: 9, - hint: () => boardHint('🔀 Pull request board'), + hint: () => boardHint(`🔀 ${words().site === 'GitLab' ? 'Merge' : 'Pull'} request board`), use: onE(() => openBoard('pulls', ctx.net, deps.boardActions())), }); ctx.interactions.define('services', { diff --git a/src/client/features/boards/world.ts b/src/client/features/boards/world.ts index 7da11f4fa..5020ecddb 100644 --- a/src/client/features/boards/world.ts +++ b/src/client/features/boards/world.ts @@ -1,6 +1,7 @@ import * as THREE from 'three'; import type { GhIssue, GhPull, GhState, QueueState, QueueTask, ServiceInfo, WorkerInfo } from '../../../shared/protocol'; import { store, workerForPull } from '../../state'; +import { prName, words } from '../../ui/forge'; export const NOTE_COLORS = ['#fff7b0', '#ffd6e0', '#caffbf', '#bde0fe', '#ffe5b4']; export const PINS = ['#ef476f', '#118ab2', '#06d6a0', '#ffd166']; @@ -98,7 +99,7 @@ export class BoardTexture { } const open = (state.items as (GhIssue | GhPull)[]).filter((i) => i.state === 'OPEN'); if (!open.length) { - const note = state.error ? `⚠️ ${state.error}` : state.loading && !state.fetchedAt ? 'Loading…' : this.kind === 'issues' ? 'No open issues 🎉' : 'No open PRs'; + const note = state.error ? `⚠️ ${state.error}` : state.loading && !state.fetchedAt ? 'Loading…' : this.kind === 'issues' ? 'No open issues 🎉' : `No open ${words().pr}s`; g.font = '800 40px Nunito, ui-rounded, system-ui, sans-serif'; const lines = wrap(g, note.replace(/`/g, ''), 760, 4); const boxH = 60 + lines.length * 50; @@ -150,7 +151,7 @@ export class BoardTexture { const w = this.kind === 'pulls' && workers ? workerForPull(workers.values(), it as GhPull) : undefined; const footer = w ? fs * 1.3 : 0; g.font = `900 ${Math.round(fs * 1.35)}px Nunito, ui-rounded, system-ui, sans-serif`; - g.fillText(`#${it.number}`, -nw / 2 + 14, -nh / 2 + fs * 2); + g.fillText(`${this.kind === 'pulls' ? words().ref : '#'}${it.number}`, -nw / 2 + 14, -nh / 2 + fs * 2); g.font = `700 ${fs}px Nunito, ui-rounded, system-ui, sans-serif`; wrap(g, it.title, nw - 28, Math.max(2, Math.floor((nh - fs * 3 - footer) / (fs * 1.1)))).forEach((line, li) => g.fillText(line, -nw / 2 + 14, -nh / 2 + fs * 3.4 + li * fs * 1.1)); if (w) { @@ -302,7 +303,7 @@ export class QueueBoardTexture { ...done.map((t) => ({ icon: t.outcome === 'done' ? '✅' : '⚠️', text: name(t), - side: t.pr ? `PR #${t.pr.number}${t.pr.state === 'MERGED' ? ' · merged' : ''}` : t.outcome === 'done' ? 'done' : t.outcome === 'failed' ? "didn't start" : t.outcome === 'killed' ? 'sent home' : 'stopped', + side: t.pr ? `${prName(t.pr.number)}${t.pr.state === 'MERGED' ? ' · merged' : ''}` : t.outcome === 'done' ? 'done' : t.outcome === 'failed' ? "didn't start" : t.outcome === 'killed' ? 'sent home' : 'stopped', color: '#8a8f98', })), ]; diff --git a/src/client/features/bookshelf/index.ts b/src/client/features/bookshelf/index.ts index 67d2c6d85..398faf98d 100644 --- a/src/client/features/bookshelf/index.ts +++ b/src/client/features/bookshelf/index.ts @@ -1,9 +1,11 @@ /** The bookshelf: the project's docs to read, with a book in your hands (and a swish as its pages turn). */ +import { gitlabProject } from '../../../shared/floors'; import type { Ctx } from '../../core/context'; import { aside, hintTitle, key, onE } from '../../core/hint'; import { saveSettings, store } from '../../state'; import { openBookshelf } from './ui'; import { clip, toast } from '../../ui/dom'; +import { words } from '../../ui/forge'; // The kinds of thing you can use that this defines (see InteractKinds in world/types.ts). declare module '../../world/types' { @@ -12,8 +14,10 @@ declare module '../../world/types' { } } -/** The project on GitHub, from the floor's origin remote, when that's where it is. */ -function githubUrl(remote?: string): string | undefined { +/** The project on GitHub, from the floor's origin remote, or on its GitLab, when that's where it is. */ +function projectUrl(remote?: string): string | undefined { + const lab = gitlabProject(store.currentFloor()?.repo); + if (lab) return `https://${lab.host}/${lab.path}`; const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(remote ?? ''); return m ? `https://github.com/${m[1]}` : undefined; } @@ -26,7 +30,8 @@ export function installBookshelf(ctx: Ctx) { openBookshelf({ floor: store.floor, project: store.project?.name, - repoUrl: githubUrl(store.project?.remote), + repoUrl: projectUrl(store.project?.remote), + site: words().site, onTurn: turnPage, pageSound: settings.pageTurns, onPageSound: (on) => { diff --git a/src/client/features/bookshelf/ui.ts b/src/client/features/bookshelf/ui.ts index 091bebec1..58b89f351 100644 --- a/src/client/features/bookshelf/ui.ts +++ b/src/client/features/bookshelf/ui.ts @@ -15,8 +15,9 @@ export interface ShelfDeps { /** The floor whose project it is, and the project's name. */ floor: string; project?: string; - /** The project on GitHub, for links to files that aren't docs. */ + /** The project on GitHub or a GitLab (`site`), for links to files that aren't docs. */ repoUrl?: string; + site?: 'GitHub' | 'GitLab'; /** You turned a page (opened a doc, or scrolled a screenful): the book in your hands turns one too. */ onTurn(): void; /** Whether a page turning makes a sound (⚙️'s setting), and the 🔈 up top that turns it on or off. */ @@ -151,6 +152,9 @@ async function getJson(url: string): Promise { export function openBookshelf(deps: ShelfDeps) { const { floor, repoUrl } = deps; + const site = deps.site ?? 'GitHub'; + /** Where a file of the project is on its site. */ + const fileUrl = (p: string) => `${repoUrl}${site === 'GitLab' ? '/-' : ''}/blob/HEAD/${p.split('/').map(encodeURIComponent).join('/')}`; const q = (params: Record) => new URLSearchParams({ floor, ...params }).toString(); const filter = h('input', { type: 'text', placeholder: 'Filter the docs…', 'aria-label': 'Filter the docs', spellcheck: 'false', autocomplete: 'off' }) as HTMLInputElement; @@ -289,7 +293,7 @@ export function openBookshelf(deps: ShelfDeps) { a.dataset.doc = to.path; a.dataset.hash = to.hash; } else if (repoUrl) { - a.href = `${repoUrl}/blob/HEAD/${to.path.split('/').map(encodeURIComponent).join('/')}${to.hash ? `#${to.hash}` : ''}`; + a.href = `${fileUrl(to.path)}${to.hash ? `#${to.hash}` : ''}`; } else { a.removeAttribute('href'); a.title = to.path; @@ -328,7 +332,7 @@ export function openBookshelf(deps: ShelfDeps) { const words = doc.text.split(/\s+/).filter(Boolean).length; meta.replaceChildren( [`${Math.max(1, Math.round(words / 220))} min read`, info ? size(info.size) : '', info ? `updated ${timeAgo(info.mtime)}` : ''].filter(Boolean).join(' · '), - repoUrl ? h('a', { href: `${repoUrl}/blob/HEAD/${path.split('/').map(encodeURIComponent).join('/')}`, target: '_blank', rel: 'noopener noreferrer', title: 'Open it on GitHub' }, 'GitHub ↗') : '', + repoUrl ? h('a', { href: fileUrl(path), target: '_blank', rel: 'noopener noreferrer', title: `Open it on ${site}` }, `${site} ↗`) : '', ); turnedAt = 0; jump(hash); diff --git a/src/client/features/gong/index.ts b/src/client/features/gong/index.ts index 381f6db96..91a737372 100644 --- a/src/client/features/gong/index.ts +++ b/src/client/features/gong/index.ts @@ -8,6 +8,7 @@ import { isAsleep } from '../../../shared/status'; import type { Ctx } from '../../core/context'; import { aside, hintTitle, key, onE } from '../../core/hint'; import { store, workerForPull } from '../../state'; +import { words } from '../../ui/forge'; import type { Stage, Worker } from '../../world/character'; import type { Area } from '../../world/confetti'; import type { Court } from '../../world/court'; @@ -43,7 +44,7 @@ export function installGong(ctx: Ctx, deps: GongDeps) { ctx.interactions.define('gong', { reach: 3.5, - hint: () => ({ k: '', parts: [hintTitle('🎉 Merge gong'), aside('rings when a PR merges'), key('E', 'Bang it')] }), + hint: () => ({ k: words().pr, parts: [hintTitle('🎉 Merge gong'), aside(`rings when a ${words().pr} merges`), key('E', 'Bang it')] }), use: onE(() => hitGong()), }); diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..2db2d78eb 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -3,6 +3,7 @@ * and F to hang a picture; the project in the corner (click it for the floors); Settings, and your * character. */ +import { words } from '../../ui/forge'; import { ROOF } from '../../../shared/rooftop'; import type { Ctx } from '../../core/context'; import type { CoreState } from '../../core/ctx'; @@ -50,7 +51,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { const hud = mountHud( [ { id: 'issues', icon: '📌', label: 'Issues', section: 'Open', count: () => store.issues.items.filter((i) => i.state === 'OPEN').length, run: () => openBoard('issues', net, actions.boardActions()) }, - { id: 'pulls', icon: '🔀', label: 'Pull requests', section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, + { id: 'pulls', icon: '🔀', label: () => (words().pr === 'MR' ? 'Merge requests' : 'Pull requests'), section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, { id: 'queue', icon: '📋', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, { id: 'services', icon: '🌐', label: 'Services', section: 'Open', count: () => store.services.items.length, title: () => 'Web servers the workers are running', run: () => openServices() }, { id: 'whiteboard', icon: '📝', label: 'Whiteboard', section: 'Open', title: () => 'Draw together, live', run: () => openWhiteboard(net) }, diff --git a/src/client/features/palette/index.ts b/src/client/features/palette/index.ts index 42673406c..15a51d19c 100644 --- a/src/client/features/palette/index.ts +++ b/src/client/features/palette/index.ts @@ -14,6 +14,7 @@ import { store } from '../../state'; import { openAccounts } from '../../ui/accounts'; import { openBoard } from '../../ui/boards'; import { STATUS_LABEL, toast } from '../../ui/dom'; +import { words } from '../../ui/forge'; import { paletteOpen, togglePalette, type PaletteEntry } from '../../ui/palette'; import { openIssue, openPull } from '../../ui/pull'; import { openServices, serviceUrl } from '../../ui/services'; @@ -67,6 +68,7 @@ export function installPalette(ctx: Ctx, parts: PaletteParts) { function paletteEntries(): PaletteEntry[] { const { waiting, actions, meeting, hanging } = parts; const out: PaletteEntry[] = []; + const fw = words(); for (const w of store.workers.values()) { const desk = DESK_BY_ID.get(w.deskId); const spot = desk && deskSpot(desk); @@ -101,17 +103,17 @@ export function installPalette(ctx: Ctx, parts: PaletteParts) { out.push({ icon: '🔎', kind: 'Action', title: 'Search the chat and every terminal', keywords: ['find'], open: showSearch }); out.push(at('issues', 'the Issues board', { icon: '📌', kind: 'Board', title: 'Issues board', open: () => openBoard('issues', net, actions.boardActions()) })); - out.push(at('pulls', 'the PR board', { icon: '🔀', kind: 'Board', title: 'PR board', keywords: ['pull requests'], open: () => openBoard('pulls', net, actions.boardActions()) })); + out.push(at('pulls', `the ${fw.pr} board`, { icon: '🔀', kind: 'Board', title: `${fw.pr} board`, keywords: ['pull requests', 'merge requests'], open: () => openBoard('pulls', net, actions.boardActions()) })); out.push(at('services', 'the Services board', { icon: '🌐', kind: 'Board', title: 'Services board', detail: 'Web servers the workers are running', open: () => openServices() })); out.push(at('whiteboard', 'the whiteboard', { icon: '📝', kind: 'Board', title: 'Whiteboard', open: () => openWhiteboard(net) })); out.push(at('meeting', 'the meeting room', { icon: '🤝', kind: 'Board', title: 'Meeting room', keywords: ['call a meeting'], open: () => meeting.showMeeting() })); for (const pr of store.pulls.items) { out.push( - at('pulls', 'the PR board', { + at('pulls', `the ${fw.pr} board`, { icon: '🔀', - kind: 'PR', - title: `#${pr.number} ${pr.title}`, + kind: fw.pr, + title: `${fw.ref}${pr.number} ${pr.title}`, detail: [pr.isDraft ? 'Draft' : pr.state.toLowerCase(), pr.headRefName, pr.author].join(' · '), open: () => openPull(pr, net, actions.boardActions()), }), diff --git a/src/client/features/workers/actions.ts b/src/client/features/workers/actions.ts index a409561ee..1d65f5d72 100644 --- a/src/client/features/workers/actions.ts +++ b/src/client/features/workers/actions.ts @@ -21,6 +21,7 @@ import { store } from '../../state'; import { openAsk } from '../../ui/ask'; import { STATUS_LABEL, clip, closeAllModals, h, toast } from '../../ui/dom'; import { openDeskLabel } from '../../ui/floorplan'; +import { prName, words } from '../../ui/forge'; import type { MeetingPreset } from '../../ui/meeting'; import { confirmDialog, lostWorktreeDialog, openPrompt, routeWorktreeMessage, sendHomeDialog } from '../../ui/prompt'; import { providerLabel, resolvedProvider } from '../../ui/provider'; @@ -250,11 +251,11 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct else window.open(w.pr.url, '_blank', 'noopener'); return; } - if (!w.worktree) return toast(`${w.name} works in the main checkout — only workers with their own worktree can open a PR`, 'warn'); + if (!w.worktree) return toast(`${w.name} works in the main checkout — only workers with their own worktree can open a ${words().pr}`, 'warn'); if (w.lost) return fixLostWorktree(w); if (w.prOpening) return; if (!prReady(w)) return toast(`${w.name} is still ${STATUS_LABEL[w.status]} — wait until it's done`, 'warn'); - toast(`Pushing ${w.worktree.branch} and opening a pull request…`); + toast(`Pushing ${w.worktree.branch} and opening a ${words().pullRequest}…`); net.send({ t: 'worker.pr', workerId: w.id }); } @@ -269,7 +270,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct if (!now || now.prOpening) return; if (now.lost) return fixLostWorktree(now); if (!prReady(now)) return toast(`${now.name} is still ${STATUS_LABEL[now.status]} — wait until it's done`, 'warn'); - toast(`Pushing ${now.worktree?.branch ?? 'its branch'} in each of ${now.name}'s repositories and opening pull requests…`); + toast(`Pushing ${now.worktree?.branch ?? 'its branch'} in each of ${now.name}'s repositories and opening ${words().pullRequest}s…`); net.send({ t: 'worker.pr', workerId: now.id }); }; if (!workerRepos(w).some((r) => r.pr)) return open(); @@ -394,7 +395,7 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct key('E', 'Open terminal'), key('C', 'Changes'), isAsleep(w.status) ? key('R', shell ? 'Restart' : 'Resume') : key('P', shell ? 'Run command' : 'Prompt'), - w.repos?.length ? reposKey(w) : w.pr ? key('O', `PR #${w.pr.number}`) : w.prOpening ? aside('⏳ Opening PR…') : prReady(w) ? key('O', 'Open PR') : '', + w.repos?.length ? reposKey(w) : w.pr ? key('O', prName(w.pr.number)) : w.prOpening ? aside(`⏳ Opening ${words().pr}…`) : prReady(w) ? key('O', `Open ${words().pr}`) : '', key('X', 'Send home'), labelKey, ], @@ -405,9 +406,10 @@ export function installWorkerActions(ctx: Ctx, core: CoreState, parts: WorkerAct function reposKey(w: WorkerInfo) { const repos = workerRepos(w); const prs = repos.filter((r) => r.pr).length; - if (w.prOpening) return aside('⏳ Opening PRs…'); - if (prs) return key('O', `${prs} of ${repos.length} PRs`); - return prReady(w) ? key('O', `Open PRs (${repos.length} repos)`) : ''; + const { pr } = words(); + if (w.prOpening) return aside(`⏳ Opening ${pr}s…`); + if (prs) return key('O', `${prs} of ${repos.length} ${pr}s`); + return prReady(w) ? key('O', `Open ${pr}s (${repos.length} repos)`) : ''; } function stationHint(deskId: string): Hint { diff --git a/src/client/features/workers/views.ts b/src/client/features/workers/views.ts index 49bf6732b..e342253c7 100644 --- a/src/client/features/workers/views.ts +++ b/src/client/features/workers/views.ts @@ -21,6 +21,7 @@ import { waitingOnSomeone } from '../../notify'; import { renderTitle } from '../../shared/title'; import { store } from '../../state'; import { $ } from '../../ui/dom'; +import { prName } from '../../ui/forge'; import { openExpand } from '../../ui/floorplan'; import { renderWorkers } from '../../ui/workers-panel'; import { renderLimits } from '../../ui/limits'; @@ -54,6 +55,12 @@ const HOLD_LEAVE = 5; /** How long a worker sent out from the herald has to turn up before its seat's forgotten. */ const HERALD_WAIT = 30_000; +/** A worker's pull request for its bubble, and what this floor calls it (PR #12, MR !12). */ +function prOf(w: WorkerInfo) { + const pr = workerPr(w, store.pulls.items, store.queue.tasks); + return [pr, pr && prName(pr.number)] as const; +} + export type WorkerViewsParts = Pick; /** @@ -161,7 +168,7 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews noOutline(v.model.root); } v.model.setAction(w.action); - v.model.setPr(workerPr(w, store.pulls.items, store.queue.tasks)); + v.model.setPr(...prOf(w)); v.model.setLost(!!w.lost); const engineBadge = w.kind === 'agent' ? modelBadge(w.provider, w.model, w.effort, w.usage?.model) : undefined; v.model.setTask(meetingCard(w) ?? (w.task && w.kind === 'agent' ? { ...w.task, name: `${providerLabel(w.provider, store.project)}${engineBadge ? ` · ${engineBadge}` : ''} · ${w.task.name}` } : w.task)); @@ -364,7 +371,7 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews const paintPrs = () => { for (const [id, v] of workerViews) { const w = store.workers.get(id); - if (w) v.model.setPr(workerPr(w, store.pulls.items, store.queue.tasks)); + if (w) v.model.setPr(...prOf(w)); } }; store.on('pulls', paintPrs); diff --git a/src/client/lite.ts b/src/client/lite.ts index 49aaf6f0e..1720b2b2e 100644 --- a/src/client/lite.ts +++ b/src/client/lite.ts @@ -17,6 +17,7 @@ import { openTerminal, openTerminalFor, routeTerminalMessage } from './ui/termin import { openChanges, openChangesFor, routeChangesMessage } from './ui/changes'; import { lostWorktreeDialog, openPrompt, routeWorktreeMessage, sendHomeDialog } from './ui/prompt'; import { openBoard } from './ui/boards'; +import { prName, words } from './ui/forge'; import type { BoardActions } from './ui/github/prompts'; import { openPull, routePullMessage } from './ui/pull'; import { openQueue } from './ui/queue'; @@ -157,7 +158,7 @@ function workerCard(w: WorkerInfo): HTMLElement { w.kind === 'agent' ? `⚙️ ${providerLabel(w.provider, store.project)}${badge ? ` · ${badge}` : ''}` : '🐚 shell', desk && (desk.station ? `📌 ${desk.label}` : desk.label), w.worktree && `🌿 ${w.worktree.branch}`, - w.pr && `🔀 PR #${w.pr.number}`, + w.pr && `🔀 ${prName(w.pr.number)}`, w.lastInput && `⌨️ ${w.lastInput.by} ${timeAgo(w.lastInput.at)}`, ].filter(Boolean); return h( @@ -326,6 +327,8 @@ function renderNav() { const count = (id: string, n: number) => ($(id).querySelector('.n')!.textContent = n ? String(n) : ''); count('btn-issues', store.issues.items.filter((i) => i.state === 'OPEN').length); count('btn-pulls', store.pulls.items.filter((p) => p.state === 'OPEN').length); + // Its label is the floor's: PRs on GitHub, MRs on a GitLab. + $('btn-pulls').firstChild!.textContent = `🔀 ${words().pr}s `; count('btn-queue', store.queue.tasks.filter((t) => t.status !== 'done').length); } store.on('issues', renderNav); diff --git a/src/client/ui/boards.ts b/src/client/ui/boards.ts index 6ca5a8707..0f0d3cfd1 100644 --- a/src/client/ui/boards.ts +++ b/src/client/ui/boards.ts @@ -3,6 +3,7 @@ import type { GhIssue, GhLabel, GhPull, WorkerInfo } from '../../shared/protocol import type { Net } from '../net'; import { store, workerForPull } from '../state'; import { h, openModal, timeAgo } from './dom'; +import { prName, words } from './forge'; import { openIssue } from './github/issue-window'; import { labelChip, openLabels } from './github/labels'; import { inProgress } from './github/progress'; @@ -25,8 +26,9 @@ interface Column { const byUpdated = (a: { updatedAt: string }, b: { updatedAt: string }) => b.updatedAt.localeCompare(a.updatedAt); function githubErrorHint(error: string): string { - if (/^Unknown JSON field:/i.test(error)) return 'The server is running an older GitHub CLI than this feature expected; update `gh` if the board still cannot load.'; - return 'The server runs `gh` in the project directory — make sure it is installed and authenticated (gh auth login).'; + const { site, cli } = words(); + if (/^Unknown JSON field:/i.test(error)) return `The server is running an older ${site} CLI than this feature expected; update \`${cli}\` if the board still cannot load.`; + return `The server runs \`${cli}\` in the project directory — make sure it is installed and authenticated (${cli} auth login).`; } function issueColumns(items: GhIssue[]): Column[] { @@ -113,10 +115,11 @@ function queueChip(issue: number): Node | '' { if (w) return workerChip(w, `${w.name} is working on this at ${store.plan().byId.get(w.deskId)?.label ?? 'a desk'} · ${provider}`); return h('span.qchip.running', {}, `🤖 ${t.workerName ?? 'a worker'} · ${provider}`); } - return t.pr ? h('span.qchip.done', {}, `🔀 PR #${t.pr.number} · ${provider}`) : ''; + return t.pr ? h('span.qchip.done', {}, `🔀 ${prName(t.pr.number)} · ${provider}`) : ''; } -function card(n: number, title: string, meta: (Node | string)[], i: number, onclick: () => void, onLabels: () => void) { +/** `ref` is what comes before the number: # for an issue, the floor's for a pull request (# or !). */ +function card(n: number, title: string, meta: (Node | string)[], i: number, onclick: () => void, onLabels: () => void, ref = '#') { return h( 'li.card', { @@ -125,8 +128,8 @@ function card(n: number, title: string, meta: (Node | string)[], i: number, oncl onclick, onkeydown: ((e: KeyboardEvent) => e.key === 'Enter' && e.target === e.currentTarget && onclick()) as EventListener, }, - h('button.card-labels', { type: 'button', title: 'Change the labels', 'aria-label': `Change the labels on #${n}`, onclick: ((e: Event) => (e.stopPropagation(), onLabels())) as EventListener }, '🏷️'), - h('div.num', {}, `#${n}`), + h('button.card-labels', { type: 'button', title: 'Change the labels', 'aria-label': `Change the labels on ${ref}${n}`, onclick: ((e: Event) => (e.stopPropagation(), onLabels())) as EventListener }, '🏷️'), + h('div.num', {}, `${ref}${n}`), h('div.ttl', {}, title), h('div.meta', {}, ...meta.filter((m) => m !== '').map((m) => (typeof m === 'string' ? h('span', {}, m) : m))), ); @@ -135,9 +138,11 @@ function card(n: number, title: string, meta: (Node | string)[], i: number, oncl export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActions) { const body = h('div.body'); const status = h('span.board-status'); - const refresh = h('button.btn', { title: 'Refresh from GitHub', onclick: () => net.send({ t: 'gh.refresh' }) }, '🔄 Refresh'); + const fw = words(); + const pulls = fw.site === 'GitLab' ? 'Merge Requests' : 'Pull Requests'; + const refresh = h('button.btn', { title: `Refresh from ${fw.site}`, onclick: () => net.send({ t: 'gh.refresh' }) }, '🔄 Refresh'); const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); - const el = h('div.modal.board', { role: 'dialog', 'aria-label': kind === 'issues' ? 'Issues board' : 'Pull requests board' }, h('header', {}, h('h2', {}, kind === 'issues' ? '📌 Issues' : '🔀 Pull Requests'), status, refresh, close), body); + const el = h('div.modal.board', { role: 'dialog', 'aria-label': kind === 'issues' ? 'Issues board' : `${fw.pullRequest[0].toUpperCase()}${fw.pullRequest.slice(1)}s board` }, h('header', {}, h('h2', {}, kind === 'issues' ? '📌 Issues' : `🔀 ${pulls}`), status, refresh, close), body); const filters = loadFilters(kind); /** What each column's filter box holds (column key → text), for as long as the board is open. */ @@ -256,7 +261,7 @@ export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActi const caret = active instanceof HTMLInputElement ? ([active.selectionStart, active.selectionEnd] as const) : null; body.replaceChildren(); if (st.error && !st.items.length) { - body.append(h('div.board-error', {}, `Couldn't load from GitHub: ${st.error}`, h('br'), h('small', {}, githubErrorHint(st.error)))); + body.append(h('div.board-error', {}, `Couldn't load from ${words().site}: ${st.error}`, h('br'), h('small', {}, githubErrorHint(st.error)))); return; } const all = boardLabels(st.items); @@ -289,6 +294,7 @@ export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActi i, () => openPull(it, net, actions), () => openLabels('pull', it, net), + words().ref, ); }), ); @@ -310,7 +316,7 @@ export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActi status.textContent = st.loading ? 'Refreshing…' : st.fetchedAt ? `Updated ${timeAgo(st.fetchedAt)}` : ''; }, 15000); const modal = openModal(el, { - doing: kind === 'issues' ? '📋 at the issues board' : '🔀 at the PR board', + doing: kind === 'issues' ? '📋 at the issues board' : `🔀 at the ${fw.pr} board`, onClose: () => { unsubs.forEach((u) => u()); clearInterval(timer); diff --git a/src/client/ui/changes.ts b/src/client/ui/changes.ts index 721dbbfd7..840cb5b1f 100644 --- a/src/client/ui/changes.ts +++ b/src/client/ui/changes.ts @@ -1,8 +1,10 @@ import './changes.css'; +import { forgeWords, type ForgeWords } from '../../shared/floors'; import { changedImageType, type ChangedFile, type ChangesState, type ServerMsg } from '../../shared/protocol'; import type { Net } from '../net'; import { store } from '../state'; import { h, openModal, type Modal } from './dom'; +import { words } from './forge'; import { confirmDialog, openPrompt } from './prompt'; // The Changes window at a desk: the files a worker changed and their diff against the branch the @@ -81,6 +83,11 @@ function renderDiff(text: string, truncated: boolean): HTMLElement { } /** Where one side of a changed picture loads from. The file's signature makes a new URL whenever it changes. */ +/** What a repository's floor calls things: this floor's (no `repo`), or another floor's. */ +function wordsOf(repo: string | undefined): ForgeWords { + return repo ? forgeWords(store.floors.find((f) => f.id === repo)?.forge) : words(); +} + function imageUrl(workerId: string, repo: string | undefined, f: ChangedFile, side: 'old' | 'new'): string { const q = new URLSearchParams({ floor: store.floor ?? '', worker: workerId, path: f.path, side, v: f.sig, ...(repo ? { repo } : {}) }); return `/api/changes/file?${q}`; @@ -244,18 +251,19 @@ export function openChanges(net: Net, workerId: string, onTerminal?: () => void, commitBtn.textContent = uncommitted ? `✅ Commit ${uncommitted} file${uncommitted > 1 ? 's' : ''}…` : '✅ Commit…'; prSlot.replaceChildren(); if (!s) return; - if (s.pr) prSlot.append(h('a.btn.primary', { href: s.pr.url, target: '_blank', rel: 'noopener', title: 'Open on GitHub' }, `🔀 PR #${s.pr.number} ↗`)); + const fw = wordsOf(repo); + if (s.pr) prSlot.append(h('a.btn.primary', { href: s.pr.url, target: '_blank', rel: 'noopener', title: `Open on ${fw.site}` }, `🔀 ${fw.pr} ${fw.ref}${s.pr.number} ↗`)); else if (s.prBase) { const why = busy ? '' : uncommitted ? 'Commit first' : !s.ahead ? `Nothing on ${s.branch} that ${s.prBase} lacks yet` : ''; - const pr = h('button.btn.primary', { type: 'button', title: why || `Push ${s.branch} and open a pull request against ${s.prBase}` }, '🔀 Open PR…'); + const pr = h('button.btn.primary', { type: 'button', title: why || `Push ${s.branch} and open a ${fw.pullRequest} against ${s.prBase}` }, `🔀 Open ${fw.pr}…`); pr.disabled = busy || !!why; pr.addEventListener('click', () => openPrompt({ - title: '🔀 Open a pull request', - subtitle: `Pushes ${s.branch} to origin and opens a PR against ${s.prBase}. The first line is the title; the rest is the description.`, + title: `🔀 Open a ${fw.pullRequest}`, + subtitle: `Pushes ${s.branch} to origin and opens a ${fw.pr} against ${s.prBase}. The first line is the title; the rest is the description.`, initial: s.subject ?? '', placeholder: 'Title', - submitLabel: 'Open PR ↗', + submitLabel: `Open ${fw.pr} ↗`, onSubmit: (text) => { const [first, ...rest] = text.split('\n'); net.send({ t: 'changes.pr', workerId, title: first.trim(), body: rest.join('\n').trim(), repo }); @@ -358,7 +366,7 @@ export function openChanges(net: Net, workerId: string, onTerminal?: () => void, 'button.btn', { type: 'button', role: 'tab', class: t.id === repo ? 'on' : '', 'aria-selected': t.id === repo ? 'true' : 'false', title: t.id ? `Its worktree of ${t.name}` : `Its worktree of this floor's project, ${t.name}`, onclick: () => show(t.id) }, `📁 ${t.name}`, - t.pr ? h('small', {}, ` · #${t.pr.number}`) : null, + t.pr ? h('small', {}, ` · ${wordsOf(t.id).ref}${t.pr.number}`) : null, ), ), ); diff --git a/src/client/ui/elevator.ts b/src/client/ui/elevator.ts index 36f6c92a9..cea53610c 100644 --- a/src/client/ui/elevator.ts +++ b/src/client/ui/elevator.ts @@ -1,6 +1,6 @@ import './elevator.css'; import type { CloneProgress, FloorInfo, RepoChoice, ServerMsg } from '../../shared/protocol'; -import { cloneLabel, cloneStep, floorPalette, normalizeRepo, sameRepo } from '../../shared/floors'; +import { cloneLabel, cloneStep, floorPalette, gitlabProject, normalizeRepo, sameRepo } from '../../shared/floors'; import { ROOF, ROOF_NAME } from '../../shared/rooftop'; import type { Net } from '../net'; import { store } from '../state'; @@ -8,7 +8,7 @@ import { h, openModal, timeAgo, toast, type Modal } from './dom'; import { confirmDialog } from './prompt'; // The elevator's panel: a button for every floor (every project), and "add a project", which clones -// one of the repositories the office's gh login can see and makes it a new floor. The first time +// one of the repositories the office's gh (or glab) login can see and makes it a new floor. The first time // the office runs there are no floors, and this is where you start. Admins can take a floor off the // building here too; its checkout stays on disk. Under the floors, it goes down to the garage. @@ -28,7 +28,7 @@ export interface ElevatorOptions { /** How many repositories the list shows at once; typing narrows it down. */ const SHOWN = 60; -/** Ask gh for the repositories again after this long. */ +/** Ask gh (and glab) for the repositories again after this long. */ const REPOS_STALE_MS = 5 * 60_000; /** Longer than the office takes to ask GitHub about a repository before its clone starts. */ const START_MS = 60_000; @@ -76,11 +76,11 @@ export function openElevator(opts: ElevatorOptions): void { const floorsEl = h('div.floors'); const addEl = h('div.add'); - const input = h('input', { type: 'text', placeholder: 'Search your repositories, or type owner/name', 'aria-label': 'Repository', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; + const input = h('input', { type: 'text', placeholder: 'Search your repositories, or type owner/name (or a GitLab URL)', 'aria-label': 'Repository', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; const listEl = h('div.repo-list', { role: 'listbox', 'aria-label': 'Repositories' }); const statusEl = h('div'); const addBtn = h('button.btn.primary', { type: 'button' }, '🛗 Add floor'); - const refreshBtn = h('button.btn', { type: 'button', title: 'Ask GitHub for the list again' }, '↻'); + const refreshBtn = h('button.btn', { type: 'button', title: 'Ask GitHub and GitLab for the list again' }, '↻'); const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close', title: 'Close (Esc)' }, '✕'); // Where clones go. Admins can move it right here: the first project is when it matters. @@ -114,7 +114,7 @@ export function openElevator(opts: ElevatorOptions): void { net.send({ t: 'floor.repos' }); }; - /** What "Add floor" would add: the row picked, else what's typed if it's owner/name. */ + /** What "Add floor" would add: the row picked, else what's typed if it's owner/name (or a GitLab project). */ const choice = (): string | undefined => selected ?? normalizeRepo(filter); const floorButton = (f: FloorInfo, i: number) => { @@ -273,26 +273,26 @@ export function openElevator(opts: ElevatorOptions): void { const typed = normalizeRepo(filter); const matches = r.list.filter((x) => !q || x.name.toLowerCase().includes(q) || (x.description ?? '').toLowerCase().includes(q)); const rows: HTMLElement[] = []; - // owner/name that isn't in the list (someone else's public repository): offer it anyway. + // owner/name (or a GitLab project) that isn't in the list (someone else's public repository): offer it anyway. if (typed && !r.list.some((x) => sameRepo(x.name, typed))) rows.push(repoRow({ name: typed, private: false, description: 'Not in your list — the office will try to clone it' })); rows.push(...matches.slice(0, SHOWN).map(repoRow)); - if (!rows.length) rows.push(h('p.empty', { style: 'padding:10px' }, r.loading ? 'Asking GitHub for your repositories…' : r.error ? '' : q ? 'Nothing matches. Type owner/name to clone any repository.' : 'No repositories.')); + if (!rows.length) rows.push(h('p.empty', { style: 'padding:10px' }, r.loading ? 'Asking GitHub and GitLab for your repositories…' : r.error ? '' : q ? 'Nothing matches. Type owner/name, or host/group/project for a GitLab, to clone any repository.' : 'No repositories.')); if (matches.length > SHOWN) rows.push(h('p.empty', { style: 'padding:8px 10px' }, `…and ${matches.length - SHOWN} more — type to narrow it down`)); listEl.replaceChildren(...rows); const pick = choice(); const dest = pick ? `${store.projectsDir.dir}/${pick}` : `${store.projectsDir.dir}//`; const change = store.me.admin ? h('button.btn.dir-change', { type: 'button', title: 'Clone new projects into another folder on the office’s machine' }, '📁 Change folder') : null; change?.addEventListener('click', () => editDir(true)); - // While it clones: how far it's got (the office asks GitHub about it first). + // While it clones: how far it's got (the office asks GitHub or the GitLab about it first). const on = addingFloor(); const lines = adding ? [ - h('p.note.busy', {}, on ? `⏳ Cloning ${on.repo ?? adding} into ${store.projectsDir.dir}/${on.repo ?? adding}` : `⏳ Asking GitHub about ${adding}…`), + h('p.note.busy', {}, on ? `⏳ Cloning ${on.repo ?? adding} into ${store.projectsDir.dir}/${on.repo ?? adding}` : `⏳ Asking ${gitlabProject(adding)?.host ?? 'GitHub'} about ${adding}…`), on ? cloneBar(on.clone) : null, on ? h('p.note', {}, [cloneStep(on.clone), on.clone?.detail].filter(Boolean).join(' · ')) : null, h('p.note', {}, 'You can close this and carry on: everyone hears when the new floor opens.'), ] - : [h('p.note', {}, `Cloned into ${dest} with this machine's gh login. Everything on the new floor works in that checkout.`, change)]; + : [h('p.note', {}, `Cloned into ${dest} with this machine's gh login (glab's for a GitLab). Everything on the new floor works in that checkout.`, change)]; statusEl.replaceChildren(...lines.filter((l): l is HTMLElement => !!l), ...[r.error, error].filter(Boolean).map((e) => h('p.err', {}, e))); addBtn.disabled = !!adding || !pick || store.floors.some((f) => sameRepo(f.repo, pick)); addBtn.textContent = adding ? '⏳ Cloning…' : pick ? `🛗 Add ${pick}` : '🛗 Add floor'; diff --git a/src/client/ui/forge.ts b/src/client/ui/forge.ts new file mode 100644 index 000000000..2910ba86d --- /dev/null +++ b/src/client/ui/forge.ts @@ -0,0 +1,16 @@ +import { forgeWords, type ForgeWords } from '../../shared/floors'; +import { store } from '../state'; + +/** + * What the floor you're on calls things: GitHub's pull requests (PR #12), or a GitLab's merge + * requests (MR !12). GitHub's words while there's no floor yet. + */ +export function words(): ForgeWords { + return forgeWords(store.currentFloor()?.forge); +} + +/** "PR #12" on GitHub, "MR !12" on GitLab. */ +export function prName(n: number): string { + const w = words(); + return `${w.pr} ${w.ref}${n}`; +} diff --git a/src/client/ui/github/close.ts b/src/client/ui/github/close.ts index 8341a38fc..f7637bff4 100644 --- a/src/client/ui/github/close.ts +++ b/src/client/ui/github/close.ts @@ -2,6 +2,7 @@ import type { GhCloseReason, GhIssue, GhPull } from '../../../shared/protocol'; import type { Net } from '../../net'; import { store, workerForPull } from '../../state'; import { h, openModal } from '../dom'; +import { words } from '../forge'; import { closeWaiters } from './api'; // ---- Close dialog ------------------------------------------------------------------------------- @@ -14,30 +15,32 @@ export function openClose(kind: 'issue' | 'pull', it: GhIssue | GhPull, net: Net const pull = kind === 'pull' ? (it as GhPull) : null; let reason: GhCloseReason = 'completed'; let busy = false; + const fw = words(); const go = h('button.btn.danger', { type: 'button' }); const reasons = h('div.seg'); const renderReasons = () => { reasons.replaceChildren(...(Object.keys(REASON_LABEL) as GhCloseReason[]).map((r) => h('button.btn', { type: 'button', class: r === reason ? 'on' : '', onclick: () => ((reason = r), renderReasons()) }, REASON_LABEL[r]))); - go.textContent = pull ? '🚫 Close pull request' : `${reason === 'completed' ? '✔️' : '🚫'} Close as ${reason}`; + go.textContent = pull ? `🚫 Close ${fw.pullRequest}` : `${reason === 'completed' ? '✔️' : '🚫'} Close as ${reason}`; }; const comment = h('textarea', { rows: 4, placeholder: 'Leave a comment (optional)', 'aria-label': 'Closing comment' }) as HTMLTextAreaElement; const del = h('input', { type: 'checkbox', id: 'close-del' }) as HTMLInputElement; const w = pull && workerForPull(store.workers.values(), pull); const result = h('div.gh-merge-result.hidden'); const cancel = h('button.btn', { type: 'button' }, 'Cancel'); - const noun = pull ? 'pull request' : 'issue'; + const noun = pull ? fw.pullRequest : 'issue'; + const ref = pull ? fw.ref : '#'; const el = h( 'div.modal.gh-merge', - { role: 'dialog', 'aria-label': `Close ${noun} #${it.number}` }, - h('header', {}, h('h2', {}, `${pull ? '🚫' : '✔️'} Close ${pull ? 'PR' : 'issue'} #${it.number}`)), + { role: 'dialog', 'aria-label': `Close ${noun} ${ref}${it.number}` }, + h('header', {}, h('h2', {}, `${pull ? '🚫' : '✔️'} Close ${pull ? fw.pr : 'issue'} ${ref}${it.number}`)), h( 'div.body', {}, h('p.gh-merge-title', {}, it.title, pull ? h('small', {}, `${pull.headRefName} → ${pull.baseRefName}`) : null), pull - ? h('div.gh-status.muted', {}, h('span', {}, 'ℹ️'), `It won't be merged, and can be reopened on GitHub later.${w ? ` ${w.name} is still at a desk working on its branch.` : ''}`) + ? h('div.gh-status.muted', {}, h('span', {}, 'ℹ️'), `It won't be merged, and can be reopened on ${fw.site} later.${w ? ` ${w.name} is still at a desk working on its branch.` : ''}`) : h('label', {}, 'Why'), pull ? h('label.gh-check', { for: 'close-del' }, del, `Delete ${pull.headRefName} too`) : reasons, comment, diff --git a/src/client/ui/github/comment-box.ts b/src/client/ui/github/comment-box.ts index 4e9d8f9e2..bc6ea11b4 100644 --- a/src/client/ui/github/comment-box.ts +++ b/src/client/ui/github/comment-box.ts @@ -1,6 +1,7 @@ import type { GhComment } from '../../../shared/protocol'; import type { Net } from '../../net'; import { h } from '../dom'; +import { words } from '../forge'; import { markdown } from '../markdown'; import { commentWaiters } from './api'; import { DRAFT_KEY, pref, savePref } from './prefs'; @@ -23,6 +24,7 @@ export interface CommentBox { export function commentBox(kind: 'issue' | 'pull', number: number, itemUrl: string, net: Net, onPosted: (c: GhComment) => void): CommentBox { const draftKey = `${DRAFT_KEY}${itemUrl}`; const waitKey = `${kind}#${number}`; + const fw = words(); let busy = false; let timer = 0; const ta = h('textarea', { rows: 4, placeholder: 'Leave a comment. Markdown works; ⌘/Ctrl+Enter posts it.', 'aria-label': 'Comment' }) as HTMLTextAreaElement; @@ -30,7 +32,7 @@ export function commentBox(kind: 'issue' | 'pull', number: number, itemUrl: stri const shown = h('div.gh-compose-preview.hidden'); const write = h('button.btn.on', { type: 'button' }, 'Write'); const preview = h('button.btn', { type: 'button' }, 'Preview'); - const who = h('span.grow', {}, "Posts to GitHub as the office's gh account"); + const who = h('span.grow', {}, `Posts to ${fw.site} as the office's ${fw.cli} account`); const post = h('button.btn.primary', { type: 'button' }, '💬 Comment'); const result = h('div.gh-merge-result.error.hidden'); const el = h( @@ -86,7 +88,7 @@ export function commentBox(kind: 'issue' | 'pull', number: number, itemUrl: stri saveDraft(); setPreview(false); onPosted(msg.comment); - } else fail(msg.error ?? 'GitHub did not take the comment'); + } else fail(msg.error ?? `${fw.site} did not take the comment`); sync(); }); // The office drops messages while it's disconnected, and then no answer comes. @@ -112,7 +114,7 @@ export function commentBox(kind: 'issue' | 'pull', number: number, itemUrl: stri return { el, setViewer(login) { - if (login) who.textContent = `Posts to GitHub as @${login}`; + if (login) who.textContent = `Posts to ${fw.site} as @${login}`; }, dispose: settle, }; diff --git a/src/client/ui/github/issue-window.ts b/src/client/ui/github/issue-window.ts index 67c8695e8..d44f4cee4 100644 --- a/src/client/ui/github/issue-window.ts +++ b/src/client/ui/github/issue-window.ts @@ -3,6 +3,7 @@ import type { GhIssue, GhIssueDetail } from '../../../shared/protocol'; import type { Net } from '../../net'; import { store } from '../../state'; import { h, openModal, timeAgo } from '../dom'; +import { words } from '../forge'; import { issueMeeting } from '../meeting'; import { providerPicker } from '../provider'; import { getJson } from './api'; @@ -31,7 +32,7 @@ export function openIssue(first: GhIssue, net: Net, actions: BoardActions) { conv.append(h('div.gh-col', {}, thread, comment.el)); // The footer stays put and renderFrame only shows, hides and relabels, so a board refresh never // pulls focus out of the provider picker. - const closeIssue = h('button.btn', { type: 'button', title: 'Close this issue on GitHub', onclick: () => openClose('issue', it, net, load) }, '✔️ Close issue…'); + const closeIssue = h('button.btn', { type: 'button', title: `Close this issue on ${words().site}`, onclick: () => openClose('issue', it, net, load) }, '✔️ Close issue…'); const queueProvider = providerPicker(store.project, `issue-provider-${it.number}`, 'Queue on'); const addIssueToQueue = () => { if (!queueProvider.valid()) return; @@ -51,7 +52,7 @@ export function openIssue(first: GhIssue, net: Net, actions: BoardActions) { h( 'footer', {}, - h('a.grow', { href: it.url, target: '_blank', rel: 'noopener noreferrer' }, 'Open on GitHub ↗'), + h('a.grow', { href: it.url, target: '_blank', rel: 'noopener noreferrer' }, `Open on ${words().site} ↗`), h('button.btn', { type: 'button', title: 'Send a worker your own prompt about this issue', onclick: () => actions.ask(issueContext(it), `Ask about issue #${it.number}`) }, '✍️ Ask a worker…'), h('button.btn', { type: 'button', title: 'Workers take it on together in the meeting room: a debate, lead & team, map-reduce or red / blue', onclick: () => actions.meeting(issueMeeting(it.number, it.title)) }, '🤝 Meeting…'), closeIssue, diff --git a/src/client/ui/github/labels.ts b/src/client/ui/github/labels.ts index cc1933422..d1a16c051 100644 --- a/src/client/ui/github/labels.ts +++ b/src/client/ui/github/labels.ts @@ -2,6 +2,7 @@ import './windows.css'; import type { GhIssue, GhLabel, GhPull } from '../../../shared/protocol'; import type { Net } from '../../net'; import { h, openModal } from '../dom'; +import { words } from '../forge'; import { repoUrlOf } from '../markdown'; import { getJson, labelWaiters } from './api'; import { errorBox, spinnerRow } from './pieces'; @@ -23,8 +24,10 @@ export function openLabels(kind: 'issue' | 'pull', it: GhIssue | GhPull, net: Ne const key = `${kind}:${it.number}`; const had = new Set(it.labels.map((l) => l.name)); const on = new Set(had); - const noun = kind === 'pull' ? 'PR' : 'issue'; - const manage = `${repoUrlOf(it.url)}/labels`; + const fw = words(); + const noun = kind === 'pull' ? fw.pr : 'issue'; + const ref = kind === 'pull' ? fw.ref : '#'; + const manage = `${repoUrlOf(it.url)}${fw.site === 'GitLab' ? '/-' : ''}/labels`; let repo: GhLabel[] | null = null; let error = ''; let busy = false; @@ -41,8 +44,8 @@ export function openLabels(kind: 'issue' | 'pull', it: GhIssue | GhPull, net: Ne const save = h('button.btn.primary', { type: 'button' }, '🏷️ Save labels'); const el = h( 'div.modal.gh-merge.gh-labeler', - { role: 'dialog', 'aria-label': `Labels on ${noun} #${it.number}` }, - h('header', {}, h('h2', {}, `🏷️ Labels on ${noun} #${it.number}`)), + { role: 'dialog', 'aria-label': `Labels on ${noun} ${ref}${it.number}` }, + h('header', {}, h('h2', {}, `🏷️ Labels on ${noun} ${ref}${it.number}`)), h('div.body', {}, h('p.gh-merge-title', {}, it.title), filter, list, none, result), h('footer', {}, summary, cancel, save), ); @@ -66,7 +69,7 @@ export function openLabels(kind: 'issue' | 'pull', it: GhIssue | GhPull, net: Ne const empty = !!repo && !shown; none.classList.toggle('hidden', !empty); if (empty) - none.replaceChildren(q ? `No labels match “${filter.value.trim()}”. ` : 'This repository has no labels yet. ', h('a', { href: manage, target: '_blank', rel: 'noopener noreferrer' }, 'Make one on GitHub ↗')); + none.replaceChildren(q ? `No labels match “${filter.value.trim()}”. ` : 'This repository has no labels yet. ', h('a', { href: manage, target: '_blank', rel: 'noopener noreferrer' }, `Make one on ${fw.site} ↗`)); }; const row = (l: GhLabel) => { const box = h('input', { type: 'checkbox' }) as HTMLInputElement; @@ -117,11 +120,11 @@ export function openLabels(kind: 'issue' | 'pull', it: GhIssue | GhPull, net: Ne if (busy || (!add.length && !remove.length)) return; busy = true; result.className = 'gh-merge-result'; - result.replaceChildren(h('span.spinner'), 'Saving the labels on GitHub…'); + result.replaceChildren(h('span.spinner'), `Saving the labels on ${fw.site}…`); sync(); labelWaiters.set(key, (msg) => { settle(); - if (!msg.labels) return fail(msg.error ?? 'GitHub did not take the labels'); + if (!msg.labels) return fail(msg.error ?? `${fw.site} did not take the labels`); modal.close(); onSaved?.(msg.labels); }); diff --git a/src/client/ui/github/merge.ts b/src/client/ui/github/merge.ts index 9b51a205d..eaea5784f 100644 --- a/src/client/ui/github/merge.ts +++ b/src/client/ui/github/merge.ts @@ -1,6 +1,7 @@ import type { GhCheck, GhMergeMethod, GhPull, GhPullDetail } from '../../../shared/protocol'; import type { Net } from '../../net'; import { h, openModal } from '../dom'; +import { prName, words } from '../forge'; import { mergeWaiters } from './api'; import { MERGE_KEY, mergePref, savePref } from './prefs'; @@ -14,7 +15,7 @@ interface MergeStatus { cls: 'ok' | 'warn' | 'bad' | 'muted'; /** False when merging can't work at all (draft, conflicts, already merged). */ can: boolean; - /** GitHub could merge it on its own once the requirements pass. */ + /** GitHub (or GitLab) could merge it on its own once the requirements pass. */ auto: boolean; } @@ -28,7 +29,7 @@ export function mergeStatus(d: GhPullDetail): MergeStatus { const pending = d.checks.filter((c) => c.state === 'pending').length; if (d.state === 'MERGED') return { icon: '🎉', text: 'Merged.', cls: 'ok', can: false, auto: false }; if (d.state === 'CLOSED') return { icon: '🗑️', text: 'Closed without merging.', cls: 'muted', can: false, auto: false }; - if (d.isDraft) return { icon: '📝', text: 'This is still a draft. Mark it ready for review on GitHub before merging.', cls: 'muted', can: false, auto: false }; + if (d.isDraft) return { icon: '📝', text: `This is still a draft. Mark it ready for review on ${words().site} before merging.`, cls: 'muted', can: false, auto: false }; if (conflicted(d)) return { icon: '⚠️', text: `This branch has conflicts with ${d.baseRefName} that must be resolved first.`, cls: 'bad', can: false, auto: false }; if (d.mergeStateStatus === 'BEHIND') return { icon: '⤵️', text: `The branch is behind ${d.baseRefName}, and this repo wants it up to date before merging.`, cls: 'warn', can: true, auto: true }; @@ -38,7 +39,7 @@ export function mergeStatus(d: GhPullDetail): MergeStatus { } if (failing) return { icon: '❌', text: `${failing} check${failing > 1 ? 's' : ''} failing. It can still be merged.`, cls: 'warn', can: true, auto: false }; if (pending || d.mergeStateStatus === 'UNSTABLE') return { icon: '🟡', text: 'Checks are still running. It can be merged now, or once they pass.', cls: 'warn', can: true, auto: true }; - if (d.mergeStateStatus === 'UNKNOWN' || d.mergeable === 'UNKNOWN') return { icon: '⏳', text: 'GitHub is still working out whether this can merge. Refresh in a moment.', cls: 'muted', can: true, auto: false }; + if (d.mergeStateStatus === 'UNKNOWN' || d.mergeable === 'UNKNOWN') return { icon: '⏳', text: `${words().site} is still working out whether this can merge. Refresh in a moment.`, cls: 'muted', can: true, auto: false }; return { icon: '✅', text: `Ready to merge: no conflicts with ${d.baseRefName}${d.checks.length ? ' and all checks passed' : ''}.`, cls: 'ok', can: true, auto: false }; } @@ -90,8 +91,8 @@ export function openMerge(it: GhPull, d: GhPullDetail, net: Net, handToWorker: ( const el = h( 'div.modal.gh-merge', - { role: 'dialog', 'aria-label': `Merge PR #${it.number}` }, - h('header', {}, h('h2', {}, `🔀 Merge #${it.number}`)), + { role: 'dialog', 'aria-label': `Merge ${prName(it.number)}` }, + h('header', {}, h('h2', {}, `🔀 Merge ${words().ref}${it.number}`)), h( 'div.body', {}, @@ -101,7 +102,7 @@ export function openMerge(it: GhPull, d: GhPullDetail, net: Net, handToWorker: ( h('label', { style: 'margin-top:14px' }, 'How'), methodBtns, h('label.gh-check', { for: 'merge-del' }, del, `Delete ${it.headRefName} after merging`), - st.auto ? h('label.gh-check', { for: 'merge-auto', title: 'gh pr merge --auto (the repo must allow auto-merge)' }, auto, 'Merge automatically once the requirements pass') : null, + st.auto ? h('label.gh-check', { for: 'merge-auto', title: words().cli === 'glab' ? 'GitLab merges it once its pipeline passes' : 'gh pr merge --auto (the repo must allow auto-merge)' }, auto, 'Merge automatically once the requirements pass') : null, result, ), h('footer', {}, st.can || conflicted(d) ? null : worker, h('span.grow'), cancel, conflicted(d) ? worker : go), @@ -120,7 +121,7 @@ export function openMerge(it: GhPull, d: GhPullDetail, net: Net, handToWorker: ( busy = true; go.disabled = true; result.className = 'gh-merge-result'; - result.replaceChildren(h('span.spinner'), auto.checked && st.auto ? 'Asking GitHub to merge it when ready…' : 'Merging…'); + result.replaceChildren(h('span.spinner'), auto.checked && st.auto ? `Asking ${words().site} to merge it when ready…` : 'Merging…'); mergeWaiters.set(it.number, (msg) => { mergeWaiters.delete(it.number); busy = false; diff --git a/src/client/ui/github/pieces.ts b/src/client/ui/github/pieces.ts index b35c44ecc..4b66b06bb 100644 --- a/src/client/ui/github/pieces.ts +++ b/src/client/ui/github/pieces.ts @@ -1,6 +1,7 @@ import type { GhComment } from '../../../shared/protocol'; import { AVATAR_COLORS } from '../../state'; import { h, timeAgo } from '../dom'; +import { words } from '../forge'; import { markdown } from '../markdown'; // ---- Small pieces --------------------------------------------------------------------------------- @@ -42,7 +43,7 @@ export function spinnerRow(text: string) { } export function errorBox(text: string, retry?: () => void) { - return h('div.gh-error', {}, `Couldn't load from GitHub: ${text}`, retry ? h('button.btn', { type: 'button', onclick: retry }, 'Try again') : null); + return h('div.gh-error', {}, `Couldn't load from ${words().site}: ${text}`, retry ? h('button.btn', { type: 'button', onclick: retry }, 'Try again') : null); } export function stateOf(it: { state: string; isDraft?: boolean }): [string, string] { diff --git a/src/client/ui/github/prompts.ts b/src/client/ui/github/prompts.ts index dd3dd2b9a..318777fe0 100644 --- a/src/client/ui/github/prompts.ts +++ b/src/client/ui/github/prompts.ts @@ -7,7 +7,7 @@ import { officePrompt } from '../prompts'; // ---- Prompts for workers ------------------------------------------------------------------------ export interface BoardActions { - /** Start a worker on a ready-made prompt (shown for editing first). With `issue`, the worker takes that GitHub issue, which moves to In progress. */ + /** Start a worker on a ready-made prompt (shown for editing first). With `issue`, the worker takes that issue (on GitHub or GitLab), which moves to In progress. */ assign(prompt: string, title: string, issue?: number): void; /** Your own prompt about an issue or PR; `context` goes first so the worker knows which. */ ask(context: string, title: string): void; @@ -31,7 +31,7 @@ export function issueVars(it: Pick & { url?: string return { number: it.number, title: it.title, url: it.url ?? store.issues.items.find((i) => i.number === it.number)?.url ?? '' }; } -/** owner/repo from a PR or issue URL. */ +/** owner/repo from a PR or issue URL (group/…/project for a GitLab's). */ function nameWithOwner(url: string): string { return repoUrlOf(url).replace(/^https?:\/\/[^/]+\//, ''); } @@ -46,6 +46,8 @@ export function reviewPrompt(it: GhPull) { } function mergeCommand(it: GhPull, method: GhMergeMethod, deleteBranch: boolean) { + // A GitLab merge request: glab merges with a merge commit unless told to squash. + if (/\/-\/merge_requests\/\d+/.test(it.url)) return `glab mr merge ${it.number}${method === 'squash' ? ' --squash' : ''}${deleteBranch ? ' --remove-source-branch' : ''} --yes`; return `gh pr merge ${it.number} --${method}${deleteBranch ? ' --delete-branch' : ''} --repo ${nameWithOwner(it.url)}`; } diff --git a/src/client/ui/github/pull-window.ts b/src/client/ui/github/pull-window.ts index fb2bd9c4f..fc33ec2fc 100644 --- a/src/client/ui/github/pull-window.ts +++ b/src/client/ui/github/pull-window.ts @@ -3,6 +3,7 @@ import type { GhPull, GhPullDetail, GhReviewComment } from '../../../shared/prot import type { Net } from '../../net'; import { store, workerForPull } from '../../state'; import { h, openModal, type Modal } from '../dom'; +import { prName, words } from '../forge'; import { officePrompt } from '../prompts'; import { getJson, getText } from './api'; import { openClose } from './close'; @@ -20,6 +21,8 @@ import { buildTree, looksGenerated, parseDiff, renderFileDiff, renderThread, rep export function openPull(first: GhPull, net: Net, actions: BoardActions) { let it = first; + const fw = words(); + const name = () => prName(it.number); const itemUrl = it.url; const reviewed = new Reviewed(it.url); let detail: GhPullDetail | null = null; @@ -38,7 +41,7 @@ export function openPull(first: GhPull, net: Net, actions: BoardActions) { // --- Frame const pill = h('span.pill'); const title = h('h2'); - const reload = h('button.btn', { type: 'button', title: 'Reload from GitHub' }, '🔄'); + const reload = h('button.btn', { type: 'button', title: `Reload from ${fw.site}` }, '🔄'); const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); const meta = h('div.gh-meta'); const tabConv = h('button.gh-tab', { type: 'button', role: 'tab' }); @@ -58,25 +61,25 @@ export function openPull(first: GhPull, net: Net, actions: BoardActions) { const footBtns = h('span.gh-foot'); const el = h( 'div.modal.gh-window', - { role: 'dialog', 'aria-label': `Pull request #${it.number}`, tabindex: -1 }, + { role: 'dialog', 'aria-label': `${fw.pullRequest[0].toUpperCase()}${fw.pullRequest.slice(1)} ${fw.ref}${it.number}`, tabindex: -1 }, h('header', {}, pill, title, reload, close), meta, h('nav.gh-tabs', { role: 'tablist' }, tabConv, tabFiles), h('div.gh-body', {}, conv, filesPane), - h('footer', {}, h('a.grow', { href: it.url, target: '_blank', rel: 'noopener noreferrer' }, 'Open on GitHub ↗'), footBtns), + h('footer', {}, h('a.grow', { href: it.url, target: '_blank', rel: 'noopener noreferrer' }, `Open on ${fw.site} ↗`), footBtns), ); const handToWorker = () => { const p = mergePref(detail?.repo.methods ?? ['squash', 'merge', 'rebase']); - if (detail && conflicted(detail)) actions.assign(fixConflictsPrompt(it, p.method, p.deleteBranch), `Fix conflicts & merge PR #${it.number}`); - else actions.assign(fixAndMergePrompt(it, p.method, p.deleteBranch), `Fix up & merge PR #${it.number}`); + if (detail && conflicted(detail)) actions.assign(fixConflictsPrompt(it, p.method, p.deleteBranch), `Fix conflicts & merge ${name()}`); + else actions.assign(fixAndMergePrompt(it, p.method, p.deleteBranch), `Fix up & merge ${name()}`); }; const renderFrame = () => { const [word, cls] = stateOf(it); pill.className = `pill ${cls}`; pill.textContent = word; - title.textContent = `#${it.number} ${it.title}`; + title.textContent = `${fw.ref}${it.number} ${it.title}`; title.title = it.title; const commits = detail ? `${detail.commits} commit${detail.commits === 1 ? '' : 's'}` : 'its commits'; meta.replaceChildren( @@ -105,23 +108,23 @@ export function openPull(first: GhPull, net: Net, actions: BoardActions) { const isOpen = it.state === 'OPEN'; const conflicts = !!detail && conflicted(detail); - const merge = h(conflicts ? 'button.btn' : 'button.btn.primary', { type: 'button', disabled: !detail, title: detail ? 'Merge this pull request' : 'Loading…' }, '🔀 Merge…'); + const merge = h(conflicts ? 'button.btn' : 'button.btn.primary', { type: 'button', disabled: !detail, title: detail ? `Merge this ${fw.pullRequest}` : 'Loading…' }, '🔀 Merge…'); merge.addEventListener('click', () => detail && openMerge(it, detail, net, handToWorker, loadAll)); const w = workerForPull(store.workers.values(), it); footBtns.replaceChildren( ...nodes( w ? h('button.btn', { type: 'button', onclick: () => actions.goToDesk(w.deskId) }, `🪑 Go to ${w.name}'s desk`) : null, - h('button.btn', { type: 'button', title: 'Send a worker your own prompt about this PR', onclick: () => actions.ask(pullContext(it), `Ask about PR #${it.number}`) }, '✍️ Ask a worker…'), - isOpen ? h('button.btn', { type: 'button', onclick: () => actions.assign(reviewPrompt(it), `Review PR #${it.number}`) }, '🔍 Review') : null, + h('button.btn', { type: 'button', title: `Send a worker your own prompt about this ${fw.pr}`, onclick: () => actions.ask(pullContext(it), `Ask about ${name()}`) }, '✍️ Ask a worker…'), + isOpen ? h('button.btn', { type: 'button', onclick: () => actions.assign(reviewPrompt(it), `Review ${name()}`) }, '🔍 Review') : null, isOpen - ? h('button.btn', { type: 'button', title: 'A few workers review it in the meeting room, each through its own lens, and the office posts one combined review', onclick: () => actions.meeting({ pattern: 'review', pr: it.number, title: `Review of PR #${it.number}`, prompt: officePrompt('pull.panel', pullVars(it)) }) }, '🤝 Review panel…') + ? h('button.btn', { type: 'button', title: 'A few workers review it in the meeting room, each through its own lens, and the office posts one combined review', onclick: () => actions.meeting({ pattern: 'review', pr: it.number, title: `Review of ${name()}`, prompt: officePrompt('pull.panel', pullVars(it)) }) }, '🤝 Review panel…') : null, conflicts ? h('button.btn.primary', { type: 'button', title: 'A new worker merges the base in, resolves the conflicts, gets the checks green, then merges', onclick: handToWorker }, '✨ Fix conflicts & merge') : isOpen ? h('button.btn', { type: 'button', title: 'A worker addresses the review comments, gets the checks green, then merges', onclick: handToWorker }, '🤖 Fix comments & merge') : null, - isOpen ? h('button.btn', { type: 'button', title: 'Close this pull request without merging it', onclick: () => openClose('pull', it, net, loadAll) }, '🚫 Close PR…') : null, + isOpen ? h('button.btn', { type: 'button', title: `Close this ${fw.pullRequest} without merging it`, onclick: () => openClose('pull', it, net, loadAll) }, `🚫 Close ${fw.pr}…`) : null, isOpen ? merge : null, ), ); @@ -508,7 +511,7 @@ export function openPull(first: GhPull, net: Net, actions: BoardActions) { renderFrame(); }); const modal: Modal = openModal(el, { - doing: `🔀 reading PR #${it.number}`, + doing: `🔀 reading ${name()}`, onClose: () => { unsub(); comment.dispose(); diff --git a/src/client/ui/help.ts b/src/client/ui/help.ts index d1d9fc809..6a39fa716 100644 --- a/src/client/ui/help.ts +++ b/src/client/ui/help.ts @@ -1,5 +1,6 @@ // The rows of the controls help (openHelp in hud.ts), in the order it shows them: a key or an emoji, and -// what it does. A new control is a new row here. +// what it does. A new control is a new row here. It's the same on every floor, so it names both a +// GitHub floor's pull requests and a GitLab floor's merge requests. import { IS_MAC } from './termkeys'; @@ -11,10 +12,10 @@ export const HELP_ROWS: readonly (readonly [string, string])[] = [ ['Click / E', "Use what you look at: hire a worker, open its terminal, read a board, call a meeting in the meeting room, watch the TV, put a song on the jukebox, tee off from the balcony, sit on a couch, a beanbag, a chair or the balcony bench (walk off to get up)"], ['👥', 'Click someone under "In the office" to walk over to them (on another floor, you ride the elevator first). The line under their name says what they have open or where they are'], ['🛗', 'Every project is a floor: step into the elevator on the north wall and press E (or click the project name, top left) to go to another one or add a project. It goes down to the garage too, and back up from there'], - ['🤖', 'An agent stands by the issues board, the PR board and the task queue. Press E at one and type what you want: it runs as an agent that knows that board. O there opens its terminal; Hide, ✕ or Esc closes the terminal without sending it home; X sends it home'], + ['🤖', 'An agent stands by the issues board, the PR board (MR board on a GitLab floor) and the task queue. Press E at one and type what you want: it runs as an agent that knows that board. O there opens its terminal; Hide, ✕ or Esc closes the terminal without sending it home; X sends it home'], ['📝', 'The whiteboard on wheels between the desks and the lounge: press E to draw on it with everyone on your floor, live. What you draw stays up on the board'], ['🕹️', 'The arcade cabinet in the lounge plays BLOCKFALL: arrows (or WASD) move and turn, Space drops, C holds, P pauses. Everyone on the floor sees your game on it, and E there watches whoever is playing. One of your workers needing input pauses it'], - ['🎉', 'Whenever a pull request merges, the gong next to the PR board rings, confetti rains down all over the floor and every worker gets up on its desk for a quick dance. Walk up to the gong and press E to bang it yourself'], + ['🎉', 'Whenever a pull request (or a GitLab merge request) merges, the gong next to the PR board rings, confetti rains down all over the floor and every worker gets up on its desk for a quick dance. Walk up to the gong and press E to bang it yourself'], ['N', "Next worker that needs you: go to whoever needs input (they have a red beacon over their desk, and a banner up top says what each is asking), then whoever is done and nobody's looked at, longest-waiting first, and again for the next one. Arrows at the edge of the screen point to the ones out of sight"], ['🏀', 'The hoop on the west wall, by the exit door: E at the ball picks it up. Hold E (or the mouse, in first person) and let go when the meter is in the green to sink it. In first person it goes where you look. Q drops it. Everyone on your floor sees your shot'], ['🏎️', "The Lambos and Ferraris in the garage: E at one gets you behind the wheel, or beside whoever's driving it. W is the gas, S brakes and reverses, A and D steer, Space brakes, H honks and E gets you out. Everyone on your floor sees you drive by"], @@ -22,7 +23,7 @@ export const HELP_ROWS: readonly (readonly [string, string])[] = [ ['🎯', 'Up on the roof, in the corner past the DJ: a dart board and an axe-throwing lane. E at either steps up to the line. The mouse (or the arrow keys) aims, and your hand wanders more after a few drinks. Hold Space (or the mouse button) and let go in the green: three darts a visit, five axes a round, chalked up for everyone up there. E steps back'], ['Drag / wheel', 'Orbit and zoom the camera in third person'], ['P', 'Prompt: give a task to a new or existing worker at the desk you face'], - ['C', 'Changes: what the worker at the desk you face changed — files and diff, commit, discard, open a PR'], + ['C', 'Changes: what the worker at the desk you face changed — files and diff, commit, discard, open a PR (an MR on GitLab)'], ['B', 'Open a shared shell (dev servers, git, tests) at an empty desk'], ['R', 'Resume a sleeping worker'], ['X', 'Send a worker home (frees the desk)'], @@ -31,11 +32,11 @@ export const HELP_ROWS: readonly (readonly [string, string])[] = [ ['F', 'Hang a picture from the web on a wall. Look at a picture and press E to move, edit or take it down'], ['Q', 'Put back the issue card in your hands (E at a note on the issues board, or ✋ Pick it up in an issue; then E at an empty desk, a worker or the queue board), or drop the basketball'], ['🐶', 'Walk up to the office dog and press E to pet it. When a worker needs input, it runs to that desk and barks. Name it in ⚙️ Settings'], - ['O', 'Open a pull request for a worker on its own branch, or see the one it has'], + ['O', 'Open a pull request (a merge request on GitLab) for a worker on its own branch, or see the one it has'], ['T', 'Chat'], ['G / 1–6', 'Emote: hold G, point at one and let go (or tap G and click one), or press 1–6: wave, thumbs up, clap, dance, point, facepalm. Everyone on your floor sees it'], ['/', 'Search the chat and every terminal on your floor, back to before the office last restarted'], - [IS_MAC ? '⌘K' : 'Ctrl+K', 'Command palette: type a few letters to find a worker, issue, PR, service, board, teammate or action. Enter opens it, Shift+Enter walks you over to it first'], + [IS_MAC ? '⌘K' : 'Ctrl+K', 'Command palette: type a few letters to find a worker, issue, PR or MR, service, board, teammate or action. Enter opens it, Shift+Enter walks you over to it first'], ['V', 'Join voice. In voice, hold V to talk (push to talk): you’re muted once you let go. Leave voice from the ☰ menu'], ['M', 'Mute or unmute your mic in voice. ⚙️ Settings can have you join muted, for push to talk'], ['Ctrl+Space', 'Dictate: in a worker’s terminal or a prompt box, hold Ctrl+Space (or the 🎤) and talk, and what you said is typed in when you let go, for you to read over and send. A quick tap leaves it listening until you tap again. The browser does the listening (Chrome, Edge and Safari can), so there’s nothing to install'], diff --git a/src/client/ui/markdown.ts b/src/client/ui/markdown.ts index c8737e225..66a0fa6be 100644 --- a/src/client/ui/markdown.ts +++ b/src/client/ui/markdown.ts @@ -44,9 +44,16 @@ function alerts(root: HTMLElement) { } const REF_RE = /(^|[^\w/&#`])(#(\d+)|@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38})))\b/g; +/** On a GitLab: #123 is an issue, !123 a merge request, and a username may have dots and underscores. */ +const LAB_REF_RE = /(^|[^\w/&#`!])(#(\d+)|@([A-Za-z0-9_](?:[A-Za-z0-9_.-]{0,254}[A-Za-z0-9_])?)|!(\d+))\b/g; -/** Links #123 to the issue or PR and @name to the person, outside code and existing links. */ -function linkify(root: HTMLElement, repoUrl?: string) { +/** A GitLab's issue or merge request page: https://host/group/project/-/issues/12. */ +const isLab = (itemUrl: string | undefined) => !!itemUrl && /\/-\/(?:merge_requests|issues)\/\d+/.test(itemUrl); + +/** Links #123 to the issue or PR (and on a GitLab !123 to the merge request) and @name to the person, outside code and existing links. */ +function linkify(root: HTMLElement, repoUrl?: string, lab = false) { + const re = lab ? LAB_REF_RE : REF_RE; + const site = lab && repoUrl ? new URL(repoUrl).origin : 'https://github.com'; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (n) => (n.parentElement?.closest('a, code, pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT), }); @@ -54,16 +61,17 @@ function linkify(root: HTMLElement, repoUrl?: string) { while (walker.nextNode()) texts.push(walker.currentNode as Text); for (const t of texts) { const s = t.data; - REF_RE.lastIndex = 0; - if (!REF_RE.test(s)) continue; - REF_RE.lastIndex = 0; + re.lastIndex = 0; + if (!re.test(s)) continue; + re.lastIndex = 0; const frag = document.createDocumentFragment(); let at = 0; - for (let m = REF_RE.exec(s); m; m = REF_RE.exec(s)) { + for (let m = re.exec(s); m; m = re.exec(s)) { const start = m.index + m[1].length; - const href = m[3] ? (repoUrl ? `${repoUrl}/issues/${m[3]}` : '') : `https://github.com/${m[4]}`; + const issues = lab ? '-/issues' : 'issues'; + const href = m[3] ? (repoUrl ? `${repoUrl}/${issues}/${m[3]}` : '') : m[5] ? (repoUrl ? `${repoUrl}/-/merge_requests/${m[5]}` : '') : `${site}/${m[4]}`; if (!href) continue; - frag.append(s.slice(at, start), h('a', { href, target: '_blank', rel: 'noopener noreferrer', class: m[3] ? 'ref' : 'mention' }, m[2])); + frag.append(s.slice(at, start), h('a', { href, target: '_blank', rel: 'noopener noreferrer', class: m[3] || m[5] ? 'ref' : 'mention' }, m[2])); at = start + m[2].length; } frag.append(s.slice(at)); @@ -73,13 +81,16 @@ function linkify(root: HTMLElement, repoUrl?: string) { const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; -/** Relative links in a PR body mean GitHub pages: #anchors on the PR, paths in the repo. */ +/** Relative links in a PR body mean GitHub (or GitLab) pages: #anchors on the PR, paths in the repo. */ function absolutize(root: HTMLElement, itemUrl: string, repoUrl: string) { + const lab = isLab(itemUrl); const fix = (v: string, anchors: boolean) => { if (!v || SCHEME_RE.test(v) || v.startsWith('//')) return v; if (v.startsWith('#')) return anchors ? `${itemUrl.split('#')[0]}${v}` : v; - if (v.startsWith('/')) return `https://github.com${v}`; - return `${repoUrl}/blob/HEAD/${v.replace(/^\.\//, '')}`; + // GitLab's attachments (/uploads//f.png) are the project's, not the site's. + if (lab && v.startsWith('/uploads/')) return `${repoUrl}${v}`; + if (v.startsWith('/')) return `${lab ? new URL(itemUrl).origin : 'https://github.com'}${v}`; + return `${repoUrl}/${lab ? '-/' : ''}blob/HEAD/${v.replace(/^\.\//, '')}`; }; for (const a of root.querySelectorAll('a[href]')) a.setAttribute('href', fix(a.getAttribute('href') ?? '', true)); for (const img of root.querySelectorAll('img[src]')) img.setAttribute('src', fix(img.getAttribute('src') ?? '', false)); @@ -90,7 +101,7 @@ function sanitized(html: string): DocumentFragment { return purify.sanitize(html, { RETURN_DOM_FRAGMENT: true, FORBID_TAGS: ['style', 'form', 'button', 'select', 'textarea'], FORBID_ATTR: ['style'] }); } -/** Renders markdown into a `.md` block. `itemUrl` (the issue or PR on GitHub) anchors its links. */ +/** Renders markdown into a `.md` block. `itemUrl` (the issue or PR on GitHub or GitLab) anchors its links. */ export function markdown(src: string, itemUrl?: string): HTMLElement { const el = h('div.md'); if (!src.trim()) { @@ -101,7 +112,7 @@ export function markdown(src: string, itemUrl?: string): HTMLElement { const repoUrl = itemUrl ? repoUrlOf(itemUrl) : undefined; if (itemUrl && repoUrl) absolutize(el, itemUrl, repoUrl); alerts(el); - linkify(el, repoUrl); + linkify(el, repoUrl, isLab(itemUrl)); return el; } @@ -117,7 +128,7 @@ export function markdownFile(src: string): HTMLElement { return el; } -/** https://github.com/owner/repo from an issue or PR URL. */ +/** https://github.com/owner/repo from an issue or PR URL (https://host/group/project from a GitLab's). */ export function repoUrlOf(itemUrl: string): string { - return itemUrl.replace(/\/(pull|issues)\/\d+.*$/, ''); + return itemUrl.replace(/\/-\/(?:merge_requests|issues)\/\d+.*$/, '').replace(/\/(pull|issues)\/\d+.*$/, ''); } diff --git a/src/client/ui/meeting.ts b/src/client/ui/meeting.ts index b3596ffde..702758f10 100644 --- a/src/client/ui/meeting.ts +++ b/src/client/ui/meeting.ts @@ -4,6 +4,7 @@ import { fmtTokens, type Meeting, type MeetingPattern, type MeetingTurn } from ' import type { Net } from '../net'; import { store } from '../state'; import { h, openModal, timeAgo, toast, STATUS_LABEL, type Modal } from './dom'; +import { prName, words } from './forge'; import { confirmDialog } from './prompt'; import { providerPicker } from './provider'; import { officePrompt } from './prompts'; @@ -97,7 +98,7 @@ function renderStatus(m: Meeting, body: HTMLElement, foot: HTMLElement, net: Net }), ); const where = m.worktree ? h('span', {}, '🌿 ', h('code', {}, m.worktree.branch), m.commit ? ` · committed ${m.commit}` : '') : null; - const review = m.review?.url ? h('a', { href: m.review.url, target: '_blank', rel: 'noopener noreferrer' }, `🔍 The review on PR #${m.pr} ↗`) : m.review?.error ? h('span.bad', {}, `Couldn't post the review: ${m.review.error}`) : null; + const review = m.review?.url ? h('a', { href: m.review.url, target: '_blank', rel: 'noopener noreferrer' }, `🔍 The review on ${m.pr ? prName(m.pr) : words().pr} ↗`) : m.review?.error ? h('span.bad', {}, `Couldn't post the review: ${m.review.error}`) : null; body.replaceChildren( ...present( h('div.meeting-head', {}, pill, h('b', {}, `${p.icon} ${p.label}`), h('span.meeting-title', { title: m.prompt }, m.title)), @@ -115,7 +116,7 @@ function renderStatus(m: Meeting, body: HTMLElement, foot: HTMLElement, net: Net ...present( h('span.grow', {}, running ? 'The workers stay at the table after it ends, so you can read their terminals.' : 'Clearing the room sends the workers home. A committed output stays on its branch.'), running ? h('button.btn', { type: 'button', onclick: () => confirmDialog('Stop the meeting?', `The workers stop where they are and stay at the table. ${m.output} is only there if it was written.`, 'Stop it', () => net.send({ t: 'meeting.stop' })) }, '⛔ Stop meeting') : null, - !running && m.commit && head?.worktree ? h('button.btn', { type: 'button', title: `Push ${m.worktree?.branch} and open a pull request`, onclick: () => actions.openPr(head.id) }, head.pr ? `🔀 PR #${head.pr.number}` : '🔀 Open PR') : null, + !running && m.commit && head?.worktree ? h('button.btn', { type: 'button', title: `Push ${m.worktree?.branch} and open a ${words().pullRequest}`, onclick: () => actions.openPr(head.id) }, head.pr ? `🔀 ${prName(head.pr.number)}` : `🔀 Open ${words().pr}`) : null, !running ? h('button.btn', { type: 'button', onclick: () => net.send({ t: 'meeting.clear' }) }, '🧹 Clear the room') : null, !running ? h('button.btn.primary', { type: 'button', onclick: callAnother }, '🤝 Call a meeting…') : null, ), @@ -136,8 +137,10 @@ function meetingForm(net: Net, preset: MeetingPreset | undefined, done: () => vo titleIn.value = preset?.title ?? ''; const outputIn = h('input', { type: 'text', 'aria-label': 'Output file', spellcheck: 'false' }) as HTMLInputElement; const outputNote = h('small.muted'); - const prSel = h('select.provider-select', { 'aria-label': 'Pull request' }) as HTMLSelectElement; - const prRow = h('div.meeting-field', {}, h('label', {}, 'Pull request'), prSel); + const fw = words(); + const prLabel = `${fw.pullRequest[0].toUpperCase()}${fw.pullRequest.slice(1)}`; + const prSel = h('select.provider-select', { 'aria-label': prLabel }) as HTMLSelectElement; + const prRow = h('div.meeting-field', {}, h('label', {}, prLabel), prSel); const partsIn = h('textarea', { rows: 3, placeholder: 'src/server/\nsrc/client/\nsrc/shared/', 'aria-label': 'Parts', spellcheck: 'false' }) as HTMLTextAreaElement; const partsRow = h('div.meeting-field', {}, h('label', {}, 'Parts, one per line'), partsIn, h('small.muted', {}, 'Handed out to the mappers in turn: files, folders, modules or issues.')); const count = h('b'); @@ -159,7 +162,7 @@ function meetingForm(net: Net, preset: MeetingPreset | undefined, done: () => vo const syncOutput = () => { if (!outputTouched) outputIn.value = def().output(slug(), pr()); const problem = outputProblem(outputIn.value.trim()); - outputNote.textContent = problem ? `⚠️ ${problem}` : pattern === 'review' ? 'It ends when this file is written; the office then posts it on the PR as one review.' : store.project?.branch ? 'It ends when this file is written; the office commits it on the meeting’s own branch.' : 'It ends when this file is written.'; + outputNote.textContent = problem ? `⚠️ ${problem}` : pattern === 'review' ? `It ends when this file is written; the office then posts it on the ${fw.pr} as one review.` : store.project?.branch ? 'It ends when this file is written; the office commits it on the meeting’s own branch.' : 'It ends when this file is written.'; outputNote.classList.toggle('bad', !!problem); }; const renderRoles = () => { @@ -275,12 +278,12 @@ function meetingForm(net: Net, preset: MeetingPreset | undefined, done: () => vo const refresh = () => { const open = store.pulls.items.filter((p) => p.state === 'OPEN'); const want = prSel.value || (preset?.pr ? String(preset.pr) : ''); - const opts: (readonly [string, string])[] = open.map((p) => [String(p.number), `#${p.number} ${p.title}`] as const); - if (preset?.pr && !open.some((p) => p.number === preset.pr)) opts.unshift([String(preset.pr), `#${preset.pr}`]); + const opts: (readonly [string, string])[] = open.map((p) => [String(p.number), `${fw.ref}${p.number} ${p.title}`] as const); + if (preset?.pr && !open.some((p) => p.number === preset.pr)) opts.unshift([String(preset.pr), `${fw.ref}${preset.pr}`]); const key = JSON.stringify(opts); if (prSel.dataset.key !== key) { prSel.dataset.key = key; - prSel.replaceChildren(h('option', { value: '' }, open.length || preset?.pr ? 'Pick a pull request…' : 'No open pull requests'), ...opts.map(([v, label]) => h('option', { value: v }, label.length > 70 ? `${label.slice(0, 69)}…` : label))); + prSel.replaceChildren(h('option', { value: '' }, open.length || preset?.pr ? `Pick a ${fw.pullRequest}…` : `No open ${fw.pullRequest}s`), ...opts.map(([v, label]) => h('option', { value: v }, label.length > 70 ? `${label.slice(0, 69)}…` : label))); prSel.value = want; syncOutput(); } diff --git a/src/client/ui/palette.ts b/src/client/ui/palette.ts index 7afea4c92..bdf7bcd7b 100644 --- a/src/client/ui/palette.ts +++ b/src/client/ui/palette.ts @@ -1,6 +1,7 @@ import './palette.css'; import { rankItems, type PaletteItem, type PaletteMatch } from '../../shared/palette'; import { h, openModal, type Modal } from './dom'; +import { words } from './forge'; // The command palette (Ctrl+K, ⌘K on a Mac): a few letters find a worker, an issue, a pull request, // a board, a teammate or an action, and Enter opens it the way clicking it in the office does. @@ -28,7 +29,7 @@ export function togglePalette(entries: () => PaletteEntry[]) { const all = entries(); const input = h('input', { type: 'text', - placeholder: 'Find a worker, issue, PR, board, teammate or action…', + placeholder: `Find a worker, issue, ${words().pr}, board, teammate or action…`, autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Find anything in the office', diff --git a/src/client/ui/prompt.ts b/src/client/ui/prompt.ts index 0bf860541..d0f2d5bd2 100644 --- a/src/client/ui/prompt.ts +++ b/src/client/ui/prompt.ts @@ -1,6 +1,8 @@ import './prompt.css'; import type { AgentEffort, AgentProvider, LostBranch, ServerMsg, WorktreeCleanup, WorktreeState } from '../../shared/protocol'; +import { forgeWords } from '../../shared/floors'; import { h, openModal } from './dom'; +import { words } from './forge'; import { store } from '../state'; import { providerPicker, type ProviderPicker } from './provider'; import { dictateField } from './dictate'; @@ -42,7 +44,7 @@ export function worktreePref(): boolean { export function repoPicker(options: { id: string; name: string }[] | undefined, wtBox: HTMLInputElement): { element: HTMLElement | null; value(): string[] } { const picks = (options ?? []).map((r) => { const box = h('input', { type: 'checkbox', value: r.id, onchange: () => box.checked && (wtBox.checked = true) }) as HTMLInputElement; - return { id: r.id, box, el: h('label.repo-pick', { title: `A worktree of ${r.name} too, on the same branch, with a pull request of its own` }, box, r.name) }; + return { id: r.id, box, el: h('label.repo-pick', { title: `A worktree of ${r.name} too, on the same branch, with a ${forgeWords(store.floors.find((f) => f.id === r.id)?.forge).pullRequest} of its own` }, box, r.name) }; }); if (!picks.length) return { element: null, value: () => [] }; wtBox.addEventListener('change', () => { @@ -176,15 +178,16 @@ const CLEANUP_LABEL: Record = { export function sendHomeDialog(opts: SendHomeOptions) { const { branch, path } = opts.worktree; const across = opts.repos && opts.repos.length > 1 ? opts.repos : undefined; + const { pullRequest } = words(); const choices: [WorktreeCleanup, string, string][] = across ? [ ['all', 'Delete the worktrees and their branch', `Removes its worktrees of ${across.join(', ')}, and ${branch} in each.`], - ['worktree', 'Delete the worktrees, keep the branch', `${branch} stays in each for a pull request or a later checkout.`], + ['worktree', 'Delete the worktrees, keep the branch', `${branch} stays in each for a ${pullRequest} or a later checkout.`], ['keep', 'Keep them all', 'Leaves everything as it is; agent-office prune in each project tidies up later.'], ] : [ ['all', 'Delete the worktree and its branch', `Removes ${path} and ${branch}.`], - ['worktree', 'Delete the worktree, keep the branch', `${branch} stays for a pull request or a later checkout.`], + ['worktree', 'Delete the worktree, keep the branch', `${branch} stays for a ${pullRequest} or a later checkout.`], ['keep', 'Keep both', 'Leaves everything as it is; agent-office prune tidies up later.'], ]; const radios = new Map(); diff --git a/src/client/ui/prompts.ts b/src/client/ui/prompts.ts index 9cbb18a69..0857493a8 100644 --- a/src/client/ui/prompts.ts +++ b/src/client/ui/prompts.ts @@ -4,9 +4,12 @@ import { store } from '../state'; import { PROMPTS, PROMPT_GROUPS, PROMPT_IDS, PROMPT_MAX, fillPrompt, placeholders, promptText, type PromptGroup, type PromptId, type PromptVars } from '../../shared/prompts'; import { h, openModal, timeAgo } from './dom'; -/** One of the office's prompts, as it has it now (rewritten in ⚙️ Settings, or the default), filled in. */ +/** + * One of the office's prompts, as it has it now (rewritten in ⚙️ Settings, or the default, which is + * GitLab's on a GitLab floor), filled in. + */ export function officePrompt(id: PromptId, vars: PromptVars = {}): string { - return fillPrompt(promptText(store.prompts.custom, id), vars); + return fillPrompt(promptText(store.prompts.custom, id, store.currentFloor()?.forge), vars); } /** How many of the office's prompts someone rewrote. */ diff --git a/src/client/ui/queue.ts b/src/client/ui/queue.ts index 9ab676839..5d0570703 100644 --- a/src/client/ui/queue.ts +++ b/src/client/ui/queue.ts @@ -3,6 +3,7 @@ import type { AgentProvider, QueueTask, Usage } from '../../shared/protocol'; import type { Net } from '../net'; import { store } from '../state'; import { h, openModal, timeAgo, STATUS_LABEL } from './dom'; +import { prName, words } from './forge'; import { confirmDialog } from './prompt'; import { providerPicker, providerLabel, providerUsageState, providerWaitingLabel, resolvedProvider, modelBadge } from './provider'; import { officeFull } from '../../shared/machine'; @@ -23,7 +24,7 @@ function taskTitle(t: QueueTask): HTMLElement { function outcome(t: QueueTask): string { switch (t.outcome) { case 'done': - return t.pr ? 'finished' : 'finished, no PR found yet'; + return t.pr ? 'finished' : `finished, no ${words().pr} found yet`; case 'exited': return t.error ? `stopped: ${t.error}` : 'stopped before finishing'; case 'killed': @@ -129,7 +130,7 @@ export function openQueue(net: Net, actions: QueueActions) { if (t.workerName) meta.push(t.workerName); if (t.branch) meta.push(`🌿 ${t.branch}`); if (t.finishedAt) meta.push(timeAgo(t.finishedAt)); - if (t.pr) buttons.push(h('a.btn', { href: t.pr.url, target: '_blank', rel: 'noopener', title: t.pr.title }, `🔀 PR #${t.pr.number}${t.pr.state === 'MERGED' ? ' ✓' : t.pr.state === 'DRAFT' ? ' (draft)' : ''}`)); + if (t.pr) buttons.push(h('a.btn', { href: t.pr.url, target: '_blank', rel: 'noopener', title: t.pr.title }, `🔀 ${prName(t.pr.number)}${t.pr.state === 'MERGED' ? ' ✓' : t.pr.state === 'DRAFT' ? ' (draft)' : ''}`)); if (w) buttons.push(h('button.btn', { type: 'button', onclick: () => actions.openTerminal(w.id) }, '🖥️ Terminal')); buttons.push(h('button.btn', { type: 'button', title: 'Put it back on the queue', onclick: () => net.send({ t: 'queue.retry', taskId: t.id }) }, '↻ Requeue')); buttons.push(h('button.btn', { type: 'button', title: 'Forget it', 'aria-label': 'Remove', onclick: () => net.send({ t: 'queue.remove', taskId: t.id }) }, '✕')); @@ -163,7 +164,7 @@ export function openQueue(net: Net, actions: QueueActions) { h('b', {}, 'Add to queue'), ' on an issue. Whenever a desk is free and fewer than ', h('b', {}, q.maxWorkers === 0 ? '0' : String(q.maxWorkers)), - " of its tasks are running, the next task gets a fresh worker in its own git worktree (workers you hire yourself don't count). Issues are assigned on GitHub when they start, and the pull request is linked when it shows up.", + ` of its tasks are running, the next task gets a fresh worker in its own git worktree (workers you hire yourself don't count). Issues are assigned on ${words().site} when they start, and the ${words().pullRequest} is linked when it shows up.`, ), queued.length && officeFull(m) ? h('p.note', {}, `⏸ The office is at its limit of ${m.limit} worker${m.limit === 1 ? '' : 's'}, so the next task waits until one goes home. A queue worker that's finished goes home by itself to make room.`) diff --git a/src/client/ui/repos.ts b/src/client/ui/repos.ts index 64753c54f..f8f423401 100644 --- a/src/client/ui/repos.ts +++ b/src/client/ui/repos.ts @@ -1,8 +1,10 @@ import './repos.css'; import type { WorkerInfo } from '../../shared/protocol'; +import { forgeWords } from '../../shared/floors'; import { isBusy } from '../../shared/status'; import { store } from '../state'; import { h, openModal } from './dom'; +import { words } from './forge'; // A worker across repositories (see WorkerInfo.repos): one task in worktrees of several floors' // projects, all on the same branch, with a pull request in each repository it commits to. @@ -31,26 +33,29 @@ export function openRepoPulls(workerId: string, actions: RepoPullsActions) { const note = h('p', { style: 'margin:0 0 12px;font-weight:700' }); const list = h('ul.repo-pulls'); const close = h('button.btn', { type: 'button' }, 'Close'); - const missing = h('button.btn.primary', { type: 'button' }, '🔀 Open the missing PRs'); - const el = h('div.modal', { role: 'dialog', 'aria-label': 'Pull requests' }, h('header', {}, title), h('div.body', {}, note, list), h('footer', {}, h('span.grow'), close, missing)); + const missing = h('button.btn.primary', { type: 'button' }); + const el = h('div.modal', { role: 'dialog', 'aria-label': `${cap(words().pullRequest)}s` }, h('header', {}, title), h('div.body', {}, note, list), h('footer', {}, h('span.grow'), close, missing)); const render = () => { const w = store.workers.get(workerId); if (!w?.worktree) return modal.close(); const repos = workerRepos(w); - title.textContent = `🔀 ${w.name}'s pull requests`; - note.textContent = `One change across ${repos.length} repositories, each on 🌿 ${w.worktree.branch}. Each repository it commits to gets a pull request of its own, and each one lists the others.`; + const fw = words(); + title.textContent = `🔀 ${w.name}'s ${fw.pullRequest}s`; + note.textContent = `One change across ${repos.length} repositories, each on 🌿 ${w.worktree.branch}. Each repository it commits to gets a ${fw.pullRequest} of its own, and each one lists the others.`; list.replaceChildren( ...repos.map((r) => { - const floor = r.floor ? store.floors.find((f) => f.id === r.floor)?.name : store.currentFloor()?.name; + const at = r.floor ? store.floors.find((f) => f.id === r.floor) : store.currentFloor(); + const floor = at?.name; + const rw = forgeWords(at?.forge); const pr = r.pr; return h( 'li', {}, h('span.name', {}, `📁 ${r.name}`, h('small', {}, floor ? `${floor} floor${r.floor ? '' : ' · this one'}` : 'no longer in the building')), pr - ? h('button.btn', { type: 'button', title: r.floor ? 'Open it on GitHub' : 'Open it', onclick: () => (r.floor ? window.open(pr.url, '_blank', 'noopener') : (modal.close(), actions.openPull(pr.number, pr.url))) }, `🔀 #${pr.number}${r.floor ? ' ↗' : ''}`) - : h('span.none', {}, 'No PR yet'), + ? h('button.btn', { type: 'button', title: r.floor ? `Open it on ${rw.site}` : 'Open it', onclick: () => (r.floor ? window.open(pr.url, '_blank', 'noopener') : (modal.close(), actions.openPull(pr.number, pr.url))) }, `🔀 ${rw.ref}${pr.number}${r.floor ? ' ↗' : ''}`) + : h('span.none', {}, `No ${rw.pr} yet`), h('button.btn', { type: 'button', title: `What ${w.name} changed in ${r.name}`, onclick: () => (modal.close(), actions.changes(r.floor)) }, '🌿 Changes'), ); }), @@ -58,14 +63,16 @@ export function openRepoPulls(workerId: string, actions: RepoPullsActions) { const busy = isBusy(w.status); missing.classList.toggle('hidden', repos.every((r) => r.pr)); missing.disabled = !!w.prOpening || busy; - missing.textContent = w.prOpening ? '⏳ Opening…' : '🔀 Open the missing PRs'; - missing.title = busy ? `${w.name} is still at it — wait until it's done` : 'Pushes the branch in each repository with commits and no pull request yet, and opens one there'; + missing.textContent = w.prOpening ? '⏳ Opening…' : `🔀 Open the missing ${fw.pr}s`; + missing.title = busy ? `${w.name} is still at it — wait until it's done` : `Pushes the branch in each repository with commits and no ${fw.pullRequest} yet, and opens one there`; }; const unsub = store.on('workers', () => render()); - const modal = openModal(el, { doing: '🔀 looking over pull requests', onClose: () => unsub() }); + const modal = openModal(el, { doing: `🔀 looking over ${words().pullRequest}s`, onClose: () => unsub() }); close.addEventListener('click', () => modal.close()); missing.addEventListener('click', () => actions.openMissing()); render(); setTimeout(() => close.focus(), 30); } + +const cap = (s: string) => s[0].toUpperCase() + s.slice(1); diff --git a/src/client/ui/settings.ts b/src/client/ui/settings.ts index ba8dae5c2..9d70e70a5 100644 --- a/src/client/ui/settings.ts +++ b/src/client/ui/settings.ts @@ -8,6 +8,7 @@ import { THEME_PICKS } from '../../shared/theme'; import { mapChoices } from '../../shared/maps'; import { dogSetting } from './settings-dog'; import { h, openModal, timeAgo } from './dom'; +import { words } from './forge'; import { agentFields, choiceLabel, officeChoice } from './provider'; import { openPromptEditor, rewrittenPrompts } from './prompts'; import { outsideSetting } from './settings-sky'; @@ -309,7 +310,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting agentBack.textContent = `Back to ${store.project?.agentCmd.split(' ')[0].split(/[\\/]/).pop() ?? 'the --agent'}`; if (!agentTouched) agent.set(now); agentNote.textContent = - 'Every worker starts on this: hired at a desk, handed an issue or a pull request from the boards, taken off the queue, the board agents and meetings. Where you start one, ✏️ Edit picks another just for it.' + + `Every worker starts on this: hired at a desk, handed an issue or a ${words().pullRequest} from the boards, taken off the queue, the board agents and meetings. Where you start one, ✏️ Edit picks another just for it.` + (picked ? ` Set by ${picked.by} ${timeAgo(picked.at)}.` : ' It’s the agent the office was started with, on its own default model.') + (admin ? '' : ' Admins can change it.'); }; @@ -372,7 +373,8 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting limitClear.addEventListener('click', () => net.send({ t: 'machine.limit', limit: null })); // Whether a worker whose pull request merged goes home by itself, for everyone. - const leaveRow = h('div.seg', { role: 'radiogroup', 'aria-label': 'Workers whose pull request merged' }); + const leaveTitle = `Workers whose ${words().pullRequest} merged`; + const leaveRow = h('div.seg', { role: 'radiogroup', 'aria-label': leaveTitle }); const leaveNote = h('p.setting-note'); const paintLeave = () => { const { on, by, at } = store.leaveOnMerge; @@ -396,9 +398,10 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting ), ), ); + const { site, pullRequest } = words(); const now = on - ? 'Once a worker’s pull request merges, it goes home as soon as it isn’t working or waiting on you and nobody has its terminal open, and its worktree and branch are deleted. A worktree with uncommitted changes, or commits that aren’t on GitHub, is kept.' - : 'A worker whose pull request merged stays at its desk, outlined in purple, until someone sends it home. Turned on, the ones already merged go too.'; + ? `Once a worker’s ${pullRequest} merges, it goes home as soon as it isn’t working or waiting on you and nobody has its terminal open, and its worktree and branch are deleted. A worktree with uncommitted changes, or commits that aren’t on ${site}, is kept.` + : `A worker whose ${pullRequest} merged stays at its desk, outlined in purple, until someone sends it home. Turned on, the ones already merged go too.`; leaveNote.textContent = `${now} It’s the same for everyone in the building${by ? `, set by ${by}${at ? ` ${timeAgo(at)}` : ''}` : ''}.`; }; paintLeave(); @@ -469,7 +472,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting workers: [ setting('Default worker', 'office', agentNow, agent.element, agentActions, agentNote), setting('Worker limit', 'office', limitRow, limitNote), - setting('Workers whose pull request merged', 'office', leaveRow, leaveNote), + setting(leaveTitle, 'office', leaveRow, leaveNote), setting('Prompts', 'office', promptsOpen, promptsNote), ], }; diff --git a/src/client/ui/signins.ts b/src/client/ui/signins.ts index d470917ac..138efef6f 100644 --- a/src/client/ui/signins.ts +++ b/src/client/ui/signins.ts @@ -2,17 +2,24 @@ import './signins.css'; import type { SignInKind, SignInState } from '../../shared/protocol'; import type { Net } from '../net'; import { store } from '../state'; +import { gitlabProject } from '../../shared/floors'; import { h, openModal, type Modal } from './dom'; import { confirmDialog } from './prompt'; import { copyButton } from './team'; -const NAMES: Record = { claude: 'Claude', github: 'GitHub' }; +const NAMES: Record = { claude: 'Claude', github: 'GitHub', gitlab: 'GitLab' }; +const ICONS: Record = { claude: '✳️', github: '🐙', gitlab: '🦊' }; + +/** The GitLab the building's floors are on, for the GitLab sign-in to default to. */ +function gitlabHost(): string | undefined { + return store.floors.map((f) => gitlabProject(f.repo)?.host).find(Boolean); +} let open: { modal: Modal; say(why?: string): void } | null = null; /** - * 🔐 Your sign-ins: the Claude plan your workers run on and the GitHub account the office acts as - * for you, both your own (see server/signins.ts). The office runs the sign-in itself and hands you + * 🔐 Your sign-ins: the Claude plan your workers run on and the GitHub (and GitLab) account the + * office acts as for you, all your own (see server/signins.ts). The office runs the sign-in itself and hands you * the page to open; or paste a token; admins may use the office machine's own instead. * `why` says what sent you here (hiring a worker before signing in, say). */ @@ -28,10 +35,10 @@ export function openSignIns(net: Net, why?: string) { h( 'div.body.team', {}, - h('p.note.lead', {}, 'Your workers run on your own Claude plan, and the office acts on GitHub as you: comments, merges and pull requests show up under your name. Only your workers use them.'), + h('p.note.lead', {}, 'Your workers run on your own Claude plan, and the office acts on GitHub (and GitLab) as you: comments, merges and pull requests show up under your name. Only your workers use them.'), banner, cards, - h('p.note', {}, 'Or open a 🐚 shell at any desk: it runs as you, so ', h('code', {}, 'claude auth login'), ' and ', h('code', {}, 'gh auth login'), ' typed there sign you in too.'), + h('p.note', {}, 'Or open a 🐚 shell at any desk: it runs as you, so ', h('code', {}, 'claude auth login'), ', ', h('code', {}, 'gh auth login'), ' and ', h('code', {}, 'glab auth login'), ' typed there sign you in too.'), ), ); @@ -40,6 +47,8 @@ export function openSignIns(net: Net, why?: string) { code: h('input', { type: 'text', placeholder: 'Paste the code here', 'aria-label': 'Code from the sign-in page', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement, claude: h('input', { type: 'password', placeholder: 'sk-ant-oat01-…', 'aria-label': 'Claude token', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement, github: h('input', { type: 'password', placeholder: 'ghp_… or github_pat_…', 'aria-label': 'GitHub token', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement, + gitlab: h('input', { type: 'password', placeholder: 'glpat-…', 'aria-label': 'GitLab token', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement, + host: h('input', { type: 'text', placeholder: 'gitlab.example.com', 'aria-label': 'Which GitLab', autocomplete: 'off', spellcheck: 'false', style: 'max-width:14em' }) as HTMLInputElement, }; const say = (text?: string) => { @@ -56,12 +65,16 @@ export function openSignIns(net: Net, why?: string) { const tokenRow = (which: SignInKind) => { const input = inputs[which]; const save = h('button.btn', { type: 'submit' }, 'Save'); - const form = h('form.invite-row', {}, input, save) as HTMLFormElement; + // A GitLab token is for one GitLab: its address goes with it. + if (which === 'gitlab' && !inputs.host.value) inputs.host.value = store.signins?.gitlab.host ?? gitlabHost() ?? ''; + const form = h('form.invite-row', {}, which === 'gitlab' ? inputs.host : null, input, save) as HTMLFormElement; form.addEventListener('submit', (e) => { e.preventDefault(); const token = input.value.trim(); if (!token) return input.focus(); - net.send({ t: 'signins.token', which, token }); + const host = which === 'gitlab' ? inputs.host.value.trim() : ''; + if (which === 'gitlab' && !host) return inputs.host.focus(); + net.send({ t: 'signins.token', which, token, ...(host ? { host } : {}) }); input.value = ''; }); return form; @@ -74,7 +87,7 @@ export function openSignIns(net: Net, why?: string) { : s.status === 'busy' ? h('span.signin-who', {}, '⏳ Signing in…') : h('span.signin-who.none', {}, 'Not signed in'); - const head = h('div.team-head', {}, h('h4', {}, which === 'claude' ? '✳️ Claude' : '🐙 GitHub'), status); + const head = h('div.team-head', {}, h('h4', {}, `${ICONS[which]} ${NAMES[which]}`), status); const body = h('div.signin-body'); const box = h('section.signin', { class: s.status }, head, body); if (s.error) body.append(h('p.team-status.error', {}, s.error)); @@ -116,21 +129,23 @@ export function openSignIns(net: Net, why?: string) { if (s.status === 'ok') { const change = button(s.how === 'office' ? 'Use my own instead' : 'Sign out', () => { if (s.how === 'office') return net.send({ t: 'signins.signout', which }); - confirmDialog(`Sign out of ${NAMES[which]}?`, which === 'claude' ? 'Workers you hire from now on need a new sign-in. The ones already running keep going.' : 'The office stops acting on GitHub as you until you sign in again.', 'Sign out', () => net.send({ t: 'signins.signout', which })); + confirmDialog(`Sign out of ${NAMES[which]}?`, which === 'claude' ? 'Workers you hire from now on need a new sign-in. The ones already running keep going.' : `The office stops acting on ${NAMES[which]} as you until you sign in again.`, 'Sign out', () => net.send({ t: 'signins.signout', which })); }); body.append(h('div.signin-actions', {}, change)); return box; } - // Not signed in (or busy looking): the ways in. - const start = button(`Sign in with ${NAMES[which]}`, () => net.send({ t: 'signins.start', which }), 'primary'); + // Not signed in (or busy looking): the ways in. A GitLab takes a token: it has no sign-in page the office can hand you. + const start = which === 'gitlab' ? null : button(`Sign in with ${NAMES[which]}`, () => net.send({ t: 'signins.start', which }), 'primary'); const actions = h('div.signin-actions', {}, start); if (office) actions.append(button('Use the office’s own', () => net.send({ t: 'signins.office', which }))); body.append( actions, which === 'claude' ? h('p.note', {}, 'Or paste a token: run ', h('code', {}, 'claude setup-token'), ' on your own computer (or use an Anthropic API key).') - : h('p.note', {}, 'Or paste a GitHub token (', h('a', { href: 'https://github.com/settings/tokens/new?scopes=repo,read:org,workflow&description=Agent%20Office', target: '_blank', rel: 'noopener noreferrer' }, 'make one'), ' with repo, read:org and workflow).'), + : which === 'gitlab' + ? h('p.note', {}, 'Paste a GitLab personal access token (', h('a', { href: `https://${s.host ?? gitlabHost() ?? 'gitlab.com'}/-/user_settings/personal_access_tokens?name=Agent%20Office&scopes=api,write_repository`, target: '_blank', rel: 'noopener noreferrer' }, 'make one'), ' with api and write_repository), and say which GitLab it’s for.') + : h('p.note', {}, 'Or paste a GitHub token (', h('a', { href: 'https://github.com/settings/tokens/new?scopes=repo,read:org,workflow&description=Agent%20Office', target: '_blank', rel: 'noopener noreferrer' }, 'make one'), ' with repo, read:org and workflow).'), tokenRow(which), ); return box; @@ -144,10 +159,12 @@ export function openSignIns(net: Net, why?: string) { cards.append(h('p.empty', {}, store.me.account ? 'Loading…' : 'On the shared office password, workers run on the office’s own sign-ins.')); return; } - cards.append(card('claude', s.claude, s.office), card('github', s.github, s.office)); + // GitLab only once there's a GitLab floor (or a GitLab sign-in already). + const lab = !!gitlabHost() || s.gitlab.status !== 'none' || s.gitlab.how === 'office'; + cards.append(card('claude', s.claude, s.office), card('github', s.github, s.office), lab ? card('gitlab', s.gitlab, s.office) : ''); if (typing instanceof HTMLInputElement && Object.values(inputs).includes(typing) && typing.isConnected) typing.focus(); - // Once both are sorted, whatever sent you here is too. - if (s.claude.status === 'ok' && s.github.status === 'ok') say(); + // Once they're all sorted, whatever sent you here is too. + if (s.claude.status === 'ok' && s.github.status === 'ok' && (!lab || s.gitlab.status === 'ok')) say(); }; const unsub = store.on('signins', render); diff --git a/src/client/ui/workers-panel.ts b/src/client/ui/workers-panel.ts index 2bd3fb817..a5eabd1f5 100644 --- a/src/client/ui/workers-panel.ts +++ b/src/client/ui/workers-panel.ts @@ -5,6 +5,7 @@ import { needyFirst } from '../nextup'; import { store } from '../state'; import { DESK_BY_ID } from '../../shared/layout'; import { $, h, STATUS_LABEL, timeAgo } from './dom'; +import { prName } from './forge'; import { usageLabel, usageTitle } from './usage'; import { providerLabel, providerUsageState, providerWaitingLabel, resolvedProvider, modelBadge } from './provider'; @@ -20,7 +21,7 @@ export function renderWorkers(onOpen: (id: string) => void) { const waiting = usageState === 'waiting' ? providerWaitingLabel(providerKind, store.project) : ''; const usageNote = usageState === 'untracked' ? ' · usage untracked' : waiting ? ` · ${waiting}` : ''; const badge = w.kind === 'agent' ? modelBadge(w.provider, w.model, w.effort, w.usage?.model) : undefined; - const sub = [provider && `⚙️ ${provider}${badge ? ` · ${badge}` : ''}${usageNote}`, w.worktree && `🌿 ${w.worktree.branch}`, w.repos?.length && `🗂️ ${w.repos.length + 1} repos`, w.pr && `🔀 PR #${w.pr.number}`, w.activity || w.title || w.prompt].filter(Boolean).join(' · '); + const sub = [provider && `⚙️ ${provider}${badge ? ` · ${badge}` : ''}${usageNote}`, w.worktree && `🌿 ${w.worktree.branch}`, w.repos?.length && `🗂️ ${w.repos.length + 1} repos`, w.pr && `🔀 ${prName(w.pr.number)}`, w.activity || w.title || w.prompt].filter(Boolean).join(' · '); // What it's stopped on, and since when, on a line of its own under its name. const ask = asking ? h('span.ask', {}, `🙋 ${w.activity ?? 'Waiting on an answer'}${w.waitingSince ? ` · ${timeAgo(w.waitingSince)}` : ''}`) : null; ul.append( diff --git a/src/client/world/character/worker-badges.ts b/src/client/world/character/worker-badges.ts index d31c81e1a..0bd70f582 100644 --- a/src/client/world/character/worker-badges.ts +++ b/src/client/world/character/worker-badges.ts @@ -36,17 +36,20 @@ const LOST_CHIP: [string, string, string] = ['🌿 WORKTREE DELETED', '#ffb703', const PR_INK: Record = { open: '#2da44e', merged: '#9d4edd' }; const PR_ICON: Record = { open: '🔀', merged: '🎉' }; +/** A worker's pull request and what its floor calls it ("PR #12", "MR !12"; PR #12 when not given). */ +export type NamedPr = WorkerPr & { name?: string }; + /** * The bubble (or the task card) over a worker's head, and a key that changes whenever it would look * different: `draw` makes it, or null for none. */ -export function bubbleFor(status: WorkerStatus, bounce: boolean, task: WorkerTask | undefined, pr: WorkerPr | undefined, lost: boolean): { key: string; draw(): THREE.Sprite | null } { +export function bubbleFor(status: WorkerStatus, bounce: boolean, task: WorkerTask | undefined, pr: NamedPr | undefined, lost: boolean): { key: string; draw(): THREE.Sprite | null } { const hot = status === 'needs_input' || (status === 'done' && bounce); const asking = status === 'needs_input' && !lost; const bg = hot ? (status === 'done' ? '#caffbf' : NEEDS_YOU.card) : status === 'working' ? '#ffec99' : '#fffaf3'; const border = pr ? PR_INK[pr.state] : asking ? NEEDS_YOU.bg : undefined; // Not working on or waiting for something more: its pull request in place of ready / done / asleep. - const prLabel = pr && status !== 'working' && status !== 'needs_input' && status !== 'starting' ? `${PR_ICON[pr.state]} PR #${pr.number} ${pr.state}` : undefined; + const prLabel = pr && status !== 'working' && status !== 'needs_input' && status !== 'starting' ? `${PR_ICON[pr.state]} ${pr.name ?? `PR #${pr.number}`} ${pr.state}` : undefined; const bubble = lost ? '🌿 worktree deleted' : prLabel ?? (status === 'needs_input' ? NEEDS_YOU.text : status === 'done' && bounce ? '✅ done!' : status === 'working' ? '⌨️ working' : isAsleep(status) ? '💤' : ''); diff --git a/src/client/world/character/worker.ts b/src/client/world/character/worker.ts index 128f5f9c4..228b4871e 100644 --- a/src/client/world/character/worker.ts +++ b/src/client/world/character/worker.ts @@ -7,7 +7,7 @@ import type { WorkerRig } from './rig'; import { ease, popIn } from './curves'; import { undress } from './props'; import { ACT_MIN, DESPAIR_MIN, TWIRL_TIME, WAIT_CYCLE, WAIT_HOPS, blendStance, type Act, type Stance } from './worker-stance'; -import { STATUS_BULB, bubbleFor } from './worker-badges'; +import { STATUS_BULB, bubbleFor, type NamedPr } from './worker-badges'; import { globe, papers } from './worker-props'; import { DANCE, groove, type Dancing, type Stage } from './worker-dance'; import { DEAD, STARVED, bones, crossedEyes, slump } from './worker-jail'; @@ -30,7 +30,7 @@ export class Worker { private bubbleIsCard = false; private task: WorkerTask | undefined; /** Its pull request, open or merged: its bubble is outlined (and labelled, while it rests) to match. */ - private pr: WorkerPr | undefined; + private pr: NamedPr | undefined; /** Its worktree was deleted outside the office (WorkerInfo.lost): its bubble says so until it's fixed. */ private lost = false; private nameTag: THREE.Sprite | null = null; @@ -297,8 +297,8 @@ export class Worker { this.drawBubble(); } - setPr(pr: WorkerPr | undefined) { - this.pr = pr; + setPr(pr: WorkerPr | undefined, name?: string) { + this.pr = pr && { ...pr, name }; this.drawBubble(); } diff --git a/src/server/building.ts b/src/server/building.ts index 2716aeacd..c81c0184e 100644 --- a/src/server/building.ts +++ b/src/server/building.ts @@ -2,16 +2,19 @@ import { execFileSync, spawn } from 'node:child_process'; import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { FLOOR_PALETTES, MAX_FLOORS, normalizeRepo, sameRepo } from '../shared/floors.js'; +import { FLOOR_PALETTES, MAX_FLOORS, gitlabProject, normalizeRepo, sameRepo } from '../shared/floors.js'; import type { CloneProgress, ProjectsDirState, RepoChoice } from '../shared/protocol.js'; import { CloneRun, dropLog, whyCloneFailed, type CloneEnd, type CloneRunOptions } from './clone.js'; import { gh } from './github.js'; +import { originOf } from './forge.js'; +import { lookUpGitLab } from './gitlab/repos.js'; +import { allRepos } from './repos.js'; /** A floor as floors.json keeps it. */ export interface FloorDef { id: string; name: string; - /** owner/name on GitHub. */ + /** owner/name on GitHub, host/group/…/project on a GitLab (see normalizeRepo). */ repo?: string; dir: string; palette: number; @@ -41,6 +44,8 @@ interface Pending { owner?: string; /** GitHub says the repository has no commits yet, so there's nothing to check out. */ empty: boolean; + /** Where git clones a GitLab project from (GitHub's go through `gh repo clone`). */ + from?: string; } /** A clone under way, as cloning.json keeps it for the next office to pick up (see resumeClones). */ @@ -63,7 +68,6 @@ export interface BuildingOptions { /** How long the list of repositories `gh` can see is reused before it's asked again. */ const REPOS_TTL_MS = 5 * 60_000; -const MAX_REPOS = 1000; /** * The floors of the building, saved in /.agent-office/floors.json: which projects there are, @@ -236,7 +240,7 @@ export class Building { ensureLocal(dir: string, by: string): FloorDef | undefined { const abs = path.resolve(dir); const known = this.defs.find((d) => path.resolve(d.dir) === abs); - this.local = { dir: abs, repo: known?.repo ?? originRepo(abs) }; + this.local = { dir: abs, repo: known?.repo ?? originOf(abs) }; if (known) { this.localId = known.id; if (this.localOff) this.setLocalOff(undefined); @@ -297,24 +301,31 @@ export class Building { this.save(); return def; } - // Asking GitHub first says whether this login can see it at all, and gets the name's real case. + // Asking GitHub (or the GitLab) first says whether this login can see it at all, and gets the name's real case. let repo: string; let empty = false; + let from: string | undefined; + const lab = gitlabProject(wanted); try { - const view = JSON.parse(await gh(['repo', 'view', wanted, '--json', 'nameWithOwner,isEmpty'], this.dataDir, 30_000)) as { nameWithOwner?: string; isEmpty?: boolean }; - repo = normalizeRepo(view.nameWithOwner) ?? wanted; - empty = view.isEmpty === true; + if (lab) ({ repo, empty, from } = await lookUpGitLab(lab, this.dataDir)); + else { + const view = JSON.parse(await gh(['repo', 'view', wanted, '--json', 'nameWithOwner,isEmpty'], this.dataDir, 30_000)) as { nameWithOwner?: string; isEmpty?: boolean }; + repo = normalizeRepo(view.nameWithOwner) ?? wanted; + empty = view.isEmpty === true; + } } catch (err) { - return `Couldn't find ${wanted} on GitHub: ${(err as Error).message}`; + return `Couldn't find ${wanted} on ${lab ? lab.host : 'GitHub'}: ${(err as Error).message}`; } const key = repo.toLowerCase(); if (this.defs.some((d) => sameRepo(d.repo, repo))) return `${repo} already has a floor`; if (this.cloning.has(key)) return `${repo} is already being cloned`; - const [owner, name] = repo.split('/'); - const dest = path.join(this.projectsDir, owner, name); + // /owner/name, or /host/group/…/project for a GitLab's. + const parts = repo.split('/'); + const name = parts[parts.length - 1]; + const dest = path.join(this.projectsDir, ...parts); if (this.defs.some((d) => path.resolve(d.dir) === dest)) return `${dest} is already a floor`; const def = this.newDef(name, repo, dest, by); - const pending: Pending = { def, owner: account, empty }; + const pending: Pending = { def, owner: account, empty, from }; this.cloning.set(key, pending); started(def); try { @@ -342,13 +353,13 @@ export class Building { } catch (err) { return `Couldn't make ${path.dirname(dest)}: ${(err as Error).message}`; } - if (this.opts.terminal) return cloneHere(repo, dest); + if (this.opts.terminal) return cloneHere(repo, dest, p.from); try { mkdirSync(this.logsDir, { recursive: true, mode: 0o700 }); } catch (err) { return `Couldn't make ${this.logsDir}: ${(err as Error).message}`; } - const run = await CloneRun.start(repo, dest, path.join(this.logsDir, `${repo.replace('/', '__')}.log`), { ...this.opts.clone, changed: () => this.cloneChanged?.() }); + const run = await CloneRun.start(repo, dest, path.join(this.logsDir, `${repo.replaceAll('/', '__')}.log`), { ...this.opts.clone, changed: () => this.cloneChanged?.() }, p.from); if (typeof run === 'string') return run; p.run = run; this.saveClones(); @@ -372,7 +383,7 @@ export class Building { async repos(refresh = false): Promise { const cached = this.repoCache; if (cached && !refresh && Date.now() - cached.at < REPOS_TTL_MS) return cached.repos; - const repos = listRepos(this.dataDir); + const repos = allRepos(this.dataDir); this.repoCache = { at: Date.now(), repos }; // A failure is worth asking again next time, not keeping for five minutes. repos.catch(() => { @@ -510,16 +521,6 @@ function unwritable(dir: string): string | undefined { return undefined; } -/** The GitHub repository a checkout's origin points at. */ -export function originRepo(dir: string): string | undefined { - try { - const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim(); - return /github\.com[/:]/i.test(url) ? normalizeRepo(url) : undefined; - } catch { - return undefined; - } -} - /** * What's at `dest`: nothing yet ('none'), a checkout of `repo` ('ok'), or why it's in the way. A * clone that was cut off has its origin but no commit checked out; an `empty` repository has none to. @@ -528,7 +529,7 @@ function checkoutAt(dest: string, repo: string, empty: boolean): 'none' | 'ok' | if (!existsSync(dest)) return 'none'; if (!statSync(dest).isDirectory()) return `${dest} is already there and isn't a folder`; if (!readdirSync(dest).length) return 'none'; - if (!sameRepo(originRepo(dest), repo)) return `${dest} already exists and isn't a checkout of ${repo} — move it out of the way first`; + if (!sameRepo(originOf(dest), repo)) return `${dest} already exists and isn't a checkout of ${repo} — move it out of the way first`; if (!empty && !hasCommit(dest)) return `${dest} is a clone of ${repo} that didn't finish — delete that folder and add the floor again`; return 'ok'; } @@ -543,45 +544,12 @@ function hasCommit(dir: string): boolean { } /** Clones `repo` to `dest` in this terminal: git shows its progress, and ssh or git can ask here. Resolves to an error, if any. */ -function cloneHere(repo: string, dest: string): Promise { +function cloneHere(repo: string, dest: string, from?: string): Promise { return new Promise((resolve) => { - const child = spawn('gh', ['repo', 'clone', repo, dest], { cwd: path.dirname(dest), stdio: 'inherit' }); - child.once('error', (err: NodeJS.ErrnoException) => resolve(err.code === 'ENOENT' ? "The GitHub CLI (gh) isn't installed on this machine" : `Couldn't run gh: ${err.message}`)); - child.once('exit', (code, signal) => resolve(code === 0 ? undefined : `Couldn't clone ${repo}: gh ${signal ? `stopped (${signal})` : `failed (exit ${code})`}`)); + // A GitLab project with git itself; a GitHub one through gh, which uses its login. + const child = from ? spawn('git', ['clone', '--', from, dest], { cwd: path.dirname(dest), stdio: 'inherit' }) : spawn('gh', ['repo', 'clone', repo, dest], { cwd: path.dirname(dest), stdio: 'inherit' }); + const tool = from ? 'git' : 'gh'; + child.once('error', (err: NodeJS.ErrnoException) => resolve(err.code === 'ENOENT' ? (from ? "git isn't installed on this machine" : "The GitHub CLI (gh) isn't installed on this machine") : `Couldn't run ${tool}: ${err.message}`)); + child.once('exit', (code, signal) => resolve(code === 0 ? undefined : `Couldn't clone ${repo}: ${tool} ${signal ? `stopped (${signal})` : `failed (exit ${code})`}`)); }); } - -async function listRepos(cwd: string): Promise { - const out = await gh( - [ - 'api', - '--paginate', - 'user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member', - '--jq', - '.[] | {name: .full_name, description: (.description // ""), private: .private, pushedAt: .pushed_at}', - ], - cwd, - 90_000, - ); - const repos: RepoChoice[] = []; - const seen = new Set(); - for (const line of out.split('\n')) { - if (!line.trim()) continue; - try { - const r = JSON.parse(line) as { name?: unknown; description?: unknown; private?: unknown; pushedAt?: unknown }; - const name = normalizeRepo(r.name); - if (!name || seen.has(name.toLowerCase())) continue; - seen.add(name.toLowerCase()); - repos.push({ - name, - description: typeof r.description === 'string' && r.description ? r.description.slice(0, 200) : undefined, - private: r.private === true, - pushedAt: typeof r.pushedAt === 'string' ? r.pushedAt : undefined, - }); - } catch { - // not a line of ours - } - if (repos.length >= MAX_REPOS) break; - } - return repos.sort((a, b) => (b.pushedAt ?? '').localeCompare(a.pushedAt ?? '')); -} diff --git a/src/server/changes.ts b/src/server/changes.ts index 91d8442e2..42bf42f60 100644 --- a/src/server/changes.ts +++ b/src/server/changes.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process'; import { readFile, realpath, stat } from 'node:fs/promises'; import path from 'node:path'; import type { ImageResult } from './decor.js'; +import { pullRequestsAt } from './forge.js'; import { changedImageType, type ChangedFile, type ChangeStatus, type ChangesState } from '../shared/protocol.js'; // What a worker changed, for the Changes window at its desk: the files it touched and their diff, @@ -306,7 +307,7 @@ export class Changes { }); } - /** Pushes the branch and opens a pull request for it with `gh`; with `env`, as whoever pressed the button. */ + /** Pushes the branch and opens a pull request for it (a merge request on GitLab); with `env`, as whoever pressed the button. */ async pullRequest(workerId: string, title: string, body: string, who: string, env?: Record, repo?: string): Promise { if (!title.trim()) return 'The pull request needs a title'; return this.action(workerId, repo, 'Pushing the branch and opening a pull request…', async (t, w) => { @@ -319,12 +320,10 @@ export class Changes { const remote = remotes.includes('origin') ? 'origin' : remotes[0]; if (!remote) return 'This project has no git remote to push to'; await git(['push', '-u', remote, s.branch], t.cwd, 120_000, env); - const r = await run('gh', ['pr', 'create', '--head', s.branch, '--base', s.prBase, '--title', title.trim(), '--body', body], t.cwd, 120_000, env); - const url = r.out.trim().split('\n').pop() ?? ''; - if (r.code !== 0 || !/^https?:\/\//.test(url)) throw new GitError(reason(r, url || 'gh pr create failed')); - const number = Number(/\/(\d+)$/.exec(url)?.[1] ?? 0); + const prs = pullRequestsAt(t.cwd); + const { number, url } = await prs.create(s.branch, s.prBase, title.trim(), body, t.cwd, env && { key: '', env }); this.opened.set(openedKey(repo, s.branch), { number, url }); - this.events.toast(`${who} opened a pull request for ${t.name}: ${url}`, 'info'); + this.events.toast(`${who} opened a ${prs.kind === 'gitlab' ? 'merge' : 'pull'} request for ${t.name}: ${url}`, 'info'); (t.refreshGitHub ?? this.events.refreshGitHub)(); return undefined; }); diff --git a/src/server/clone.ts b/src/server/clone.ts index e966fe11a..43170571d 100644 --- a/src/server/clone.ts +++ b/src/server/clone.ts @@ -3,7 +3,7 @@ import { closeSync, fstatSync, openSync, readSync, rmSync } from 'node:fs'; import path from 'node:path'; import type { CloneProgress } from '../shared/protocol.js'; -// One `gh repo clone`, run so the office can see how it's getting on. git's progress goes to a log +// One `gh repo clone` (or `git clone` of a GitLab project), run so the office can see how it's getting on. git's progress goes to a log // file, which the office reads every second: that says how far along it is, and a log that stops // growing is a clone that's stuck. The clone runs in its own session with no terminal, so an ssh // host-key question or a password prompt fails it straight away instead of waiting, unseen, on the @@ -96,8 +96,11 @@ export class CloneRun { this.timer = setInterval(() => this.tick(), opts.tickMs ?? 1000); } - /** Starts `gh repo clone repo dest`, writing to `log`. Resolves once it's running, or to why it couldn't start. */ - static start(repo: string, dest: string, log: string, opts: CloneRunOptions = {}): Promise { + /** + * Starts `gh repo clone repo dest`, or with `from` (a GitLab project's URL) `git clone from dest`, + * writing to `log`. Resolves once it's running, or to why it couldn't start. + */ + static start(repo: string, dest: string, log: string, opts: CloneRunOptions = {}, from?: string): Promise { let fd: number; try { fd = openSync(log, 'w', 0o600); @@ -107,7 +110,8 @@ export class CloneRun { return new Promise((resolve) => { let child: ChildProcess; try { - child = spawn('gh', ['repo', 'clone', repo, dest, '--', '--progress'], { + const [cmd, args] = from ? ['git', ['clone', '--progress', '--', from, dest]] : ['gh', ['repo', 'clone', repo, dest, '--', '--progress']]; + child = spawn(cmd, args, { cwd: path.dirname(dest), detached: GROUPS, stdio: ['ignore', fd, fd], @@ -118,7 +122,7 @@ export class CloneRun { } finally { closeSync(fd); } - child.once('error', (err: NodeJS.ErrnoException) => resolve(err.code === 'ENOENT' ? "The GitHub CLI (gh) isn't installed on the office's machine" : `Couldn't run gh: ${err.message}`)); + child.once('error', (err: NodeJS.ErrnoException) => resolve(err.code === 'ENOENT' ? (from ? "git isn't installed on the office's machine" : "The GitHub CLI (gh) isn't installed on the office's machine") : `Couldn't run ${from ? 'git' : 'gh'}: ${err.message}`)); child.once('spawn', () => { const run = new CloneRun(child.pid!, log, child, opts); child.once('exit', (code) => run.end(code)); @@ -239,10 +243,10 @@ function alive(pid: number): boolean { } } -/** The process is a `gh repo clone` (the pid wasn't handed to something else since). */ +/** The process is a `gh repo clone` or a `git clone` (the pid wasn't handed to something else since). */ function isClone(pid: number): boolean { try { - return /\brepo clone\b/.test(execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 })); + return /\b(?:repo clone|git clone)\b/.test(execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 })); } catch { return false; } diff --git a/src/server/floor.ts b/src/server/floor.ts index ad3bd25fd..796a359d4 100644 --- a/src/server/floor.ts +++ b/src/server/floor.ts @@ -8,7 +8,9 @@ import type { FloorDef } from './building.js'; import { excludeFromGit } from './config.js'; import { agentProviders, configuredProvider } from './agents.js'; import { WorkerManager, workedMs, type HookEnv, type RunAs } from './workers.js'; -import { GitHub, MergeWatch } from './github.js'; +import { MergeWatch } from './github.js'; +import { forgeWords, repoForge, type ForgeKind } from '../shared/floors.js'; +import { forgeFor, originOf, type Forge } from './forge.js'; import type { GhAs } from './signins.js'; import { TaskQueue } from './queue.js'; import { Changes } from './changes.js'; @@ -26,7 +28,7 @@ import { Worktrees, type WorktreeCleanup } from './worktrees.js'; import { landedWork, landedWorkers, type Landed } from './leave-on-merge.js'; import type { Ledger } from './usage.js'; import type { Capacity } from './machine.js'; -import { officePrompt, type PromptSource } from './prompts.js'; +import { forgePrompts, officePrompt, type PromptSource } from './prompts.js'; type ToastLevel = 'info' | 'warn' | 'error'; @@ -45,8 +47,8 @@ export interface FloorContext { prompts: PromptSource; /** Workers hired by an account run on its own sign-ins (see signins.ts). */ runAs?: RunAs; - /** How to run gh as an account: its own sign-in, the office's (undefined), or why it can't. */ - ghAs(owner: string | undefined): GhAs | undefined | string; + /** How to run gh (or glab, for a GitLab floor) as an account: its own sign-in, the office's (undefined), or why it can't. */ + ghAs(owner: string | undefined, forge: ForgeKind): GhAs | undefined | string; /** To everyone on this floor. */ emit(floor: Floor, msg: ServerMsg, droppable?: boolean): void; toast(floor: Floor, text: string, level?: ToastLevel): void; @@ -74,13 +76,13 @@ export interface FloorContext { /** The open pull request on a floor's board whose head is `branch`. */ function openPull(floor: Floor, branch: string): { number: number; url: string } | undefined { - const pr = floor.github.pulls.items.find((p) => p.state === 'OPEN' && p.headRefName === branch); + const pr = floor.forge.pulls.items.find((p) => p.state === 'OPEN' && p.headRefName === branch); return pr ? { number: pr.number, url: pr.url } : undefined; } /** How long after a PR list or a worker's change the office looks for workers whose PR merged. */ const LANDED_DELAY_MS = 1500; -/** Boards on a floor nobody is on, with nothing running, are asked GitHub about this seldom. */ +/** Boards on a floor nobody is on, with nothing running, are asked GitHub (or GitLab) about this seldom. */ const IDLE_REFRESH_MS = 10 * 60_000; const REFRESH_MS = 90_000; @@ -113,7 +115,10 @@ export class Floor { readonly dir: string; readonly project: ProjectInfo; readonly workers: WorkerManager; - readonly github: GitHub; + /** Its repository: owner/name on GitHub, host/group/…/project on a GitLab; the floor's own when it was added, else its origin's. */ + readonly repo?: string; + /** Its issues and pull requests (merge requests on GitLab). */ + readonly forge: Forge; readonly queue: TaskQueue; readonly changes: Changes; readonly decor: Decor; @@ -153,6 +158,9 @@ export class Floor { mkdirSync(dataDir, { recursive: true, mode: 0o700 }); excludeFromGit(def.dir); this.project = projectInfo(def.dir, def.name, ctx.agentCmd, ctx.agentArgs); + this.repo = def.repo ?? originOf(def.dir); + // The prompts its workers get: GitLab's defaults (merge requests, glab) on a GitLab floor. + const prompts = forgePrompts(ctx.prompts, repoForge(this.repo) ?? 'github'); this.docs = new Docs(def.dir); // Before the workers and the dog: the back office's desks are only there once it's built. this.plan = new FloorPlanStore(dataDir); @@ -201,14 +209,15 @@ export class Floor { }, ctx.ledger, ctx.capacity, - ctx.prompts, + prompts, ctx.runAs, ctx.dshProfile, ); this.workers.wing = () => this.plan.wing; - this.github = new GitHub( + this.forge = forgeFor( def.dir, + this.repo, (state) => ctx.emit(this, { t: 'gh.issues', state }), (state) => { ctx.emit(this, { t: 'gh.pulls', state }); @@ -217,14 +226,15 @@ export class Floor { // A worker may have opened one from a branch it made itself, mid-turn or from a shell. void this.workers.syncBranches(); for (const p of this.merges.look(state.items)) { - ctx.toast(this, `🎉 PR #${p.number} merged: ${p.title}`); + const w = forgeWords(this.forge.kind); + ctx.toast(this, `🎉 ${w.pr} ${w.ref}${p.number} merged: ${p.title}`); this.merged(p.number); } this.sendLandedHome(); ctx.pullsChanged(this); }, ); - // The 📋 task queue seats workers by itself: it watches the workers and links PRs from GitHub. + // The 📋 task queue seats workers by itself: it watches the workers and links PRs from GitHub (or MRs from GitLab). this.queue = new TaskQueue(dataDir, this.workers, !!this.project.branch, { update: (state) => { ctx.emit(this, { t: 'queue', state }); @@ -233,17 +243,17 @@ export class Floor { }, toast: (text, level) => ctx.toast(this, text, level), claimIssue: (issue, owner) => { - const as = ctx.ghAs(owner); - return typeof as === 'string' ? Promise.resolve(as) : this.github.claim(issue, as); + const as = ctx.ghAs(owner, this.forge.kind); + return typeof as === 'string' ? Promise.resolve(as) : this.forge.claim(issue, as); }, - refreshGitHub: () => void this.github.refresh(), + refreshGitHub: () => void this.forge.refresh(), hiringPaused: () => ctx.ledger.hiringPaused, room: () => ctx.capacity.room(), emptied: () => { ctx.toast(this, '📋 The queue is empty: every task is done 🎉'); ctx.emit(this, { t: 'gong', why: 'queue' }); }, - worktreeNote: () => officePrompt(ctx.prompts, 'queue.worktree'), + worktreeNote: () => officePrompt(prompts, 'queue.worktree'), }); // Meetings seat their own workers round the meeting room's table and run them round by round. @@ -268,10 +278,11 @@ export class Floor { toast: (text, level) => ctx.toast(this, text, level), hiringPaused: () => ctx.ledger.hiringPaused, postReview: (pr, file, owner) => { - const as = ctx.ghAs(owner); - return typeof as === 'string' ? Promise.reject(new Error(as)) : this.github.review(pr, file, as); + const as = ctx.ghAs(owner, this.forge.kind); + return typeof as === 'string' ? Promise.reject(new Error(as)) : this.forge.review(pr, file, as); }, - prompt: (id) => ctx.prompts.text(id), + prompt: (id) => prompts.text(id), + forge: () => this.forge.kind, }, ); @@ -294,14 +305,14 @@ export class Floor { worktreeBase: r.base, baseBranch: r.from ?? null, openPull: (branch) => (other ? openPull(other, branch) : undefined), - refreshGitHub: () => void other?.github.refresh(), + refreshGitHub: () => void other?.forge.refresh(), }; }, (branch) => openPull(this, branch), { state: (state, ids) => ctx.changes(state, ids), toast: (text, level) => ctx.toast(this, text, level), - refreshGitHub: () => void this.github.refresh(), + refreshGitHub: () => void this.forge.refresh(), }, ); @@ -310,10 +321,10 @@ export class Floor { this.whiteboard = new Whiteboard(dataDir); this.ready = this.workers.start(); - void this.github.refresh(); + void this.forge.refresh(); // A floor with people on it, or work under way, keeps its boards fresh; the others check in now and then. this.timer = setInterval(() => { - if (this.active() || Date.now() - this.github.issues.fetchedAt > IDLE_REFRESH_MS) void this.github.refresh(); + if (this.active() || Date.now() - this.forge.issues.fetchedAt > IDLE_REFRESH_MS) void this.forge.refresh(); }, REFRESH_MS); } @@ -333,11 +344,12 @@ export class Floor { this.landedTimer = setTimeout(() => { this.landedTimer = undefined; if (!this.ctx.leaveOnMerge()) return; - const pullsOf = (id: string) => this.ctx.floor(id)?.github.pulls.items; - for (const landed of landedWorkers(this.workers.list(), this.github.pulls.items, this.queue.state().tasks, pullsOf)) { + const pullsOf = (id: string) => this.ctx.floor(id)?.forge.pulls.items; + for (const landed of landedWorkers(this.workers.list(), this.forge.pulls.items, this.queue.state().tasks, pullsOf)) { const { worker, head, heads } = landed; if (!worker.repos?.length) { - this.goHome(worker, `PR #${landed.pr} merged`, head); + const w = forgeWords(this.forge.kind); + this.goHome(worker, `${w.pr} ${w.ref}${landed.pr} merged`, head); continue; } // Across repositories, one PR can merge before another repository's work even has one: @@ -357,7 +369,7 @@ export class Floor { * and, for a worker across repositories, on the others too (see landedWork). */ landed(worker: WorkerInfo): Landed | undefined { - return landedWork(worker, this.github.pulls.items, this.queue.state().tasks, (id) => this.ctx.floor(id)?.github.pulls.items); + return landedWork(worker, this.forge.pulls.items, this.queue.state().tasks, (id) => this.ctx.floor(id)?.forge.pulls.items); } /** @@ -382,7 +394,7 @@ export class Floor { /** Someone just walked in: boards that haven't been looked at in a while get fetched again. */ arrived() { - if (Date.now() - Math.max(this.github.issues.fetchedAt, this.github.pulls.fetchedAt) > REFRESH_MS) void this.github.refresh(); + if (Date.now() - Math.max(this.forge.issues.fetchedAt, this.forge.pulls.fetchedAt) > REFRESH_MS) void this.forge.refresh(); } private active(): boolean { @@ -394,7 +406,8 @@ export class Floor { return { id: this.id, name: this.def.name, - repo: this.def.repo, + repo: this.repo, + forge: this.forge.kind, dir: this.dir, branch: this.project.branch, palette: this.def.palette, @@ -413,7 +426,7 @@ export class Floor { clearInterval(this.timer); clearTimeout(this.landedTimer); this.dog.stop(); - this.github.stop(); + this.forge.stop(); this.queue.shutdown(); this.meetings.shutdown(); this.changes.stop(); diff --git a/src/server/forge.ts b/src/server/forge.ts new file mode 100644 index 000000000..4a1e37578 --- /dev/null +++ b/src/server/forge.ts @@ -0,0 +1,148 @@ +// Where a floor's repository lives: GitHub (through `gh`) or a GitLab (through `glab`). Everything +// about issues and pull requests goes through here, so the rest of the office never asks which. +// A GitLab merge request is a pull request to the office: its boards, windows and workers use the +// same types (shared/protocol/github.ts) for both. +import { execFileSync } from 'node:child_process'; +import type { GhCloseReason, GhComment, GhIssue, GhIssueDetail, GhLabel, GhMergeMethod, GhPull, GhPullDetail, GhRepoInfo, GhState } from '../shared/protocol.js'; +import { gitlabProject, normalizeRepo, repoForge, type ForgeKind } from '../shared/floors.js'; +import { GitHub, gh } from './github.js'; +import { GitLab } from './gitlab/boards.js'; +import { GitLabApi, glab, stateOf } from './gitlab/api.js'; +import { isGitLabHost } from './gitlab/repos.js'; +import type { GhAs } from './signins.js'; + +/** A floor's issues and pull requests, kept fresh, and what the office does to them (see GitHub and GitLab). */ +export interface Forge { + readonly kind: ForgeKind; + issues: GhState; + pulls: GhState; + start(): void; + stop(): void; + refresh(): Promise; + repoInfo(): Promise; + pullDetail(n: number, me?: string): Promise; + pullDiff(n: number): Promise; + issueDetail(n: number, me?: string): Promise; + comment(kind: 'issue' | 'pull', n: number, body: string, as?: GhAs): Promise<{ comment?: GhComment; error?: string }>; + review(n: number, file: string, as?: GhAs): Promise; + merge(n: number, method: GhMergeMethod, deleteBranch: boolean, auto: boolean, as?: GhAs): Promise; + close(kind: 'issue' | 'pull', n: number, opts: { comment?: string; reason?: GhCloseReason; deleteBranch?: boolean }, as?: GhAs): Promise; + repoLabels(): Promise; + setLabels(kind: 'issue' | 'pull', n: number, add: string[], remove: string[], as?: GhAs): Promise<{ labels?: GhLabel[]; error?: string }>; + claim(issue: number, as?: GhAs): Promise; +} + +/** A floor's boards: GitLab's for a GitLab repository, else GitHub's (which say so when there's no GitHub remote). */ +export function forgeFor(dir: string, repo: string | undefined, onIssues: (s: GhState) => void, onPulls: (s: GhState) => void): Forge { + const lab = gitlabProject(repo); + return lab ? new GitLab(dir, lab, onIssues, onPulls) : new GitHub(dir, onIssues, onPulls); +} + +/** The repository a checkout's origin points at: owner/name on GitHub, host/group/…/project on a GitLab. */ +export function originOf(dir: string): string | undefined { + let repo: string | undefined; + try { + repo = normalizeRepo(execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim()); + } catch { + return undefined; + } + // Another host that isn't a GitLab (GitHub Enterprise): gh, in the checkout, knows it. + const lab = gitlabProject(repo); + return lab && !isGitLabHost(lab.host) ? undefined : repo; +} + +// --- Pull requests from a checkout ------------------------------------------------------------------ + +export interface OpenPull { + number: number; + url: string; +} + +/** Opening and reading a branch's pull request (a merge request on GitLab), from a checkout of its repository. */ +export interface PullRequests { + readonly kind: ForgeKind; + /** "PR" or "MR", for what the office says. */ + readonly short: string; + /** The open one whose head is `branch`. */ + find(branch: string, cwd: string): Promise; + /** Opens one for a pushed branch; without `base`, into the repository's default branch. */ + create(branch: string, base: string | undefined, title: string, body: string, cwd: string, as?: GhAs): Promise; + body(url: string, cwd: string, as?: GhAs): Promise; + setBody(url: string, body: string, cwd: string, as?: GhAs): Promise; + /** Its page and OPEN, MERGED or CLOSED. */ + view(n: number, cwd: string): Promise<{ url: string; state: string }>; +} + +const github: PullRequests = { + kind: 'github', + short: 'PR', + async find(branch, cwd) { + const out = await gh(['pr', 'list', '--head', branch, '--state', 'open', '--limit', '1', '--json', 'number,url'], cwd); + const found = (JSON.parse(out || '[]') as OpenPull[])[0]; + return found ? { number: found.number, url: found.url } : undefined; + }, + async create(branch, base, title, body, cwd, as) { + const out = await gh(['pr', 'create', '--head', branch, ...(base ? ['--base', base] : []), '--title', title, '--body', body], cwd, 60_000, as?.env); + const url = out.trim().split('\n').pop() ?? ''; + const number = Number(/\/pull\/(\d+)/.exec(url)?.[1]); + if (!number) throw new Error(`gh did not return a pull request URL (${out.trim().slice(0, 120)})`); + return { number, url }; + }, + body: (url, cwd, as) => gh(['pr', 'view', url, '--json', 'body', '--jq', '.body'], cwd, 30_000, as?.env), + async setBody(url, body, cwd, as) { + await gh(['pr', 'edit', url, '--body', body], cwd, 60_000, as?.env); + }, + async view(n, cwd) { + return JSON.parse(await gh(['pr', 'view', String(n), '--json', 'url,state'], cwd)) as { url: string; state: string }; + }, +}; + +/** The checkout's project on its GitLab, from its origin. */ +function labOf(cwd: string): GitLabApi { + const lab = gitlabProject(originOf(cwd)); + if (!lab) throw new Error("This checkout's origin isn't a GitLab project"); + return new GitLabApi(lab.host, lab.path, cwd, glab); +} + +/** iid from an MR's page: https://host/group/project/-/merge_requests/12. */ +function iidOf(url: string): number { + const n = Number(/\/-\/merge_requests\/(\d+)/.exec(url)?.[1]); + if (!n) throw new Error(`Not a GitLab merge request: ${url}`); + return n; +} + +const gitlab: PullRequests = { + kind: 'gitlab', + short: 'MR', + async find(branch, cwd) { + const found = (await labOf(cwd).get(`merge_requests?state=opened&source_branch=${encodeURIComponent(branch)}&per_page=1`))?.[0]; + return found ? { number: Number(found.iid), url: String(found.web_url) } : undefined; + }, + async create(branch, base, title, body, cwd, as) { + const api = labOf(cwd); + const target = base ?? String((await api.get('')).default_branch ?? 'main'); + const mr = await api.rest('POST', 'merge_requests', { raw: { source_branch: branch, target_branch: target, title, description: body }, env: as?.env, timeout: 60_000 }); + if (!mr?.iid) throw new Error('GitLab did not return the merge request'); + return { number: Number(mr.iid), url: String(mr.web_url) }; + }, + async body(url, cwd, as) { + return String((await labOf(cwd).get(`merge_requests/${iidOf(url)}`, as?.env)).description ?? ''); + }, + async setBody(url, body, cwd, as) { + await labOf(cwd).rest('PUT', `merge_requests/${iidOf(url)}`, { raw: { description: body }, env: as?.env, timeout: 60_000 }); + }, + async view(n, cwd) { + const mr = await labOf(cwd).get(`merge_requests/${n}`); + return { url: String(mr.web_url), state: stateOf(mr.state) }; + }, +}; + +/** Pull requests (or merge requests) for a repository as normalizeRepo gives it; GitHub's when it's unknown. */ +export function pullRequests(repo: string | undefined): PullRequests { + return repoForge(repo) === 'gitlab' ? gitlab : github; +} + +/** The same, for the checkout at `cwd`. */ +export function pullRequestsAt(cwd: string): PullRequests { + return pullRequests(originOf(cwd)); +} diff --git a/src/server/github.ts b/src/server/github.ts index bb089336b..a31f97963 100644 --- a/src/server/github.ts +++ b/src/server/github.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process'; import type { GhCheck, GhCloseReason, GhComment, GhIssue, GhIssueDetail, GhLabel, GhMergeMethod, GhPull, GhPullDetail, GhRepoInfo, GhReviewComment, GhState } from '../shared/protocol.js'; +import type { Forge } from './forge.js'; import type { GhAs } from './signins.js'; const REFRESH_MS = 90_000; @@ -162,7 +163,8 @@ export class Claims { } } -export class GitHub { +export class GitHub implements Forge { + readonly kind = 'github' as const; issues: GhState = { items: [], fetchedAt: 0, loading: false }; pulls: GhState = { items: [], fetchedAt: 0, loading: false }; private timer?: NodeJS.Timeout; diff --git a/src/server/gitlab/api.ts b/src/server/gitlab/api.ts new file mode 100644 index 000000000..c013c9fe8 --- /dev/null +++ b/src/server/gitlab/api.ts @@ -0,0 +1,268 @@ +// Talking to a GitLab (gitlab.com or a company's own) through `glab api`, and reading what it says +// as the boards' GitHub-shaped types (shared/protocol/github.ts), so the windows work unchanged. +import { execFile } from 'node:child_process'; +import type { GhCheck, GhComment, GhIssue, GhLabel, GhPull, GhReviewComment } from '../../shared/protocol.js'; + +export type GlabRunner = (args: string[], cwd: string, timeout?: number, env?: Record) => Promise; + +/** Turns glab's stderr into something a person standing at the board can act on. */ +export function friendlyGlabError(raw: string): string { + const lines = raw.trim().split('\n').map((l) => l.trim()).filter(Boolean); + const text = lines.join(' '); + if (/401|unauthorized|not authenticated|no token|glab auth login/i.test(text)) return "glab isn't signed in to this GitLab on the office's machine — run `glab auth login --hostname ` there"; + if (/no such host|could not resolve host|dial tcp|i\/o timeout|connection refused|tls handshake/i.test(text)) return "The office can't reach this GitLab (check the network or VPN)"; + if (/404|not found/i.test(text)) return "glab can't find this project on GitLab (check the remote and access)"; + if (/403|forbidden/i.test(text)) return "GitLab says this account isn't allowed to do that"; + return lines.slice(-2).join(' ') || raw; +} + +/** How many glab calls run at once, across every floor. */ +const MAX_RUNNING = 8; +/** What a GitLab that's there says when the way to it hiccups: worth one more try. */ +const TRANSIENT = /i\/o timeout|lookup .* (?:no such host|server misbehaving)|tls handshake timeout|connection reset/i; +let running = 0; +const waiting: (() => void)[] = []; + +async function turn(fn: () => Promise): Promise { + if (running >= MAX_RUNNING) await new Promise((go) => waiting.push(go)); + running++; + try { + return await fn(); + } finally { + running--; + waiting.shift()?.(); + } +} + +/** One run of glab: its output, or what it said on failing (and whether it never started). */ +function once(args: string[], cwd: string, timeout: number, env: Record): Promise { + return new Promise((resolve, reject) => { + execFile('glab', args, { cwd, maxBuffer: 32 * 1024 * 1024, timeout, env }, (err, stdout, stderr) => { + if (!err) return resolve(stdout); + const e = new Error((stderr || stdout || err.message || '').trim()) as NodeJS.ErrnoException; + e.code = (err as NodeJS.ErrnoException).code; + reject(e); + }); + }); +} + +/** + * The environment glab runs in: no prompts (nobody would see them), plain errors, and Go's own DNS + * resolver. A company GitLab is often reached over a VPN, and on a Mac the system resolver glab uses + * by default times out when several look it up at once (as a building's boards do); Go's doesn't. + */ +export function glabEnv(env: Record): Record { + return { ...env, GLAB_NO_PROMPT: '1', NO_COLOR: '1', GODEBUG: env.GODEBUG || 'netdns=go' } as Record; +} + +/** Runs glab as the office, or with `env` as someone signed in to their own GitLab (see signins.ts). */ +export function glab(args: string[], cwd: string, timeout = 30_000, env?: Record): Promise { + const full = glabEnv(env ?? process.env); + return turn(() => once(args, cwd, timeout, full).catch((err: NodeJS.ErrnoException) => (TRANSIENT.test(err.message) ? new Promise((r) => setTimeout(r, 1000)).then(() => once(args, cwd, timeout, full)) : Promise.reject(err)))).catch((err: NodeJS.ErrnoException) => { + const msg = err.message; + const signedOut = env && /401|unauthorized|not authenticated|no token/i.test(msg); + throw new Error(err.code === 'ENOENT' ? 'GitLab CLI (glab) is not installed on the server' : signedOut ? 'Your GitLab sign-in stopped working — sign in again (☰ → 🔐 Your sign-ins)' : friendlyGlabError(msg)); + }); +} + +/** One project on one GitLab: REST paths under /api/v4 and GraphQL, through glab. */ +export class GitLabApi { + /** The project's id in REST paths: its full path, URL-encoded. */ + readonly id: string; + + constructor( + readonly host: string, + readonly path: string, + private cwd: string, + private run: GlabRunner = glab, + ) { + this.id = encodeURIComponent(path); + } + + /** Its page: https://host/group/project. */ + get webUrl(): string { + return `https://${this.host}/${this.path}`; + } + + /** A REST call; `rel` is under projects// unless it starts with "/". Fields: -f (strings) or -F (typed). */ + async rest(method: string, rel: string, opts: { raw?: Record; typed?: Record; env?: Record; timeout?: number; text?: boolean } = {}): Promise { + const where = rel.startsWith('/') ? rel.slice(1) : `projects/${this.id}${rel ? `/${rel}` : ''}`; + const args = ['api', '--hostname', this.host, '--method', method, where]; + for (const [k, v] of Object.entries(opts.raw ?? {})) args.push('-f', `${k}=${v}`); + for (const [k, v] of Object.entries(opts.typed ?? {})) args.push('-F', `${k}=${v}`); + const out = await this.run(args, this.cwd, opts.timeout, opts.env); + if (opts.text) return out as T; + return (out.trim() ? JSON.parse(out) : undefined) as T; + } + + get(rel: string, env?: Record): Promise { + return this.rest('GET', rel, { env }); + } + + /** Up to `limit` items of a list, a page of 100 at a time. */ + async list(rel: string, limit: number): Promise { + const all: T[] = []; + const per = Math.min(100, limit); + for (let page = 1; all.length < limit; page++) { + const items = await this.get(`${rel}${rel.includes('?') ? '&' : '?'}per_page=${per}&page=${page}`); + all.push(...(items ?? [])); + if (!items || items.length < per) break; + } + return all.slice(0, limit); + } + + /** A GraphQL query, with its variables as strings. */ + async graphql(query: string, vars: Record): Promise { + const args = ['api', '--hostname', this.host, 'graphql', '-f', `query=${query}`]; + for (const [k, v] of Object.entries(vars)) args.push('-f', `${k}=${v}`); + const res = JSON.parse(await this.run(args, this.cwd, 60_000)); + if (res.errors?.length) throw new Error(String(res.errors[0]?.message ?? 'GraphQL error')); + return res.data as T; + } +} + +// --- What GitLab says, as the boards read it -------------------------------------------------------- + +/** GitLab's states, as GitHub's: opened (or locked, an MR while it merges) → OPEN, merged → MERGED, closed → CLOSED. */ +export function stateOf(s: unknown): string { + const v = String(s ?? '').toLowerCase(); + return v === 'opened' || v === 'locked' ? 'OPEN' : v === 'merged' ? 'MERGED' : 'CLOSED'; +} + +/** Labels from REST (with_labels_details) or GraphQL ({title, color}). */ +export function labelsOf(raw: any[]): GhLabel[] { + return (raw ?? []).map((l) => (typeof l === 'string' ? { name: l, color: '#888888' } : { name: String(l.name ?? l.title), color: String(l.color ?? '#888888'), ...(l.description ? { description: String(l.description) } : {}) })); +} + +/** The issues an MR closes, as GitLab's default closing pattern reads its description: "Closes #12, #13". */ +export function closesOf(body: string): number[] { + const out = new Set(); + for (const m of body.matchAll(/\b(?:clos(?:e|es|ed|ing)|fix(?:|es|ed|ing)|resolv(?:e|es|ed|ing)|implement(?:|s|ed|ing))\b:?((?:\s*(?:,|and)?\s*#\d+)+)/gi)) { + for (const n of m[1].matchAll(/#(\d+)/g)) out.add(Number(n[1])); + } + return [...out]; +} + +export function issueOf(i: any): GhIssue { + return { + number: Number(i.iid), + title: String(i.title ?? ''), + state: stateOf(i.state), + url: String(i.web_url ?? ''), + author: i.author?.username ?? '', + labels: labelsOf(i.labels), + assignees: (i.assignees ?? []).map((a: any) => String(a.username)), + createdAt: i.created_at ?? '', + updatedAt: i.updated_at ?? i.created_at ?? '', + body: String(i.description ?? '').slice(0, 4000), + comments: Number(i.user_notes_count ?? 0), + }; +} + +/** A pipeline's status as a board card's checks (GraphQL says SUCCESS, REST success). */ +export function pipelineChecks(status: unknown): GhPull['checks'] { + const s = String(status ?? '').toLowerCase(); + if (!s) return 'none'; + if (s === 'success' || s === 'manual' || s === 'skipped') return 'pass'; + if (s === 'failed' || s === 'canceled' || s === 'canceling') return 'fail'; + return 'pending'; +} + +/** An MR from the GraphQL list (see GitLab.listPulls). */ +export function pullOf(m: any): GhPull { + const body = String(m.description ?? ''); + const changes = (m.reviewers?.nodes ?? []).some((r: any) => /REQUESTED_CHANGES/i.test(String(r.mergeRequestInteraction?.reviewState ?? ''))); + return { + number: Number(m.iid), + title: String(m.title ?? ''), + state: stateOf(m.state), + isDraft: !!m.draft, + url: String(m.webUrl ?? ''), + author: m.author?.username ?? '', + labels: labelsOf(m.labels?.nodes), + reviewDecision: changes ? 'CHANGES_REQUESTED' : m.approved ? 'APPROVED' : '', + headRefName: String(m.sourceBranch ?? ''), + headRefOid: typeof m.diffHeadSha === 'string' ? m.diffHeadSha : undefined, + baseRefName: String(m.targetBranch ?? ''), + createdAt: m.createdAt ?? '', + updatedAt: m.updatedAt ?? m.createdAt ?? '', + additions: Number(m.diffStatsSummary?.additions ?? 0), + deletions: Number(m.diffStatsSummary?.deletions ?? 0), + checks: pipelineChecks(m.headPipeline?.status), + body: body.slice(0, 4000), + closes: closesOf(body), + }; +} + +/** A job of the MR's pipeline, as one of its checks. */ +export function jobCheck(j: any): GhCheck { + const s = String(j.status ?? '').toLowerCase(); + let state: GhCheck['state'] = 'pass'; + if (s === 'failed') state = j.allow_failure ? 'skip' : 'fail'; + else if (s === 'canceled') state = 'fail'; + else if (s === 'skipped' || s === 'manual') state = 'skip'; + else if (s !== 'success') state = 'pending'; + return { name: j.stage ? `${j.stage} / ${j.name}` : String(j.name ?? 'job'), state, url: j.web_url ?? undefined }; +} + +/** + * Whether an MR can merge, as GitHub's mergeable and mergeStateStatus (what the merge dialog reads), + * from GitLab's detailed_merge_status. + */ +export function mergeStatusOf(detailed: unknown, conflicts: boolean): { mergeable: string; mergeStateStatus: string } { + const d = String(detailed ?? '').toLowerCase(); + if (conflicts || d === 'conflict' || d === 'broken_status') return { mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }; + if (!d || ['checking', 'unchecked', 'preparing', 'approvals_syncing'].includes(d)) return { mergeable: 'UNKNOWN', mergeStateStatus: 'UNKNOWN' }; + if (d === 'mergeable') return { mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }; + if (d === 'need_rebase') return { mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND' }; + if (d === 'draft_status') return { mergeable: 'MERGEABLE', mergeStateStatus: 'DRAFT' }; + if (d === 'ci_still_running') return { mergeable: 'MERGEABLE', mergeStateStatus: 'UNSTABLE' }; + return { mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED' }; +} + +function noteComment(n: any, url: string): GhComment { + return { id: String(n.id), author: n.author?.username ?? 'ghost', body: String(n.body ?? ''), createdAt: n.created_at ?? '', url: `${url}#note_${n.id}` }; +} + +/** + * An MR's discussions: its conversation (notes on the MR itself) and its line comments (DiffNotes, + * each reply pointing at the first note of its thread). GitLab's own notes ("added 2 commits") are left out. + */ +export function discussionsOf(discussions: any[], url: string): { comments: GhComment[]; reviewComments: GhReviewComment[] } { + const comments: GhComment[] = []; + const reviewComments: GhReviewComment[] = []; + for (const d of discussions ?? []) { + const notes = (d.notes ?? []).filter((n: any) => !n.system); + const first = notes[0]; + if (!first) continue; + if (first.type !== 'DiffNote' || !first.position) { + for (const n of notes) comments.push(noteComment(n, url)); + continue; + } + for (const n of notes) { + const p = n.position ?? first.position; + const right = p.new_line !== null && p.new_line !== undefined; + reviewComments.push({ + id: Number(n.id), + replyTo: n === first ? undefined : Number(first.id), + author: n.author?.username ?? 'ghost', + body: String(n.body ?? ''), + createdAt: n.created_at ?? '', + url: `${url}#note_${n.id}`, + path: String(p.new_path ?? p.old_path ?? ''), + line: right ? Number(p.new_line) : p.old_line !== null && p.old_line !== undefined ? Number(p.old_line) : null, + side: right ? 'RIGHT' : 'LEFT', + }); + } + } + comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return { comments, reviewComments }; +} + +/** Notes on an issue (or an MR), without GitLab's own, oldest first. */ +export function notesOf(notes: any[], url: string): GhComment[] { + return (notes ?? []) + .filter((n) => !n.system) + .map((n) => noteComment(n, url)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} diff --git a/src/server/gitlab/boards.ts b/src/server/gitlab/boards.ts new file mode 100644 index 000000000..26f475b37 --- /dev/null +++ b/src/server/gitlab/boards.ts @@ -0,0 +1,342 @@ +// A GitLab floor's boards: its issues and merge requests, and what the office does to them. The same +// as the GitHub boards (server/github.ts) to everything around it, through `glab api`. +import type { GhCloseReason, GhComment, GhIssue, GhIssueDetail, GhLabel, GhMergeMethod, GhPull, GhPullDetail, GhRepoInfo, GhState } from '../../shared/protocol.js'; +import { Claims } from '../github.js'; +import type { Forge } from '../forge.js'; +import type { GhAs } from '../signins.js'; +import { GitLabApi, discussionsOf, glab, issueOf, jobCheck, labelsOf, mergeStatusOf, notesOf, pullOf, stateOf, type GlabRunner } from './api.js'; + +const REFRESH_MS = 90_000; +const LABELS_MS = 60_000; + +const MR_FIELDS = [ + 'iid', + 'title', + 'state', + 'draft', + 'webUrl', + 'author { username }', + 'labels { nodes { title color } }', + 'approved', + 'reviewers { nodes { username mergeRequestInteraction { reviewState } } }', + 'sourceBranch', + 'targetBranch', + 'diffHeadSha', + 'createdAt', + 'updatedAt', + 'diffStatsSummary { additions deletions }', + 'headPipeline { status }', + 'description', +]; +const REQUIRED_MR_FIELDS = new Set(['iid', 'title', 'state']); + +/** The field GitLab's GraphQL doesn't have (an older GitLab, or CE without the EE ones): "Field 'approved' doesn't exist on type 'MergeRequest'". */ +function missingField(message: string): string | undefined { + return /Field '(\w+)' doesn't exist on type/i.exec(message)?.[1]; +} + +export class GitLab implements Forge { + readonly kind = 'gitlab' as const; + issues: GhState = { items: [], fetchedAt: 0, loading: false }; + pulls: GhState = { items: [], fetchedAt: 0, loading: false }; + readonly api: GitLabApi; + private timer?: NodeJS.Timeout; + private repo?: Promise; + private login?: Promise; + private labelList?: { at: number; list: Promise }; + private relabeled = new Map(); + private claims = new Claims(); + private listing?: Promise; + /** GraphQL fields this GitLab turned away, left out of the next lists. */ + private dropped = new Set(); + + constructor( + dir: string, + project: { host: string; path: string }, + private onIssues: (s: GhState) => void, + private onPulls: (s: GhState) => void, + run: GlabRunner = glab, + ) { + this.api = new GitLabApi(project.host, project.path, dir, run); + } + + start() { + void this.refresh(); + this.timer = setInterval(() => void this.refresh(), REFRESH_MS); + } + + stop() { + clearInterval(this.timer); + } + + async refresh() { + await Promise.all([this.refreshIssues(), this.refreshPulls()]); + } + + /** The project's full path and how it lets MRs merge: squash, a merge commit, or (by its settings) either. */ + repoInfo(): Promise { + this.repo ??= this.api.get('').then((p: any) => { + const squash = String(p.squash_option ?? 'default_off'); + const methods: GhMergeMethod[] = squash === 'always' ? ['squash'] : squash === 'never' ? ['merge'] : squash === 'default_on' ? ['squash', 'merge'] : ['merge', 'squash']; + return { nameWithOwner: String(p.path_with_namespace ?? this.api.path), methods }; + }); + this.repo.catch(() => (this.repo = undefined)); + return this.repo; + } + + /** Who the office's own glab is signed in as. Asked once; '' when glab can't say. */ + viewer(): Promise { + this.login ??= this.api.get('/user').then((u: any) => String(u?.username ?? '')); + this.login.catch(() => (this.login = undefined)); + return this.login.catch(() => ''); + } + + async pullDetail(n: number, me?: string): Promise { + const mr = `merge_requests/${n}`; + const [m, discussions, approvals, commits, reviewers, repo, viewer] = await Promise.all([ + this.api.get(mr), + this.api.list(`${mr}/discussions`, 500), + this.api.get(`${mr}/approvals`).catch(() => undefined), + this.api.list(`${mr}/commits`, 250), + this.api.get(`${mr}/reviewers`).catch(() => []), + this.repoInfo(), + me ?? this.viewer(), + ]); + const url = String(m.web_url ?? `${this.api.webUrl}/-/merge_requests/${n}`); + const { comments, reviewComments } = discussionsOf(discussions, url); + const jobs = m.head_pipeline?.id ? await this.api.list(`pipelines/${m.head_pipeline.id}/jobs`, 200).catch(() => []) : []; + const approvedBy: any[] = approvals?.approved_by ?? []; + const changes = (reviewers ?? []).some((r: any) => r.state === 'requested_changes'); + const reviewDecision = changes ? 'CHANGES_REQUESTED' : approvals?.approved && approvedBy.length ? 'APPROVED' : Number(approvals?.approvals_left ?? 0) > 0 ? 'REVIEW_REQUIRED' : ''; + return { + number: n, + body: String(m.description ?? ''), + state: stateOf(m.state), + isDraft: !!(m.draft ?? m.work_in_progress), + reviewDecision, + headRefName: String(m.source_branch ?? ''), + baseRefName: String(m.target_branch ?? ''), + ...mergeStatusOf(m.detailed_merge_status, m.has_conflicts === true), + commits: commits.length, + comments, + reviews: approvedBy.map((a: any) => ({ id: `approval-${a.user?.id}`, author: a.user?.username ?? 'ghost', body: '', createdAt: '', state: 'APPROVED' })), + reviewComments, + checks: jobs.map(jobCheck), + repo, + viewer, + }; + } + + /** The MR's unified diff, as `git diff` prints it. */ + pullDiff(n: number): Promise { + return this.api.rest('GET', `merge_requests/${n}/raw_diffs`, { text: true, timeout: 60_000 }); + } + + async issueDetail(n: number, me?: string): Promise { + const [i, notes, viewer] = await Promise.all([this.api.get(`issues/${n}`), this.api.list(`issues/${n}/notes?sort=asc`, 500), me ?? this.viewer()]); + return { number: n, state: i.state === 'opened' ? 'OPEN' : 'CLOSED', body: String(i.description ?? ''), comments: notesOf(notes, String(i.web_url ?? '')), viewer }; + } + + private path(kind: 'issue' | 'pull', n: number): string { + return `${kind === 'issue' ? 'issues' : 'merge_requests'}/${n}`; + } + + async comment(kind: 'issue' | 'pull', n: number, body: string, as?: GhAs): Promise<{ comment?: GhComment; error?: string }> { + let comment: GhComment; + try { + // -f sends the body as a plain string: no @file reading. + const note = await this.api.rest('POST', `${this.path(kind, n)}/notes`, { raw: { body }, env: as?.env }); + const url = `${this.api.webUrl}/-/${kind === 'issue' ? 'issues' : 'merge_requests'}/${n}`; + [comment] = notesOf([note], url); + } catch (err) { + return { error: (err as Error).message }; + } + void (kind === 'issue' ? this.refreshIssues() : this.refreshPulls()); + return { comment }; + } + + /** Posts the meeting room's review on an MR as a comment, its body read from a file. Resolves to its URL. */ + async review(n: number, file: string, as?: GhAs): Promise { + const note = await this.api.rest('POST', `merge_requests/${n}/notes`, { typed: { body: `@${file}` }, env: as?.env, timeout: 60_000 }); + void this.refreshPulls(); + return `${this.api.webUrl}/-/merge_requests/${n}#note_${note?.id}`; + } + + /** Merges an MR (squashed, or with a merge commit), or with `auto` once its pipeline passes. Returns an error. */ + async merge(n: number, method: GhMergeMethod, deleteBranch: boolean, auto: boolean, as?: GhAs): Promise { + try { + const typed: Record = { squash: String(method === 'squash'), should_remove_source_branch: String(deleteBranch) }; + // auto_merge since GitLab 17; merge_when_pipeline_succeeds before it (each ignores the other). + if (auto) Object.assign(typed, { auto_merge: 'true', merge_when_pipeline_succeeds: 'true' }); + await this.api.rest('PUT', `merge_requests/${n}/merge`, { typed, env: as?.env, timeout: 90_000 }); + } catch (err) { + return (err as Error).message; + } + void this.refreshPulls(); + return undefined; + } + + /** Closes an issue, or an MR without merging it, optionally saying why. GitLab keeps no reason, so it goes in the comment. */ + async close(kind: 'issue' | 'pull', n: number, opts: { comment?: string; reason?: GhCloseReason; deleteBranch?: boolean }, as?: GhAs): Promise { + try { + const why = kind === 'issue' && opts.reason === 'not planned' ? 'Closed as not planned.' : ''; + const comment = [opts.comment, why].filter(Boolean).join('\n\n'); + if (comment) await this.api.rest('POST', `${this.path(kind, n)}/notes`, { raw: { body: comment }, env: as?.env }); + const closed = await this.api.rest('PUT', this.path(kind, n), { raw: { state_event: 'close' }, env: as?.env }); + // A fork's MR has its branch in the fork: one by that name here is someone else's. + if (kind === 'pull' && opts.deleteBranch && closed?.source_branch && closed.source_project_id === closed.target_project_id) { + await this.api.rest('DELETE', `repository/branches/${encodeURIComponent(closed.source_branch)}`, { env: as?.env, text: true }); + } + } catch (err) { + return (err as Error).message; + } + const refresh = () => (kind === 'issue' ? this.refreshIssues() : this.refreshPulls()); + void refresh().then(() => { + if ((kind === 'issue' ? this.issues : this.pulls).items.some((i) => i.number === n && i.state === 'OPEN')) setTimeout(() => void refresh(), 3000); + }); + return undefined; + } + + /** Every label the project has, its group's too, for the label picker. */ + repoLabels(): Promise { + if (!this.labelList || Date.now() - this.labelList.at > LABELS_MS) { + const list = this.api.list('labels?include_ancestor_groups=true', 1000).then(labelsOf); + this.labelList = { at: Date.now(), list }; + list.catch(() => this.labelList?.list === list && (this.labelList = undefined)); + } + return this.labelList.list; + } + + async setLabels(kind: 'issue' | 'pull', n: number, add: string[], remove: string[], as?: GhAs): Promise<{ labels?: GhLabel[]; error?: string }> { + let now: GhLabel[]; + try { + // A label's name can't have a comma in it on GitLab, so the lists are safe to join. + const raw: Record = {}; + if (add.length) raw.add_labels = add.join(','); + if (remove.length) raw.remove_labels = remove.join(','); + const it = await this.api.rest('PUT', this.path(kind, n), { raw, env: as?.env }); + const colors = new Map((await this.repoLabels().catch(() => [] as GhLabel[])).map((l) => [l.name, l.color])); + now = (it?.labels ?? []).map((name: string) => ({ name, color: colors.get(name) ?? '#888888' })); + } catch (err) { + void (kind === 'issue' ? this.refreshIssues() : this.refreshPulls()); + return { error: (err as Error).message }; + } + const at = Date.now(); + this.relabeled.set(`${kind}:${n}`, { labels: now, at }); + if (kind === 'issue') { + this.issues = { ...this.issues, items: this.relabel('issue', this.issues.items, at) }; + this.onIssues(this.issues); + void this.refreshIssues(); + } else { + this.pulls = { ...this.pulls, items: this.relabel('pull', this.pulls.items, at) }; + this.onPulls(this.pulls); + void this.refreshPulls(); + } + return { labels: now }; + } + + /** See GitHub.relabel. */ + private relabel(kind: 'issue' | 'pull', items: T[], asked: number): T[] { + return items.map((it) => { + const key = `${kind}:${it.number}`; + const r = this.relabeled.get(key); + if (!r) return it; + if (r.at < asked) { + this.relabeled.delete(key); + return it; + } + return { ...it, labels: r.labels }; + }); + } + + /** A worker took the issue: In progress at once, and assigned on GitLab to `as` (else the office's glab) alongside whoever has it. */ + async claim(issue: number, as?: GhAs): Promise { + const answered = this.claims.take(issue); + this.showClaims(); + try { + const [me, it] = await Promise.all([this.api.get('/user', as?.env), this.api.get(`issues/${issue}`)]); + const ids = [...new Set([...(it?.assignees ?? []).map((a: any) => Number(a.id)), Number(me.id)])]; + await this.api.rest('PUT', `issues/${issue}`, { typed: { assignee_ids: JSON.stringify(ids) }, env: as?.env }); + } catch (err) { + answered(false); + this.showClaims(); + return (err as Error).message; + } + answered(true); + void this.refreshIssues().then(() => (this.claims.has(issue) ? this.refreshIssues() : undefined)); + return undefined; + } + + private showClaims() { + this.issues = { ...this.issues, items: this.claims.mark(this.issues.items) }; + this.onIssues(this.issues); + } + + private refreshIssues(): Promise { + this.listing ??= this.listIssues().finally(() => (this.listing = undefined)); + return this.listing; + } + + private async listIssues() { + this.issues = { ...this.issues, loading: true }; + this.onIssues(this.issues); + const asked = Date.now(); + try { + // Open and closed separately, so old open issues are never crowded out by recent closed ones. + const [open, closed] = await Promise.all([ + this.api.list('issues?state=opened&with_labels_details=true&order_by=updated_at', 300), + this.api.list('issues?state=closed&with_labels_details=true&order_by=updated_at', 40), + ]); + const items = this.claims.mark(this.relabel('issue', [...open, ...closed].map(issueOf), asked), asked); + this.issues = { items, fetchedAt: Date.now(), loading: false }; + } catch (err) { + this.issues = { ...this.issues, loading: false, error: (err as Error).message, fetchedAt: Date.now() }; + } + this.onIssues(this.issues); + } + + private async refreshPulls() { + if (this.pulls.loading) return; + this.pulls = { ...this.pulls, loading: true }; + this.onPulls(this.pulls); + const asked = Date.now(); + try { + const [open, merged, closed] = await Promise.all([this.listPulls('opened', 150), this.listPulls('merged', 30), this.listPulls('closed', 40)]); + const seen = new Set(); + const all = [...open, ...merged, ...closed].map(pullOf).filter((p) => !seen.has(p.number) && seen.add(p.number)); + this.pulls = { items: this.relabel('pull', all, asked), fetchedAt: Date.now(), loading: false }; + } catch (err) { + this.pulls = { ...this.pulls, loading: false, error: (err as Error).message, fetchedAt: Date.now() }; + } + this.onPulls(this.pulls); + } + + /** MRs in one state, newest activity first, through GraphQL: one call has their pipelines, approvals and sizes. */ + private async listPulls(state: string, limit: number): Promise { + for (;;) { + const fields = MR_FIELDS.filter((f) => !this.dropped.has(f.split(' ')[0])); + const nodes: any[] = []; + try { + let after = ''; + while (nodes.length < limit) { + const first = Math.min(100, limit - nodes.length); + const query = `query($p: ID!, $after: String) { project(fullPath: $p) { mergeRequests(state: ${state}, first: ${first}, after: $after, sort: UPDATED_DESC) { pageInfo { hasNextPage endCursor } nodes { ${fields.join(' ')} } } } }`; + const data = await this.api.graphql(query, after ? { p: this.api.path, after } : { p: this.api.path }); + const page = data?.project?.mergeRequests; + if (!data?.project) throw new Error("glab can't find this project on GitLab (check the remote and access)"); + nodes.push(...(page?.nodes ?? [])); + if (!page?.pageInfo?.hasNextPage) break; + after = page.pageInfo.endCursor; + } + return nodes; + } catch (err) { + // A field inside another (reviewers' mergeRequestInteraction) takes the one it's in with it. + // The three lists ask at once, so another may have dropped it already: what counts is whether this one asked for it. + const missing = missingField((err as Error).message); + const field = missing && MR_FIELDS.find((f) => new RegExp(`\\b${missing}\\b`).test(f))?.split(' ')[0]; + if (!field || REQUIRED_MR_FIELDS.has(field) || !fields.some((f) => f.split(' ')[0] === field)) throw err; + this.dropped.add(field); + } + } + } +} diff --git a/src/server/gitlab/repos.ts b/src/server/gitlab/repos.ts new file mode 100644 index 000000000..4dd7c084c --- /dev/null +++ b/src/server/gitlab/repos.ts @@ -0,0 +1,87 @@ +// The GitLab projects the elevator offers and clones: every GitLab `glab` is signed in to on the +// office's machine (gitlab.com, or a company's own). +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { RepoChoice } from '../../shared/protocol.js'; +import { GitLabApi, glab, glabEnv } from './api.js'; + +/** Projects listed per GitLab, most recently active first. */ +const MAX_PROJECTS = 500; + +/** What `glab auth status` says, stdout and stderr together (it writes to either, by version). */ +function authStatus(cwd: string): Promise { + return new Promise((resolve) => { + execFile('glab', ['auth', 'status'], { cwd, timeout: 20_000, env: glabEnv(process.env) }, (_err, stdout, stderr) => resolve(`${stdout ?? ''}\n${stderr ?? ''}`)); + }); +} + +/** The GitLabs glab is signed in to ("Logged in to gitlab.example.com as …"). None when glab isn't installed. */ +export async function gitlabHosts(cwd: string): Promise { + const out = await authStatus(cwd); + return [...new Set([...out.matchAll(/Logged in to ([\w.-]+(?::\d+)?) as/gi)].map((m) => m[1].toLowerCase()))]; +} + +/** Where glab keeps its config (and so its sign-ins), by OS: Go's user config folder, or GLAB_CONFIG_DIR. */ +function glabConfigs(): string[] { + const home = os.homedir(); + const dirs = [process.env.GLAB_CONFIG_DIR, process.env.XDG_CONFIG_HOME && path.join(process.env.XDG_CONFIG_HOME, 'glab-cli'), path.join(home, '.config', 'glab-cli'), path.join(home, 'Library', 'Application Support', 'glab-cli'), process.env.APPDATA && path.join(process.env.APPDATA, 'glab-cli')]; + return dirs.filter((d): d is string => !!d).map((d) => path.join(d, 'config.yml')); +} + +/** + * Whether a checkout's origin on `host` is a GitLab's: one called so, or one glab has a sign-in for. + * Any other host (a GitHub Enterprise, say) stays with gh, as it was before there were GitLab floors. + */ +export function isGitLabHost(host: string): boolean { + if (/(?:^|[.-])gitlab(?:[.-]|$)/i.test(host)) return true; + const entry = new RegExp(`^\\s+${host.replace(/[.]/g, '\\.')}:\\s*$`, 'im'); + return glabConfigs().some((file) => { + try { + return entry.test(readFileSync(file, 'utf8')); + } catch { + return false; + } + }); +} + +/** How glab clones from `host`: over ssh (its default) or https. */ +function gitProtocol(host: string, cwd: string): Promise<'ssh' | 'https'> { + return new Promise((resolve) => { + execFile('glab', ['config', 'get', 'git_protocol', '--host', host], { cwd, timeout: 10_000, env: glabEnv(process.env) }, (_err, stdout) => resolve(String(stdout ?? '').trim() === 'https' ? 'https' : 'ssh')); + }); +} + +/** + * A GitLab project the office was asked to clone: its name with GitLab's case (host/group/…/project), + * whether it has no commits yet, and the URL git clones it from (as glab would: ssh unless set to https). + */ +export async function lookUpGitLab(lab: { host: string; path: string }, cwd: string): Promise<{ repo: string; empty: boolean; from: string }> { + const [p, protocol] = await Promise.all([new GitLabApi(lab.host, lab.path, cwd, glab).get(''), gitProtocol(lab.host, cwd)]); + const from = String((protocol === 'https' ? p.http_url_to_repo : p.ssh_url_to_repo) ?? p.ssh_url_to_repo ?? ''); + if (!/^(?:https:\/\/|ssh:\/\/|[\w.-]+@[\w.-]+:)/.test(from)) throw new Error("GitLab didn't say where to clone it from"); + return { repo: `${lab.host}/${String(p.path_with_namespace ?? lab.path)}`, empty: p.empty_repo === true, from }; +} + +/** Every project the office's glab can see on each GitLab it's signed in to, as elevator choices. */ +export async function gitlabRepos(cwd: string): Promise { + const hosts = await gitlabHosts(cwd); + const lists = await Promise.allSettled( + hosts.map(async (host) => { + const api = new GitLabApi(host, '', cwd, glab); + const projects = await api.list('/projects?membership=true&simple=true&archived=false&order_by=last_activity_at', MAX_PROJECTS); + return projects.map( + (p): RepoChoice => ({ + name: `${host}/${String(p.path_with_namespace)}`, + description: typeof p.description === 'string' && p.description ? p.description.slice(0, 200) : undefined, + private: p.visibility !== 'public', + pushedAt: typeof p.last_activity_at === 'string' ? p.last_activity_at : undefined, + }), + ); + }), + ); + const ok = lists.filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled'); + if (hosts.length && !ok.length) throw (lists[0] as PromiseRejectedResult).reason; + return ok.flatMap((r) => r.value); +} diff --git a/src/server/hooks/office-workers.ts b/src/server/hooks/office-workers.ts index 8b276d339..867045bc8 100644 --- a/src/server/hooks/office-workers.ts +++ b/src/server/hooks/office-workers.ts @@ -1,7 +1,8 @@ import type http from 'node:http'; import { notLeaving } from '../leave-on-merge.js'; import { findWorker, readHireRequest, readHomeRequest, readPrRequest, workerRow, type PullsView } from '../office-workers.js'; -import { gh } from '../github.js'; +import { pullRequests } from '../forge.js'; +import { forgeWords } from '../../shared/floors.js'; import type { Floor } from '../floor.js'; import { nextFreeSeat } from '../../shared/layout.js'; import type { WorkerInfo } from '../../shared/protocol.js'; @@ -14,20 +15,21 @@ import { readBody, send } from '../http/util.js'; * on the floor's list. A string says why it can't be. */ async function pullOf(floor: Floor, n: number, repo?: string): Promise<{ number: number; url: string } | string> { - const here = floor.def.repo; - if (repo && here && repo.toLowerCase() !== here.toLowerCase()) return `That pull request is in ${repo}, and this floor is ${here}`; - const listed = floor.github.pulls.items.find((p) => p.number === n); + const here = floor.repo; + const w = forgeWords(floor.forge.kind); + if (repo && here && repo.toLowerCase() !== here.toLowerCase()) return `That ${w.pullRequest} is in ${repo}, and this floor is ${here}`; + const listed = floor.forge.pulls.items.find((p) => p.number === n); let pr: { url: string; state: string } | undefined = listed; if (!pr) { try { - pr = JSON.parse(await gh(['pr', 'view', String(n), '--json', 'url,state'], floor.dir)) as { url: string; state: string }; + pr = await pullRequests(here).view(n, floor.dir); } catch (err) { - return `No pull request #${n} here: ${(err as Error).message}`; + return `No ${w.pullRequest} ${w.ref}${n} here: ${(err as Error).message}`; } } - if (pr.state === 'CLOSED') return `PR #${n} was closed without merging`; + if (pr.state === 'CLOSED') return `${w.pr} ${w.ref}${n} was closed without merging`; // The office follows the open ones and the last ones merged: an older one would look open for good. - if (!listed && pr.state === 'MERGED') return `PR #${n} merged too long ago for the office to follow: send the worker home by name instead`; + if (!listed && pr.state === 'MERGED') return `${w.pr} ${w.ref}${n} merged too long ago for the office to follow: send the worker home by name instead`; return { number: n, url: pr.url }; } @@ -45,7 +47,7 @@ export async function officeWorkers(ctx: Ctx, req: http.IncomingMessage, res: ht const me = floor?.workers.authenticate(workerId, token); if (!floor || !me) return send(res, 401, { error: 'Send your own AGENT_OFFICE_WORKER_ID as ?worker= and AGENT_OFFICE_HOOK_TOKEN as the bearer token' }); const who = me.name; - const view: PullsView = { pulls: floor.github.pulls.items, tasks: floor.queue.state().tasks, pullsOf: (id) => ctx.floors.get(id)?.github.pulls.items }; + const view: PullsView = { pulls: floor.forge.pulls.items, tasks: floor.queue.state().tasks, pullsOf: (id) => ctx.floors.get(id)?.forge.pulls.items }; const row = (id: string) => { const w = floor.workers.get(id); return w && workerRow(w, view, me.id); @@ -160,9 +162,10 @@ export async function officeWorkers(ctx: Ctx, req: http.IncomingMessage, res: ht if (ask.issue) { const n = ask.issue; floor.queue.dropIssue(n); - const as = owner ? ctx.signins.ghAs(owner) : undefined; - if (typeof as === 'string') ctx.toastFloor(floor, `Couldn't assign issue #${n} on GitHub: ${as}`, 'warn'); - else void floor.github.claim(n, as).then((e) => e && ctx.toastFloor(floor, `Couldn't assign issue #${n} on GitHub: ${e}`, 'warn')); + const as = owner ? ctx.signins.ghAs(owner, floor.forge.kind) : undefined; + const site = forgeWords(floor.forge.kind).site; + if (typeof as === 'string') ctx.toastFloor(floor, `Couldn't assign issue #${n} on ${site}: ${as}`, 'warn'); + else void floor.forge.claim(n, as).then((e) => e && ctx.toastFloor(floor, `Couldn't assign issue #${n} on ${site}: ${e}`, 'warn')); } send(res, 200, { ok: true, worker: row(r.id) }); } diff --git a/src/server/http/routes/github.ts b/src/server/http/routes/github.ts index eb29e66c0..1a1a0ace8 100644 --- a/src/server/http/routes/github.ts +++ b/src/server/http/routes/github.ts @@ -15,10 +15,10 @@ export const githubRoutes = { // The repo's labels (for the label picker) are the one thing not about a single issue or PR. if (p !== '/api/gh/labels' && (!Number.isSafeInteger(n) || n <= 0)) return send(res, 400, { error: 'Bad number' }); if (!floor) return send(res, 404, { error: 'No such floor' }); - const github = floor.github; + const github = floor.forge; try { - // "You" on comments is your own GitHub login once you've signed in to it. - const me = session.account ? ctx.signins.githubLogin(session.account.id) : undefined; + // "You" on comments is your own GitHub (or GitLab) login once you've signed in to it. + const me = session.account ? ctx.signins.githubLogin(session.account.id, github.kind) : undefined; if (p === '/api/gh/pull') return send(res, 200, await github.pullDetail(n, me)); if (p === '/api/gh/issue') return send(res, 200, await github.issueDetail(n, me)); if (p === '/api/gh/labels') return send(res, 200, await github.repoLabels()); diff --git a/src/server/meetings.ts b/src/server/meetings.ts index 31c8919d4..a9cd72ec0 100644 --- a/src/server/meetings.ts +++ b/src/server/meetings.ts @@ -9,7 +9,7 @@ import { isAgentEffort, isAgentProvider, tokensOf, type AgentChoice, type AgentE import { validateWorkerEffort, validateWorkerModel } from './agents.js'; import { providerMeta, takesEffort, takesModel } from '../shared/providers.js'; import { gitError, type WorktreeRef, type WorktreeState } from './worktrees.js'; -import { PROMPTS, fillPrompt, type PromptId, type PromptVars } from '../shared/prompts.js'; +import { PROMPTS, fillPrompt, meetingRefs, type PromptId, type PromptVars } from '../shared/prompts.js'; const execFileP = promisify(execFile); @@ -42,8 +42,9 @@ export interface MeetingEvents { hiringPaused(): string | undefined; /** Posts the review panel's review on its pull request. Resolves to the review's URL. */ postReview(pr: number, file: string, owner?: string): Promise; - /** One of the office's prompts as it has it now (rewritten in ⚙️ Settings, or the default). */ + /** One of the office's prompts as it has it now (rewritten in ⚙️ Settings, or the default), and where the floor's pull requests are. */ prompt?(id: PromptId): string; + forge?(): 'github' | 'gitlab'; } const PUMP_MS = 3000; @@ -533,8 +534,7 @@ export class MeetingRoom { others: list(others), how: how[m.pattern], about: m.prompt, - pullRequest: m.pr !== undefined ? `The pull request is #${m.pr}: read it with gh pr view ${m.pr} and gh pr diff ${m.pr}.` : '', - issue: m.issue !== undefined ? `It comes from GitHub issue #${m.issue}: gh issue view ${m.issue} --comments.` : '', + ...meetingRefs(this.events.forge?.(), m.pr, m.issue), cwd: this.cwd(m), notes: path.join(this.cwd(m), m.notes), output: m.output, diff --git a/src/server/office-workers.ts b/src/server/office-workers.ts index 9865e3d7a..7e986ef6a 100644 --- a/src/server/office-workers.ts +++ b/src/server/office-workers.ts @@ -183,10 +183,13 @@ export function readPrRequest(body: unknown): PrRequest | string { const who = typeof b.worker === 'string' ? { worker: b.worker.trim() } : {}; if (b.unlink === true) return b.pr === undefined ? who : 'Give pr or unlink: true, not both'; const text = typeof b.pr === 'string' ? b.pr.trim() : ''; - const url = /^https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(text); - const n = typeof b.pr === 'number' ? b.pr : url ? Number(url[2]) : /^#?\d+$/.test(text) ? Number(text.replace('#', '')) : NaN; + // A pull request's page on GitHub, or a merge request's on a GitLab (…/project/-/merge_requests/12). + const hub = /^https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(text); + const lab = hub ? null : /^https?:\/\/([\w.-]+)(?::\d+)?\/([\w./-]+?)\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i.exec(text); + const n = typeof b.pr === 'number' ? b.pr : hub ? Number(hub[2]) : lab ? Number(lab[3]) : /^[#!]?\d+$/.test(text) ? Number(text.replace(/^[#!]/, '')) : NaN; if (!Number.isSafeInteger(n) || n < 1) return "Say which pull request: pr, its number or its URL (or unlink: true to take the worker's off)"; - return { ...who, pr: n, ...(url ? { repo: url[1] } : {}) }; + const repo = hub ? hub[1] : lab ? `${lab[1].toLowerCase()}/${lab[2]}` : undefined; + return { ...who, pr: n, ...(repo ? { repo } : {}) }; } // --- The MCP server ------------------------------------------------------------------------------- diff --git a/src/server/office/context.ts b/src/server/office/context.ts index 96090a2e0..91794eb8a 100644 --- a/src/server/office/context.ts +++ b/src/server/office/context.ts @@ -18,6 +18,7 @@ import type { Webhook } from '../webhook.js'; import type { Machine } from '../machine.js'; import type { Building, FloorDef } from '../building.js'; import type { Floor } from '../floor.js'; +import type { ForgeKind } from '../../shared/floors.js'; import type { Sky } from '../sky.js'; import type { Themes } from '../theme.js'; import type { Maps } from '../maps.js'; @@ -170,8 +171,8 @@ export interface Gates { * are still there. */ withFreshBase(c: Client, floor: Floor | Floor[], go: () => void): void; - /** Runs `go` with how the office acts on GitHub for `c`: as them, or as itself (no account, or an admin's choice). */ - withGitHub(c: Client, go: (as: GhAs | undefined) => void, refused?: (why: string) => void): void; + /** Runs `go` with how the office acts on GitHub (or a GitLab) for `c`: as them, or as itself (no account, or an admin's choice). */ + withForge(c: Client, forge: ForgeKind, go: (as: GhAs | undefined) => void, refused?: (why: string) => void): void; /** Needs a Claude sign-in of its own when the worker it starts runs Claude. */ claudeFor(provider: string | undefined): SignInKind | undefined; } diff --git a/src/server/office/floors.ts b/src/server/office/floors.ts index cec674fb4..d269fce6f 100644 --- a/src/server/office/floors.ts +++ b/src/server/office/floors.ts @@ -118,7 +118,7 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise [...floors.values()].some((f) => f !== floor && worksIn(f, floor)), locksUp: () => !!ctx.maps.plan().sendHome?.keeps, runAs: ctx.signins, - ghAs: (owner) => (owner ? ctx.signins.ghAs(owner) : undefined), + ghAs: (owner, forge) => (owner ? ctx.signins.ghAs(owner, forge) : undefined), }; /** Whether a worker on `from` works in `on`'s project too (see WorkerInfo.repos). */ const worksIn = (from: Floor, on: Floor) => from.workers.list().some((w) => w.repos?.some((r) => r.floor === on.id)); diff --git a/src/server/office/gates.ts b/src/server/office/gates.ts index 9546050f4..5430ba355 100644 --- a/src/server/office/gates.ts +++ b/src/server/office/gates.ts @@ -2,6 +2,7 @@ import { WebSocket } from 'ws'; import type { GhAs } from '../signins.js'; import type { Floor } from '../floor.js'; import type { SignInKind } from '../../shared/protocol.js'; +import { forgeWords, type ForgeKind } from '../../shared/floors.js'; import type { Ctx, Gates } from './context.js'; import type { Client } from './client.js'; @@ -14,9 +15,11 @@ export function gates(ctx: Ctx): Gates { */ const takeIssue = (c: Client, floor: Floor, n: number) => { floor.queue.dropIssue(n); - const as = c.accountId ? ctx.signins.ghAs(c.accountId) : undefined; - if (typeof as === 'string') return ctx.warn(c, `Couldn't assign issue #${n} on GitHub: ${as}`); - void floor.github.claim(n, as).then((err) => ctx.warn(c, err && `Couldn't assign issue #${n} on GitHub: ${err}`)); + const kind = floor.forge.kind; + const as = c.accountId ? ctx.signins.ghAs(c.accountId, kind) : undefined; + const site = forgeWords(kind).site; + if (typeof as === 'string') return ctx.warn(c, `Couldn't assign issue #${n} on ${site}: ${as}`); + void floor.forge.claim(n, as).then((err) => ctx.warn(c, err && `Couldn't assign issue #${n} on ${site}: ${err}`)); }; /** @@ -27,7 +30,7 @@ export function gates(ctx: Ctx): Gates { const withSignIn = (c: Client, which: SignInKind | undefined, go: () => void, refused?: (why: string) => void) => { const { signins } = ctx; const id = c.accountId; - const ready = (a: string) => (which === 'claude' ? signins.claudeReady(a) : signins.githubReady(a)); + const ready = (a: string) => (which === 'claude' ? signins.claudeReady(a) : signins.githubReady(a, which ?? 'github')); if (!which || !id || ready(id)) return go(); void signins.look(id, true).then(() => { if (c.out || c.ws.readyState !== WebSocket.OPEN) return; @@ -52,13 +55,13 @@ export function gates(ctx: Ctx): Gates { go(); }); }; - /** Runs `go` with how the office acts on GitHub for `c`: as them, or as itself (no account, or an admin's choice). */ - const withGitHub = (c: Client, go: (as: GhAs | undefined) => void, refused?: (why: string) => void) => + /** Runs `go` with how the office acts on GitHub (or a GitLab) for `c`: as them, or as itself (no account, or an admin's choice). */ + const withForge = (c: Client, forge: ForgeKind, go: (as: GhAs | undefined) => void, refused?: (why: string) => void) => withSignIn( c, - 'github', + forge, () => { - const as = c.accountId ? ctx.signins.ghAs(c.accountId) : undefined; + const as = c.accountId ? ctx.signins.ghAs(c.accountId, forge) : undefined; if (typeof as !== 'string') return go(as); if (refused) refused(as); else ctx.warn(c, as); @@ -68,5 +71,5 @@ export function gates(ctx: Ctx): Gates { /** Needs a Claude sign-in of its own when the worker it starts runs Claude. */ const claudeFor = (provider: string | undefined): SignInKind | undefined => (provider === 'claude' ? 'claude' : undefined); - return { takeIssue, withSignIn, withFreshBase, withGitHub, claudeFor }; + return { takeIssue, withSignIn, withFreshBase, withForge, claudeFor }; } diff --git a/src/server/office/services.ts b/src/server/office/services.ts index 847f05a52..0aab16549 100644 --- a/src/server/office/services.ts +++ b/src/server/office/services.ts @@ -48,12 +48,13 @@ export function createServices(ctx: Ctx): BuildingServices { ); const claudeBin = configuredProvider(cfg.agentCmd) === 'claude' ? resolveCommand(cfg.agentCmd) : resolveCommand('claude'); - // Everyone with an account runs on their own Claude and GitHub sign-ins (see signins.ts). On the + // Everyone with an account runs on their own Claude, GitHub and GitLab sign-ins (see signins.ts). On the // shared password, with no accounts, the office's own are used, as they always were. const signins = new SignIns( cfg.dataDir, claudeBin, resolveCommand('gh'), + resolveCommand('glab'), childEnv, (id) => accounts.get(id)?.role === 'admin', (id) => { diff --git a/src/server/prompts.ts b/src/server/prompts.ts index c537804cc..0308ba182 100644 --- a/src/server/prompts.ts +++ b/src/server/prompts.ts @@ -3,10 +3,11 @@ import path from 'node:path'; import { isAgentEffort, isAgentProvider, type AgentChoice, type AgentProvider, type PromptsState } from '../shared/protocol.js'; import { PROMPTS, PROMPT_MAX, fillPrompt, isPromptId, promptText, type PromptId, type PromptVars } from '../shared/prompts.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; +import type { ForgeKind } from '../shared/floors.js'; -/** What the floors read: a prompt as the office has it now, and what workers start on. */ +/** What the floors read: a prompt as the office has it now (GitLab's default on a GitLab floor), and what workers start on. */ export interface PromptSource { - text(id: PromptId): string; + text(id: PromptId, forge?: ForgeKind): string; /** The worker picked in ⚙️ Settings, when one was. */ agent(): AgentChoice | undefined; } @@ -16,6 +17,11 @@ export function officePrompt(source: PromptSource | undefined, id: PromptId, var return fillPrompt(source ? source.text(id) : PROMPTS[id].text, vars); } +/** `source` as a floor on `forge` reads it: GitLab's defaults where they differ. */ +export function forgePrompts(source: PromptSource, forge: ForgeKind): PromptSource { + return { text: (id) => source.text(id, forge), agent: () => source.agent() }; +} + /** * The prompts the office writes for workers by itself (shared/prompts.ts), as rewritten in * ⚙️ Settings, and the provider, model and effort every worker starts on unless whoever starts it @@ -39,8 +45,8 @@ export class OfficePrompts implements PromptSource { return { custom: { ...this.saved.custom }, ...(this.saved.agent ? { agent: { ...this.saved.agent } } : {}) }; } - text(id: PromptId): string { - return promptText(this.saved.custom, id); + text(id: PromptId, forge?: ForgeKind): string { + return promptText(this.saved.custom, id, forge); } agent(): AgentChoice | undefined { diff --git a/src/server/providers/claude.ts b/src/server/providers/claude.ts index 1eac357c3..4294cb1d5 100644 --- a/src/server/providers/claude.ts +++ b/src/server/providers/claude.ts @@ -163,7 +163,7 @@ function claudeHook(h: WorkerHandle, event: string, payload: any): boolean { case 'PostToolUse': case 'PostToolUseFailure': noteOutcome(h, payload, event === 'PostToolUseFailure'); - // It opened a pull request itself (`gh pr create`): that one is its own. + // It opened a pull request itself (`gh pr create`, or `glab mr create`): that one is its own. h.notePr(payload?.tool_input?.command, toolOutput(payload)); answered(h, now); break; diff --git a/src/server/repos.ts b/src/server/repos.ts new file mode 100644 index 000000000..e111edcf4 --- /dev/null +++ b/src/server/repos.ts @@ -0,0 +1,55 @@ +// The repositories the elevator offers: GitHub's through `gh`, and every GitLab's `glab` is signed in to. +import type { RepoChoice } from '../shared/protocol.js'; +import { normalizeRepo } from '../shared/floors.js'; +import { gh } from './github.js'; +import { gitlabRepos } from './gitlab/repos.js'; + +const MAX_REPOS = 1000; + +/** + * Repositories the office can clone: GitHub's through `gh`, and the projects of every GitLab `glab` + * is signed in to. One that fails leaves the others; only all of them failing is an error. + */ +export async function allRepos(cwd: string): Promise { + const [hub, lab] = await Promise.allSettled([listRepos(cwd), gitlabRepos(cwd)]); + const lists = [hub, lab].filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled').map((r) => r.value); + if (!lists.length) throw (hub as PromiseRejectedResult).reason; + // glab signed in to nothing and gh failing is gh's error, which says what to do. + if (hub.status === 'rejected' && !lists.some((l) => l.length)) throw hub.reason; + return lists.flat().sort((a, b) => (b.pushedAt ?? '').localeCompare(a.pushedAt ?? '')); +} + +async function listRepos(cwd: string): Promise { + const out = await gh( + [ + 'api', + '--paginate', + 'user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member', + '--jq', + '.[] | {name: .full_name, description: (.description // ""), private: .private, pushedAt: .pushed_at}', + ], + cwd, + 90_000, + ); + const repos: RepoChoice[] = []; + const seen = new Set(); + for (const line of out.split('\n')) { + if (!line.trim()) continue; + try { + const r = JSON.parse(line) as { name?: unknown; description?: unknown; private?: unknown; pushedAt?: unknown }; + const name = normalizeRepo(r.name); + if (!name || seen.has(name.toLowerCase())) continue; + seen.add(name.toLowerCase()); + repos.push({ + name, + description: typeof r.description === 'string' && r.description ? r.description.slice(0, 200) : undefined, + private: r.private === true, + pushedAt: typeof r.pushedAt === 'string' ? r.pushedAt : undefined, + }); + } catch { + // not a line of ours + } + if (repos.length >= MAX_REPOS) break; + } + return repos.sort((a, b) => (b.pushedAt ?? '').localeCompare(a.pushedAt ?? '')); +} diff --git a/src/server/setup.ts b/src/server/setup.ts index 8dad2f93a..a0c954e66 100644 --- a/src/server/setup.ts +++ b/src/server/setup.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { createInterface } from 'node:readline/promises'; import tty from 'node:tty'; import { normalizeRepo, sameRepo } from '../shared/floors.js'; +import { gitlabHosts } from './gitlab/repos.js'; import type { RepoChoice } from '../shared/protocol.js'; import { Building, tildify } from './building.js'; import { officeHome, type Config } from './config.js'; @@ -144,12 +145,12 @@ export async function setupCommand(argv: string[]): Promise { return 0; } -/** The questions: the workspace folder (when `askFolder`), GitHub, then repositories to add. */ +/** The questions: the workspace folder (when `askFolder`), GitHub (or a GitLab glab is signed in to), then repositories to add. */ async function walkthrough(building: Building, dataDir: string, askFolder: boolean) { if (askFolder) await pickFolder(building); - const login = await githubLogin(dataDir); + const login = (await githubLogin(dataDir)) ?? (await gitlabLogin(dataDir)); if (!login) { - console.log('\n Add projects from the elevator in the office once gh is ready (or run `agent-office setup` again).'); + console.log('\n Add projects from the elevator in the office once gh (or glab) is ready (or run `agent-office setup` again).'); return; } await pickProjects(building, login); @@ -210,6 +211,14 @@ async function githubLogin(cwd: string): Promise { } } +/** Someone to add floors as when glab is signed in to a GitLab, which the office clones from too. */ +async function gitlabLogin(cwd: string): Promise { + const hosts = await gitlabHosts(cwd); + if (!hosts.length) return undefined; + console.log(`\n 🦊 glab is signed in to ${hosts.join(', ')}: its projects can be floors too`); + return whoAmI(); +} + function ghUser(cwd: string): Promise<{ login?: string; missing?: boolean; signedOut?: boolean; error?: string }> { return new Promise((resolve) => { execFile('gh', ['api', 'user', '--jq', '.login'], { cwd, timeout: 30_000 }, (err, stdout, stderr) => { @@ -222,7 +231,7 @@ function ghUser(cwd: string): Promise<{ login?: string; missing?: boolean; signe } async function pickProjects(building: Building, login: string) { - process.stdout.write(' Asking GitHub for your repositories…'); + process.stdout.write(' Asking GitHub and GitLab for your repositories…'); let repos: RepoChoice[] = []; try { repos = await building.repos(); @@ -235,7 +244,7 @@ async function pickProjects(building: Building, login: string) { if (shown.length) { console.log(`\n 🛗 Your repositories, most recently pushed first${repos.length > SHOWN ? ` (${SHOWN} of ${repos.length}; type a word to search)` : ''}:\n`); printRepos(shown, building); - } else if (!repos.length) console.log('\n 🛗 No repositories to list. Type owner/name to clone any repository you can see.'); + } else if (!repos.length) console.log('\n 🛗 No repositories to list. Type owner/name (or host/group/project for a GitLab) to clone any repository you can see.'); let added = 0; for (;;) { const answer = await ask( diff --git a/src/server/signins-gitlab.ts b/src/server/signins-gitlab.ts new file mode 100644 index 000000000..671cdb4e5 --- /dev/null +++ b/src/server/signins-gitlab.ts @@ -0,0 +1,62 @@ +// Everyone's own GitLab (see signins.ts): a personal access token, pasted in the office or typed into +// `glab auth login` at a desk's shell, kept by glab in the account's own folder (GLAB_CONFIG_DIR), +// never the machine's keyring, which every account would share. +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { quote, run } from './signins-run.js'; +import { glabEnv } from './gitlab/api.js'; + +/** GitLab credentials the office's own environment may carry. None of them reach anything run as someone else. */ +export const GITLAB_VARS = ['GITLAB_TOKEN', 'GITLAB_ACCESS_TOKEN', 'OAUTH_TOKEN', 'GITLAB_HOST', 'GLAB_CONFIG_DIR']; +/** glpat-…, or an older token without the prefix. */ +export const GITLAB_TOKEN = /^[A-Za-z0-9_.-]{20,255}$/; +export const GITLAB_HOST = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?::\d{1,5})?$/; + +/** Where an account's glab keeps its sign-ins. */ +export function glabDir(home: string): string { + return path.join(home, 'glab'); +} + +/** `env` running glab as the account whose folder is `home`. */ +export function asGitLab(env: Record, home: string): Record { + for (const k of GITLAB_VARS) delete env[k]; + env.GLAB_CONFIG_DIR = glabDir(home); + return env; +} + +/** Signs glab in to `host` with a token, in the account's folder. Resolves to why it didn't take. */ +export async function glabLogin(glab: string, env: Record, host: string, token: string): Promise { + const r = await run(glab, ['auth', 'login', '--hostname', host, '--stdin', '--insecure-storage', '--git-protocol', 'ssh'], glabEnv(env), `${token}\n`); + return r.code === 0 ? undefined : r.last || 'glab auth login failed'; +} + +export async function glabLogout(glab: string, env: Record, host: string) { + await run(glab, ['auth', 'logout', '--hostname', host], glabEnv(env)); +} + +/** The GitLab the account's glab is signed in to, when it signed in at a desk's shell (`glab auth login`) rather than here. */ +export function glabSignedIn(glab: string, env: Record): Promise { + // It says so among several lines on stderr (or stdout, by version), not the last. + return new Promise((resolve) => { + execFile(glab, ['auth', 'status'], { env: glabEnv(env), timeout: 20_000 }, (_err, stdout, stderr) => resolve(/Logged in to ([\w.-]+(?::\d+)?) as/i.exec(`${stdout ?? ''}\n${stderr ?? ''}`)?.[1]?.toLowerCase())); + }); +} + +/** Who glab is signed in to `host` as, or nothing (and why, unless it's just signed out). */ +export async function glabWho(glab: string, env: Record, host: string): Promise<{ who?: string; name?: string; email?: string; error?: string }> { + const r = await run(glab, ['api', '--hostname', host, 'user'], glabEnv(env)); + let user: { username?: string; name?: string; commit_email?: string; email?: string } = {}; + try { + user = r.code === 0 ? JSON.parse(r.out) : {}; + } catch { + // not JSON: treated as not signed in + } + if (user.username) return { who: `@${user.username} on ${host}`, name: user.name?.trim() || user.username, email: user.commit_email || user.email || undefined }; + const signedOut = /401|unauthorized|not authenticated|no token|glab auth login/i.test(r.last); + return { error: signedOut || !r.last ? undefined : r.last }; +} + +/** git's credential helper for a GitLab over https: glab's own login, so pushes go out as the account. */ +export function glabCredential(glab: string, host: string): string[] { + return [`[credential ${quote(`https://${host}`)}]`, '\thelper =', `\thelper = ${quote(`!'${glab.replace(/'/g, `'\\''`)}' auth git-credential`)}`]; +} diff --git a/src/server/signins-run.ts b/src/server/signins-run.ts new file mode 100644 index 000000000..738f5730b --- /dev/null +++ b/src/server/signins-run.ts @@ -0,0 +1,63 @@ +// Running the sign-in commands (claude, gh, glab) for signins.ts, and reading what they say. +import { execFile } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const LOOK_TIMEOUT_MS = 30_000; + +/** A value for a git config file, quoted. */ +export function quote(v: string): string { + return `"${v.replace(/[\p{C}]/gu, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +/** Terminal output as plain text: no colors, links or cursor moves. */ +export function plain(s: string): string { + return s + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-_]/g, '') + .replace(/\r\n?/g, '\n'); +} + +/** The last thing a command said, minus the link and the prompt, for an error line. */ +export function lastWords(s: string): string { + const lines = plain(s) + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !/https?:\/\/|paste code|opening browser|press enter/i.test(l)); + return (lines.at(-1) ?? '').slice(0, 300); +} + +/** Runs a command to the end; never rejects. `last` is the last line it said on stderr (or stdout). */ +export function run(cmd: string, args: string[], env: Record, input?: string): Promise<{ code: number; out: string; last: string }> { + return new Promise((resolve) => { + const p = execFile(cmd, args, { env, timeout: LOOK_TIMEOUT_MS, maxBuffer: 1024 * 1024, encoding: 'utf8' }, (err, stdout, stderr) => { + const e = err as { code?: number | string } | null; + const code = !e ? 0 : typeof e.code === 'number' ? e.code : -1; + const last = lastWords(stderr || (err && !stdout ? err.message : '')); + resolve({ code, out: stdout.trim(), last }); + }); + if (input !== undefined) p.stdin?.end(input); + else p.stdin?.end(); + }); +} + +/** Claude's own settings in an account's folder: its first-run questions already answered. */ +export function seedClaude(dir: string, change?: (c: any) => void) { + const file = path.join(dir, '.claude.json'); + let c: any = {}; + try { + c = JSON.parse(readFileSync(file, 'utf8')); + } catch { + // new + } + const before = JSON.stringify(c); + c.hasCompletedOnboarding = true; + change?.(c); + if (JSON.stringify(c) === before) return; + try { + writeFileSync(file, JSON.stringify(c, null, 2), { mode: 0o600 }); + } catch (err) { + console.error(`agent-office: couldn't write ${file}: ${(err as Error).message}`); + } +} diff --git a/src/server/signins.ts b/src/server/signins.ts index ccf586fa6..41fd926c6 100644 --- a/src/server/signins.ts +++ b/src/server/signins.ts @@ -1,13 +1,15 @@ -import { execFile, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import * as pty from '@lydell/node-pty'; import type { SignInKind, SignInState, SignInsState } from '../shared/protocol.js'; +import { lastWords, plain, quote, run, seedClaude } from './signins-run.js'; +import { GITLAB_HOST, GITLAB_TOKEN, asGitLab, glabCredential, glabDir, glabLogin, glabLogout, glabSignedIn, glabWho } from './signins-gitlab.js'; /* - * Everyone's own Claude and GitHub - * -------------------------------- + * Everyone's own Claude, GitHub and GitLab + * ----------------------------------------- * In an office with accounts, each person's workers run on that person's own Claude plan and * GitHub account. Every account gets a folder, .agent-office/homes//, holding its own Claude * config (CLAUDE_CONFIG_DIR), gh config (GH_CONFIG_DIR) and git config (GIT_CONFIG_GLOBAL), and @@ -32,7 +34,6 @@ const GITHUB_VARS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ const FLOW_MS = 15 * 60_000; /** Someone's sign-ins are looked at again at most this often, unless they ask. */ const LOOK_GAP_MS = 20_000; -const LOOK_TIMEOUT_MS = 30_000; /** From `claude setup-token` (sk-ant-oat01-…), or an Anthropic API key (sk-ant-api03-…). */ const CLAUDE_TOKEN = /^sk-ant-[a-z]+\d*-[A-Za-z0-9_-]{20,}$/; const API_KEY = /^sk-ant-api/; @@ -45,8 +46,11 @@ interface Saved { /** Unset: its own login, in its folder. A token pasted from `claude setup-token` (or an API key). The office machine's own (admins). */ claude?: { use: 'token'; token: string } | { use: 'office' }; github?: { use: 'office' }; + gitlab?: { use: 'office' }; + /** The GitLab its own GitLab sign-in is for (see signins-gitlab.ts). */ + gitlabHost?: string; /** Who the last look found each signed in as, so workers can start before the next look. */ - seen?: { claude?: string; github?: string }; + seen?: { claude?: string; github?: string; gitlab?: string }; } /** A sign-in the office is running for someone. */ @@ -59,6 +63,7 @@ interface Flow { interface Live { claude: Omit; github: Omit; + gitlab: Omit; flows: Partial>; looking?: Promise; lookedAt: number; @@ -76,9 +81,10 @@ export class SignIns { constructor( dataDir: string, - /** The `claude` and `gh` binaries, or null when they aren't installed. */ + /** The `claude`, `gh` and `glab` binaries, or null when they aren't installed. */ private claude: string | null, private gh: string | null, + private glab: string | null, /** The office's own environment, which everyone's is built on. */ private base: () => Record, /** Whether the account may use the office's own sign-ins (admins). */ @@ -94,6 +100,7 @@ export class SignIns { return { claude: { ...l.claude, how: this.how(id, s, 'claude') }, github: { ...l.github, how: this.how(id, s, 'github') }, + gitlab: { ...l.gitlab, how: this.how(id, s, 'gitlab'), ...(s.gitlabHost ? { host: s.gitlabHost } : {}) }, office: this.mayUseOffice(id), }; } @@ -104,16 +111,16 @@ export class SignIns { return this.how(id, s, 'claude') !== 'login' || !!s.seen?.claude; } - githubReady(id: string): boolean { + githubReady(id: string, which: 'github' | 'gitlab' = 'github'): boolean { const s = this.load(id); - return this.how(id, s, 'github') === 'office' || !!s.seen?.github; + return this.how(id, s, which) === 'office' || !!s.seen?.[which]; } /** What to tell someone who needs `which` signed in first. */ why(which: SignInKind): string { return which === 'claude' ? `Sign in to Claude first (${HELP_WHERE}): your workers run on your own Claude plan` - : `Sign in to GitHub first (${HELP_WHERE}): the office acts on GitHub as you`; + : `Sign in to ${which === 'gitlab' ? 'GitLab' : 'GitHub'} first (${HELP_WHERE}): the office acts on ${which === 'gitlab' ? 'GitLab' : 'GitHub'} as you`; } /** @@ -127,10 +134,10 @@ export class SignIns { return `${how}:${s.claude?.use === 'token' ? s.claude.token.slice(-12) : ''}:${s.seen?.claude ?? ''}`; } - /** The GitHub login `id` acts as (for "you" on comments); undefined when it's the office's own or none. */ - githubLogin(id: string): string | undefined { + /** The GitHub (or GitLab) login `id` acts as (for "you" on comments); undefined when it's the office's own or none. */ + githubLogin(id: string, which: 'github' | 'gitlab' = 'github'): string | undefined { const s = this.load(id); - return this.how(id, s, 'github') === 'login' ? s.seen?.github?.replace(/^@/, '') : undefined; + return this.how(id, s, which) === 'login' ? s.seen?.[which]?.replace(/^@/, '').replace(/ on .*$/, '') : undefined; } /** @@ -151,14 +158,15 @@ export class SignIns { env.GH_CONFIG_DIR = path.join(home, 'gh'); env.GIT_CONFIG_GLOBAL = path.join(home, 'gitconfig'); } + if (only !== 'claude' && this.how(id, s, 'gitlab') !== 'office') asGitLab(env, home); return env; } - /** How the office runs gh for `id`: as them (GhAs), as itself (undefined, an admin's choice), or a reason it can't. */ - ghAs(id: string): GhAs | undefined | string { + /** How the office runs gh (or glab) for `id`: as them (GhAs), as itself (undefined, an admin's choice), or a reason it can't. */ + ghAs(id: string, which: 'github' | 'gitlab' = 'github'): GhAs | undefined | string { const s = this.load(id); - if (this.how(id, s, 'github') === 'office') return undefined; - if (!s.seen?.github) return this.why('github'); + if (this.how(id, s, which) === 'office') return undefined; + if (!s.seen?.[which]) return this.why(which); return { key: id, env: this.apply(id, this.base(), [], 'github') }; } @@ -167,7 +175,7 @@ export class SignIns { const l = this.get(id); if (l.looking) return l.looking; if (!force && Date.now() - l.lookedAt < LOOK_GAP_MS) return Promise.resolve(); - l.looking = Promise.all([this.lookClaude(id), this.lookGithub(id)]).then(() => { + l.looking = Promise.all([this.lookClaude(id), this.lookGithub(id), this.lookGitlab(id)]).then(() => { l.lookedAt = Date.now(); l.looking = undefined; this.onChange(id); @@ -177,6 +185,7 @@ export class SignIns { /** Starts signing `id` in from the office. The page to open comes through state(). Returns why it can't. */ start(id: string, which: SignInKind): string | undefined { + if (which === 'gitlab') return 'Paste a personal access token from your GitLab (Preferences → Access tokens, with the api scope)'; return which === 'claude' ? this.startClaude(id) : this.startGithub(id); } @@ -198,10 +207,11 @@ export class SignIns { void this.look(id, true); } - /** A pasted token: from `claude setup-token` (or an API key), or a GitHub token. Resolves to why it didn't take. */ - async token(id: string, which: SignInKind, raw: string): Promise { + /** A pasted token: from `claude setup-token` (or an API key), a GitHub token, or a GitLab one for `host`. Resolves to why it didn't take. */ + async token(id: string, which: SignInKind, raw: string, host?: string): Promise { const token = raw.trim(); this.stop(id, which); + if (which === 'gitlab') return this.gitlabToken(id, token, (host ?? this.load(id).gitlabHost ?? '').trim().toLowerCase()); if (which === 'claude') { if (!CLAUDE_TOKEN.test(token)) return "That isn't a token from `claude setup-token` (sk-ant-oat01-…) or an Anthropic API key (sk-ant-api03-…)"; const s = this.load(id); @@ -209,7 +219,7 @@ export class SignIns { this.save(id, s); // Claude asks once before it uses an API key from the environment; this one is theirs, so it's approved. if (API_KEY.test(token)) { - this.seed(path.join(this.prepare(id), 'claude'), (c) => { + seedClaude(path.join(this.prepare(id), 'claude'), (c) => { c.customApiKeyResponses ??= { approved: [], rejected: [] }; const tail = token.slice(-20); if (!c.customApiKeyResponses.approved.includes(tail)) c.customApiKeyResponses.approved.push(tail); @@ -247,7 +257,7 @@ export class SignIns { delete s[which]; if (s.seen) delete s.seen[which]; this.save(id, s); - if (which === 'github') this.writeGitConfig(id); + if (which !== 'claude') this.writeGitConfig(id); await this.look(id, true); } @@ -303,6 +313,7 @@ export class SignIns { l = { claude: seen.claude ? { status: 'ok', who: seen.claude } : { status: 'none' }, github: seen.github ? { status: 'ok', who: seen.github } : { status: 'none' }, + gitlab: seen.gitlab ? { status: 'ok', who: seen.gitlab } : { status: 'none' }, flows: {}, lookedAt: 0, }; @@ -320,8 +331,8 @@ export class SignIns { private prepare(id: string): string { const home = this.home(id); if (!existsSync(path.join(home, 'gitconfig'))) { - for (const d of [this.homes, home, path.join(home, 'claude'), path.join(home, 'gh')]) mkdirSync(d, { recursive: true, mode: 0o700 }); - this.seed(path.join(home, 'claude')); + for (const d of [this.homes, home, path.join(home, 'claude'), path.join(home, 'gh'), glabDir(home)]) mkdirSync(d, { recursive: true, mode: 0o700 }); + seedClaude(path.join(home, 'claude')); this.writeGitConfig(id); } return home; @@ -540,6 +551,36 @@ export class SignIns { this.remember(id, 'github', how === 'office' ? undefined : l.github.who); } + private async gitlabToken(id: string, token: string, host: string): Promise { + if (!this.glab) return "The GitLab CLI (glab) isn't installed on the office's machine"; + if (!GITLAB_HOST.test(host)) return 'Say which GitLab: its address, like gitlab.example.com'; + if (!GITLAB_TOKEN.test(token)) return "That doesn't look like a GitLab token (glpat-…)"; + const s = this.load(id); + delete s.gitlab; + this.save(id, { ...s, gitlabHost: host }); + const err = await glabLogin(this.glab, asGitLab(this.base(), this.prepare(id)), host, token); + await this.look(id, true); + return err && `GitLab didn't take that token: ${err}`; + } + + private async lookGitlab(id: string) { + const l = this.get(id); + const s = this.load(id); + const how = this.how(id, s, 'gitlab'); + // Signed in at a desk's shell, not here: which GitLab, from its own glab. + const host = s.gitlabHost ?? (how !== 'office' && this.glab ? await glabSignedIn(this.glab, asGitLab(this.base(), this.prepare(id))) : undefined); + if (how === 'office' || !this.glab || !host) { + l.gitlab = how === 'office' ? { status: 'ok', who: "the office's own glab" } : { status: 'none', error: this.glab ? undefined : "The GitLab CLI (glab) isn't installed on the office's machine" }; + return this.remember(id, 'gitlab', undefined); + } + if (host !== s.gitlabHost) this.save(id, { ...s, gitlabHost: host }); + const r = await glabWho(this.glab, asGitLab(this.base(), this.prepare(id)), host); + l.gitlab = r.who ? { status: 'ok', who: r.who } : { status: 'none', error: r.error }; + // Commits carry their GitLab name, unless they're signed in to GitHub too, whose name comes first. + if (r.who && !s.seen?.github) this.writeGitConfig(id, { name: r.name!, email: r.email ?? '' }); + this.remember(id, 'gitlab', r.who); + } + /** Keeps who the last look found, for the next start of the office. */ private remember(id: string, which: SignInKind, who: string | undefined) { const s = this.load(id); @@ -552,32 +593,15 @@ export class SignIns { private async logout(id: string, which: SignInKind) { if (which === 'claude') { if (this.claude) await run(this.claude, ['auth', 'logout'], this.claudeEnv(id)); + } else if (which === 'gitlab') { + const host = this.load(id).gitlabHost; + if (this.glab && host) await glabLogout(this.glab, asGitLab(this.base(), this.prepare(id)), host); } else { if (this.gh) await run(this.gh, ['auth', 'logout', '--hostname', 'github.com'], this.githubEnv(id)); rmSync(path.join(this.home(id), 'gh', 'hosts.yml'), { force: true }); } } - /** Claude's own settings in an account's folder: its first-run questions already answered. */ - private seed(dir: string, change?: (c: any) => void) { - const file = path.join(dir, '.claude.json'); - let c: any = {}; - try { - c = JSON.parse(readFileSync(file, 'utf8')); - } catch { - // new - } - const before = JSON.stringify(c); - c.hasCompletedOnboarding = true; - change?.(c); - if (JSON.stringify(c) === before) return; - try { - writeFileSync(file, JSON.stringify(c, null, 2), { mode: 0o600 }); - } catch (err) { - console.error(`agent-office: couldn't write ${file}: ${(err as Error).message}`); - } - } - /** Folders the office's own Claude trusts, the account's Claude trusts too: they're the same projects. */ private trust(configDir: string, dirs: string[]) { let office: any; @@ -589,7 +613,7 @@ export class SignIns { } const trusted = (d: string) => office?.projects?.[d]?.hasTrustDialogAccepted === true; if (!dirs.some(trusted)) return; - this.seed(configDir, (c) => { + seedClaude(configDir, (c) => { c.projects ??= {}; for (const d of dirs) c.projects[d] = { ...c.projects[d], hasTrustDialogAccepted: true }; }); @@ -610,7 +634,9 @@ export class SignIns { lines.push(`[credential ${quote(host)}]`, '\thelper =', `\thelper = ${quote(`!'${this.gh.replace(/'/g, `'\\''`)}' auth git-credential`)}`); } } - if (user) lines.push('[user]', `\tname = ${quote(user.name)}`, `\temail = ${quote(user.email)}`); + const lab = this.load(id).gitlabHost; + if (this.glab && lab) lines.push(...glabCredential(this.glab, lab)); + if (user) lines.push('[user]', `\tname = ${quote(user.name)}`, ...(user.email ? [`\temail = ${quote(user.email)}`] : [])); const text = `${lines.join('\n')}\n`; const file = path.join(home, 'gitconfig'); try { @@ -621,40 +647,3 @@ export class SignIns { } } } - -/** A value for a git config file, quoted. */ -function quote(v: string): string { - return `"${v.replace(/[\p{C}]/gu, '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; -} - -/** Terminal output as plain text: no colors, links or cursor moves. */ -function plain(s: string): string { - return s - .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') - .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') - .replace(/\x1b[@-_]/g, '') - .replace(/\r\n?/g, '\n'); -} - -/** The last thing a command said, minus the link and the prompt, for an error line. */ -function lastWords(s: string): string { - const lines = plain(s) - .split('\n') - .map((l) => l.trim()) - .filter((l) => l && !/https?:\/\/|paste code|opening browser|press enter/i.test(l)); - return (lines.at(-1) ?? '').slice(0, 300); -} - -/** Runs a command to the end; never rejects. `last` is the last line it said on stderr (or stdout). */ -function run(cmd: string, args: string[], env: Record, input?: string): Promise<{ code: number; out: string; last: string }> { - return new Promise((resolve) => { - const p = execFile(cmd, args, { env, timeout: LOOK_TIMEOUT_MS, maxBuffer: 1024 * 1024, encoding: 'utf8' }, (err, stdout, stderr) => { - const e = err as { code?: number | string } | null; - const code = !e ? 0 : typeof e.code === 'number' ? e.code : -1; - const last = lastWords(stderr || (err && !stdout ? err.message : '')); - resolve({ code, out: stdout.trim(), last }); - }); - if (input !== undefined) p.stdin?.end(input); - else p.stdin?.end(); - }); -} diff --git a/src/server/workers/pr.ts b/src/server/workers/pr.ts index 092569f50..eec6298d4 100644 --- a/src/server/workers/pr.ts +++ b/src/server/workers/pr.ts @@ -5,7 +5,8 @@ import path from 'node:path'; import type { WorkerInfo } from '../../shared/protocol.js'; import { DESK_BY_ID } from '../../shared/layout.js'; import { isBusy } from '../../shared/status.js'; -import { gh } from '../github.js'; +import { forgeWords, repoForge } from '../../shared/floors.js'; +import { pullRequestsAt } from '../forge.js'; import type { GhAs } from '../signins.js'; import { Worktrees } from '../worktrees.js'; import { run } from './process.js'; @@ -18,42 +19,31 @@ const PR_TASK_MAX = 2500; /** Around the list of a change's pull requests in each of their descriptions, so it can be brought up to date. */ const RELATED_START = ''; const RELATED_END = ''; -/** `gh pr create` being run, alone or in a longer line: not one that only names it (a grep for it, a quoted string). */ -const CREATES_PR = /(?:^|[\s;&|(])gh\s+pr\s+create\b/; +/** `gh pr create` (or `glab mr create`) being run, alone or in a longer line: not one that only names it (a grep for it, a quoted string). */ +const CREATES_PR = /(?:^|[\s;&|(])(?:gh\s+pr|glab\s+mr)\s+create\b/; /** How many of the pull requests a worker opened before its latest are remembered. */ const MAX_PAST_PRS = 20; -const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g; +/** A pull request's page on GitHub, or a merge request's on a GitLab (https://host/group/…/project/-/merge_requests/12). */ +const PR_URL = /https:\/\/(?:github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)|([\w.-]+(?::\d+)?\/[\w./-]+?)\/-\/merge_requests\/(\d+))/g; /** * The pull request a worker opened itself, read off a shell command it ran and what that printed: - * `gh pr create` prints the new pull request's URL, or the one its branch already had. The last one - * printed is it. + * `gh pr create` (or `glab mr create`) prints the new one's URL, or the one its branch already had. + * The last one printed is it. `repo` is as normalizeRepo gives it: owner/name, or host/group/…/project. */ export function ownPr(command: unknown, output: string): { repo: string; number: number; url: string } | undefined { if (typeof command !== 'string' || !CREATES_PR.test(command)) return undefined; const last = [...output.matchAll(PR_URL)].pop(); - return last && { repo: last[1], number: Number(last[2]), url: last[0] }; + if (!last) return undefined; + return last[1] ? { repo: last[1], number: Number(last[2]), url: last[0] } : { repo: last[3].replace(/:\d+(?=\/)/, ''), number: Number(last[4]), url: last[0] }; } -async function findOpenPr(branch: string, cwd: string): Promise<{ number: number; url: string } | undefined> { - const out = await gh(['pr', 'list', '--head', branch, '--state', 'open', '--limit', '1', '--json', 'number,url'], cwd); - const found = (JSON.parse(out || '[]') as { number: number; url: string }[])[0]; - return found ? { number: found.number, url: found.url } : undefined; -} - -/** `gh pr create` for a pushed branch; resolves to the new pull request. */ -async function createPr(branch: string, base: string | undefined, title: string, body: string, cwd: string, as?: GhAs): Promise<{ number: number; url: string }> { - const out = await gh(['pr', 'create', '--head', branch, ...(base ? ['--base', base] : []), '--title', title, '--body', body], cwd, 60_000, as?.env); - const url = out.trim().split('\n').pop() ?? ''; - const number = Number(/\/pull\/(\d+)/.exec(url)?.[1]); - if (!number) throw new Error(`gh did not return a pull request URL (${truncate(out, 120)})`); - return { number, url }; -} - -/** owner/name#12 for a pull request on GitHub (which links it with its title), else its URL. */ +/** owner/name#12 for a pull request on GitHub, group/project!12 for a merge request on GitLab (each links it with its title), else its URL. */ function prRef(url: string): string { const m = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(url); - return m ? `${m[1]}#${m[2]}` : url; + if (m) return `${m[1]}#${m[2]}`; + const lab = /^https:\/\/[^/]+\/(.+?)\/-\/merge_requests\/(\d+)/.exec(url); + return lab ? `${lab[1]}!${lab[2]}` : url; } /** The list of a change's pull requests across repositories, for the description of the one at `self`. */ @@ -101,7 +91,7 @@ export class WorkerPrs { constructor(private ctx: WorkerContext) {} /** - * A worker ran `gh pr create` itself (see ownPr): that pull request is its own from then on, as + * A worker ran `gh pr create` (or `glab mr create`) itself (see ownPr): that pull request is its own from then on, as * one opened from its desk is. A worker in the main checkout works on a branch the office never * made, so this is how the office knows which pull request is whose. For a worker across * repositories, one in another of its repositories is kept with that repository. @@ -120,7 +110,8 @@ export class WorkerPrs { (other ?? info).pr = { number: pr.number, url: pr.url }; this.ctx.persist(); this.ctx.emit(w); - this.ctx.events.toast(`${info.name} opened PR #${pr.number}${other ? ` in ${other.name}` : ''}`, 'info'); + const words = forgeWords(repoForge(pr.repo)); + this.ctx.events.toast(`${info.name} opened ${words.pr} ${words.ref}${pr.number}${other ? ` in ${other.name}` : ''}`, 'info'); } /** @@ -168,7 +159,8 @@ export class WorkerPrs { const commits = (await run('git', ['log', '--reverse', '--format=%h %s', `${wt.base}..${branch}`], cwd)).split('\n').filter(Boolean); const dirty = (await run('git', ['status', '--porcelain'], cwd)) !== ''; if (!commits.length) return dirty ? `${info.name} hasn't committed anything yet — ask it to commit first` : `${info.name} has no commits on ${branch} yet`; - const open = await findOpenPr(branch, cwd); + const prs = pullRequestsAt(cwd); + const open = await prs.find(branch, cwd); if (open) { info.pr = open; this.ctx.persist(); @@ -177,12 +169,12 @@ export class WorkerPrs { await run('git', ['push', '-u', 'origin', branch], cwd, 90_000, as?.env); const base = await this.pushedBranch([wt.from, this.ctx.trees.currentBranch()], branch); const { title, body } = draftPr(info, commits, by); - const { number, url } = await createPr(branch, base, title, body, cwd, as); + const { number, url } = await prs.create(branch, base, title, body, cwd, as); info.pr = { number, url }; this.ctx.persist(); return { prs: [{ number, url, existed: false, dirty }], failed: [] }; } catch (err) { - return `Couldn't open a PR for ${info.name}: ${(err as Error).message}`; + return `Couldn't open a ${pullRequestsAt(cwd).short} for ${info.name}: ${(err as Error).message}`; } finally { info.prOpening = false; // The worker may have been sent home meanwhile; an update would bring it back as a ghost. @@ -216,7 +208,7 @@ export class WorkerPrs { const cwd = path.join(this.ctx.dir, p.path); try { const dirty = (await run('git', ['status', '--porcelain'], cwd)) !== ''; - const known = p.pr ?? (await findOpenPr(p.branch, cwd)); + const known = p.pr ?? (await pullRequestsAt(cwd).find(p.branch, cwd)); if (known) { p.set(known); prs.push({ repo: p.name, ...known, existed: true, dirty, cwd }); @@ -230,12 +222,12 @@ export class WorkerPrs { await run('git', ['push', '-u', 'origin', p.branch], cwd, 90_000, as?.env); const base = await this.pushedBranch([p.from, new Worktrees(p.dir).currentBranch()], p.branch, p.dir); const { title, body } = draftPr(info, commits, by, p.own ? undefined : { home }); - const pr = await createPr(p.branch, base, title, body, cwd, as); + const pr = await pullRequestsAt(cwd).create(p.branch, base, title, body, cwd, as); p.set(pr); this.ctx.persist(); prs.push({ repo: p.name, ...pr, existed: false, dirty, cwd }); } catch (err) { - failed.push(`Couldn't open a PR in ${p.name}: ${(err as Error).message}`); + failed.push(`Couldn't open a ${pullRequestsAt(cwd).short} in ${p.name}: ${(err as Error).message}`); } } if (!prs.length) { @@ -245,9 +237,10 @@ export class WorkerPrs { if (prs.length > 1 && prs.some((p) => !p.existed)) { for (const p of prs) { try { - const body = await gh(['pr', 'view', p.url, '--json', 'body', '--jq', '.body'], p.cwd, 30_000, as?.env); + const forge = pullRequestsAt(p.cwd); + const body = await forge.body(p.url, p.cwd, as); const next = withRelated(body, relatedBlock(prs, p.url, wt.branch)); - if (next !== body) await gh(['pr', 'edit', p.url, '--body', next], p.cwd, 60_000, as?.env); + if (next !== body) await forge.setBody(p.url, next, p.cwd, as); } catch (err) { failed.push(`Couldn't list the other pull requests on ${p.repo} #${p.number}: ${(err as Error).message}`); } diff --git a/src/server/workers/worktree.ts b/src/server/workers/worktree.ts index 844fe76e4..09cfb94a4 100644 --- a/src/server/workers/worktree.ts +++ b/src/server/workers/worktree.ts @@ -50,7 +50,7 @@ export function validRepos(raw: unknown): WorkerRepo[] | undefined { return repos.length ? repos : undefined; } -/** owner/name of a checkout's origin on GitHub, when it has one. */ +/** owner/name of a checkout's origin on GitHub (host/group/…/project on a GitLab), when it has one. */ export function originRepo(dir: string): string | undefined { try { return normalizeRepo(execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim()); diff --git a/src/server/ws/handlers/changes.ts b/src/server/ws/handlers/changes.ts index 0ad26e6a2..abdc24273 100644 --- a/src/server/ws/handlers/changes.ts +++ b/src/server/ws/handlers/changes.ts @@ -45,7 +45,9 @@ export const changesHandlers = { 'changes.pr'(ctx, c, msg) { const who = c.peer.name; const w = workerOf(ctx, msg.workerId); - if (w) ctx.withGitHub(c, (as) => void w.floor.changes.pullRequest(w.wid, str(msg.title, 300), str(msg.body, 20000), who, as?.env, repoOf(msg.repo)).then((err) => ctx.warn(c, err))); + // On the floor of the repository it's for: a worker across repositories opens one in another floor's. + const forge = w && ((msg.repo && ctx.floors.get(String(msg.repo))?.forge.kind) || w.floor.forge.kind); + if (w && forge) ctx.withForge(c, forge, (as) => void w.floor.changes.pullRequest(w.wid, str(msg.title, 300), str(msg.body, 20000), who, as?.env, repoOf(msg.repo)).then((err) => ctx.warn(c, err))); }, } satisfies HandlerMap; diff --git a/src/server/ws/handlers/floors.ts b/src/server/ws/handlers/floors.ts index 3f0f2cdab..a45eeed62 100644 --- a/src/server/ws/handlers/floors.ts +++ b/src/server/ws/handlers/floors.ts @@ -21,12 +21,12 @@ export const floorHandlers = { 'floor.repos'(ctx, c, msg) { void ctx.building.repos(msg.refresh === true).then( (repos) => ctx.sendTo(c, { t: 'floor.repos', repos }), - (err: Error) => ctx.sendTo(c, { t: 'floor.repos', repos: [], error: `Couldn't list your repositories with gh: ${err.message}` }), + (err: Error) => ctx.sendTo(c, { t: 'floor.repos', repos: [], error: `Couldn't list your repositories with gh or glab: ${err.message}` }), ); }, 'floor.add'(ctx, c, msg) { const who = c.peer.name; - const repo = str(msg.repo, 200); + const repo = str(msg.repo, 300); void ctx.building .add( repo, diff --git a/src/server/ws/handlers/github.ts b/src/server/ws/handlers/github.ts index 311686382..03dd2178d 100644 --- a/src/server/ws/handlers/github.ts +++ b/src/server/ws/handlers/github.ts @@ -1,17 +1,18 @@ -// The floor's GitHub boards: refreshing them, and merging, commenting on, closing and labeling -// issues and pull requests, as whoever asks (see withGitHub). +// The floor's GitHub (or GitLab) boards: refreshing them, and merging, commenting on, closing and +// labeling issues and pull requests (merge requests), as whoever asks (see withForge). import type { GitHubClientMsg } from '../../../shared/protocol.js'; import { GH_COMMENT_MAX, GH_LABEL_MAX } from '../../../shared/protocol.js'; +import { forgeWords } from '../../../shared/floors.js'; import { num, str } from '../../office/input.js'; import { here } from './common.js'; import type { HandlerMap, ViewPieces } from './types.js'; -export const issuesView: ViewPieces['issues'] = (_ctx, floor) => floor?.github.issues ?? { items: [], fetchedAt: 0, loading: false }; -export const pullsView: ViewPieces['pulls'] = (_ctx, floor) => floor?.github.pulls ?? { items: [], fetchedAt: 0, loading: false }; +export const issuesView: ViewPieces['issues'] = (_ctx, floor) => floor?.forge.issues ?? { items: [], fetchedAt: 0, loading: false }; +export const pullsView: ViewPieces['pulls'] = (_ctx, floor) => floor?.forge.pulls ?? { items: [], fetchedAt: 0, loading: false }; export const githubHandlers = { 'gh.refresh'(ctx, c) { - void ctx.floorOf(c)?.github.refresh(); + void ctx.floorOf(c)?.forge.refresh(); }, 'gh.merge'(ctx, c, msg) { const who = c.peer.name; @@ -19,13 +20,15 @@ export const githubHandlers = { const n = num(msg.number); const method = (['squash', 'merge', 'rebase'] as const).find((m) => m === msg.method); if (!floor || !Number.isSafeInteger(n) || n <= 0 || !method) return; - ctx.withGitHub( + const w = forgeWords(floor.forge.kind); + ctx.withForge( c, + floor.forge.kind, (as) => - void floor.github.merge(n, method, msg.deleteBranch === true, msg.auto === true, as).then((error) => { + void floor.forge.merge(n, method, msg.deleteBranch === true, msg.auto === true, as).then((error) => { ctx.sendTo(c, { t: 'gh.merged', number: n, error }); if (error) return; - ctx.toastFloor(floor, msg.auto ? `${who} set PR #${n} to merge once its checks pass` : `🎉 ${who} merged PR #${n}`); + ctx.toastFloor(floor, msg.auto ? `${who} set ${w.pr} ${w.ref}${n} to merge once its checks pass` : `🎉 ${who} merged ${w.pr} ${w.ref}${n}`); // An auto-merge rings once GitHub gets round to it and the boards see it merged. if (!msg.auto) floor.merged(n, who); }), @@ -40,17 +43,19 @@ export const githubHandlers = { if (!floor || !Number.isSafeInteger(n) || n <= 0) return; const body = typeof msg.body === 'string' ? msg.body : ''; // Refused rather than cut short: a comment that silently lost its end would read as finished. - const invalid = !body.trim() ? 'The comment is empty' : body.length > GH_COMMENT_MAX ? `GitHub takes comments of up to ${GH_COMMENT_MAX} characters` : ''; + const w = forgeWords(floor.forge.kind); + const invalid = !body.trim() ? 'The comment is empty' : body.length > GH_COMMENT_MAX ? `${w.site} takes comments of up to ${GH_COMMENT_MAX} characters` : ''; if (invalid) { ctx.sendTo(c, { t: 'gh.commented', kind, number: n, error: invalid }); return; } - ctx.withGitHub( + ctx.withForge( c, + floor.forge.kind, (as) => - void floor.github.comment(kind, n, body, as).then((r) => { + void floor.forge.comment(kind, n, body, as).then((r) => { ctx.sendTo(c, { t: 'gh.commented', kind, number: n, ...r }); - if (r.comment) ctx.toastFloor(floor, `💬 ${who} commented on ${kind === 'pull' ? 'PR' : 'issue'} #${n}`); + if (r.comment) ctx.toastFloor(floor, `💬 ${who} commented on ${kind === 'pull' ? `${w.pr} ${w.ref}` : 'issue #'}${n}`); }), (error) => ctx.sendTo(c, { t: 'gh.commented', kind, number: n, error }), ); @@ -62,13 +67,15 @@ export const githubHandlers = { const kind = msg.kind === 'issue' || msg.kind === 'pull' ? msg.kind : undefined; if (!floor || !Number.isSafeInteger(n) || n <= 0 || !kind) return; const reason = msg.reason === 'not planned' ? 'not planned' : 'completed'; - ctx.withGitHub( + const w = forgeWords(floor.forge.kind); + ctx.withForge( c, + floor.forge.kind, (as) => - void floor.github.close(kind, n, { comment: str(msg.comment, 20000).trim() || undefined, reason, deleteBranch: msg.deleteBranch === true }, as).then((error) => { + void floor.forge.close(kind, n, { comment: str(msg.comment, 20000).trim() || undefined, reason, deleteBranch: msg.deleteBranch === true }, as).then((error) => { ctx.sendTo(c, { t: 'gh.closed', kind, number: n, error }); if (error) return; - if (kind === 'pull') return ctx.toastFloor(floor, `${who} closed PR #${n} without merging`); + if (kind === 'pull') return ctx.toastFloor(floor, `${who} closed ${w.pr} ${w.ref}${n} without merging`); // Nobody should be seated for an issue that's closed. const dropped = floor.queue.dropIssue(n); ctx.toastFloor(floor, `${who} closed issue #${n}${reason === 'not planned' ? ' as not planned' : ''}${dropped ? ' and took it off the queue' : ''}`); @@ -89,12 +96,14 @@ export const githubHandlers = { ctx.sendTo(c, { t: 'gh.labeled', kind, number: n, error: 'No labels to change' }); return; } - ctx.withGitHub( + const w = forgeWords(floor.forge.kind); + ctx.withForge( c, + floor.forge.kind, (as) => - void floor.github.setLabels(kind, n, add, remove, as).then((r) => { + void floor.forge.setLabels(kind, n, add, remove, as).then((r) => { ctx.sendTo(c, { t: 'gh.labeled', kind, number: n, ...r }); - if (r.labels) ctx.toastFloor(floor, `🏷️ ${who} labeled ${kind === 'pull' ? 'PR' : 'issue'} #${n}: ${[...add.map((l) => `+${l}`), ...remove.map((l) => `−${l}`)].join(' ')}`); + if (r.labels) ctx.toastFloor(floor, `🏷️ ${who} labeled ${kind === 'pull' ? `${w.pr} ${w.ref}` : 'issue #'}${n}: ${[...add.map((l) => `+${l}`), ...remove.map((l) => `−${l}`)].join(' ')}`); }), (error) => ctx.sendTo(c, { t: 'gh.labeled', kind, number: n, error }), ); diff --git a/src/server/ws/handlers/signins.ts b/src/server/ws/handlers/signins.ts index 985c409e1..be396e1a4 100644 --- a/src/server/ws/handlers/signins.ts +++ b/src/server/ws/handlers/signins.ts @@ -1,4 +1,4 @@ -// Your own Claude and GitHub sign-ins (see signins.ts). Accounts only: the shared password runs on the office's. +// Your own Claude, GitHub and GitLab sign-ins (see signins.ts). Accounts only: the shared password runs on the office's. import type { SignInKind, SignInsClientMsg } from '../../../shared/protocol.js'; import type { Ctx } from '../../office/context.js'; import type { Client } from '../../office/client.js'; @@ -11,7 +11,7 @@ const accountOf = (ctx: Ctx, c: Client): string | undefined => { if (!id) ctx.warn(c, "On the shared office password, workers run on the office's own sign-ins"); return id; }; -const whichOf = (msg: SignInsClientMsg): SignInKind => ('which' in msg && msg.which === 'github' ? 'github' : 'claude'); +const whichOf = (msg: SignInsClientMsg): SignInKind => ('which' in msg && (msg.which === 'github' || msg.which === 'gitlab') ? msg.which : 'claude'); export const signinsHandlers = { 'signins.get'(ctx, c) { @@ -38,7 +38,8 @@ export const signinsHandlers = { 'signins.token'(ctx, c, msg) { const id = accountOf(ctx, c); if (!id) return; - void ctx.signins.token(id, whichOf(msg), str(msg.token, 4096)).then((err) => ctx.warn(c, err)); + const host = str(msg.host, 300).trim() || undefined; + void ctx.signins.token(id, whichOf(msg), str(msg.token, 4096), host).then((err) => ctx.warn(c, err)); }, 'signins.office'(ctx, c, msg) { const id = accountOf(ctx, c); diff --git a/src/server/ws/handlers/workers.ts b/src/server/ws/handlers/workers.ts index 3e468890c..63efbc569 100644 --- a/src/server/ws/handlers/workers.ts +++ b/src/server/ws/handlers/workers.ts @@ -1,5 +1,6 @@ // Workers at their desks and the board agents at their kiosks: hiring them, their terminals, their // worktrees and pull requests. +import { forgeWords } from '../../../shared/floors.js'; import { MAX_REPOS, type RepoSource } from '../../workers.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; import { isAgentEffort, isAgentProvider, type WorkerClientMsg } from '../../../shared/protocol.js'; @@ -142,27 +143,28 @@ export const workerHandlers = { const w = workerOf(ctx, msg.workerId); if (!w) return; const { floor, wid } = w; - ctx.withGitHub(c, (as) => void floor.workers.openPr(wid, who, as).then((r) => { + ctx.withForge(c, floor.forge.kind, (as) => void floor.workers.openPr(wid, who, as).then((r) => { if (typeof r === 'string') return ctx.warn(c, r); const info = floor.workers.get(wid); const name = info?.name ?? 'the worker'; const [one] = r.prs; - if (r.prs.length === 1 && !one.repo) ctx.toastFloor(floor, one.existed ? `${name}'s branch already has PR #${one.number}` : `${who} opened PR #${one.number} for ${name}`); + const words = forgeWords(floor.forge.kind); + if (r.prs.length === 1 && !one.repo) ctx.toastFloor(floor, one.existed ? `${name}'s branch already has ${words.pr} ${words.ref}${one.number}` : `${who} opened ${words.pr} ${words.ref}${one.number} for ${name}`); else { - // Across repositories: one line for them all. - const list = r.prs.map((p) => `${p.repo} #${p.number}`).join(', '); - ctx.toastFloor(floor, r.prs.every((p) => p.existed) ? `${name}'s pull requests are already open: ${list}` : `${who} opened ${name}'s pull requests: ${list}`); + // Across repositories: one line for them all (each repository's own # or !). + const list = r.prs.map((p) => `${p.repo} ${/\/-\/merge_requests\//.test(p.url) ? '!' : '#'}${p.number}`).join(', '); + ctx.toastFloor(floor, r.prs.every((p) => p.existed) ? `${name}'s ${words.pullRequest}s are already open: ${list}` : `${who} opened ${name}'s ${words.pullRequest}s: ${list}`); } const dirty = r.prs.filter((p) => p.dirty); - if (dirty.length) ctx.warn(c, `${name} still has uncommitted changes in ${dirty.some((p) => p.repo) ? `its worktree${dirty.length > 1 ? 's' : ''} of ${dirty.map((p) => p.repo).join(', ')}` : 'its worktree'} — they are not in the PR`); + if (dirty.length) ctx.warn(c, `${name} still has uncommitted changes in ${dirty.some((p) => p.repo) ? `its worktree${dirty.length > 1 ? 's' : ''} of ${dirty.map((p) => p.repo).join(', ')}` : 'its worktree'} — they are not in the ${words.pr}`); for (const f of r.failed) ctx.warn(c, f); // Put it on the board now rather than at the next poll. A refresh already in flight // returns at once and can miss it, so look again shortly after. const own = r.prs.find((p) => !p.repo || p.repo === info?.worktree?.path.split(/[\\/]/).pop()); - void floor.github.refresh().then(() => { - if (own && !floor.github.pulls.items.some((p) => p.number === own.number)) setTimeout(() => void floor.github.refresh(), 3000); + void floor.forge.refresh().then(() => { + if (own && !floor.forge.pulls.items.some((p) => p.number === own.number)) setTimeout(() => void floor.forge.refresh(), 3000); }); - for (const x of info?.repos ?? []) void ctx.floors.get(x.floor)?.github.refresh(); + for (const x of info?.repos ?? []) void ctx.floors.get(x.floor)?.forge.refresh(); })); }, 'term.input'(ctx, c, msg) { diff --git a/src/shared/actions.ts b/src/shared/actions.ts index a76cdf9a9..fe9507027 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -39,7 +39,7 @@ const CHECK = const CHECK_SCRIPT = /^(?:t|test|tests|build|check|lint|typecheck|type-check|tsc|compile|e2e|spec|verify|ci)(?:[:-]\S*)?$/; /** Looks at code or history without changing it: the programs, and the subcommands. */ const READ = /^(?:cat|bat|head|tail|less|more|grep|egrep|fgrep|rg|ag|ack|find|fd|ls|tree|wc|stat|file|du|jq|diff|nl|awk)$/; -const READ_SUB = /^sed -n\b|^git(?: (?:-C \S+|--?[\w-]+(?:=\S+)?))* (?:log|show|diff|status|blame|grep|ls-files|shortlog)\b|^gh (?:issue|pr|repo|run) (?:view|list|diff)\b/; +const READ_SUB = /^sed -n\b|^git(?: (?:-C \S+|--?[\w-]+(?:=\S+)?))* (?:log|show|diff|status|blame|grep|ls-files|shortlog)\b|^gh (?:issue|pr|repo|run) (?:view|list|diff)\b|^glab (?:issue|mr|repo|ci) (?:view|list|diff|status)\b/; /** Leading `FOO=bar` assignments and the wrappers in front of the program that really runs. */ const PREFIX = /^(?:\w+=(?:'[^']*'|"[^"]*"|\S*)\s+|(?:sudo|time|nice|nohup|command|exec|env|npx|bunx|pnpx|caffeinate|xvfb-run)\s+|(?:bundle exec|poetry run|uv run|pipenv run|pdm run|pnpm exec|yarn exec|python3? -m)\s+|timeout\s+\S+\s+)/; diff --git a/src/shared/floors.ts b/src/shared/floors.ts index 3a7792aa1..836b87681 100644 --- a/src/shared/floors.ts +++ b/src/shared/floors.ts @@ -35,16 +35,82 @@ export function floorPalette(i: number): FloorPalette { return FLOOR_PALETTES[((i % FLOOR_PALETTES.length) + FLOOR_PALETTES.length) % FLOOR_PALETTES.length]; } +/** Where a repository lives: GitHub, or a GitLab (gitlab.com or one of a company's own). */ +export type ForgeKind = 'github' | 'gitlab'; + +/** A GitLab host: a domain or address, lower case. */ +const GITLAB_HOST = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/; +const GITLAB_SEGMENT = /^[a-zA-Z0-9_](?:[a-zA-Z0-9_.-]{0,254})$/; + +/** + * `host/group/…/project` from a GitLab URL (https, ssh or git@) or one typed that way, with or + * without .git; a URL may go on past the project (…/project/-/merge_requests/12). Any host but + * github.com counts, since a company's GitLab can be called anything. + */ +function normalizeGitLab(s: string): string | undefined { + // https://host:8443/path or ssh://git@host:2222/path; git@host:path; host/path as typed. The port + // is the way in, not the project's name, so the same project reached two ways is one floor. + const m = + /^(?:https?|ssh):\/\/(?:[^@/\s]+@)?([\w.-]+)(?::\d{1,5})?\/(.+)$/i.exec(s) ?? + /^[\w.-]+@([\w.-]+):(?!\/)(.+)$/.exec(s) ?? + /^([\w.-]+)\/(.+)$/.exec(s); + if (!m) return undefined; + const host = m[1].toLowerCase(); + if (host === 'github.com' || !GITLAB_HOST.test(host)) return undefined; + const rest = m[2].replace(/\/-\/.*$/, '').replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/i, ''); + const parts = rest.split('/'); + if (parts.length < 2 || parts.length > 20 || !parts.every((p) => GITLAB_SEGMENT.test(p) && p !== '..' && !p.endsWith('.'))) return undefined; + return `${host}/${parts.join('/')}`; +} + +/** Which forge a repository (as normalizeRepo gives it) is on: a GitHub owner never has a dot, a GitLab host always does. */ +export function repoForge(repo: string | undefined): ForgeKind | undefined { + if (!repo) return undefined; + return repo.split('/')[0].includes('.') ? 'gitlab' : 'github'; +} + +/** What the office calls things on a forge: a GitLab has merge requests (!12) where GitHub has pull requests (#12). */ +export interface ForgeWords { + site: 'GitHub' | 'GitLab'; + /** "PR" or "MR". */ + pr: string; + /** "pull request" or "merge request". */ + pullRequest: string; + /** What comes before a pull request's number: # or !. */ + ref: string; + /** Its command line tool. */ + cli: 'gh' | 'glab'; +} + +const WORDS: Record = { + github: { site: 'GitHub', pr: 'PR', pullRequest: 'pull request', ref: '#', cli: 'gh' }, + gitlab: { site: 'GitLab', pr: 'MR', pullRequest: 'merge request', ref: '!', cli: 'glab' }, +}; + +/** GitHub's words when it isn't known. */ +export function forgeWords(kind: ForgeKind | undefined): ForgeWords { + return WORDS[kind ?? 'github']; +} + +/** A GitLab repository's host and its project's path ("gitlab.example.com", "group/sub/project"). */ +export function gitlabProject(repo: string | undefined): { host: string; path: string } | undefined { + if (repoForge(repo) !== 'gitlab') return undefined; + const at = repo!.indexOf('/'); + return { host: repo!.slice(0, at), path: repo!.slice(at + 1) }; +} + /** * `owner/repo` from what someone typed or pasted: owner/repo, a github.com URL (https, ssh or - * git@), with or without .git. Undefined for anything else, so it can never become a CLI option, - * a path or another host. + * git@), with or without .git. A GitLab project comes back as `host/group/…/project` (see + * normalizeGitLab). Undefined for anything else, so it can never become a CLI option or a path. */ export function normalizeRepo(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; let s = value.trim(); - if (s.length > 200) return undefined; - s = s.replace(/^(?:https?:\/\/|ssh:\/\/)?(?:[\w.-]+@)?github\.com[/:]/i, ''); + if (s.length > 300) return undefined; + // A host with a dot that isn't github.com: a GitLab. + if (!/^(?:https?:\/\/|ssh:\/\/)?(?:[^@/\s]+@)?github\.com[/:]/i.test(s) && /^(?:(?:https?|ssh):\/\/)?(?:[^@/\s]+@)?[\w-]+\.[\w.-]+(?::\d+)?[/:]/i.test(s)) return normalizeGitLab(s); + s = s.replace(/^(?:https?:\/\/|ssh:\/\/)?(?:[^@/\s]+@)?github\.com[/:]/i, ''); s = s.replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/i, ''); const parts = s.split('/'); // A URL may go on past the repository (…/owner/repo/issues/12). diff --git a/src/shared/prompts-gitlab.ts b/src/shared/prompts-gitlab.ts new file mode 100644 index 000000000..efbedd4c0 --- /dev/null +++ b/src/shared/prompts-gitlab.ts @@ -0,0 +1,63 @@ +// The office's prompts for a floor on a GitLab: the same as GitHub's (prompts.ts), with merge +// requests (!12) for pull requests and the glab CLI for gh. A prompt rewritten in ⚙️ Settings is +// used as it's written on every floor; these are only the defaults. + +import type { StationKind } from './layout.js'; +import type { ForgeKind } from './floors.js'; +import type { PromptId } from './prompts.js'; + +/** What a board agent looks after, on a GitLab floor. */ +export const GITLAB_JOB: Record = { + issues: `You look after this project's GitLab issues with the glab CLI: file new ones (a clear title, what's wrong or wanted, and how to reproduce it when that applies), find and sum them up, triage, label, comment on, close and reopen them. To get an issue worked on, put it on the task queue with its number.`, + pulls: `You look after this project's merge requests with the glab CLI: sum them up and review them (glab mr view --comments, glab mr diff, glab ci status), comment, approve or ask for changes, merge when you're asked to, and close stale ones. Read an MR's code with glab mr diff rather than checking its branch out here. To get changes made on an MR, queue a task that tells the worker to check out that MR's branch in its worktree (glab mr checkout), make the fix and push it.`, + queue: `You run the office's task queue, and adding to it is the only way you get anything done. Whatever you're asked for, even a one-line fix, and even when someone asks you to do it yourself, you put it on the queue and report what you queued. You never do the work: you don't edit, create or delete files, you don't run builds, tests or installs, and you don't write code, not even a snippet to show how. Read the code and glab issue list only as far as it takes to write a good task. Add one task per independent piece of work, each prompt complete on its own (what to change and where, how to check it, and to open a merge request), since the worker who picks it up knows nothing else. Link a task to its GitLab issue when it's for one. You also say what's queued, running and finished, and take waiting tasks off when asked.`, +}; + +const CHECKOUT = 'Get onto its branch: `glab mr checkout {{number}}`. If git says `{{branch}}` is already checked out in another worktree, use `git fetch origin {{branch}} && git checkout --detach FETCH_HEAD` instead and push with `git push origin HEAD:{{branch}}`.'; +const FEEDBACK = 'Read all the feedback: `glab mr view {{number}} --comments`, and the threads on lines of code with `glab api projects/:fullpath/merge_requests/{{number}}/discussions`.'; +const CHECKS = 'Wait for the pipeline with `glab ci status --branch {{branch}} --live` and fix anything that fails.'; + +/** The defaults that say gh or GitHub, as a GitLab floor has them. */ +export const GITLAB_PROMPTS: Partial> = { + 'issue.work': 'Work on GitLab issue #{{number}}: "{{title}}".\n\nRead it first with `glab issue view {{number}} --comments`. Create a new branch, implement the change, verify it, then open a merge request that closes #{{number}}.', + 'issue.ask': 'This is about GitLab issue #{{number}} "{{title}}" ({{url}}). Read it with `glab issue view {{number}} --comments`.', + 'issue.meeting': 'GitLab issue #{{number}}: “{{title}}”. Read it first with glab issue view {{number}} --comments.', + 'pull.review': "Review merge request !{{number}}: \"{{title}}\".\n\nUse `glab mr view {{number}} --comments` and `glab mr diff {{number}}`. Look for bugs, risky changes and missing tests, then give me a short summary with concrete suggestions. Don't push any commits.", + 'pull.fixMerge': [ + 'Get merge request !{{number}} "{{title}}" ({{url}}) ready and merge it.', + '', + `1. ${CHECKOUT}`, + `2. ${FEEDBACK}`, + '3. Address every review thread that is still open: fix it, or if you disagree, reply on the MR saying why. If the branch conflicts with `{{base}}`, merge `{{base}}` in and resolve the conflicts.', + '4. Verify your changes the way this project does (build, typecheck, tests), then commit and push.', + `5. ${CHECKS}`, + '6. When the pipeline passes and no feedback is left, merge it: `{{merge}}`. If something only a person can decide is in the way, stop and tell me instead of merging.', + ].join('\n'), + 'pull.fixConflicts': [ + 'Merge request !{{number}} "{{title}}" ({{url}}) has merge conflicts with `{{base}}`. Resolve them and merge it.', + '', + `1. ${CHECKOUT}`, + '2. Bring in the latest `{{base}}`: `git fetch origin {{base}} && git merge origin/{{base}}`.', + "3. Resolve every conflict so both sides' changes survive. Read the MR (`glab mr view {{number}}`) and the `{{base}}` commits that touched the same code to see what each side meant; don't just take one side.", + '4. Verify the result the way this project does (build, typecheck, tests), then commit the merge and push.', + `5. ${CHECKS}`, + '6. When the pipeline passes, merge it: `{{merge}}`. If a conflict needs a decision only a person can make, stop and tell me instead of merging.', + ].join('\n'), + 'pull.ask': 'This is about merge request !{{number}} "{{title}}" ({{url}}), branch `{{branch}}` into `{{base}}`. Read it with `glab mr view {{number}} --comments` and see its changes with `glab mr diff {{number}}`.', + 'pull.panel': 'Review merge request !{{number}}: “{{title}}”.', + 'queue.worktree': "You're in your own git worktree, on a fresh branch made for this task. Commit there, push it, and open the merge request from it (glab mr create).", + 'meeting.review.review': "Review merge request !{{pr}} through your lens, {{role}}, and nothing else. Read it with glab mr view {{pr}} --comments and glab mr diff {{pr}}; don't check it out or change any files. Write your findings to {{file}}, one per bullet: the file:line, what's wrong and what to do about it, the most serious first. If you find nothing, write just NO FINDINGS. Then end your turn.", +}; + +/** A board agent's brief says how to link a queued task to an issue: GitLab's words for GitHub's. */ +export function gitlabQueueApi(text: string): string { + return text.replace('linked to that GitHub issue', 'linked to that GitLab issue').replace('a task usually ends with a pull request', 'a task usually ends with a merge request').replace("each task's id, status, title, worker and pull request", "each task's id, status, title, worker and merge request"); +} + +/** The meeting brief's lines saying which pull request (or merge request) and issue it's about, and how to read them. */ +export function meetingRefs(forge: ForgeKind | undefined, pr: number | undefined, issue: number | undefined): { pullRequest: string; issue: string } { + const lab = forge === 'gitlab'; + const pull = lab ? `The merge request is !${pr}: read it with glab mr view ${pr} --comments and glab mr diff ${pr}.` : `The pull request is #${pr}: read it with gh pr view ${pr} and gh pr diff ${pr}.`; + const from = lab ? `It comes from GitLab issue #${issue}: glab issue view ${issue} --comments.` : `It comes from GitHub issue #${issue}: gh issue view ${issue} --comments.`; + return { pullRequest: pr !== undefined ? pull : '', issue: issue !== undefined ? from : '' }; +} diff --git a/src/shared/prompts.ts b/src/shared/prompts.ts index e6a09af93..894092501 100644 --- a/src/shared/prompts.ts +++ b/src/shared/prompts.ts @@ -5,6 +5,10 @@ // A {{name}} in one is filled in by the office when it's sent. import { STATION_AGENT, type StationKind } from './layout.js'; +import type { ForgeKind } from './floors.js'; +import { GITLAB_JOB, GITLAB_PROMPTS, gitlabQueueApi } from './prompts-gitlab.js'; + +export { meetingRefs } from './prompts-gitlab.js'; export type PromptGroup = 'issues' | 'pulls' | 'queue' | 'repos' | 'stations' | 'meetings' | 'office'; @@ -57,14 +61,15 @@ const QUEUE_API = `The task queue gives each task a fresh worker in its own git EOF - Take a waiting task off: office-queue remove `; -/** What a board agent is told ahead of the first request typed to it. */ -function stationDefault(kind: StationKind): string { +/** What a board agent is told ahead of the first request typed to it (on a GitLab floor, about GitLab and glab). */ +function stationDefault(kind: StationKind, forge?: ForgeKind): string { const queue = kind === 'queue'; + const lab = forge === 'gitlab'; return [ `You're the ${STATION_AGENT[kind].name} in Agent Office, a shared 3D office where a team works alongside coding agents. You stand at a kiosk by ${BOARD[kind]}, and whoever walks up types you a request. The first one is at the end of this message.`, - JOB[kind], + lab ? GITLAB_JOB[kind] : JOB[kind], `You're in the project's main checkout, which other people and workers use too: don't switch branches, commit, or leave edits in it. Work that needs code changed goes on the task queue, ${queue ? 'always' : 'unless the person asks you for something else'}.`, - QUEUE_API, + lab ? gitlabQueueApi(QUEUE_API) : QUEUE_API, `${queue ? "When you've queued it, say in a few lines what you queued: each task's id and title." : "When you've done what was asked, say in a few lines what you did, with links."} Then wait: the next request may come from someone else.`, `The request:`, ].join('\n\n'); @@ -419,7 +424,13 @@ export function placeholders(template: string): string[] { return [...new Set([...template.matchAll(PLACEHOLDER)].map((m) => m[1]))]; } -/** A prompt's text as the office has it now: rewritten in ⚙️ Settings, or the default. */ -export function promptText(custom: Partial> | undefined, id: PromptId): string { - return custom?.[id]?.text ?? PROMPTS[id].text; +/** + * A prompt's text as the office has it now: rewritten in ⚙️ Settings, or the default, which on a + * GitLab floor speaks of merge requests and glab (prompts-gitlab.ts). + */ +export function promptText(custom: Partial> | undefined, id: PromptId, forge?: ForgeKind): string { + const own = custom?.[id]?.text; + if (own !== undefined || forge !== 'gitlab') return own ?? PROMPTS[id].text; + const station = /^station\.(issues|pulls|queue)$/.exec(id)?.[1] as StationKind | undefined; + return station ? stationDefault(station, 'gitlab') : (GITLAB_PROMPTS[id] ?? PROMPTS[id].text); } diff --git a/src/shared/protocol/accounts.ts b/src/shared/protocol/accounts.ts index ba137ef26..fd426a3b5 100644 --- a/src/shared/protocol/accounts.ts +++ b/src/shared/protocol/accounts.ts @@ -10,8 +10,8 @@ export interface Me { admin: boolean; } -/** What someone signs in to for their own workers: Claude Code, and the GitHub CLI. */ -export type SignInKind = 'claude' | 'github'; +/** What someone signs in to for their own workers: Claude Code, the GitHub CLI, and the GitLab CLI. */ +export type SignInKind = 'claude' | 'github' | 'gitlab'; /** One of your sign-ins, as the office sees it (see server/signins.ts). */ export interface SignInState { @@ -19,20 +19,23 @@ export interface SignInState { status: 'ok' | 'none' | 'busy'; /** Its own login in your folder on the office's machine, a pasted token, or the machine's own (admins). */ how: 'login' | 'token' | 'office'; - /** Who it signs in as: an email and plan for Claude, @login for GitHub. */ + /** Who it signs in as: an email and plan for Claude, @login for GitHub, @login on host for GitLab. */ who?: string; + /** GitLab only: the GitLab it signs in to (gitlab.example.com). */ + host?: string; /** A sign-in under way: the page to open, GitHub's one-time code to type there, and whether Claude's code was sent back. */ pending?: { url?: string; code?: string; sent?: boolean }; error?: string; } /** - * Your own Claude and GitHub sign-ins, which your workers run with and the office acts on GitHub - * with for you. Only accounts have them: on the shared password, the office's own are used. + * Your own Claude, GitHub and GitLab sign-ins, which your workers run with and the office acts on + * GitHub and GitLab with for you. Only accounts have them: on the shared password, the office's own are used. */ export interface SignInsState { claude: SignInState; github: SignInState; + gitlab: SignInState; /** You may use the office machine's own sign-ins instead of yours (admins). */ office: boolean; } @@ -115,8 +118,8 @@ export type SignInsClientMsg = /** The code Claude's sign-in page gave you. */ | { t: 'signins.code'; code: string } | { t: 'signins.cancel'; which: SignInKind } - /** A token instead: from `claude setup-token` (or an Anthropic API key), or a GitHub token. */ - | { t: 'signins.token'; which: SignInKind; token: string } + /** A token instead: from `claude setup-token` (or an Anthropic API key), a GitHub token, or a GitLab one for `host`. */ + | { t: 'signins.token'; which: SignInKind; token: string; host?: string } /** Use the office machine's own sign-in (admins only). */ | { t: 'signins.office'; which: SignInKind } | { t: 'signins.signout'; which: SignInKind }; diff --git a/src/shared/protocol/floors.ts b/src/shared/protocol/floors.ts index ce908eaa2..58cd66ad8 100644 --- a/src/shared/protocol/floors.ts +++ b/src/shared/protocol/floors.ts @@ -2,6 +2,7 @@ import type { CabinetView } from '../cabinet.js'; import type { Decoration } from '../decor.js'; +import type { ForgeKind } from '../floors.js'; import type { DogState } from '../dog.js'; import type { FloorPlan } from '../floorplan.js'; import type { CarState } from '../garage.js'; @@ -34,8 +35,10 @@ export interface FloorInfo { id: string; /** The repository's name, or the folder's when it isn't on GitHub. */ name: string; - /** owner/name on GitHub. */ + /** owner/name on GitHub, host/group/…/project on a GitLab (see normalizeRepo). */ repo?: string; + /** Where its issues and pull requests are: GitHub, or a GitLab (merge requests). Unset while it's being cloned. */ + forge?: ForgeKind; /** Its checkout on the office's machine. */ dir: string; /** The branch that checkout is on ('HEAD' when detached); none when it isn't a git checkout. */ @@ -85,7 +88,7 @@ export interface ProjectsDirState { /** A repository the office's `gh` login can clone, for the elevator's "add a project". */ export interface RepoChoice { - /** owner/name */ + /** owner/name, or host/group/…/project on a GitLab */ name: string; description?: string; private: boolean; diff --git a/tests/gitlab.test.ts b/tests/gitlab.test.ts new file mode 100644 index 000000000..96e3be73e --- /dev/null +++ b/tests/gitlab.test.ts @@ -0,0 +1,206 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { forgeWords, gitlabProject, normalizeRepo, repoForge } from '../src/shared/floors.js'; +import { closesOf, discussionsOf, friendlyGlabError, issueOf, jobCheck, mergeStatusOf, pullOf, stateOf } from '../src/server/gitlab/api.js'; +import { GitLab } from '../src/server/gitlab/boards.js'; +import { isGitLabHost } from '../src/server/gitlab/repos.js'; +import { originOf } from '../src/server/forge.js'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { GhIssue, GhPull, GhState } from '../src/shared/protocol.js'; + +test('a GitLab project is named by its host and full path, however it was written', () => { + const key = 'gitlab.example.com/acme/web/app'; + for (const s of ['git@gitlab.example.com:acme/web/app.git', 'ssh://git@gitlab.example.com:2222/acme/web/app.git', 'https://gitlab.example.com/acme/web/app', 'https://gitlab.example.com/acme/web/app/-/merge_requests/5', 'gitlab.example.com/acme/web/app']) { + assert.equal(normalizeRepo(s), key, s); + } + assert.deepEqual(gitlabProject(key), { host: 'gitlab.example.com', path: 'acme/web/app' }); + assert.equal(repoForge(key), 'gitlab'); + // GitHub's are as they were. + assert.equal(normalizeRepo('git@github.com:acme/app.git'), 'acme/app'); + assert.equal(normalizeRepo('acme/app/issues/3'), 'acme/app'); + assert.equal(repoForge('acme/app'), 'github'); + assert.equal(gitlabProject('acme/app'), undefined); + // Never an option, a way up the tree, or a host alone. + for (const s of ['gitlab.example.com/app', 'gitlab.example.com/../etc', 'gitlab.example.com/-x/y', '--upload-pack=x/y', 'https://evil..com/a/b']) assert.equal(normalizeRepo(s), undefined, s); +}); + +test("a forge's words: GitLab has merge requests, !12 and glab", () => { + assert.deepEqual([forgeWords('gitlab').pr, forgeWords('gitlab').ref, forgeWords('gitlab').cli], ['MR', '!', 'glab']); + assert.deepEqual([forgeWords(undefined).pr, forgeWords(undefined).ref, forgeWords(undefined).site], ['PR', '#', 'GitHub']); +}); + +test("GitLab's issues and merge requests read as the boards' cards", () => { + // locked: an MR while GitLab merges it, still open until it has. + assert.deepEqual([stateOf('opened'), stateOf('merged'), stateOf('closed'), stateOf('locked')], ['OPEN', 'MERGED', 'CLOSED', 'OPEN']); + const issue = issueOf({ iid: 4, title: 'Crash', state: 'opened', web_url: 'u', author: { username: 'ada' }, labels: [{ name: 'bug', color: '#d73a4a' }], assignees: [{ username: 'bo' }], description: 'x', user_notes_count: 3, created_at: 'c', updated_at: 'u2' }); + assert.deepEqual(issue, { number: 4, title: 'Crash', state: 'OPEN', url: 'u', author: 'ada', labels: [{ name: 'bug', color: '#d73a4a' }], assignees: ['bo'], createdAt: 'c', updatedAt: 'u2', body: 'x', comments: 3 }); + + const mr = pullOf({ + iid: '9', + title: 'Fix it', + state: 'opened', + draft: false, + webUrl: 'https://gitlab.example.com/acme/app/-/merge_requests/9', + author: { username: 'ada' }, + labels: { nodes: [{ title: 'ready', color: '#00ff00' }] }, + approved: true, + reviewers: { nodes: [] }, + sourceBranch: 'fix-it', + targetBranch: 'main', + diffHeadSha: 'abc', + diffStatsSummary: { additions: 5, deletions: 2 }, + headPipeline: { status: 'RUNNING' }, + description: 'Closes #4, #5 and fixes #6', + }); + assert.equal(mr.number, 9); + assert.equal(mr.reviewDecision, 'APPROVED'); + assert.equal(mr.checks, 'pending'); + assert.deepEqual([mr.headRefName, mr.baseRefName, mr.headRefOid, mr.additions, mr.deletions], ['fix-it', 'main', 'abc', 5, 2]); + assert.deepEqual(mr.closes, [4, 5, 6]); + assert.deepEqual(mr.labels, [{ name: 'ready', color: '#00ff00' }]); + // A reviewer asking for changes outweighs an approval. + assert.equal(pullOf({ iid: 1, approved: true, reviewers: { nodes: [{ mergeRequestInteraction: { reviewState: 'REQUESTED_CHANGES' } }] } }).reviewDecision, 'CHANGES_REQUESTED'); + assert.deepEqual(closesOf('Related to #3. Resolves #8'), [8]); +}); + +test("an MR's pipeline jobs, merge status and threads read as the PR window's", () => { + assert.deepEqual(jobCheck({ name: 'test', stage: 'verify', status: 'failed', allow_failure: false, web_url: 'w' }), { name: 'verify / test', state: 'fail', url: 'w' }); + assert.equal(jobCheck({ name: 'lint', status: 'failed', allow_failure: true }).state, 'skip'); + assert.equal(jobCheck({ name: 'deploy', status: 'manual' }).state, 'skip'); + assert.equal(jobCheck({ name: 'build', status: 'running' }).state, 'pending'); + assert.deepEqual(mergeStatusOf('mergeable', false), { mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }); + assert.deepEqual(mergeStatusOf('need_rebase', false), { mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND' }); + assert.deepEqual(mergeStatusOf('not_approved', false), { mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED' }); + assert.deepEqual(mergeStatusOf('checking', false), { mergeable: 'UNKNOWN', mergeStateStatus: 'UNKNOWN' }); + assert.deepEqual(mergeStatusOf('mergeable', true), { mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }); + + const url = 'https://gitlab.example.com/acme/app/-/merge_requests/9'; + const note = (id: number, extra: object = {}) => ({ id, body: `n${id}`, author: { username: 'ada' }, created_at: `2026-01-0${id}`, system: false, ...extra }); + const position = { new_path: 'src/a.ts', old_path: 'src/a.ts', new_line: 12, old_line: null }; + const { comments, reviewComments } = discussionsOf( + [ + { individual_note: true, notes: [note(2)] }, + { individual_note: true, notes: [note(1, { system: true, body: 'added 1 commit' })] }, + { individual_note: false, notes: [note(3, { type: 'DiffNote', position }), note(4, { type: 'DiffNote', position })] }, + { individual_note: false, notes: [note(5, { type: 'DiffNote', position: { new_path: 'b.ts', old_path: 'b.ts', new_line: null, old_line: 7 } })] }, + ], + url, + ); + assert.deepEqual(comments.map((c) => [c.id, c.url]), [['2', `${url}#note_2`]]); + assert.deepEqual(reviewComments.map((c) => [c.id, c.replyTo, c.path, c.line, c.side]), [[3, undefined, 'src/a.ts', 12, 'RIGHT'], [4, 3, 'src/a.ts', 12, 'RIGHT'], [5, undefined, 'b.ts', 7, 'LEFT']]); +}); + +test("glab's errors say what to do", () => { + assert.match(friendlyGlabError('ERROR: 401 Unauthorized'), /glab auth login/); + assert.match(friendlyGlabError('dial tcp: lookup gitlab.example.com: no such host'), /network or VPN/); + assert.match(friendlyGlabError('404 Project Not Found'), /can't find this project/); +}); + +/** A GitLab whose glab answers from `answer`, keeping every call. */ +function fake(answer: (args: string[]) => unknown) { + const calls: string[][] = []; + const issues: GhState[] = []; + const pulls: GhState[] = []; + const lab = new GitLab('/tmp', { host: 'gitlab.example.com', path: 'acme/app' }, (s) => issues.push(s), (s) => pulls.push(s), async (args) => { + calls.push(args); + const out = answer(args); + if (out instanceof Error) throw out; + return typeof out === 'string' ? out : JSON.stringify(out ?? null); + }); + return { lab, calls, issues, pulls }; +} + +test('the MR list drops a GraphQL field an older GitLab lacks, and keeps the rest', async () => { + const { lab, calls } = fake((args) => { + if (args.includes('graphql')) { + const query = args.find((a) => a.startsWith('query='))!; + if (query.includes('mergeRequestInteraction')) return { errors: [{ message: "Field 'mergeRequestInteraction' doesn't exist on type 'MergeRequestReviewer'" }] }; + return { data: { project: { mergeRequests: { pageInfo: { hasNextPage: false }, nodes: query.includes('state: opened') ? [{ iid: '3', title: 'T', state: 'opened', sourceBranch: 'b' }] : [] } } } }; + } + return []; + }); + await lab.refresh(); + assert.deepEqual(lab.pulls.items.map((p) => [p.number, p.state, p.headRefName]), [[3, 'OPEN', 'b']]); + assert.equal(lab.pulls.error, undefined); + // It asks with --hostname, so the office's own checkout never decides which GitLab. + assert.ok(calls.every((c) => c.includes('--hostname') && c.includes('gitlab.example.com'))); + assert.ok(calls.some((c) => c.some((a) => a.startsWith('projects/acme%2Fapp/issues?state=opened')))); +}); + +test('taking an issue adds the taker to its assignees; merging squashes and removes the branch as asked', async () => { + const { lab, calls } = fake((args) => { + const at = args.find((a) => a.startsWith('projects/') || a === 'user') ?? ''; + if (args.includes('user')) return { id: 77, username: 'office' }; + if (at === 'projects/acme%2Fapp/issues/4' && args.includes('GET')) return { iid: 4, assignees: [{ id: 5 }] }; + if (args.includes('graphql')) return { data: { project: { mergeRequests: { pageInfo: { hasNextPage: false }, nodes: [] } } } }; + return []; + }); + assert.equal(await lab.claim(4), undefined); + const put = calls.find((c) => c.includes('PUT') && c.includes('projects/acme%2Fapp/issues/4'))!; + assert.deepEqual(put.slice(put.indexOf('-F'), put.indexOf('-F') + 2), ['-F', 'assignee_ids=[5,77]']); + + assert.equal(await lab.merge(9, 'squash', true, false), undefined); + const merge = calls.find((c) => c.includes('projects/acme%2Fapp/merge_requests/9/merge'))!; + assert.ok(merge.includes('squash=true') && merge.includes('should_remove_source_branch=true')); + assert.ok(!merge.some((a) => a.startsWith('auto_merge'))); +}); + +test('a comment goes as a plain string, and comes back as the window shows it', async () => { + const { lab, calls } = fake((args) => (args.includes('POST') ? { id: 31, body: '@file -f x', author: { username: 'ada' }, created_at: 'c', system: false } : args.includes('graphql') ? { data: { project: { mergeRequests: { pageInfo: {}, nodes: [] } } } } : [])); + const r = await lab.comment('pull', 9, '@file -f x'); + assert.deepEqual(r.comment, { id: '31', author: 'ada', body: '@file -f x', createdAt: 'c', url: 'https://gitlab.example.com/acme/app/-/merge_requests/9#note_31' }); + const post = calls.find((c) => c.includes('POST'))!; + // -f: glab never reads "@file" as a file to send. + assert.equal(post[post.indexOf('body=@file -f x') - 1], '-f'); +}); + +test('a project that only squashes, or never does, offers only that', async () => { + for (const [option, methods] of [['always', ['squash']], ['never', ['merge']], ['default_on', ['squash', 'merge']], ['default_off', ['merge', 'squash']]] as const) { + const { lab } = fake(() => ({ path_with_namespace: 'acme/app', squash_option: option })); + assert.deepEqual((await lab.repoInfo()).methods, methods, option); + } +}); + +test("closing an MR deletes its branch only when it's in this project, never a fork's namesake here", async () => { + for (const [source, deleted] of [[1, true], [2, false]] as const) { + const { lab, calls } = fake((args) => (args.includes('PUT') ? { iid: 9, source_branch: 'main', source_project_id: source, target_project_id: 1 } : args.includes('graphql') ? { data: { project: { mergeRequests: { pageInfo: {}, nodes: [] } } } } : [])); + assert.equal(await lab.close('pull', 9, { deleteBranch: true }), undefined); + assert.equal( + calls.some((c) => c.includes('DELETE') && c.includes('projects/acme%2Fapp/repository/branches/main')), + deleted, + `source project ${source}`, + ); + } +}); + +test("an origin with a token in it is still the project, and the token isn't kept", () => { + assert.equal(normalizeRepo('https://oauth2:glpat-secret@gitlab.example.com/acme/app.git'), 'gitlab.example.com/acme/app'); + assert.equal(normalizeRepo('https://x-access-token:ghp_secret@github.com/acme/app.git'), 'acme/app'); +}); + +test("a checkout on a host that isn't a GitLab (GitHub Enterprise) stays with gh", () => { + const conf = mkdtempSync(path.join(os.tmpdir(), 'glab-')); + writeFileSync(path.join(conf, 'config.yml'), 'hosts:\n code.example.org:\n token: x\n'); + const before = process.env.GLAB_CONFIG_DIR; + process.env.GLAB_CONFIG_DIR = conf; + try { + assert.equal(isGitLabHost('gitlab.corp.example'), true); + assert.equal(isGitLabHost('code.example.org'), true); + assert.equal(isGitLabHost('github.mycorp.com'), false); + const repo = (url: string) => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'origin-')); + execFileSync('git', ['init', '-q', dir]); + execFileSync('git', ['remote', 'add', 'origin', url], { cwd: dir }); + return originOf(dir); + }; + assert.equal(repo('git@github.mycorp.com:org/app.git'), undefined); + assert.equal(repo('git@code.example.org:org/app.git'), 'code.example.org/org/app'); + assert.equal(repo('git@github.com:org/app.git'), 'org/app'); + } finally { + if (before === undefined) delete process.env.GLAB_CONFIG_DIR; + else process.env.GLAB_CONFIG_DIR = before; + } +}); diff --git a/tests/office-workers.test.ts b/tests/office-workers.test.ts index f380f03d7..fd1bd51fe 100644 --- a/tests/office-workers.test.ts +++ b/tests/office-workers.test.ts @@ -253,12 +253,20 @@ test('a worker in the main checkout has the pull request it opened itself', () = assert.equal(ownPr('grep -rn "gh pr create" docs', `docs/a.md: gh pr create … ${url}`), undefined); assert.equal(ownPr('gh pr create --fill', 'pull request create failed: GraphQL: No commits between main and fix-it'), undefined); assert.equal(ownPr(undefined, url), undefined); + + // `glab mr create` on a GitLab prints the merge request's page; its project can be in a subgroup. + const mr = 'https://gitlab.example.com/acme/web/app/-/merge_requests/7'; + assert.deepEqual(ownPr('glab mr create --fill --yes', `Creating merge request for fix-it into main in acme/web/app\n\n!7 Fix it (fix-it)\n ${mr}\n`), { repo: 'gitlab.example.com/acme/web/app', number: 7, url: mr }); + assert.equal(ownPr('glab mr view 7', mr), undefined); }); test('reads a request to say which pull request is whose', () => { assert.deepEqual(readPrRequest({ pr: 12 }), { pr: 12 }); assert.deepEqual(readPrRequest({ pr: ' #12 ', worker: ' Bolt ' }), { worker: 'Bolt', pr: 12 }); assert.deepEqual(readPrRequest({ pr: 'https://github.com/acme/app/pull/12/files', worker: 'Bolt' }), { worker: 'Bolt', pr: 12, repo: 'acme/app' }); + // A merge request on a GitLab: its page, or !12. + assert.deepEqual(readPrRequest({ pr: 'https://GitLab.example.com/acme/web/app/-/merge_requests/12/diffs' }), { pr: 12, repo: 'gitlab.example.com/acme/web/app' }); + assert.deepEqual(readPrRequest({ pr: '!12' }), { pr: 12 }); assert.deepEqual(readPrRequest({ unlink: true, worker: 'Bolt' }), { worker: 'Bolt' }); assert.deepEqual(readPrRequest({ unlink: true }), {}); assert.match(readPrRequest({}) as string, /Say which pull request/); diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index ae46714c2..84bc41535 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { PROMPTS, PROMPT_IDS, PROMPT_MAX, fillPrompt, placeholders, promptText } from '../src/shared/prompts.js'; -import { OfficePrompts, officePrompt, type PromptSource } from '../src/server/prompts.js'; +import { OfficePrompts, forgePrompts, officePrompt, type PromptSource } from '../src/server/prompts.js'; import { stationBrief } from '../src/server/stations.js'; import { TaskQueue, type QueueWorkers } from '../src/server/queue.js'; import type { AgentChoice, PromptsState, WorkerInfo } from '../src/shared/protocol.js'; @@ -156,3 +156,23 @@ test('the worktree note the queue adds can be rewritten, or left off', (t) => { none.queue.add('Fix it', 'Ada'); assert.equal(none.workers[0].prompt, 'Fix it'); }); + +test('a GitLab floor gets the defaults about merge requests and glab, but a rewritten prompt as written', (t) => { + for (const id of PROMPT_IDS) { + const text = promptText({}, id, 'gitlab'); + assert.doesNotMatch(text, /\bgh (?:pr|issue|api)\b|GitHub/, `${id} still says gh or GitHub on a GitLab floor`); + // The same placeholders as the GitHub default, so nothing the office fills in goes missing. + for (const name of placeholders(text)) assert.ok(name in PROMPTS[id].vars, `${id} uses {{${name}}}`); + } + assert.match(promptText({}, 'issue.work', 'gitlab'), /glab issue view \{\{number\}\} --comments/); + assert.match(promptText({}, 'pull.review', 'gitlab'), /merge request !\{\{number\}\}/); + assert.match(promptText({}, 'station.pulls', 'gitlab'), /glab mr diff/); + assert.equal(promptText({}, 'issue.work', 'github'), PROMPTS['issue.work'].text); + + const book = new OfficePrompts(scratch(t), { list: ['claude'], configured: 'claude' }, () => {}); + book.setPrompt('issue.work', 'Do #{{number}} our way', 'Ada'); + const lab = forgePrompts(book, 'gitlab'); + assert.equal(officePrompt(lab, 'issue.work', { number: 4 }), 'Do #4 our way'); + assert.match(officePrompt(lab, 'issue.ask', { number: 4, title: 'T', url: 'u' }), /^This is about GitLab issue #4/); + assert.match(stationBrief('issues', lab), /GitLab issues with the glab CLI/); +});