Commit c0b4329d authored by xuwang's avatar xuwang
Browse files

feat(agentic): bot-token setup, tmux launcher, reviewer claim, issue-close



- scripts/agent-bots.sh + `make agentic-bots`: provision/list/revoke per-role bot
  identities (project access tokens, ~1yr expiry) so review/merge run as a distinct
  GitLab user than the MR author — needed even for a solo operator.
- scripts/agent-tmux.sh + `make agents-multi[-stop]`: supervised multi-identity
  launch — a tmux pane per looped role, each in its own git worktree (isolates the
  resolver's branch switches) and its own bot token.
- gl-flow.sh: `issue-close` (resolver now answers AND closes howto/question issues),
  `mr-assign-reviewer` + reviewers in mr-get (reviewer claim so two reviewers don't
  double-review the same MR).
- resolver: stage only changed paths (never `git add -A`); close answered how-tos.
  reviewer: claim-before-review.
- runbook: single-identity operating mode, per-role bots, agents-multi, config table.

Changelog: added
Co-Authored-By: default avatarClaude Opus 4.8 <noreply@anthropic.com>
parent a7f2d961
Loading
Loading
Loading
Loading
+9 −2
Original line number Diff line number Diff line
@@ -23,8 +23,15 @@ For each, **adopt it into the workflow**:
  `scripts/gl-flow.sh issue-relabel <iid> --add "kind::<x>,status::ready" --remove "status::triage"`
  (the `--remove` is a no-op when there was no status label — safe for untracked issues).
For `kind::howto` / `kind::question` that need **no code change**: answer from the
framework + the working examples (see "Answering questions" below), post the answer
with `issue-note`, then `issue-relabel <iid> --add "status::merged" --remove "status::ready"` (no MR).
framework + the working examples (see "Answering questions" below), then **resolve
and close it** (no MR, no branch):
```
scripts/gl-flow.sh issue-note    <iid> "<the answer, with cited paths/links>"
scripts/gl-flow.sh issue-relabel <iid> --add "status::merged" --remove "status::ready"
scripts/gl-flow.sh issue-close   <iid>
```
`status::merged` here just means "resolved"; `issue-close` actually closes the
GitLab issue (there's no MR to auto-close it).

### 2. Claim ONE issue (the lock)
`scripts/gl-flow.sh issue-list --labels "status::ready" --unassigned`.
+8 −0
Original line number Diff line number Diff line
@@ -18,6 +18,14 @@ any other. Pick the lowest `iid` across both lists.
Run `scripts/gl-flow.sh whoami`; if you are the MR `author`, skip it (you can't
review your own) and pick the next. If none remain, stop.

### 1b. Claim the MR (so two reviewers don't both review it)
`scripts/gl-flow.sh mr-get <iid>` and look at `reviewers`:
- If it already lists a reviewer **other than you**, someone (another reviewer or a
  human) has it — **skip** to the next candidate.
- Otherwise claim it: `scripts/gl-flow.sh mr-assign-reviewer <iid> --me`, then
  **re-read** `mr-get <iid>` and confirm `reviewers` is **you**. If it isn't
  (another reviewer won the race), skip. Only proceed once you own the claim.

### 2. Read the change
- `scripts/gl-flow.sh mr-get <iid>` for branch + context.
- `git fetch origin && git --no-pager diff origin/main...origin/<source_branch>`.
+12 −0
Original line number Diff line number Diff line
@@ -45,6 +45,18 @@ agentic-tick: ## run one headless workflow tick. make agentic-tick ROLE=<resolve
	fi
	@scripts/agent-tick.sh $(ROLE)

.PHONY: agents-multi
agents-multi: ## launch looped roles in tmux — a pane + worktree + bot identity per role
	@scripts/agent-tmux.sh

.PHONY: agents-multi-stop
agents-multi-stop: ## stop the tmux session and remove the per-role worktrees
	@scripts/agent-tmux.sh stop

.PHONY: agentic-bots
agentic-bots: ## manage per-role bot identities. make agentic-bots ARGS="provision-all|list|revoke-all|provision <role>|revoke <role>"
	@scripts/agent-bots.sh $(ARGS)

# Labels self-heal: both `make agents` and each headless tick run gl-flow.sh
# labels-ensure. For a manual force re-create / re-sync use:
#   scripts/gl-flow.sh labels-init
+75 −13
Original line number Diff line number Diff line
@@ -66,10 +66,12 @@ agents will respect it on their next tick.
GitLab scoped labels (`status::*`, `review::*`) are **mutually exclusive within a
scope** — adding `status::in-review` auto-removes `status::in-progress`. So the
current label *is* the baton: each role only acts on items in its own inbound
state, and within a role the claim is **assign-then-reread** (assign self, flip
the label, re-GET to confirm ownership). Run one instance per role and there is
zero contention; run several resolvers and the assignee re-read still prevents
double-work.
state, and within a role the claim is **assign-then-reread** — the resolver claims
an issue via its assignee, the reviewer claims an MR via its **reviewer** field
(`mr-assign-reviewer`), each re-reading to confirm ownership before working. Run
one instance per role and there is zero contention; run several (e.g. two
operators) and the claim re-read prevents double-work. Note last-writer-wins
leaves a small simultaneous-claim window — stagger loop intervals to avoid it.

| Scope       | Values                                                              | Owner who sets it |
|-------------|--------------------------------------------------------------------|-------------------|
@@ -87,14 +89,57 @@ This is deliberately distinct from a consumer project's `GITLAB_TOKEN` — under
consumer repo those two tokens differ. The PAT needs `api` + `write_repository`
scope. With `~/.otica-token` in place, nothing to export — it just works.

> **Caveat of a single identity:** GitLab forbids a user from approving and (often)
> merging their own MR. With one token, the reviewer's `mr-approve` and the integrator's
> merge may be rejected or only cosmetic. If your project has approval rules /
> protected-branch merge restrictions, give each role its own bot PAT and point
> `OTICA_SERVER_TOKEN_FILE` at it per session:
> ```bash
> export OTICA_SERVER_TOKEN_FILE=~/.otica-tokens/reviewer.token   # overrides the default
> ```
#### Single-identity operating mode (everything on `~/.otica-token`)
The simplest setup: run reporter, resolver, reviewer, and integrator all as **you**.
Because it's one identity, you can run them from **one terminal** (`make agents`,
agent view) or cron — no bots, nothing to provision. Just ensure no per-role token
files exist so every role falls back to `~/.otica-token`:
```bash
rm -f ~/.otica-tokens/{resolver,reviewer,integrator,reporter}.token
make agents     # then dispatch /loop /agent-{resolver,reviewer,integrator}
```

What it handles — GitLab only blocks you from approving/merging **your own** MRs,
not other people's:

| Submitted by | Issue → **resolver** | MR opened directly → **reviewer + integrator** |
|--------------|----------------------|-----------------------------------------------|
| Someone else | ✅ triage, fix, open MR | ✅ review, approve, **merge** (you're not the author) |
| You          | ✅ triage, fix, open MR | ❌ **self** — reviewer/integrator skip their own author |

So single-identity gives you: **every issue fixed into an MR**, and **every MR
others open reviewed and merged automatically.** The catch: anything **you**
author can't be self-reviewed/merged — and the resolver's fix MRs are authored by
*you*, so they (and any MR you open yourself) wait at `review::pending` for a
non-you approver. To also auto-complete those, add the two bots below.

#### Per-role bot identities (needed to merge your own / resolver-authored MRs)
**GitLab forbids approving or merging your own MR**, so the reviewer/integrator
*must* be a different GitLab user than the resolver that authored the MR — this is
true **even when one person runs every role**. The bots are still all yours; each
role's loop just reads its own token file. `scripts/agent-bots.sh` provisions them
as **project access tokens** (one bot user per role) into `~/.otica-tokens/<role>.token`,
which `agent-tick.sh` picks up automatically:

```bash
make agentic-bots ARGS="provision-all"   # 4 bots: reporter/resolver/reviewer (Developer), integrator (Maintainer)
make agentic-bots ARGS="list"            # show them
make agentic-bots ARGS="revoke-all"      # revoke + delete local files
```
Creation uses your admin `~/.otica-token` (Owner/Maintainer, `api` scope); bots
are long-lived (~365 days, the usual GitLab cap; `OTICA_BOT_EXPIRES` to change) —
re-run `provision-all` to rotate before they lapse.

**Solo operator — what you actually need:** the **reporter and resolver can run as
you** (`~/.otica-token`), and you only need bots for **reviewer + integrator** so
they differ from the author:
```bash
make agentic-bots ARGS="provision reviewer"
make agentic-bots ARGS="provision integrator"
```
Without any bots, reporter + resolver run fully autonomously and review/merge
simply **hold** for a human (the reviewer/integrator ticks no-op on their own MRs,
as designed). Provision the two bots whenever you want the back half automated.

### 2. Create the labels — automatic
Nothing to do: both deployment modes self-heal. `make agents` and every headless
@@ -218,6 +263,21 @@ With distinct identities, confirm each pane is a **different** GitLab user befor
relying on approvals — run `scripts/gl-flow.sh whoami` in each and check the
usernames differ (reviewer/integrator must not be the resolver who authored the MR).

### Supervised mode, one command (tmux) — `make agents-multi`
Automates the multi-pane layout: a tmux session with a pane per looped role
(resolver/reviewer/integrator), **each in its own git worktree** (so the resolver's
branch switches never disrupt the others) and **its own bot identity** (auto-picked
from `~/.otica-tokens/<role>.token`, falling back to `~/.otica-token`). Each pane
runs a tick loop and prints its log tail, so you can watch all roles at once.
```bash
make agents-multi          # launch; tmux attach -t otica-agents to watch
make agents-multi-stop     # kill the session + remove the worktrees
```
It warns if `reviewer`/`integrator` bots aren't provisioned (without them those
roles can't act on your own MRs). Worktrees live in `~/.otica-agents/` and persist
until `agents-multi-stop`. This is the easiest way to run the full multi-identity
workflow locally; for true 24/7 use cron below.

### Unattended deployment (OS cron, headless)
`scripts/agent-tick.sh <role>` runs **one** headless tick (`claude -p
"/agent-<role>"` with the per-role settings/model) and exits — run one manually
@@ -353,7 +413,7 @@ calls. `scripts/gl-flow.sh help` lists commands:
```
whoami · maintainer · check · labels-init · labels-ensure
issue-create · issue-list · issue-get · issue-relabel · issue-assign · issue-note
mr-create · mr-list · mr-get · mr-relabel · mr-note · mr-approve · mr-approvals · mr-merge
mr-create · mr-list · mr-get · mr-assign-reviewer · mr-relabel · mr-note · mr-approve · mr-approvals · mr-merge
```

`gl-flow.sh check` is a **pre-flight**: it verifies the token authenticates, can
@@ -373,3 +433,5 @@ Maintainer). `make agents` runs it before opening agent view, and every headless
- `scripts/gl-flow.sh` — GitLab REST dispatcher (incl. `check` pre-flight).
- `scripts/agent-view.sh` — supervised launch (`make agents`): pre-flight + open agent view.
- `scripts/agent-tick.sh` — headless one-tick cron runner (per-role identity, settings, model, lock, logs).
- `scripts/agent-tmux.sh``make agents-multi`: tmux pane + worktree + bot per looped role.
- `scripts/agent-bots.sh` — provision/list/revoke per-role bot identities (`make agentic-bots`).

scripts/agent-bots.sh

0 → 100755
+104 −0
Original line number Diff line number Diff line
#!/bin/bash -e
###############################################################################
# agent-bots.sh — provision / list / revoke per-role GitLab bot identities
# (project access tokens) for the agentic workflow.
#
# Why, even if ONE person runs every role: GitLab forbids approving or merging
# your own MR. So the reviewer/integrator must be a DIFFERENT GitLab user than the
# resolver that authored the MR. These bots are all controlled by you; each role's
# loop just points OTICA_SERVER_TOKEN_FILE at ~/.otica-tokens/<role>.token — which
# agent-tick.sh already does automatically.
#
# Minimum viable for a solo operator: provision `reviewer` + `integrator` (so they
# differ from the author). You can author as yourself (reporter/resolver on
# ~/.otica-token) and let the two bots handle approve + merge. Or provision all 4.
#
# Creation uses YOUR admin token (OTICA_SERVER_TOKEN[_FILE], default ~/.otica-token)
# — it must be Owner/Maintainer with `api` scope, and the instance must allow
# project access tokens.
#
# Usage:
#   agent-bots.sh provision <role> [access_level]   # one role
#   agent-bots.sh provision-all                      # all four
#   agent-bots.sh list                               # show otica-* bot tokens
#   agent-bots.sh revoke <role> | revoke-all         # revoke + remove local file
#
# Tunables: OTICA_TOKENS_DIR (~/.otica-tokens), OTICA_BOT_EXPIRES (YYYY-MM-DD,
# default +365 days), GITLAB_REPO (else auto from origin).
###############################################################################
THIS_DIR=$(cd "$(dirname "$0")" && pwd)
source "$THIS_DIR/otica-server-token.sh"   # YOUR admin token -> GITLAB_TOKEN
source "$THIS_DIR/gitlab.sh"               # GITLAB_API, get_project_id

default_repo() {
    local url; url="$(git remote get-url origin 2>/dev/null)" || return 1
    url="${url%.git}"
    case "$url" in
        *://*) echo "${url#*://*/}";;
        *:*)   echo "${url#*:}";;
        *)     echo "$url";;
    esac
}
REPO="${GITLAB_REPO:-$(default_repo || true)}"; REPO="${REPO:-iac/cloud-framework}"
PID="$(get_project_id "$REPO")"
TOKENS_DIR="${OTICA_TOKENS_DIR:-$HOME/.otica-tokens}"
# Long-lived by default (~1 year). GitLab requires an expiry and usually caps it at
# 365 days; if your instance caps lower, creation errors and you can pass a nearer
# OTICA_BOT_EXPIRES=YYYY-MM-DD. Re-run provision-all to rotate before expiry.
EXPIRES="${OTICA_BOT_EXPIRES:-$(date -v+365d +%F 2>/dev/null || date -d '+365 days' +%F)}"
hdr=(--header "PRIVATE-TOKEN: ${GITLAB_TOKEN}")
api="${GITLAB_API}/projects/${PID}/access_tokens"
ROLES="reporter resolver reviewer integrator"

# Access level per role: only the integrator merges to protected main (Maintainer);
# the rest push branches / approve / label / file issues (Developer).
role_level() { case "$1" in integrator) echo 40;; reporter|resolver|reviewer) echo 30;; *) echo "";; esac; }

cmd_provision() {
    local role="$1" lvl="${2:-$(role_level "$1")}"
    [ -n "$role" ] && [ -n "$lvl" ] || { echo "usage: provision <reporter|resolver|reviewer|integrator> [access_level]" >&2; exit 2; }
    mkdir -p "$TOKENS_DIR"; chmod 700 "$TOKENS_DIR"
    local resp tok user
    resp=$(curl -s "${hdr[@]}" -X POST "$api" \
        --data "name=otica-$role" \
        --data "scopes[]=api" --data "scopes[]=write_repository" \
        --data "access_level=$lvl" --data "expires_at=$EXPIRES")
    tok=$(echo "$resp" | jq -r '.token // empty')
    if [ -z "$tok" ]; then
        echo "FAIL provisioning $role: $(echo "$resp" | jq -r '.message // .error // .')" >&2; exit 1
    fi
    printf '%s' "$tok" > "$TOKENS_DIR/$role.token"; chmod 600 "$TOKENS_DIR/$role.token"
    user=$(curl -s --header "PRIVATE-TOKEN: $tok" "${GITLAB_API}/user" | jq -r '.username // "?"')
    echo "  $role -> $TOKENS_DIR/$role.token  (user: $user, access: $lvl, expires: $EXPIRES)"
}

cmd_provision_all() { for r in $ROLES; do cmd_provision "$r"; done; }

cmd_list() {
    echo "otica-* bot tokens on ${REPO}:"
    curl -s "${hdr[@]}" "$api" \
        | jq -r '.[] | select(.name|startswith("otica-")) | "  \(.name)  id=\(.id) access=\(.access_level) active=\(.active) expires=\(.expires_at)"'
}

_token_id() { curl -s "${hdr[@]}" "$api" | jq -r --arg n "otica-$1" '.[] | select(.name==$n and .active==true) | .id' | head -1; }

cmd_revoke() {
    local role="$1" id; [ -n "$role" ] || { echo "usage: revoke <role>" >&2; exit 2; }
    id=$(_token_id "$role")
    [ -n "$id" ] && curl -s "${hdr[@]}" -X DELETE "$api/$id" -o /dev/null -w "  revoked otica-$role (id=$id): HTTP %{http_code}\n"
    [ -f "$TOKENS_DIR/$role.token" ] && { rm -f "$TOKENS_DIR/$role.token"; echo "  removed $TOKENS_DIR/$role.token"; } || true
    [ -z "$id" ] && echo "  (no active otica-$role token on the server)" || true
}

cmd_revoke_all() { for r in $ROLES; do cmd_revoke "$r"; done; }

cmd="${1:-help}"; shift || true
case "$cmd" in
    provision)     cmd_provision "$@";;
    provision-all) cmd_provision_all "$@";;
    list)          cmd_list "$@";;
    revoke)        cmd_revoke "$@";;
    revoke-all)    cmd_revoke_all "$@";;
    *) echo "usage: agent-bots.sh <provision <role> | provision-all | list | revoke <role> | revoke-all>";
       echo "roles: $ROLES   (integrator=Maintainer, others=Developer)";;
esac
Loading