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
108 changes: 108 additions & 0 deletions packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
id: cloud-onboarding-analytics-funnel
description: Validate campaign attribution, OAuth continuity, production GA delivery, and the matching server-side onboarding trail without leaking internal identifiers to GA.
areas: [cross-surface]
surfaces: [server, web]
---

# Cloud Onboarding Analytics Funnel

## Goal

Confirm that one tagged acquisition remains observable through the website,
OAuth, Cloud onboarding, and first chat. The production Cloud SPA must send real
GA4 requests for route changes, anonymous auth attempts/results, and the small
onboarding event vocabulary, while the server retains the authenticated funnel
context needed for diagnosis. This case owns the live browser/provider boundary
that deterministic tests cannot prove; event names, visible-step suppression,
and parameter filtering remain product-test responsibilities.

## Preconditions

- Use a throwaway production account and a disposable campaign URL. Coordinate
the low-volume production run with the analytics owner so the events can be
isolated in GA4 DebugView or Realtime without treating ordinary traffic as
evidence.
- Use a browser profile with analytics blocking disabled. Preserve redacted
network and server-log evidence; never retain OAuth state, cookies, invite
tokens, internal user IDs, or organization IDs in shared artifacts.
- Have a connected computer/runtime available so the new-user path can reach a
first chat. Also use staging or localhost for the production-gate negative
check; those environments must not contact Google Analytics.
- In GA4 property `539170414`, register the event-scoped custom dimensions used
for reporting before the run: `form_id`, `scan_attempt_id`, `variant`,
`auth_attempt_id`, `provider`, `result`, `entry_point`, `join_path`,
`account_type`, `reason_code`, `step`, `path`, `nextStep`, `reasonCode`,
`retryable`, and `outcome`. Mark `start_scan`, `sign_up`, and
`onboarding_kickoff_chat_started` as key events. Confirm downstream
campaign-export readers accept schema version 2. Standard GA traffic-source
dimensions own UTM attribution; do not duplicate them as custom dimensions.

## Operate

1. In a clean profile, enter an ordinary tagged production website URL and use
the primary Cloud CTA. Confirm the decorated `_gl` handoff is consumed and
the Cloud GA client/session retains the original UTM source, medium,
campaign, and content through OAuth and onboarding.
2. In a second clean profile, enter the tagged production-scan page, submit the
campaign form, continue through OAuth, and complete Cloud onboarding through
the first chat. Keep only the anonymous attempt IDs needed for the
reconciliation.
3. Exercise one controlled OAuth failure (for example cancel/expired state),
then retry successfully. Confirm the retry creates a new auth attempt.
4. In a separate throwaway path, reach a visible setup step, choose "finish
later", then resume from Settings and continue.
5. Exercise one safe, user-visible onboarding failure and retry, such as a
connect-token mint failure in a controlled environment or a runtime that
does not become ready before the timeout.
6. Capture the GA script/config exchange and `collect` requests, the matching
GA4 DebugView or Realtime events, and the corresponding structured
`oauth.*` / `onboarding.*` server log lines. Reconcile the campaign attempt
with the schema-v2 Campaign export and its action-conversion fields.
7. Repeat a route change on staging or localhost and confirm that neither the
GA script nor a `collect` request is emitted.

## Observe

- The pre-OAuth `auth_started` and post-OAuth `auth_result` carry the same
anonymous `auth_attempt_id`, provider, and low-cardinality entry point. The
failed attempt has a fixed `reason_code`; the successful new-account result
has `account_type=created` and emits `sign_up` exactly once at account
creation, not again when onboarding completes.
- Production emits one sanitized `page_view` per SPA route transition and GA
receives `onboarding_step_viewed`, `onboarding_step_completed`,
`onboarding_step_failed`, `onboarding_step_paused`, and `onboarding_resumed`
at the exercised actions.
- Auto-skipped implementation states do not appear as viewed pages. Step events
carry only low-cardinality context such as `step`, `path`, `nextStep`,
`outcome`, `reasonCode`, and `retryable`; GA requests contain no user, member,
organization, agent, chat, repo, raw error, URL query, or hash credentials.
- The server trail contains the same milestones with the authenticated,
server-controlled user identity and the internal diagnostic context that is
intentionally excluded from GA. Correlate adjacent asynchronous posts by
timestamp; their server completion order is not the browser event order.
- The successful path reaches the first-chat completion outcome; pause and
resume remain distinguishable instead of looking like silent abandonment.
- The ordinary CTA path remains one GA user/session with its original standard
traffic-source dimensions before and after the cross-domain and OAuth hops.
- The Website scan attempt, Cloud auth attempt/result, first chat, Campaign
export row, and action conversion reconcile without relying on a raw user ID.
- Staging and localhost produce no GA traffic.

## Expected Result

`PASS` when the browser and GA provider view agree on acquisition, auth, the
exercised visible path, and classified failure; the server/export trail contains
its diagnostic counterparts; registered dimensions are queryable; and there is
no duplicate transition event or identifier leak.
`FAIL` for missing production collection, an incorrect event order, a visible
step omission, a skipped-step false positive, sensitive parameters, or any
staging/local GA traffic. `BLOCKED` when production analytics access, a
throwaway account/runtime, or an unblocked browser profile is unavailable.

## Evidence

Keep redacted request names/statuses and parameter keys, GA event timestamps,
the corresponding structured server-event order, and the deployed commit.
Never retain cookies, OAuth material, invite tokens, raw campaign linker values,
or authenticated internal identifiers.
Loading
Loading