Skip to content
Open
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
26 changes: 26 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,13 @@ type API interface {
CreateAITaskBuilderDataset(workspaceID string, payload CreateAITaskBuilderDatasetPayload) (*CreateAITaskBuilderDatasetResponse, error)
CreateAITaskBuilderCollection(payload model.CreateAITaskBuilderCollection) (*CreateAITaskBuilderCollectionResponse, error)
GetAITaskBuilderBatch(batchID string) (*GetAITaskBuilderBatchResponse, error)
GetAITaskBuilderDataset(datasetID string) (*GetAITaskBuilderDatasetResponse, error)
UpdateAITaskBuilderBatch(params UpdateBatchParams) (*UpdateAITaskBuilderBatchResponse, error)
GetAITaskBuilderBatchStatus(batchID string) (*GetAITaskBuilderBatchStatusResponse, error)
GetAITaskBuilderBatches(workspaceID string) (*GetAITaskBuilderBatchesResponse, error)
GetAITaskBuilderResponses(batchID string) (*GetAITaskBuilderResponsesResponse, error)
GetAITaskBuilderTasks(batchID string) (*GetAITaskBuilderTasksResponse, error)
GetAITaskBuilderTaskGroups(batchID string) (*GetAITaskBuilderTaskGroupsResponse, error)
InitiateBatchExport(batchID string) (*BatchExportResponse, error)
GetBatchExportStatus(batchID, exportID string) (*BatchExportResponse, error)
SyncAITaskBuilderBatch(batchID string) (*AITaskBuilderBatchSyncResponse, error)
Expand Down Expand Up @@ -1384,6 +1386,18 @@ func (c *Client) GetAITaskBuilderTasks(batchID string) (*GetAITaskBuilderTasksRe
return &response, nil
}

// GetAITaskBuilderTaskGroups will return the task group IDs for an AI Task Builder batch.
func (c *Client) GetAITaskBuilderTaskGroups(batchID string) (*GetAITaskBuilderTaskGroupsResponse, error) {
var response GetAITaskBuilderTaskGroupsResponse

url := fmt.Sprintf("/api/v1/data-collection/batches/%s/task-groups", batchID)
_, err := c.Execute(http.MethodGet, url, nil, &response)
if err != nil {
return nil, fmt.Errorf("unable to fulfil request %s: %s", url, err)
}
return &response, nil
}

// InitiateBatchExport starts a batch export job via POST.
// Returns "generating" + ExportID (202) if a new job was enqueued,
// or "complete" + URL immediately (200) if a valid export already exists.
Expand Down Expand Up @@ -1463,6 +1477,18 @@ func (c *Client) GetAITaskBuilderBatchSyncStatus(batchID, syncID string) (*AITas
return &response, nil
}

// GetAITaskBuilderDataset will return an AI Task Builder dataset by ID.
func (c *Client) GetAITaskBuilderDataset(datasetID string) (*GetAITaskBuilderDatasetResponse, error) {
var response GetAITaskBuilderDatasetResponse

url := fmt.Sprintf("/api/v1/data-collection/datasets/%s", datasetID)
_, err := c.Execute(http.MethodGet, url, nil, &response)
if err != nil {
return nil, fmt.Errorf("unable to fulfil request %s: %s", url, err)
}
return &response, nil
}

// GetAITaskBuilderDatasetStatus will return the status of an AI Task Builder dataset.
func (c *Client) GetAITaskBuilderDatasetStatus(datasetID string) (*GetAITaskBuilderDatasetStatusResponse, error) {
var response GetAITaskBuilderDatasetStatusResponse
Expand Down
20 changes: 20 additions & 0 deletions client/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,26 @@ type GetAITaskBuilderResponsesResponse struct {
// The API returns a simple array of task ID strings.
type GetAITaskBuilderTasksResponse []string

// GetAITaskBuilderTaskGroupsResponse is the response for the get AI Task Builder task groups endpoint.
// The API returns a simple array of task group ID strings.
type GetAITaskBuilderTaskGroupsResponse []string

// GetAITaskBuilderDatasetResponse is the response for the get AI Task Builder dataset endpoint.
type GetAITaskBuilderDatasetResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
WorkspaceID string `json:"workspace_id"`
TotalDatapointCount int `json:"total_datapoint_count"`
SchemaVersion int `json:"schema_version"`
Status model.DatasetStatus `json:"status,omitempty"`
Filename *string `json:"filename,omitempty"`
HasPredeterminedGroupingID *bool `json:"has_predetermined_grouping_id,omitempty"`
Schema *DatasetSchema `json:"schema"`
Imports []model.DatasetImportJob `json:"imports"`
}

// GetAITaskBuilderDatasetStatusResponse is the response for the get AI Task Builder dataset status endpoint.
type GetAITaskBuilderDatasetStatusResponse struct {
Status model.DatasetStatus `json:"status"`
Expand Down
106 changes: 106 additions & 0 deletions cmd/aitaskbuilder/batch_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package aitaskbuilder

import (
"errors"
"fmt"
"io"

"github.com/pkg/browser"
"github.com/prolific-oss/cli/client"
"github.com/spf13/cobra"
)

// BrowserOpener is a function type for opening URLs in a browser.
// This allows for dependency injection in tests.
type BrowserOpener func(url string) error

// DefaultBrowserOpener uses the system browser to open URLs.
var DefaultBrowserOpener BrowserOpener = browser.OpenURL

// BatchPreviewOptions is the options for the batch preview command.
type BatchPreviewOptions struct {
Args []string
BatchID string
BrowserOpener BrowserOpener
}

// NewBatchPreviewCommand creates a new `batch preview` command to open a batch
// preview in the browser.
func NewBatchPreviewCommand(c client.API, w io.Writer) *cobra.Command {
return NewBatchPreviewCommandWithOpener(c, w, DefaultBrowserOpener)
}

// NewBatchPreviewCommandWithOpener creates a new `batch preview` command with a
// custom browser opener. This is useful for testing to avoid opening actual
// browser windows.
func NewBatchPreviewCommandWithOpener(c client.API, w io.Writer, browserOpener BrowserOpener) *cobra.Command {
var opts BatchPreviewOptions
opts.BrowserOpener = browserOpener

cmd := &cobra.Command{
Use: "preview <batch-id>",
Args: cobra.ExactArgs(1),
Short: "Preview a batch in the browser",
Long: `Preview a batch in the browser

Opens the batch's first task group in your default web browser so you can
preview it before launch.`,
Example: `
Preview an AI Task Builder batch in the browser:
$ prolific aitaskbuilder batch preview <batch_id>
`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Args = args
opts.BatchID = args[0]

err := renderAITaskBuilderBatchPreview(c, opts, w)
if err != nil {
return fmt.Errorf("error: %s", err)
}

return nil
},
}

return cmd
}

// renderAITaskBuilderBatchPreview validates access to the batch, resolves a task
// group, builds the preview URL, prints it, and attempts to open it in a browser.
func renderAITaskBuilderBatchPreview(c client.API, opts BatchPreviewOptions, w io.Writer) error {
if opts.BatchID == "" {
return errors.New(ErrBatchIDRequired)
}

// Fetch batch to validate access
_, err := c.GetAITaskBuilderBatch(opts.BatchID)
if err != nil {
return fmt.Errorf("failed to get batch: %s", err)
}

taskGroups, err := c.GetAITaskBuilderTaskGroups(opts.BatchID)
if err != nil {
return fmt.Errorf("failed to get task groups: %s", err)
}
if len(*taskGroups) == 0 {
return fmt.Errorf("%s %s", ErrNoTaskGroupsFound, opts.BatchID)
}

taskGroupID := (*taskGroups)[0]

// Build the preview URL and display it
previewURL := GetBatchPreviewURL(opts.BatchID, taskGroupID)
fmt.Fprintln(w, "Opening batch preview in browser...")
fmt.Fprintln(w)
fmt.Fprintln(w, previewURL)

// Attempt to open the browser - don't fail if it doesn't work
// (e.g., in headless/CI environments)
if opts.BrowserOpener != nil {
if err := opts.BrowserOpener(previewURL); err != nil {
fmt.Fprintln(w, "(Browser did not open automatically - use the URL above)")
}
}

return nil
}
206 changes: 206 additions & 0 deletions cmd/aitaskbuilder/batch_preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package aitaskbuilder_test

import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"testing"

"github.com/golang/mock/gomock"
"github.com/prolific-oss/cli/client"
"github.com/prolific-oss/cli/cmd/aitaskbuilder"
"github.com/prolific-oss/cli/mock_client"
"github.com/prolific-oss/cli/model"
)

// noOpBrowserOpener is a no-op browser opener for testing.
func noOpBrowserOpener(url string) error {
return nil
}

const testTaskGroupID = "task-group-1"

func TestNewBatchPreviewCommand(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c := mock_client.NewMockAPI(ctrl)

cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener)

use := "preview <batch-id>"
short := "Preview a batch in the browser"

if cmd.Use != use {
t.Fatalf("expected use: %s; got %s", use, cmd.Use)
}

if cmd.Short != short {
t.Fatalf("expected short: %s; got %s", short, cmd.Short)
}
}

func TestBatchPreviewRequiresBatchID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c := mock_client.NewMockAPI(ctrl)

cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener)
err := cmd.Args(cmd, []string{})

if err == nil {
t.Fatal("expected error when batch-id is missing")
}
}

func TestBatchPreviewAcceptsPositionalBatchID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c := mock_client.NewMockAPI(ctrl)

batchID := testBatchUUID
taskGroupID := testTaskGroupID

response := client.GetAITaskBuilderBatchResponse{
AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID},
}
taskGroups := client.GetAITaskBuilderTaskGroupsResponse{taskGroupID}

c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1)
c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1)

cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener)
if err := cmd.RunE(cmd, []string{batchID}); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}

func TestBatchPreviewCallsGetBatchAndTaskGroups(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c := mock_client.NewMockAPI(ctrl)

batchID := testBatchUUID
taskGroupID := testTaskGroupID

response := client.GetAITaskBuilderBatchResponse{
AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID},
}
taskGroups := client.GetAITaskBuilderTaskGroupsResponse{taskGroupID}

c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1)
c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1)

var b bytes.Buffer
writer := bufio.NewWriter(&b)

cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, writer, noOpBrowserOpener)
err := cmd.RunE(cmd, []string{batchID})
writer.Flush()

if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
}

func TestBatchPreviewReturnsErrorOnBatchClientError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c := mock_client.NewMockAPI(ctrl)

batchID := "an-invalid-batch-id"

c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(nil, errors.New("batch not found")).Times(1)

cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener)
err := cmd.RunE(cmd, []string{batchID})

expected := "error: failed to get batch: batch not found"
if err == nil || err.Error() != expected {
t.Fatalf("expected error %q, got %v", expected, err)
}
}

func TestBatchPreviewReturnsErrorWhenNoTaskGroups(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c := mock_client.NewMockAPI(ctrl)

batchID := testBatchUUID

response := client.GetAITaskBuilderBatchResponse{
AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID},
}
taskGroups := client.GetAITaskBuilderTaskGroupsResponse{}

c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1)
c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1)

cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener)
err := cmd.RunE(cmd, []string{batchID})

expected := fmt.Sprintf("error: %s %s", aitaskbuilder.ErrNoTaskGroupsFound, batchID)
if err == nil || err.Error() != expected {
t.Fatalf("expected error %q, got %v", expected, err)
}
}

func TestBatchPreviewOutputContainsURL(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
c := mock_client.NewMockAPI(ctrl)

batchID := testBatchUUID
taskGroupID := testTaskGroupID

response := client.GetAITaskBuilderBatchResponse{
AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID},
}
taskGroups := client.GetAITaskBuilderTaskGroupsResponse{taskGroupID}

c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1)
c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1)

var b bytes.Buffer
writer := bufio.NewWriter(&b)

cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, writer, noOpBrowserOpener)
err := cmd.RunE(cmd, []string{batchID})
writer.Flush()

if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

output := b.String()
expectedStrings := []string{
"Opening batch preview in browser",
"data-collection-tool/batches/" + batchID + "/task-groups/" + taskGroupID,
"preview=true",
}

for _, expected := range expectedStrings {
if !bytes.Contains([]byte(output), []byte(expected)) {
t.Errorf("expected output to contain %q, got: %s", expected, output)
}
}
}

func TestGetBatchPreviewPath(t *testing.T) {
path := aitaskbuilder.GetBatchPreviewPath("batch-123", "task-group-456")

expected := "data-collection-tool/batches/batch-123/task-groups/task-group-456?preview=true"
if path != expected {
t.Fatalf("expected path %q, got %q", expected, path)
}
}

func TestGetBatchPreviewURL(t *testing.T) {
url := aitaskbuilder.GetBatchPreviewURL("batch-123", "task-group-456")

expected := "https://app.prolific.com/data-collection-tool/batches/batch-123/task-groups/task-group-456?preview=true"
if url != expected {
t.Fatalf("expected URL %q, got %q", expected, url)
}
}
Loading
Loading