diff --git a/DESIGN.md b/DESIGN.md index d3b8463..2a2e7cd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -816,6 +816,32 @@ brings the owner back after the installation starts a reading of the suggestions Adding by an address, which asks GitHub whether the App is installed there, stays for a repository that is not in the list. +The state at the top of a project's card, its chip ("active · priority ×2" or "inactive"), is a button in a small form: +one click turns the project's bursts on or off (`POST /projects///active`). The form says the state that it +wants and does not flip the one that it finds, so that a page that is out of date never turns the project the wrong way. +It changes only the project's active flag, but for one thing: a project with no priority (weight 0 or less, which only +`lastcall project add --weight 0` makes) is off whatever its flag says, so turning it on also gives it weight 1. The +owner comes back to the card (`#project-owner--name`) with no notice, since the chip is the answer; the Active box in +the card's Settings stays and works as before. The chip's accessible name says what a press does and keeps the text that +the chip shows ("Turn off bursts on owner/name (active · priority ×2)"), so it has no `aria-pressed`, which goes with a +name that never changes. + +The trash button at the other end of the header removes a project from lastcall, and it only asks: it opens a popover +(`popovertarget`, as the help bubbles do) that says what removing means, with a Cancel button that closes it and has the +focus, and the Remove button of the form that posts `POST /projects///remove`. Browsers without `popover` +show neither the question nor the trash button, since inline the question would sit in every card one click away from +removing it; `lastcall project remove` does it there. While a run is going on the project, the removal is refused and +the Projects page says to remove it when the run ends. Otherwise one transaction deletes the owner's orders for the +project that have not started (the service does not start one that it had already read) and the project's row, and the +owner is told that its history stays. Nothing else goes: runs, findings, ideas, labelled issues and pull requests are +keyed by the repository's name, not by the project, so adding the repository again, from the suggestions, where it is +back at once, or by its address, picks up where it left off, and the judge does not propose again what the owner +rejected. Only the project's own settings (priority, language, merge on approve and the rest) are forgotten. The choices +of containers and Loki sources in the settings stay too, and come back with the project: the Settings page shows them +apart, as chosen for a project that is not one now, and saving the page never erases them. While the project is out, +sync goes on following its open pull requests, so that what the owner does with them is still recorded, but lastcall +neither merges nor watches them. `lastcall project remove ` follows the same rules from the command line. + A project with merge on approve or the watch on shows a notice on its card while GitHub says that the App's installation cannot read checks and commit statuses (an App made before v0.7.2 cannot): the two steps, which are to set them to Read-only on the App's permissions page and then to accept them on the installation, and the link to that page. The diff --git a/README.md b/README.md index df40db4..4b2763a 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,8 @@ Projects, and Settings holds a fourth that is optional: docker compose --profile local run --rm ollama-pull qwen3-coder # a model, about 19 GB: a graphics card helps ``` - 📁 **Projects.** lastcall suggests your repositories and says why. Tick the ones you want, or add the suggested ones - or all of them at once, with a filter when you have many. Any other repository goes in by its address. + or all of them at once, with a filter when you have many. Any other repository goes in by its address. A click on + a project's state turns its bursts on or off, and its trash button removes it from lastcall but keeps its history. - 🔔 **Settings.** Where lastcall should notify you. Choose the service and fill in what it asks for: Telegram (paste your bot's token and lastcall finds your chat), ntfy, Discord, Slack or email, or any of the [services that Apprise reaches](https://github.com/caronc/apprise#supported-notifications). lastcall saves a @@ -196,6 +197,7 @@ docker compose run --rm cli quota claude-max docker compose run --rm cli account tier claude-max deep --effort max # the judge and fixes, at max docker compose run --rm cli github repos docker compose run --rm cli project add --signals +docker compose run --rm cli project remove # its history stays docker compose run --rm cli signals connect --url https://sentry.io --org # asks for the token docker compose run --rm cli signals show # its errors, as audits read them docker compose run --rm cli logs list # containers, with suggestions diff --git a/src/lastcall/cli.py b/src/lastcall/cli.py index 88c1379..9ec148c 100644 --- a/src/lastcall/cli.py +++ b/src/lastcall/cli.py @@ -5,6 +5,7 @@ lastcall account add --engine opencode --provider openrouter --model --budget 20 # an API key lastcall account tier deep --effort max # a tier's model and effort; - for lastcall's default lastcall project add [--weight 1] [--language Spanish] [--inactive] [--signals ] +lastcall project remove # stop working on it; its history stays lastcall signals connect --url https://sentry.io --org # the error tracker, Sentry or GlitchTip lastcall signals show # the errors in production that the audits read lastcall logs list # this host's containers, with their projects or suggestions @@ -149,6 +150,10 @@ def main(argv: list[str] | None = None) -> int: proj_add.add_argument("--inactive", action="store_true", help="keep it, but do not audit it") proj_add.add_argument("--signals", help="its project in the error tracker (Sentry or GlitchTip), by slug") proj.add_parser("list", help="the projects") + proj_remove = proj.add_parser( + "remove", help="remove a project; its history stays, so adding it again picks up where it left off" + ) + proj_remove.add_argument("repo", help="owner/name") sig = commands.add_parser("signals", help="errors in production, from Sentry or GlitchTip").add_subparsers( dest="action", required=True @@ -324,6 +329,8 @@ def dispatch(args: argparse.Namespace) -> int: state = "active" if p.active and p.weight > 0 else "inactive" print(f"{p.repo:<40} {state:<8} weight {p.weight:g} {p.language or ''}") return 0 + case "project", "remove": + return remove_project(args.repo) case "quota", _: return quota(account(args.account)) case "github", "setup": @@ -413,6 +420,23 @@ def add_account(args: argparse.Namespace) -> int: return 0 +def remove_project(repo: str) -> int: + """The same as the card's trash button: refused while a run is going on the project; otherwise its orders that + have not started go with it, and its history stays.""" + st = store() + project = st.project(repo) + if project is None: + raise ValueError(f"no project {repo!r}: `lastcall project list` shows them") + if not st.remove_project(project.repo): + print(f"A run is going on {project.repo}: remove it when the run ends.", file=sys.stderr) + return 1 + print( + f"{project.repo} removed. Its history stays: `lastcall project add {project.repo}` picks up where it " + "left off, with the default settings." + ) + return 0 + + def connect_signals(url: str, org: str, token_file: str | None) -> int: url = url.strip().rstrip("/") if not signals.address(url): diff --git a/src/lastcall/locales/en.json b/src/lastcall/locales/en.json index 32c8ee8..3ef18d0 100644 --- a/src/lastcall/locales/en.json +++ b/src/lastcall/locales/en.json @@ -715,7 +715,7 @@ "web.help.dashboard.state": "State: only active projects get bursts. The priority multiplies a project's points when lastcall picks what to work on next; you change both on the Projects page.", "web.help.dashboard.trigger": "Trigger: burst, when lastcall started it on its own with leftover quota; manual, when you started it, from the Projects page or the command line. After the dot comes the kind of work, when it is not an audit: improvement, labelled issues, tests or dependencies.", "web.help.dashboard.status": "Status: stopped early means that before a step the account had reached your reserve or its monthly budget, had paid usage on, or had no quota reading, so the run ended there. Interrupted means lastcall itself stopped while the run was going, for example at a restart. Failed means an error stopped it.", - "web.help.projects.active": "Active projects take part in bursts. Even then, a burst skips a project while it has 2 lastcall pull requests waiting for you, or when the same account audited it in the last 7 days, unless you ticked ideas or labelled issues for it. The run buttons work even when the project is inactive.", + "web.help.projects.active": "Active projects take part in bursts. Even then, a burst skips a project while it has 2 lastcall pull requests waiting for you, or when the same account audited it in the last 7 days, unless you ticked ideas or labelled issues for it. The run buttons work even when the project is inactive. Click the state at the top of a card to turn bursts on or off; the “Active” box in Settings does the same.", "web.help.projects.weight": "When an account has quota to spend, lastcall gives each active project points (never audited by that account, days and new commits since its last audit, pending findings, errors in production, what you asked for) and starts with the highest. Priority multiplies those points: High (×2) doubles them, Low (×0.5) halves them. It does not make a run longer.", "web.help.projects.language": "The language of this project's pull requests, issues and reports. With “The account's”, lastcall writes in the language of whichever account does the run, set on the Accounts page. It does not change these pages or your notifications.", "web.help.projects.max_prs": "The most pull requests one run opens on this project, whatever it does: an audit, tests, dependency upgrades, the ideas of a plan or labelled issues. What does not fit waits for a later run.", @@ -812,5 +812,19 @@ "web.setup.step1_text": "lastcall writes a setup code in its log. Show the log with:", "web.setup.step2": "Choose a password", "web.error.title_origin": "Form not accepted", - "web.error.title_csrf": "Form expired" + "web.error.title_csrf": "Form expired", + "web.project.turn_off": "Turn off bursts on {repo} ({state})", + "web.project.turn_on": "Turn on bursts on {repo} ({state})", + "web.project.remove": "Remove {repo} from lastcall", + "web.project.remove_title": "Remove {repo} from lastcall?", + "web.project.remove_stops": "lastcall stops working on this repository.", + "web.project.remove_history": "Its history and what lastcall learned stay. Add it again and it picks up where it left off, without proposing again what you rejected.", + "web.project.remove_settings": "Its settings (priority, language and the rest) are forgotten.", + "web.project.remove_github": "Nothing changes on GitHub: the repository and its pull requests stay.", + "web.project.remove_back": "It goes back to the list of repositories to add.", + "web.project.remove_confirm": "Remove", + "web.project.remove_cancel": "Cancel", + "web.project.remove_busy": "A run is going on {repo} right now. Remove it when the run ends.", + "web.done.removed": "{repo} removed. Its history stays: add it again from the list below at any time.", + "web.logs.gone": "It was chosen for {project}, which is not a project now: add {project} again and it is read again." } diff --git a/src/lastcall/locales/es.json b/src/lastcall/locales/es.json index c015530..833f31c 100644 --- a/src/lastcall/locales/es.json +++ b/src/lastcall/locales/es.json @@ -715,7 +715,7 @@ "web.help.dashboard.state": "Estado: solo los proyectos activos reciben ráfagas. La prioridad multiplica los puntos de un proyecto cuando lastcall elige en qué trabajar; las dos cosas se cambian en la página de proyectos.", "web.help.dashboard.trigger": "Origen: ráfaga, cuando lastcall la empezó por su cuenta con cuota sobrante; manual, cuando la empezaste tú, desde la página de proyectos o la línea de comandos. Tras el punto va el tipo de trabajo, cuando no es una auditoría: mejora, issues etiquetados, tests o dependencias.", "web.help.dashboard.status": "Estado: parada antes de tiempo significa que, antes de un paso, la cuenta había llegado a tu reserva o a su presupuesto mensual, tenía activo el uso de pago o no tenía lectura de cuota, así que la ejecución terminó ahí. Interrumpida significa que lastcall se detuvo mientras la ejecución estaba en marcha, por ejemplo al reiniciarse. Fallida significa que un error la detuvo.", - "web.help.projects.active": "Los proyectos activos entran en las ráfagas. Aun así, una ráfaga se salta un proyecto mientras tenga 2 pull requests de lastcall esperándote, o si la misma cuenta lo auditó en los últimos 7 días, salvo que hayas marcado ideas o etiquetado issues para él. Los botones para lanzar una ejecución funcionan aunque el proyecto esté inactivo.", + "web.help.projects.active": "Los proyectos activos entran en las ráfagas. Aun así, una ráfaga se salta un proyecto mientras tenga 2 pull requests de lastcall esperándote, o si la misma cuenta lo auditó en los últimos 7 días, salvo que hayas marcado ideas o etiquetado issues para él. Los botones para lanzar una ejecución funcionan aunque el proyecto esté inactivo. Haz clic en el estado, arriba en la tarjeta, para activar o desactivar las ráfagas; la casilla «Activo» de Ajustes hace lo mismo.", "web.help.projects.weight": "Cuando una cuenta tiene cuota que gastar, lastcall da puntos a cada proyecto activo (nunca auditado por esa cuenta, días y commits nuevos desde su última auditoría, hallazgos pendientes, errores en producción, lo que hayas pedido) y empieza por el que más tiene. La prioridad multiplica esos puntos: Alta los duplica y Baja los reduce a la mitad. No hace la ejecución más larga.", "web.help.projects.language": "El idioma de los pull requests, issues e informes de este proyecto. Con «El de la cuenta», lastcall escribe en el idioma de la cuenta que haga la ejecución, el que se elige en Cuentas. No cambia estas páginas ni tus notificaciones.", "web.help.projects.max_prs": "El máximo de pull requests que abre una ejecución en este proyecto, haga lo que haga: una auditoría, tests, actualizaciones de dependencias, las ideas de un plan o issues etiquetados. Lo que no cabe espera a una ejecución posterior.", @@ -812,5 +812,19 @@ "web.setup.step1_text": "lastcall escribe un código de configuración en su log. Muestra el log con:", "web.setup.step2": "Elige una contraseña", "web.error.title_origin": "Formulario no aceptado", - "web.error.title_csrf": "Formulario caducado" + "web.error.title_csrf": "Formulario caducado", + "web.project.turn_off": "Desactivar las ráfagas en {repo} ({state})", + "web.project.turn_on": "Activar las ráfagas en {repo} ({state})", + "web.project.remove": "Quitar {repo} de lastcall", + "web.project.remove_title": "¿Quitar {repo} de lastcall?", + "web.project.remove_stops": "lastcall deja de trabajar en este repositorio.", + "web.project.remove_history": "Su historial y lo que lastcall aprendió se quedan. Si lo añades de nuevo, sigue donde lo dejó, sin volver a proponer lo que rechazaste.", + "web.project.remove_settings": "Sus ajustes (prioridad, idioma y el resto) se olvidan.", + "web.project.remove_github": "En GitHub no cambia nada: el repositorio y sus pull requests se quedan.", + "web.project.remove_back": "Vuelve a la lista de repositorios que puedes añadir.", + "web.project.remove_confirm": "Quitar", + "web.project.remove_cancel": "Cancelar", + "web.project.remove_busy": "Hay una ejecución en curso en {repo}. Quítalo cuando termine.", + "web.done.removed": "{repo} quitado. Su historial se queda: vuelve a añadirlo cuando quieras desde la lista de abajo.", + "web.logs.gone": "Se eligió para {project}, que ya no es un proyecto: añade {project} de nuevo y se vuelve a leer." } diff --git a/src/lastcall/logs.py b/src/lastcall/logs.py index cf4cf93..7a66a9f 100644 --- a/src/lastcall/logs.py +++ b/src/lastcall/logs.py @@ -67,6 +67,17 @@ def choose(store: Store, choices: Mapping[str, str], key: str = CHOSEN) -> None: store.set_setting(key, json.dumps(dict(sorted(choices.items())))) +def shown(choice: str, repos: Iterable[str]) -> tuple[str, str]: + """What the Settings page shows of the owner's choice for a container or a source: the project, as the projects + page writes it, while it is one; or, apart, the name that the choice keeps after the owner removed the project. + Settings keeps that choice, so that adding the project again brings it back, and the page shows none meanwhile: + a form that sent the removed name back as the one it showed would erase it.""" + for repo in repos: + if repo.lower() == choice.lower(): + return repo, "" + return "", choice + + def project_of(container: Mapping[str, Any], choices: Mapping[str, str]) -> str: """The project of a container: its label, or else the owner's choice; "" for none.""" return str(container.get("label") or "") or choices.get(str(container.get("name") or ""), "") diff --git a/src/lastcall/service.py b/src/lastcall/service.py index 4b4d617..ff6a36c 100644 --- a/src/lastcall/service.py +++ b/src/lastcall/service.py @@ -278,7 +278,8 @@ def take_orders(self, now: float) -> None: account = max(free, key=self.leftover) recipe = order["kind"] if order["kind"] in ("improve", "issues", "tests", "deps") else "audit" crew = [a for a in free if a is not account] if order["account"] == "*" and recipe == "audit" else [] - store.start_order(order["id"], now) + if not store.start_order(order["id"], now): # the owner removed its project while it waited + continue self.log(f"{account.id}: {recipe} of {order['repo']} ordered by the owner") self.launch(account, order["repo"], "manual", crew, recipe) diff --git a/src/lastcall/store.py b/src/lastcall/store.py index 7f2938b..9209934 100644 --- a/src/lastcall/store.py +++ b/src/lastcall/store.py @@ -382,6 +382,33 @@ def pause_project(self, repo: str) -> None: """After a revert: no more bursts on the project until the owner activates it again.""" self.x("UPDATE projects SET active = 0 WHERE repo = ?", (repo,)) + def set_active(self, repo: str, active: bool, weight: float | None = None) -> None: + """Bursts on or off for a project: its flag, and its weight when one is given, but nothing else of it.""" + self.x( + "UPDATE projects SET active = ?, weight = COALESCE(?, weight) WHERE repo = ?", (int(active), weight, repo) + ) + + def remove_project(self, repo: str) -> bool: + """Take a project out of lastcall, with the owner's orders for it that have not started. False, and nothing + changes, while a run is going on it. The rest stays: runs, findings, ideas, labelled issues and pull requests + are keyed by the repository's name and not by the project, and so are the choices of containers and Loki + sources in the settings, so adding the project again picks up where it left off. The check and the deletes are + one transaction.""" + with self.lock: + self.db.execute("BEGIN IMMEDIATE") + try: + going = self.db.execute( + "SELECT 1 FROM runs WHERE status = 'running' AND lower(repo) = lower(?) LIMIT 1", (repo,) + ).fetchone() + if going is None: + self.db.execute("DELETE FROM orders WHERE started IS NULL AND lower(repo) = lower(?)", (repo,)) + self.db.execute("DELETE FROM projects WHERE lower(repo) = lower(?)", (repo,)) + except BaseException: + self.db.execute("ROLLBACK") + raise + self.db.execute("COMMIT") + return going is None + # suggestions for the installed repositories, refreshed now and then from GitHub def save_suggestions(self, found: Iterable[Suggestion], now: float) -> None: @@ -413,8 +440,9 @@ def orders(self) -> list[dict[str, Any]]: """Orders not started yet, oldest first.""" return self.q("SELECT * FROM orders WHERE started IS NULL ORDER BY id") - def start_order(self, order_id: int, now: float) -> None: - self.x("UPDATE orders SET started = ? WHERE id = ?", (now, order_id)) + def start_order(self, order_id: int, now: float) -> bool: + """Mark an order started. False when it is not there any more: its project was removed while it waited.""" + return self.x("UPDATE orders SET started = ? WHERE id = ? AND started IS NULL", (now, order_id)).rowcount == 1 # quota readings diff --git a/src/lastcall/web/__init__.py b/src/lastcall/web/__init__.py index 644220c..eeb34f5 100644 --- a/src/lastcall/web/__init__.py +++ b/src/lastcall/web/__init__.py @@ -295,6 +295,9 @@ def show_projects(status: int = 200, error: str = "", install: str = "", query: count = request.args.get("count", 0, type=int) # how many projects "Add many" added if done == "added_many" and 0 < count <= MAX_ADDED and not error: message = i18n.plural(g.lang, "web.done.added_many", count) + gone = request.args.get("repo", "") # the project that was removed: its name is in the address too + if done == "removed" and projects.REPO.fullmatch(gone) and not error: + message = t("web.done.removed", repo=gone) return page("projects.html", status, v=view, done=message, error=error, install=install) def permissions_page(repo: str) -> str | None: @@ -450,6 +453,34 @@ def project_settings(owner: str, name: str) -> ResponseReturnValue: ) return redirect(url_for("projects_page", done="saved") + project_anchor(project.repo)) + @app.post("/projects///active") + @signed_in + def project_active(owner: str, name: str) -> ResponseReturnValue: + """The state chip of a card: bursts on or off with one click, and nothing else of the project changes. The + form says which state it wants, so that an old page cannot flip it the wrong way. The owner goes back to the + card, with no notice: the chip is the answer.""" + project = store.project(f"{owner}/{name}") + if project is None: + return problem(404, "web.error.not_found") + on = request.form.get("active") == "1" + # A project with no priority (weight 0 or less, which only `lastcall project add --weight 0` makes) is off + # whatever its flag says: turning it on gives it weight 1, or the chip would still say that it is off. + store.set_active(project.repo, on, 1.0 if on and project.weight <= 0 else None) + return redirect(url_for("projects_page") + project_anchor(project.repo)) + + @app.post("/projects///remove") + @signed_in + def remove_project(owner: str, name: str) -> ResponseReturnValue: + """Take a project out of lastcall, as the card's trash button asks after its popover. Its history stays (see + `Store.remove_project`), and it refuses while a run is going on the project.""" + project = store.project(f"{owner}/{name}") + if project is None: + return problem(404, "web.error.not_found") + if not store.remove_project(project.repo): + return show_projects(409, t("web.project.remove_busy", repo=project.repo)) + log(f"Web UI: project {project.repo} removed") + return redirect(url_for("projects_page", done="removed", repo=project.repo)) + @app.post("/projects///audit") @signed_in def audit_now(owner: str, name: str) -> ResponseReturnValue: @@ -622,9 +653,9 @@ def containers_view() -> dict[str, Any]: rows = [] for c in listed: name = str(c.get("name") or "") - choice = choices.get(name, "") + choice, gone = logs.shown(choices.get(name, ""), repos) hint = "" if c.get("label") or name in choices else logs.suggest(c, repos) - rows.append({**c, "selected": choice or hint, "suggested": hint}) + rows.append({**c, "selected": choice or hint, "suggested": hint, "gone": gone}) return {"error": "", "rows": rows, "repos": repos} @app.post("/settings/loki") @@ -719,8 +750,9 @@ def loki_view(tried: Mapping[str, str] | None) -> dict[str, Any]: rows = [] for s in listed: name = s["name"] + choice, gone = logs.shown(choices.get(name, ""), repos) hint = "" if name in choices else logs.suggest(s, repos) - rows.append({**s, "selected": choices.get(name, "") or hint, "suggested": hint}) + rows.append({**s, "selected": choice or hint, "suggested": hint, "gone": gone}) return {**view, "labels": names, "label": label, "rows": rows, "repos": repos} @app.post("/settings/signals/remove") diff --git a/src/lastcall/web/static/style.css b/src/lastcall/web/static/style.css index f692c6e..c462e13 100644 --- a/src/lastcall/web/static/style.css +++ b/src/lastcall/web/static/style.css @@ -1009,6 +1009,67 @@ main > .notice.done { box-shadow: inset 0 3px 0 var(--edge), var(--shadow); } .project-name a:hover { text-decoration: underline; text-decoration-thickness: 2px; } .project-name a::after { content: "\2197"; margin-left: .25rem; color: var(--muted); font-family: var(--font-body); font-size: .8em; font-weight: 600; } +/* The state is a button that turns bursts on or off: the chip keeps its look, and on hover and focus it says that it can + be pressed. It and its "?" stay together with the trash button, beside the name when there is room and under it when + not, and the trash button sits at the far end, away from the chip, so that the two are not mixed up. Both reach 44 px + with their hit area, as the "?" does. */ +.project-state { flex: 1 1 auto; display: flex; align-items: center; gap: var(--s2) var(--s3); } +.state-form { margin: 0; } +button.chip { position: relative; font-family: var(--font-body); cursor: pointer; box-shadow: none; } +button.chip::after { content: ""; position: absolute; inset: -9px 0; } +button.chip.state-active:hover, button.chip.state-active:focus-visible { background: color-mix(in srgb, var(--good) 8%, var(--good-soft)); border-color: var(--good); } +button.chip.state-off:hover, button.chip.state-off:focus-visible { color: var(--text); background: var(--raised); border-color: var(--accent); } +.trash { + position: relative; + flex: none; + display: inline-grid; + place-items: center; + width: 2rem; + height: 2rem; + min-height: 0; + margin-left: auto; + padding: 0; + color: var(--muted); + background: none; + border: 1.5px solid transparent; + border-radius: 50%; + box-shadow: none; +} +.trash::after { content: ""; position: absolute; inset: -6px; } +.trash:hover, .trash:focus-visible { color: var(--bad); background: var(--bad-soft); border-color: color-mix(in srgb, var(--bad) 35%, transparent); } +.trash svg { width: 1.15rem; height: 1.15rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +@media print { .state-form { display: block !important; } } /* the state stays on paper, though it is a form */ + +/* The question that the trash button opens: a help bubble in the colour of danger, with what removing does and its two + buttons; Cancel has the focus when it opens. Without popover support neither shows: inline, the question would sit + in every card one click away from removing it. There, `lastcall project remove` does it. */ +.bubble.confirm { box-shadow: inset 0 3px 0 var(--bad), var(--shadow-lift); } +.bubble.confirm::backdrop { background: rgb(26 20 15 / .28); } +.bubble.confirm h3::before { content: "!"; color: var(--surface); background: var(--bad); } +.bubble.confirm:popover-open { display: flex; flex-direction: column; overflow: hidden; } /* only while it is open: a display of its own would show it closed */ +.bubble.confirm h3, .bubble.confirm .form-actions { flex: none; } +.bubble.confirm ul { /* when the room is short the list scrolls, with a shade at the edge that has more; the title and the buttons stay in view */ + display: grid; + gap: var(--s2); + min-height: 0; + margin: 0 0 var(--s3); + padding-left: 1.2rem; + overflow: auto; + background: + linear-gradient(var(--surface) 30%, transparent) top / 100% 2.5rem no-repeat local, + linear-gradient(transparent, var(--surface) 70%) bottom / 100% 2.5rem no-repeat local, + radial-gradient(farthest-side at 50% 0, var(--shade), transparent) top / 100% .8rem no-repeat scroll, + radial-gradient(farthest-side at 50% 100%, var(--shade), transparent) bottom / 100% .8rem no-repeat scroll; +} +.bubble.confirm .form-actions { justify-content: flex-end; } +@media (max-width: 47.99rem) { /* more to read than a help bubble: a little more room, and a little tighter */ + .bubble.confirm { max-height: min(90dvh, 36rem); font-size: .93rem; } + .bubble.confirm ul { gap: var(--s1); line-height: 1.45; } +} +@supports not selector(:popover-open) { + .trash, .bubble.confirm { display: none; } /* more specific than the help's fallback, which shows every .bubble */ +} + /* Facts: a label and its value on each line. */ .facts { margin: 0; padding: 0; } .facts > div { display: grid; grid-template-columns: 8.75rem minmax(0, 1fr); align-items: baseline; gap: 0 var(--s3); padding: .45rem 0; border-bottom: 1px solid var(--border); } @@ -1378,7 +1439,7 @@ fieldset > .hint { margin: 0; } @media (forced-colors: active) { .card, .hero, .welcome, .notice, .bubble, .bubbles { border-top: 3px solid CanvasText; } .chip, .badge, .pill, .run, .plan, .readout a, .segmented a, .toc a { border: 1px solid CanvasText; } - .help { border: 1px solid ButtonText; } + .help, .trash { border: 1px solid ButtonText; } summary::after, .steps > li::after { forced-color-adjust: none; background: CanvasText; } .steps > li::before { border: 2px solid CanvasText; } meter, meter::-webkit-meter-bar { background: Canvas; border: 1px solid CanvasText; } diff --git a/src/lastcall/web/templates/projects.html b/src/lastcall/web/templates/projects.html index befa121..b63480f 100644 --- a/src/lastcall/web/templates/projects.html +++ b/src/lastcall/web/templates/projects.html @@ -130,8 +130,39 @@

{{ t("web.suggest.title") }}

{{ p.repo }}

- {{ p.state }}{{ help(hb, "projects-active", t("web.project.active"), ["projects.active"]) }} + {#- The state, its "?" and the trash button stay together: beside the name when there is room, else under it. -#} +
+ {#- The state is a button: one click turns bursts on or off. Its form says the state it wants, so that an old page + cannot flip it the wrong way. Its name says what a press does and keeps the visible text; it has no + aria-pressed, because that goes with a name that never changes. -#} +
+
+ + + +
+ {{ help(hb, "projects-active", t("web.project.active"), ["projects.active"]) }} +
+ {#- The trash button only opens the question below, as a popover: it never removes anything by itself. -#} + +
+ {#- The question. Without popover support neither it nor the trash button shows (style.css). -#} +
{{ t("web.projects.last") }}
{{ p.last }}{% if p.last_status %} {{ p.last_status }}{% endif %}
{{ t("web.projects.findings") }}
{{ p.findings }}{{ help(hb, "common-findings", t("web.projects.findings"), ["common.findings"]) }}
diff --git a/src/lastcall/web/templates/settings.html b/src/lastcall/web/templates/settings.html index 7a0961a..4243fef 100644 --- a/src/lastcall/web/templates/settings.html +++ b/src/lastcall/web/templates/settings.html @@ -340,6 +340,7 @@

{{ t("web.logs.title") }}

{%- for repo in v.logs.repos %}{% endfor %} {%- if c.suggested %}

{{ t("web.logs.suggested", project=c.suggested) }}

{% endif %} + {%- if c.gone %}

{{ t("web.logs.gone", project=c.gone) }}

{% endif %} {%- endif %} {%- endfor %} @@ -435,6 +436,7 @@

{{ t("web.logs.title") }}

{%- for repo in v.loki.repos %}{% endfor %} {%- if s.suggested %}

{{ t("web.logs.suggested", project=s.suggested) }}

{% endif %} + {%- if s.gone %}

{{ t("web.logs.gone", project=s.gone) }}

{% endif %} {%- endfor %} diff --git a/tests/test_docs.py b/tests/test_docs.py index 5332999..4a59adf 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -82,3 +82,24 @@ def test_the_documents_say_how_the_first_steps_go() -> None: source = (ROOT / "src/lastcall/web/__init__.py").read_text(encoding="utf-8") assert '"/github/installed"' in source and '"/projects/add-many"' in source assert 'id="suggestions"' in (ROOT / "src/lastcall/web/templates/projects.html").read_text(encoding="utf-8") + + +def test_the_documents_describe_the_state_chip_and_the_trash_button() -> None: + steps = " ".join(README.split("**3. Follow the dashboard.**")[1].split("That is all.")[0].split()) + assert "A click on a project's state turns its bursts on or off, and its trash button removes it" in steps + assert "from lastcall but keeps its history" in steps + web_ui = " ".join(DESIGN.split("## 13. Web UI and CLI")[1].split("## 14. Extensibility")[0].split()) + for words in ( + "`POST /projects///active`", + "`POST /projects///remove`", + "also gives it weight 1", + "`#project-owner--name`", + "Nothing else goes: runs, findings, ideas, labelled issues and pull requests are keyed by the repository's", + "`lastcall project remove `", + ): + assert words in web_ui, words + # the routes and the command that they name exist + source = (ROOT / "src/lastcall/web/__init__.py").read_text(encoding="utf-8") + assert '"/projects///active"' in source and '"/projects///remove"' in source + cli = (ROOT / "src/lastcall/cli.py").read_text(encoding="utf-8") + assert "lastcall project remove " in cli and "cli project remove " in README diff --git a/tests/test_logs.py b/tests/test_logs.py index 570fd55..c569d4c 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -237,3 +237,13 @@ def test_the_command_line_chooses_a_containers_project(monkeypatch: pytest.Monke assert logs.chosen(Store(tmp_path / "state" / "lastcall.db")) == {"shop-web-1": "acme/shop"} assert cli.main(["logs", "set", "shop-web-1", "-"]) == 0 # a no, so that lastcall stops suggesting one assert logs.chosen(Store(tmp_path / "state" / "lastcall.db")) == {"shop-web-1": ""} + + +def test_the_settings_page_shows_a_choice_as_its_project_is_written_and_keeps_apart_one_that_is_not_a_project() -> ( + None +): + repos = ["acme/shop", "acme/Blog"] + assert logs.shown("acme/shop", repos) == ("acme/shop", "") + assert logs.shown("ACME/BLOG", repos) == ("acme/Blog", "") # the command line takes the project in any case + assert logs.shown("acme/old", repos) == ("", "acme/old") # the owner removed that project: kept, and shown apart + assert logs.shown("", repos) == ("", "") # no choice, or a no diff --git a/tests/test_service.py b/tests/test_service.py index 81d5cfb..3f3bd40 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -190,6 +190,28 @@ def test_the_owners_orders_start_on_a_free_account_even_when_paused(store: Store assert store.orders() == [] +def test_an_order_whose_project_the_owner_removed_while_it_waited_never_starts( + store: Store, monkeypatch: pytest.MonkeyPatch +) -> None: + audits: list[tuple[str, str]] = [] + started: list[Callable[[], None]] = [] + readings: dict[str, Reading | Exception] = { + "claude": reading("claude", 30, 150), + "chatgpt": reading("chatgpt", 30, 150), + } + store.add_order("audit", "ne0x86/a", None, NOW) + store.add_order("audit", "ne0x86/b", None, NOW) + waiting = store.orders() # what the service has read when the owner removes the first project + assert store.remove_project("ne0x86/a") and [o["repo"] for o in store.orders()] == ["ne0x86/b"] + monkeypatch.setattr(store, "orders", lambda: waiting) + svc = service(store, readings, audits, Clock(), started) + svc.take_orders(NOW) + assert svc.running == {"claude": "ne0x86/b"} or svc.running == {"chatgpt": "ne0x86/b"} # the other one goes on + for work in started: + work() + assert [repo for _, repo in audits] == ["ne0x86/b (manual)"] # and nothing runs on the removed project + + def test_problems_and_bursts_are_told_once(store: Store, tmp_path: Path) -> None: told: list[str] = [] diff --git a/tests/test_store.py b/tests/test_store.py index 675e1a5..064f09c 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,3 +1,4 @@ +from dataclasses import replace from pathlib import Path from typing import Any, cast @@ -280,3 +281,138 @@ def test_sync_reads_the_owners_ticks(store: Store) -> None: fake.answers["/repos/ne0x86/demo/issues/7"] = {"state": "closed", "state_reason": "completed", "body": body} assert sync(store, cast(GitHub, fake), messages.append) == 1 assert idea_states(store) == {"I1": "wanted", "I2": "dismissed", "I3": "discarded"} # what was ticked stays + + +# turning a project on or off, and removing it + +TICKET = "https://github.com/ne0x86/demo/issues/3" + + +def test_a_project_turns_on_and_off_and_nothing_else_of_it_changes(store: Store) -> None: + store.add_project(REPO, weight=2, language="Spanish") + store.update_project( + REPO, + active=True, + weight=2, + language="Spanish", + max_prs=1, + merge_on_approve=True, + watch=True, + health_url="https://example.com/health", + tests=True, + deps=True, + signals="demo", + ) + on = store.project(REPO) + assert on is not None and on.active + store.set_active(REPO, False) + assert store.project(REPO) == replace(on, active=False) # only the flag + store.set_active(REPO, True) + assert store.project(REPO) == on + store.set_active(REPO, False, weight=0.5) # a weight, when one is given + assert store.project(REPO) == replace(on, active=False, weight=0.5) + + +def history(store: Store) -> None: + """What a project leaves behind: a run, findings, a pull request, an improvement plan with an idea ticked, and a + labelled issue that waits.""" + store.start_run("run1", REPO, "claude", "burst") + store.finish_run("run1", "ok", "abc1234") + report = judged() + store.record_findings("run1", report) + report.deliveries = [ + {"title": "Fix B1", "ids": ["B1"], "status": "opened", "url": "https://github.com/ne0x86/demo/pull/5"} + ] + store.record_deliveries("run1", report) + store.close_pr("https://github.com/ne0x86/demo/pull/5", merged=False) # the owner rejected it + store.record_ideas("run1", improved()) + store.tick_ideas(PLAN, {"I1": True}) + store.sync_tickets(REPO, [{"number": 3, "url": TICKET, "title": "Make it faster"}]) + + +def kept(store: Store) -> dict[str, Any]: + """What the judge, the ranking and the pull requests of the project read from the store.""" + return { + "runs": [r["id"] for r in store.runs()], + "statuses": store.statuses(REPO), + "earlier": store.earlier(REPO), + "earlier_ideas": store.earlier_ideas(REPO), + "wanted": store.wanted_count(REPO), + "waiting": store.waiting_count(REPO), + "prs": store.q("SELECT url, state FROM prs WHERE repo = ?", (REPO,)), + } + + +def test_removing_a_project_keeps_all_its_history_and_adding_it_again_picks_it_up(store: Store) -> None: + store.add_account("claude", "claude-code") + store.add_project(REPO) + store.add_project("ne0x86/other") + history(store) + before = kept(store) + assert before["statuses"]["rejected"] == 1 and before["wanted"] == 1 and before["waiting"] == 1 + store.add_order("audit", REPO, None, 1.0) # waits + store.add_order("tests", REPO.upper(), "claude", 2.0) # waits too, written in another case + store.add_order("audit", "ne0x86/other", None, 3.0) # another project's + store.add_order("improve", REPO, None, 4.0) + store.start_order(store.orders()[-1]["id"], 5.0) # one that has started already + assert store.remove_project(REPO) is True + assert [p.repo for p in store.projects()] == ["ne0x86/other"] and store.project(REPO) is None + assert [o["repo"] for o in store.orders()] == ["ne0x86/other"] # what waited for it is cancelled + assert [o["kind"] for o in store.q("SELECT kind FROM orders WHERE started IS NOT NULL")] == ["improve"] + assert kept(store) == before # and nothing else goes + store.add_project(REPO) + assert kept(store) == before # the judge still knows what the owner rejected + assert store.remove_project(REPO) is True # a project that is not there: nothing to do + assert store.remove_project("ne0x86/never-added") is True + + +def test_a_project_with_a_run_going_is_not_removed_and_nothing_changes(store: Store) -> None: + store.add_project(REPO) + store.add_order("audit", REPO, None, 1.0) + store.start_run("run1", REPO.upper(), "claude", "manual") # whatever the case + assert store.remove_project(REPO) is False + assert store.project(REPO) is not None and len(store.orders()) == 1 + store.finish_run("run1", "failed", None) + assert store.remove_project(REPO) is True + assert store.project(REPO) is None and store.orders() == [] + assert [r["id"] for r in store.runs()] == ["run1"] + + +def test_an_order_that_was_cancelled_cannot_start(store: Store) -> None: + store.add_project(REPO) + store.add_order("audit", REPO, None, 1.0) + [order] = store.orders() + assert store.remove_project(REPO) + assert store.start_order(order["id"], 2.0) is False # a service that still holds it does not run it + store.add_order("audit", "ne0x86/other", None, 3.0) + [waiting] = store.orders() + assert store.start_order(waiting["id"], 4.0) is True + assert store.start_order(waiting["id"], 5.0) is False # started once + assert store.orders() == [] + + +def test_the_command_line_removes_a_project_by_the_same_rules( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + from lastcall import cli + + monkeypatch.setenv("LASTCALL_STATE", str(tmp_path / "state")) + assert cli.main(["project", "add", REPO]) == 0 and cli.main(["project", "add", "ne0x86/other"]) == 0 + st = Store(tmp_path / "state" / "lastcall.db") + st.add_order("audit", REPO, None, 1.0) + st.start_run("run1", REPO, "claude", "manual") + capsys.readouterr() + assert cli.main(["project", "remove", REPO]) == 1 # a run is going on it: nothing changes + assert "A run is going on ne0x86/demo: remove it when the run ends." in capsys.readouterr().err + assert st.project(REPO) is not None and len(st.orders()) == 1 + st.finish_run("run1", "ok", "abc1234") + assert cli.main(["project", "remove", "NE0X86/DEMO"]) == 0 # whatever the case + assert "ne0x86/demo removed. Its history stays" in capsys.readouterr().out + assert st.project(REPO) is None and st.orders() == [] and [r["id"] for r in st.runs()] == ["run1"] + with pytest.raises(SystemExit): # it is not a project any more + cli.main(["project", "remove", REPO]) + assert "no project 'ne0x86/demo'" in capsys.readouterr().err + assert cli.main(["project", "list"]) == 0 + listed = capsys.readouterr().out + assert "ne0x86/other" in listed and REPO not in listed + assert cli.main(["project", "add", REPO]) == 0 # and it can be added again diff --git a/tests/test_watch.py b/tests/test_watch.py index df91024..f9220a7 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -174,6 +174,41 @@ def test_nothing_is_watched_after_its_window(store: Store) -> None: assert store.watched(NOW + (WATCH_HOURS + 1) * 3600 + 10**10) == [] +def test_a_merged_pull_request_of_a_removed_project_is_left_alone_until_it_comes_back(store: Store) -> None: + watched(store) + assert store.remove_project(REPO) + client = FakeGitHub({}) # it must not be asked anything + reverts = Reverts() + assert watch(store, cast(GitHub, client), lambda m: None, now=NOW, revert=reverts) == 0 + assert client.calls == [] and reverts.asked == [] + store.add_project(REPO) # the owner adds it again, and its settings start again: no watch + assert watch(store, cast(GitHub, client), lambda m: None, now=NOW, revert=reverts) == 0 + assert client.calls == [] + store.update_project(REPO, active=True, weight=1, language=None, max_prs=3, watch=True) + client.answers = { + ("GET", f"/repos/{REPO}/commits/merged1/check-runs?per_page=100"): runs("success"), + } + assert watch(store, cast(GitHub, client), lambda m: None, now=NOW, revert=reverts) == 0 + assert len(client.calls) == 1 # watched again, as its pull request still is under watch + + +def test_sync_goes_on_with_the_pull_requests_of_a_removed_project_but_never_merges_or_watches_them( + store: Store, +) -> None: + store.update_project(REPO, active=True, weight=1, language=None, max_prs=3, merge_on_approve=True, watch=True) + assert store.remove_project(REPO) + pull = {"state": "open", "number": 5, "head": {"sha": "h1"}, "base": {"ref": "main"}, "mergeable": True} + client = FakeGitHub({("GET", f"/repos/{REPO}/pulls/5"): pull}) # not a word about reviews or checks + assert sync(store, cast(GitHub, client), lambda m: None) == 0 + assert [(method, path) for method, path, _ in client.calls] == [("GET", f"/repos/{REPO}/pulls/5")] + assert len(store.open_prs(REPO)) == 1 + merged = {"state": "closed", "merged_at": "2026-10-27T10:00:00Z", "merge_commit_sha": "merged1"} + client.answers[("GET", f"/repos/{REPO}/pulls/5")] = merged # the owner merges it on GitHub + assert sync(store, cast(GitHub, client), lambda m: None) == 1 + assert status(store) == "merged" and store.open_prs(REPO) == [] # its story goes on + assert store.watched(NOW) == [] # and nobody watches it: its project is gone + + def test_sync_merges_what_the_owner_approved_and_watches_it(store: Store) -> None: store.update_project(REPO, active=True, weight=1, language=None, max_prs=3, merge_on_approve=True, watch=True) head = { diff --git a/tests/test_web.py b/tests/test_web.py index 7efb7ab..88cde8c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -2712,3 +2712,358 @@ def test_the_layout_of_the_add_many_form_keeps_its_buttons_in_view() -> None: assert ( "button.secondary, a.button.secondary {" in css ) # "Next: choose your projects" is a link in a button's look + + +# the state of a card is a button, and the trash button removes a project after asking + + +def head(page: str, repo: str) -> str: + """The header of a project's card: its name, its state with its "?", and its trash button.""" + return card(page, repo).split("")[0] + + +def question(page: str, number: int) -> str: + """What the trash button of the `number`-th card opens: the popover that asks before a project is removed.""" + found = re.search(rf'
]*>(.*?)
', page, re.S) + assert found, number + return found.group(1) + + +def test_the_state_of_a_card_is_a_button_in_a_small_form_that_says_what_a_press_does(github_web: Web) -> None: + web = github_web + web.store.add_project("acme/api") # on, with priority 1 + web.store.add_project("acme/web", active=False) + csrf = web.csrf() + page = web.client.get("/projects").get_data(as_text=True) + on, off = head(page, "acme/api"), head(page, "acme/web") + # a form with the CSRF field and the state that a press asks for + assert '
' in on + assert f'' in on and f'name="csrf" value="{csrf}"' in off + assert '' in on + assert '' in off + # it keeps its look and its text; its name says what a press does, and keeps the text that the chip shows + state = "active · priority\u00a0\u00d71" + assert ( + f'' in on + ) + assert ( + '' in off + ) + # its "?" (one for the page: the first card has it) comes right after it, and says what a click does + assert re.search(r'
\s*" in page and 'aria-label="Turn on bursts on acme/api (inactive)"' in page + assert press("1").headers["Location"] == "/projects#project-acme--api" + assert web.store.project("acme/api") == settings + assert "priority\u00a0\u00d72" in web.client.get("/projects").get_data(as_text=True) + # the form says the state that it wants: a page that is out of date cannot flip the project the wrong way + press("1") + assert web.store.project("acme/api") == settings + press("0") + press("0") + assert web.store.project("acme/api") == replace(settings, active=False) + press("1") + press("") # a form that says nothing wants it off + assert web.store.project("acme/api") == replace(settings, active=False) + # the Active box in Settings goes on working + form = {"csrf": csrf, "weight": "2", "language": "Spanish", "max_prs": "1", "active": "1"} + assert web.client.post("/projects/acme/api", data=form, headers=SITE).status_code == 302 + project = web.store.project("acme/api") + assert project is not None and project.active and project.weight == 2 + web.client.post("/projects/acme/api", data={k: v for k, v in form.items() if k != "active"}, headers=SITE) + project = web.store.project("acme/api") + assert project is not None and not project.active + + +def test_turning_on_a_project_that_has_no_priority_gives_it_weight_1_or_it_would_stay_off(github_web: Web) -> None: + web = github_web + web.store.add_project("acme/api") + web.store.update_project("acme/api", active=True, weight=0, language=None, max_prs=3) # `project add --weight 0` + csrf = web.csrf() + + def press(active: str) -> None: + web.client.post("/projects/acme/api/active", data={"csrf": csrf, "active": active}, headers=SITE) + + page = web.client.get("/projects").get_data(as_text=True) + assert 'aria-label="Turn on bursts on acme/api (inactive)"' in page # active, and yet off + press("0") + project = web.store.project("acme/api") + assert project is not None and (project.active, project.weight) == (False, 0) # turning off changes the flag only + press("1") + project = web.store.project("acme/api") + assert project is not None and (project.active, project.weight) == (True, 1) + page = web.client.get("/projects").get_data(as_text=True) + assert 'aria-label="Turn off bursts on acme/api (active · priority\u00a0\u00d71)"' in page + web.store.update_project("acme/api", active=False, weight=0.5, language=None, max_prs=3) + press("1") + project = web.store.project("acme/api") + assert project is not None and (project.active, project.weight) == (True, 0.5) # a priority of 0.5 stays + + +def test_the_state_and_the_trash_button_need_the_csrf_token_this_sites_origin_and_a_signed_in_owner( + tmp_path: Path, +) -> None: + web = Web(tmp_path / "state-and-trash", github=FakeGitHub()) + web.set_up() + web.store.add_project("acme/api") + web.store.add_order("audit", "acme/api", None, NOW) + before = web.store.project("acme/api") + for action in ("active", "remove"): + url, form = f"/projects/acme/api/{action}", {"active": "0"} + missing = web.client.post(url, data=form, headers=SITE) + forged = web.client.post(url, data={**form, "csrf": "forged"}, headers=SITE) + for refused in (missing, forged): + assert refused.status_code == 403 and "This form has expired" in refused.get_data(as_text=True) + elsewhere = web.client.post( + url, data={**form, "csrf": web.csrf()}, headers={"Origin": "https://evil.example"} + ) + assert elsewhere.status_code == 403 and "Form not accepted" in elsewhere.get_data(as_text=True) + stranger = web.app.test_client() + signed_out = stranger.post(url, data={**form, "csrf": web.csrf()}, headers=SITE) + assert signed_out.status_code == 302 and signed_out.headers["Location"] == "/login" + assert web.client.get(url).status_code == 405 # a link cannot do it + nothing = web.client.post(f"/projects/acme/nothing/{action}", data={**form, "csrf": web.csrf()}, headers=SITE) + assert nothing.status_code == 404 and "This page does not exist" in nothing.get_data(as_text=True) + assert web.store.project("acme/api") == before and len(web.store.orders()) == 1 # nothing happened + fresh = Web(tmp_path / "fresh") # no password yet: the owner signs up first + for action in ("active", "remove"): + sign_up = fresh.client.post(f"/projects/acme/api/{action}", data={"csrf": "x"}, headers=SITE) + assert sign_up.status_code == 302 and sign_up.headers["Location"] == "/setup" + + +def test_the_state_and_the_trash_button_have_a_name_in_english_and_in_spanish(github_web: Web) -> None: + web = github_web + web.store.add_project("acme/api") + web.store.add_project("acme/web", active=False) + english = web.client.get("/projects").get_data(as_text=True) + spanish = web.client.get("/projects", headers={"Accept-Language": "es"}).get_data(as_text=True) + names = { + "en": ( + "Turn off bursts on acme/api (active · priority\u00a0\u00d71)", + "Turn on bursts on acme/web (inactive)", + "Remove acme/api from lastcall", + "Remove acme/web from lastcall", + ), + "es": ( + "Desactivar las ráfagas en acme/api (activo · prioridad\u00a0\u00d71)", + "Activar las ráfagas en acme/web (inactivo)", + "Quitar acme/api de lastcall", + "Quitar acme/web de lastcall", + ), + } + for lang, page in (("en", english), ("es", spanish)): + for name in names[lang]: + assert f'aria-label="{name}"' in page, name + assert "Haz clic en el estado, arriba en la tarjeta, para activar o desactivar las ráfagas" in spanish + assert '

¿Quitar acme/api de lastcall?

' in spanish + assert '' in spanish and ">Cancelar" in spanish + + +def test_the_trash_button_only_opens_a_question_whose_form_holds_the_removal(github_web: Web) -> None: + web = github_web + web.store.add_project("acme/api") + web.store.add_project("acme/web") + csrf = web.csrf() + page = web.client.get("/projects").get_data(as_text=True) + # it is no submit button and it is in no form of its own: it opens its popover and removes nothing by itself + top = head(page, "acme/api") + trash = re.search( + r'', + top, + ) + assert trash and top.index("") < trash.start() + assert top.count("Remove acme/api from lastcall?' in asked + for line in ( + "lastcall stops working on this repository.", + "Its history and what lastcall learned stay. Add it again and it picks up where it left off, without " + "proposing again what you rejected.", + "Its settings (priority, language and the rest) are forgotten.", + "Nothing changes on GitHub: the repository and its pull requests stay.", + "It goes back to the list of repositories to add.", + ): + assert f"
  • {line}
  • " in asked + assert '
    ' in asked + assert f'' in asked + cancel = '" in asked # it closes the popover, and has the focus when it opens + assert '' in asked # the only button that submits + assert asked.count(" None: + web = many_web(tmp_path) + assert add_many(web, "selected", "acme/api", "acme/web").status_code == 302 + web.store.add_account("claude", "claude-code") + report = AuditReport("acme/api", "abc1234", "claude-code", "English") + finding = {"title": "T", "severity": "high", "file": "a.py", "lines": "1", "why": "w", "fix": "f"} + report.findings = [{**finding, "id": f"B{n}"} for n in (1, 2, 3)] + report.verdicts = [{"id": f"B{n}", "verdict": "valid", "decision": "pr"} for n in (1, 2, 3)] + report.deliveries = [ + {"status": "opened", "url": "https://github.com/acme/api/pull/7", "title": "Fix B1", "ids": ["B1"]}, + {"status": "opened", "url": "https://github.com/acme/api/pull/8", "title": "Fix B2", "ids": ["B2"]}, + ] + web.store.start_run("api-1", "acme/api", "claude", "burst") + web.store.finish_run("api-1", "ok", "abc1234") + web.store.record_findings("api-1", report) + web.store.record_deliveries("api-1", report) + web.store.close_pr("https://github.com/acme/api/pull/7", merged=False) # the owner rejected one + web.store.add_order("audit", "acme/api", None, NOW) + web.store.add_order("audit", "acme/web", None, NOW) + earlier = web.store.earlier("acme/api") + assert {e["state"] for e in earlier} == {"rejected", "in_pr", "pending"} + web.logs.clear() + response = web.client.post("/projects/acme/api/remove", data={"csrf": web.csrf()}, headers=SITE) + assert response.status_code == 302 and response.headers["Location"] == "/projects?done=removed&repo=acme/api" + assert web.logs == ["Web UI: project acme/api removed"] # one line + assert added(web) == {"acme/web"} and [o["repo"] for o in web.store.orders()] == ["acme/web"] + assert web.store.earlier("acme/api") == earlier and [r["id"] for r in web.store.runs()] == ["api-1"] + page = web.client.get(response.headers["Location"]).get_data(as_text=True) + assert ( + '

    acme/api removed. Its history stays: add it again from the list below ' + "at any time.

    " in page + ) + assert 'id="project-acme--api"' not in page and 'id="project-acme--web"' in page + assert 'id="pick-acme--api"' in pick_form(page) # back in the list of repositories to add + spanish = web.client.get(response.headers["Location"], headers={"Accept-Language": "es"}).get_data(as_text=True) + assert ( + "acme/api quitado. Su historial se queda: vuelve a añadirlo cuando quieras desde la lista de abajo." + in spanish + ) + for path in ("/", "/projects", "/accounts", "/settings", "/github"): # nothing breaks with the project gone + assert web.client.get(path).status_code == 200, path + dashboard = web.client.get("/").get_data(as_text=True) + assert "acme/api#8" in dashboard # its open pull request still waits for the owner + # adding it again picks up where it left off, and the judge still knows what the owner rejected + assert add_many(web, "selected", "acme/api").status_code == 302 + assert added(web) == {"acme/api", "acme/web"} and web.store.earlier("acme/api") == earlier + page = web.client.get("/projects").get_data(as_text=True) + assert "1 in review · 1 pending · 1 rejected" in card(page, "acme/api") + + +def test_a_project_with_a_run_going_is_not_removed_and_the_page_says_why(github_web: Web) -> None: + web = github_web + web.store.add_project("acme/api") + web.store.add_account("claude", "claude-code") + web.store.add_order("audit", "acme/api", None, NOW) + web.store.start_run("api-1", "acme/api", "claude", "manual") + csrf = web.csrf() + web.logs.clear() + response = web.client.post("/projects/acme/api/remove", data={"csrf": csrf}, headers=SITE) + page = response.get_data(as_text=True) + assert response.status_code == 409 and 'class="notice error" role="alert"' in page + assert "

    A run is going on acme/api right now. Remove it when the run ends.

    " in page + assert 'id="project-acme--api"' in page and 'role="status"' not in page # the Projects page, with its card + assert web.store.project("acme/api") is not None and len(web.store.orders()) == 1 and web.logs == [] + spanish = web.client.post( + "/projects/acme/api/remove", data={"csrf": csrf}, headers={**SITE, "Accept-Language": "es"} + ).get_data(as_text=True) + assert "Hay una ejecución en curso en acme/api. Quítalo cuando termine." in spanish + web.store.finish_run("api-1", "ok", None) # the run ends, and the same button removes it + assert web.client.post("/projects/acme/api/remove", data={"csrf": csrf}, headers=SITE).status_code == 302 + assert web.store.project("acme/api") is None and web.store.orders() == [] + + +@pytest.mark.parametrize( + "repo", ["", "x", "acme/api ", "../etc/passwd", "acme/api/extra", "a" * 300, "https://e.x/y"] +) +def test_a_removal_notice_names_only_what_looks_like_a_repository(github_web: Web, repo: str) -> None: + page = github_web.client.get("/projects", query_string={"done": "removed", "repo": repo}).get_data(as_text=True) + assert 'role="status"' not in page # no notice, and no text that anyone wrote in the address + + +def test_a_removed_project_keeps_its_container_and_source_choices_and_settings_never_erases_them( + tmp_path: Path, +) -> None: + from lastcall import logs, loki + + listed = [{"name": "shop-web-1", "image": "ghcr.io/acme/shop:1.2", "state": "running", "compose_project": "shop"}] + fake = FakeLoki([{"container": "shop-web-1", "compose_project": "shop", "host": "vps-1"}]) + web = Web(tmp_path, containers=lambda: listed, loki_fetch=fake) + web.set_up() + loki.Loki("http://loki.example.com:3100").save(web.secrets) + web.store.add_project("acme/shop") + web.store.add_project("acme/blog") + logs.choose(web.store, {"shop-web-1": "acme/shop"}) + web.store.set_setting(loki.LABEL, "container") + loki.choose(web.store, {"shop-web-1": "acme/shop"}) + csrf = web.csrf() + assert web.client.post("/projects/acme/shop/remove", data={"csrf": csrf}, headers=SITE).status_code == 302 + page = web.client.get("/settings").get_data(as_text=True) + note = "It was chosen for acme/shop, which is not a project now: add acme/shop again and it is read again." + assert page.count(note) == 2 # a container and a source + assert '') == 2 + assert "is not a project now" not in page + assert page.count('') == 2 + assert logs.names_for("acme/shop", listed, logs.chosen(web.store)) == ["shop-web-1"] + + +def test_without_popover_support_neither_the_question_nor_the_trash_button_shows() -> None: + css = (Path(__file__).parent.parent / "src/lastcall/web/static/style.css").read_text(encoding="utf-8") + fallbacks = re.findall(r"@supports not selector\(:popover-open\) \{(.*?)\n\}", css, re.S) + mine = next(block for block in fallbacks if ".bubble.confirm" in block) + assert ".trash, .bubble.confirm { display: none; }" in mine # never a Remove button one click away in a card + # an author's display on a popover overrides the browser's, and would show the closed question + assert not re.search(r"\.bubble\.confirm\s*\{[^}]*\bdisplay:", css.replace(mine, ""))