Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions apps/cli/src/__tests__/gitlab-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ afterEach(() => {
});

describe("gitlab entity attention commands", () => {
it("registers only follow, following, and unfollow without GitHub-only flags", async () => {
it("registers follow, following, and unfollow with pair-aware rebind", async () => {
const { registerGitlabCommands } = await import("../commands/gitlab/index.js");
const root = new Command();
registerGitlabCommands(root);

const gitlab = command(root, "gitlab");
expect(gitlab.commands.map((entry) => entry.name()).sort()).toEqual(["follow", "following", "unfollow"]);
expect(command(gitlab, "follow").options.map((option) => option.long)).toEqual(["--chat", "--agent"]);
expect(command(gitlab, "follow").options.map((option) => option.long)).toEqual(["--chat", "--agent", "--rebind"]);
expect(gitlab.commands.some((entry) => entry.name() === "context-review")).toBe(false);
});

Expand Down Expand Up @@ -75,15 +75,18 @@ describe("gitlab entity attention commands", () => {
const followRoot = new Command();
registerGitlabFollowCommand(followRoot);
await command(followRoot, "follow").parseAsync([entity.entityUrl, "--agent", "builder"], { from: "user" });
expect(sdk.followGitlabEntity).toHaveBeenCalledWith("chat-env", { entityUrl: entity.entityUrl });
expect(sdk.followGitlabEntity).toHaveBeenCalledWith("chat-env", { entityUrl: entity.entityUrl, rebind: false });
expect(localAgentMocks.createSdk).toHaveBeenCalledWith("builder");
expect(String(outputMocks.success.mock.calls.at(-1)?.[0].hint)).toContain("remains pending");
expect(String(outputMocks.success.mock.calls.at(-1)?.[0].hint)).toContain("has not called GitLab");

await command(followRoot, "follow").parseAsync([entity.entityUrl, "--chat", "chat-explicit"], {
from: "user",
});
expect(sdk.followGitlabEntity).toHaveBeenLastCalledWith("chat-explicit", { entityUrl: entity.entityUrl });
expect(sdk.followGitlabEntity).toHaveBeenLastCalledWith("chat-explicit", {
entityUrl: entity.entityUrl,
rebind: false,
});
expect(String(outputMocks.success.mock.calls.at(-1)?.[0].hint)).toContain("Already pending");

const { registerGitlabFollowingCommand } = await import("../commands/gitlab/following.js");
Expand Down Expand Up @@ -112,6 +115,7 @@ describe("gitlab entity attention commands", () => {
expect(() => handleGitlabSdkError(new SdkError(404, "GitLab connection is not configured"))).toThrow(
"NO_GITLAB_CONNECTION",
);
expect(() => handleGitlabSdkError(new SdkError(409, "line lives elsewhere"))).toThrow("GITLAB_FOLLOW_CONFLICT");
const generic = new Error("server unavailable");
expect(() => handleGitlabSdkError(generic)).toThrow("server unavailable");
expect(localAgentMocks.handleSdkError).toHaveBeenLastCalledWith(generic);
Expand Down
7 changes: 7 additions & 0 deletions apps/cli/src/commands/gitlab/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export function handleGitlabSdkError(error: unknown): never {
1,
);
}
if (error.statusCode === 409) {
fail(
"GITLAB_FOLLOW_CONFLICT",
`${error.message} Default to the existing chat, or retry with --rebind to move this attention line.`,
1,
);
}
}
handleSdkError(error);
}
12 changes: 8 additions & 4 deletions apps/cli/src/commands/gitlab/follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { handleGitlabSdkError } from "./_shared.js";
type FollowOptions = {
chat?: string;
agent?: string;
rebind?: boolean;
};

export function registerGitlabFollowCommand(gitlab: Command): void {
Expand All @@ -19,18 +20,21 @@ export function registerGitlabFollowCommand(gitlab: Command): void {
)
.option("--chat <chatId>", "Target chat (default: the session's FIRST_TREE_CHAT_ID)")
.option("--agent <name>", "Agent name on the First Tree server (default: first configured on this client)")
.option("--rebind", "Move this agent's existing attention line from another chat into the target chat")
.action(async (entityUrl: string, options: FollowOptions) => {
try {
const chatId = resolveTargetChatId(options.chat);
const sdk = createSdk(options.agent);
const result = await sdk.followGitlabEntity(chatId, { entityUrl });
const result = await sdk.followGitlabEntity(chatId, { entityUrl, rebind: options.rebind ?? false });
const hint =
result.status === "created"
? "Declaration recorded locally. It remains pending until the next matching valid GitLab webhook; " +
"First Tree has not called GitLab or verified that the entity exists."
: result.entity.status === "pending"
? "Already pending in this chat — idempotent success. Wait for a matching valid webhook; do not retry."
: "Already active in this chat — idempotent success, do not retry.";
: result.status === "rebound"
? "Attention line moved into this chat. A pending line activates on the next matching valid webhook."
: result.entity.status === "pending"
? "Already pending in this chat — idempotent success. Wait for a matching valid webhook; do not retry."
: "Already active in this chat — idempotent success, do not retry.";
success({
...result,
hint:
Expand Down
20 changes: 12 additions & 8 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,26 +615,29 @@ GitLab API, validate an entity live, or use the current `glab` account.

```
first-tree gitlab
├── follow <issue-or-mr-url> [--chat <chatId>] [--agent <name>]
├── follow <issue-or-mr-url> [--chat <chatId>] [--agent <name>] [--rebind]
├── following [--chat <chatId>] [--agent <name>]
└── unfollow <issue-or-mr-url> [--chat <chatId>] [--agent <name>]
```

```bash
# Inside an agent session the chat is inferred from FIRST_TREE_CHAT_ID
first-tree gitlab follow https://gitlab.example/acme/api/-/issues/42
first-tree gitlab follow https://gitlab.example/acme/api/-/merge_requests/42
first-tree gitlab follow https://gitlab.example/acme/api/-/merge_requests/42 --rebind
first-tree gitlab following
first-tree gitlab unfollow https://gitlab.example/acme/api/-/issues/42
```

`follow` accepts only a full Issue or Merge Request URL from the Team's one
configured GitLab instance. It records a pending declaration without provider
egress; the next matching valid webhook supplies numeric project identity and
activates the declaration. Repeating a follow in the same chat is idempotent,
and the same entity may be followed independently by multiple chats. A pending
activates the declaration. Repeating a follow by the same human/delegate pair
in the same chat is idempotent. The same pair cannot follow the entity from a
second chat: the command reports the existing room, and `--rebind` atomically
moves that line when the task context intentionally changes. Different pairs
remain independent. A pending
declaration reports `state: null` because First Tree has not verified provider
state. There is no GitHub-style `--rebind` or `context-review` command.
state. There is no GitLab `context-review` command.

`following` returns every active binding in the chat as a stable public
projection, including automatic reviewer / assignee / mention routing and
Expand Down Expand Up @@ -1272,9 +1275,10 @@ Old per-route rate-limit env vars are no longer read.
| `FIRST_TREE_ARCHIVE_SWEEP_INTERVAL_SECONDS` | `300` (set `0` to disable) |
| `FIRST_TREE_ARCHIVE_MAPPED_IDLE_SECONDS` | `3600` |

`FIRST_TREE_ARCHIVE_MAPPED_IDLE_SECONDS` is the GitHub-source archive idle
threshold. Mapped chats also require all bound entities to be closed/merged;
source=github chats with no mapping use the same idle threshold.
`FIRST_TREE_ARCHIVE_MAPPED_IDLE_SECONDS` is the SCM-source archive idle
threshold. Mapped GitHub/GitLab chats also require all bound entities to be
closed/merged; provider-owned chats with no mapping use the same idle
threshold.

**Observability:**

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/__tests__/agent-briefing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ describe("buildAgentBriefing — asking humans, GitHub, and CLI overview", () =>
expect(gitlab).toContain("first-tree gitlab follow <url>");
expect(gitlab).toContain("pending and inbound-only");
expect(gitlab).toContain("without calling GitLab");
expect(gitlab).toContain("gitlab follow <url> --rebind");
expect(gitlab).toContain("does not invalidate an entity that was already created");
expect(gitlab).toContain("only when the human explicitly asks for personal-account notifications");
expect(gitlab).toContain("only when the human explicitly asks this chat");
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/runtime/templates/agent-briefing.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ For upstream-dependency follows, `409` / `--rebind`, and full flags, see
## GitLab Entity Attention

- **Default: follow what you create.** After creating a GitLab Issue or Merge Request for this task, wire its inbound webhook activity into the current chat with `<%- bin %> gitlab follow <url>`. Skip only when the entity is clearly unrelated to this chat or the human explicitly does not want it tracked. A follow failure does not invalidate an entity that was already created; report only the First Tree chat attention gap.
- GitLab follow is pending and inbound-only: First Tree records the declaration without calling GitLab, then activates it when the next matching valid webhook arrives. It is independent of the current account's native GitLab notifications. Use native `glab` subscribe/unsubscribe only when the human explicitly asks for personal-account notifications.
- GitLab follow is pending and inbound-only: First Tree records the declaration without calling GitLab, then activates it when the next matching valid webhook arrives. One human/delegate attention line lives in one chat; if it is already followed elsewhere, work there by default or use `<%- bin %> gitlab follow <url> --rebind` only when the task context should move. It is independent of the current account's native GitLab notifications. Use native `glab` subscribe/unsubscribe only when the human explicitly asks for personal-account notifications.
- Run `<%- bin %> gitlab unfollow <url>` only when the human explicitly asks this chat to stop tracking the entity, never automatically when an Issue closes, an MR merges, or the task finishes. Unfollow removes every automatic or manual binding for that entity in the current chat; a later explicit reviewer, assignee, or mention event may create a new route. Use the current URL shown by `<%- bin %> gitlab following` after a project rename. Full flags and pending/active output live under `<%- bin %> gitlab follow --help`, `<%- bin %> gitlab following --help`, and `<%- bin %> gitlab unfollow --help`.

## Asking Humans
Expand Down
31 changes: 25 additions & 6 deletions packages/qa/cases/cross-surface/gitlab-webhook-basic-card.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ completed until a customer-side source mapping exists in a later phase.
- Before that matching webhook, run `first-tree gitlab following` in both chats and confirm the stable public projection
reports `pending` without connection, organization, mapping, actor, normalized-path, or timestamp fields. After the
webhook, confirm both independent chat declarations report `active`, the latest URL/title/state projection is visible,
and First Tree made no outbound request to GitLab during follow or list.
and First Tree made no outbound request to GitLab during follow or list. Deliver an ordinary Note after activation and
confirm each explicit human/delegate line produces a notifying Inbox entry and wakes its delegate rather than only
appending a silent card.
- Attempt to follow one human/delegate line from a second chat. Confirm the non-rebind request reports the existing chat
without duplicating the line, then use `--rebind` and confirm the line moves atomically while other pairs remain in
their original chats.
- Rename the project, declare the new path before its next event, and verify the pending declaration collapses into the
existing numeric mapping rather than duplicating or failing it. Confirm two projects with the same entity type and IID
can coexist in one chat because GitLab IIDs are project-scoped.
Expand All @@ -50,8 +55,9 @@ completed until a customer-side source mapping exists in a later phase.
the delegate. The card may say the review request was routed/source unavailable; it must not claim source review
running/completed.
- Deliver ordinary assignee and explicit Note `@mention` targets and confirm they route and wake without being labelled as
code review. Verify exact case-insensitive usernames only; display-name, email, similar-name, inactive link, inactive
member, missing delegate, and ineligible delegate cases must skip independently and emit only bounded operational logs.
code review. Also exercise a new or changed MR/Issue description containing an exact `@mention`. Verify exact
case-insensitive usernames only; display-name, email, similar-name, inactive link, inactive member, missing delegate,
and ineligible delegate cases must skip independently and emit only bounded operational logs.
- Verify reviewer mode starts unknown. For `User-Agent: GitLab/15.3.0` or newer, a missing `reviewers` field means no
reviewer and never falls back to assignee; a valid top-level `reviewers: []` proves modern capability without a target.
For an older declared version, use assignee as reviewer only when `reviewers` is absent. If the version is unavailable or
Expand All @@ -71,6 +77,17 @@ completed until a customer-side source mapping exists in a later phase.
at-least-once delivery and may still reach the old delegate once, matching GitHub Inbox behavior.
- Redeliver with the same stable provider delivery id and confirm whole-request deduplication. Deliver without a stable id
and confirm no claim is stored; repeated cards are an accepted weak-reliability outcome.
- Exercise MR code updates, draft/ready transitions, terminal MR/Issue observations, and metadata-only changes. Confirm
code updates normalize to actionable synchronized cards, ready transitions can route current reviewers, terminal and
metadata-only observations refresh the public title/state projection without inventing an extra card, and label-only
Issue updates remain silent.
- Confirm provider-created chat topics use `MR <project>!<iid>`, `MR Review <project>!<iid>`, or
`Issue <project>#<iid>` and refresh after a valid title/project-path observation only while the topic still matches the
automatic grammar. A manually renamed topic must remain untouched. In the Web header, confirm the external-link button
opens the typed metadata URL in a new tab and identifies GitLab; a manual/follow-only chat must not guess an anchor URL.
- With all mapped entities terminal, no unread state, no open request, no working/blocked runtime session, and the idle
threshold elapsed, confirm the SCM sweeper archives the provider-owned chat. Any one guard must prevent archive, and a
later delivered event must revive an archived chat.
- Regenerate the bearer and confirm the old URL stops authenticating immediately while the replacement URL is returned
only once. Confirm regeneration also clears the learned GitLab version/reviewer mode so the new bearer learns afresh.
Replace the Team's single GitLab connection and confirm the old connection, bearer, identity links, and entity/chat
Expand All @@ -91,9 +108,11 @@ completed until a customer-side source mapping exists in a later phase.
`PASS`: endpoint identity alone selects the Team and authority, secrets remain redacted, Cloud performs no GitLab egress,
the Team has at most one GitLab connection, supported entity events reach only existing followed chats as basic cards,
stable ids are connection-scoped, exact active identities route to the current eligible delegate with one card per chat and
a generic at-least-once wake, agent follow/list/unfollow expose only the stable URL contract, chat-scoped unfollow removes
automatic and manual bindings while later directed events may route afresh, anomalies fail closed, and no source-review
state is claimed.
a generic at-least-once wake, explicit lines wake on ordinary subscribed events, pair-aware rebind moves rather than
duplicates a line, agent follow/list/unfollow expose only the stable URL contract, chat-scoped unfollow removes automatic
and manual bindings while later directed events may route afresh, lifecycle-only observations safely refresh public
state/topic, terminal chats archive only after all safety guards pass, anomalies fail closed, and no source-review state
is claimed.

`FAIL`: cross-Team resolution, secret exposure, any outbound request to GitLab, incorrect pending binding, duplicate cards
for one chat within one pass, a fuzzy personnel match, new personnel routing after its authority was removed, reviewer
Expand Down
Loading
Loading