Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ca17283
feat(experiments): manage draft and running lifecycle
joshdholtz Sep 25, 2026
5be11fa
fix(experiments): review full scope before starting
joshdholtz Sep 25, 2026
d5c7c9d
fix(experiments): review running changes and resume
joshdholtz Sep 25, 2026
f448a19
fix(experiments): render lifecycle previews as readable fields
joshdholtz Sep 25, 2026
5c87fee
fix(experiments): use detail view for lifecycle results
joshdholtz Sep 25, 2026
799a673
feat(experiments): duplicate drafts and describe config in schema
joshdholtz Sep 25, 2026
3caa200
test(experiments): exercise mutation guards and requests
joshdholtz Sep 25, 2026
0fa2028
fix(experiments): validate enrollment on update
joshdholtz Sep 28, 2026
17890ca
fix(experiments): review and confirm pause before changing enrollment
joshdholtz Sep 28, 2026
6b56826
fix(experiments): guard draft deletion and sanitize conflict notices
joshdholtz Sep 28, 2026
deda2e1
fix(experiments): enforce update limits after start
joshdholtz Sep 29, 2026
2dbee9a
chore(experiments): integrate read review fixes into lifecycle
joshdholtz Oct 6, 2026
4550e5a
fix(experiments): document audience clearing and identify delete target
joshdholtz Oct 6, 2026
734c763
fix(experiments): correct empty targeting and lifecycle previews
joshdholtz Oct 6, 2026
801a4eb
fix(schema): use standard targeting condition value types
joshdholtz Oct 6, 2026
906ab90
chore(experiments): integrate permission fallback
joshdholtz Oct 7, 2026
b772a60
fix(experiments): reject fallback placements without overrides
joshdholtz Oct 7, 2026
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
9 changes: 9 additions & 0 deletions docs/command-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,15 @@ rc paywalls delete <id> [--force] # attached/published pa
rc experiments list [--status <status>]
rc experiments show <id> # human detail view shows variants, targeting, placements, and chronological timeline
rc experiments results <id> [--platform <p>] [--country <code>] [--exposure-status exposed|not_exposed] [--currency <code>] # human totals and decision signals; unknown platforms warn before passing through
# Enrollment is 1–100 in the development API and backend validator; the dashboard guide currently says 10% minimum.
rc experiments create (--name <name> --control <offering-id> --treatment <offering-id> --enrollment <percent> | --config <json-file>)
rc experiments duplicate <id> [--name <name>] # copy configuration into a new draft; never copies results or status
rc experiments update <id> --config <json-file> # partial update; file or - for stdin
rc experiments delete <id> # draft only; confirmation/--yes
rc experiments start <id> # approval gate: confirmation/--yes; shows current configuration first
rc experiments pause <id> # pauses enrollment; configuration review + confirmation/--yes
rc experiments resume <id> # resumes enrollment; configuration review + confirmation/--yes
rc experiments stop <id> # permanent; confirmation/--yes

# Rico (AI assistant)
rc rico [message] # streaming chat window in a TTY (--plain for a line loop)
Expand Down
48 changes: 27 additions & 21 deletions docs/previews/experiments-show.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions docs/previews/experiments-start.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 57 additions & 0 deletions docs/previews/experiments-stop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions docs/specs/cli-coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,22 @@ endpoints:
# Experiments (development-status; provided by the beta overlay)
- method: GET
path: /projects/{project_id}/experiments
- method: POST
path: /projects/{project_id}/experiments
- method: GET
path: /projects/{project_id}/experiments/{experiment_id}
- method: POST
path: /projects/{project_id}/experiments/{experiment_id}
- method: DELETE
path: /projects/{project_id}/experiments/{experiment_id}
- method: POST
path: /projects/{project_id}/experiments/{experiment_id}/actions/start
- method: POST
path: /projects/{project_id}/experiments/{experiment_id}/actions/pause
- method: POST
path: /projects/{project_id}/experiments/{experiment_id}/actions/resume
- method: POST
path: /projects/{project_id}/experiments/{experiment_id}/actions/stop
- method: GET
path: /projects/{project_id}/experiments/{experiment_id}/results

Expand Down
118 changes: 118 additions & 0 deletions docs/specs/v2-beta-overlay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,16 @@ paths:
responses:
'200':
description: OK
post:
summary: Create an experiment
operationId: create-experiment
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:experiments:read_write
responses:
'200':
description: OK
/projects/{project_id}/experiments/{experiment_id}:
parameters:
- name: project_id
Expand All @@ -948,6 +958,16 @@ paths:
required: true
schema:
type: string
delete:
summary: Delete an experiment
operationId: delete-experiment
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:experiments:read_write
responses:
'200':
description: OK
get:
summary: Get an experiment
operationId: get-experiment
Expand All @@ -958,6 +978,104 @@ paths:
responses:
'200':
description: OK
post:
summary: Update an experiment
operationId: update-experiment
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:experiments:read_write
responses:
'200':
description: OK
/projects/{project_id}/experiments/{experiment_id}/actions/pause:
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: experiment_id
in: path
required: true
schema:
type: string
post:
summary: Pause an experiment
operationId: pause-experiment
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:experiments:read_write
responses:
'200':
description: OK
/projects/{project_id}/experiments/{experiment_id}/actions/resume:
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: experiment_id
in: path
required: true
schema:
type: string
post:
summary: Resume an experiment
operationId: resume-experiment
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:experiments:read_write
responses:
'200':
description: OK
/projects/{project_id}/experiments/{experiment_id}/actions/start:
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: experiment_id
in: path
required: true
schema:
type: string
post:
summary: Start an experiment
operationId: start-experiment
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:experiments:read_write
responses:
'200':
description: OK
/projects/{project_id}/experiments/{experiment_id}/actions/stop:
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: experiment_id
in: path
required: true
schema:
type: string
post:
summary: Stop an experiment
operationId: stop-experiment
x-release-status: development
x-revenuecat-release-gatekeeping: false
x-scopes:
- project_configuration:experiments:read_write
responses:
'200':
description: OK
/projects/{project_id}/experiments/{experiment_id}/results:
parameters:
- name: project_id
Expand Down
58 changes: 58 additions & 0 deletions internal/api/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,64 @@ type ExperimentResultsOptions struct {
Currency string
}

type ExperimentCreate struct {
DisplayName string `json:"display_name"`
EnrollmentPercentage int `json:"enrollment_percentage"`
OfferingAID string `json:"offering_a_id"`
OfferingBID string `json:"offering_b_id"`
OfferingCID *string `json:"offering_c_id,omitempty"`
OfferingDID *string `json:"offering_d_id,omitempty"`
TargetingConditions json.RawMessage `json:"targeting_conditions,omitempty"`
AudienceID *string `json:"audience_id,omitempty"`
Placements json.RawMessage `json:"placements,omitempty"`
Notes *string `json:"notes,omitempty"`
ExperimentType *string `json:"experiment_type,omitempty"`
PrimaryMetric *string `json:"primary_metric,omitempty"`
SecondaryMetrics []string `json:"secondary_metrics,omitempty"`
EnrollmentMode *string `json:"enrollment_mode,omitempty"`
ExperimentDurationSettings json.RawMessage `json:"experiment_duration_settings,omitempty"`
}

type ExperimentUpdate map[string]json.RawMessage

func (s *ExperimentsService) Create(ctx context.Context, projectID string, body ExperimentCreate) (*Experiment, error) {
var out Experiment
err := s.c.do(ctx, http.MethodPost, pathExperiments(projectID), body, &out)
return &out, err
}

func (s *ExperimentsService) Update(ctx context.Context, projectID, id string, body ExperimentUpdate) (*Experiment, error) {
var out Experiment
err := s.c.do(ctx, http.MethodPost, pathExperiment(projectID, id), body, &out)
return &out, err
}

func (s *ExperimentsService) Delete(ctx context.Context, projectID, id string) error {
return s.c.do(ctx, http.MethodDelete, pathExperiment(projectID, id), nil, nil)
}

func (s *ExperimentsService) Start(ctx context.Context, projectID, id string) (*Experiment, error) {
return s.action(ctx, pathExperimentActionsStart(projectID, id))
}

func (s *ExperimentsService) Pause(ctx context.Context, projectID, id string) (*Experiment, error) {
return s.action(ctx, pathExperimentActionsPause(projectID, id))
}

func (s *ExperimentsService) Resume(ctx context.Context, projectID, id string) (*Experiment, error) {
return s.action(ctx, pathExperimentActionsResume(projectID, id))
}

func (s *ExperimentsService) Stop(ctx context.Context, projectID, id string) (*Experiment, error) {
return s.action(ctx, pathExperimentActionsStop(projectID, id))
}

func (s *ExperimentsService) action(ctx context.Context, path string) (*Experiment, error) {
var out Experiment
err := s.c.do(ctx, http.MethodPost, path, nil, &out)
return &out, err
}

func (s *ExperimentsService) Results(ctx context.Context, projectID, id string, opts ExperimentResultsOptions) (*ExperimentResults, error) {
q := url.Values{}
if opts.Platform != "" {
Expand Down
54 changes: 54 additions & 0 deletions internal/api/experiments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api_test

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -10,6 +11,59 @@ import (
"github.com/revenuecat/cli/internal/api"
)

func TestExperimentLifecycleRoutes(t *testing.T) {
requests := []string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost && r.URL.Path == "/projects/proj/experiments" {
var body api.ExperimentCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.OfferingAID != "ofrng_a" || body.OfferingBID != "ofrng_b" || body.EnrollmentPercentage != 50 {
t.Fatalf("unexpected create request: %+v", body)
}
}
if r.Method == http.MethodDelete {
fmt.Fprint(w, `{"object":"deleted_object","id":"exp1"}`)
return
}
fmt.Fprint(w, `{"object":"experiment","id":"exp1","display_name":"Test","status":"draft","created_at":1,"updated_at":2}`)
}))
t.Cleanup(srv.Close)
client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL})
ctx := context.Background()
_, err := client.Experiments.Create(ctx, "proj", api.ExperimentCreate{DisplayName: "Test", OfferingAID: "ofrng_a", OfferingBID: "ofrng_b", EnrollmentPercentage: 50})
if err != nil {
t.Fatal(err)
}
_, err = client.Experiments.Update(ctx, "proj", "exp1", api.ExperimentUpdate{"notes": json.RawMessage(`"reviewed"`)})
if err != nil {
t.Fatal(err)
}
for _, action := range []func(context.Context, string, string) (*api.Experiment, error){client.Experiments.Start, client.Experiments.Pause, client.Experiments.Resume, client.Experiments.Stop} {
if _, err := action(ctx, "proj", "exp1"); err != nil {
t.Fatal(err)
}
}
if err := client.Experiments.Delete(ctx, "proj", "exp1"); err != nil {
t.Fatal(err)
}
want := []string{
"POST /projects/proj/experiments",
"POST /projects/proj/experiments/exp1",
"POST /projects/proj/experiments/exp1/actions/start",
"POST /projects/proj/experiments/exp1/actions/pause",
"POST /projects/proj/experiments/exp1/actions/resume",
"POST /projects/proj/experiments/exp1/actions/stop",
"DELETE /projects/proj/experiments/exp1",
}
if fmt.Sprint(requests) != fmt.Sprint(want) {
t.Fatalf("requests = %v, want %v", requests, want)
}
}

func TestExperimentsReadRoutesAndFilters(t *testing.T) {
requests := []string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
16 changes: 16 additions & 0 deletions internal/api/paths_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading