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
5 changes: 5 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/jules-ai/jules-agent-sdk-go

go 1.24.3

require github.com/google/go-cmp v0.7.0 // indirect
2 changes: 2 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
36 changes: 36 additions & 0 deletions go/jules/activities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package jules

import (
"context"
"fmt"
"net/url"
)

// ActivityListResponse represents a response from listing activities.
type ActivityListResponse struct {
Activities []Activity `json:"activities"`
NextPageToken string `json:"nextPageToken"`
}

// ListActivities lists all activities for a session.
func (c *Client) ListActivities(ctx context.Context, sessionID string, pageSize int, pageToken string) (*ActivityListResponse, error) {
query := url.Values{}
if pageSize > 0 {
query.Set("pageSize", fmt.Sprintf("%d", pageSize))
}
if pageToken != "" {
query.Set("pageToken", pageToken)
}

path := fmt.Sprintf("/sessions/%s/activities", sessionID)
if len(query) > 0 {
path += "?" + query.Encode()
}

var response ActivityListResponse
if err := c.doRequest(ctx, "GET", path, nil, &response); err != nil {
return nil, err
}

return &response, nil
}
109 changes: 109 additions & 0 deletions go/jules/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package jules

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)

const (
DefaultBaseURL = "https://jules.googleapis.com/v1alpha"
DefaultTimeout = 30 * time.Second
)

// Client is the Jules API client.
type Client struct {
apiKey string
baseURL string
httpClient *http.Client
}

// NewClient creates a new Jules API client.
func NewClient(apiKey string, opts ...ClientOption) *Client {
c := &Client{
apiKey: apiKey,
baseURL: DefaultBaseURL,
httpClient: &http.Client{
Timeout: DefaultTimeout,
},
}

for _, opt := range opts {
opt(c)
}

return c
}

// ClientOption is an option for configuring the client.
type ClientOption func(*Client)

// WithBaseURL sets the base URL for the client.
func WithBaseURL(url string) ClientOption {
return func(c *Client) {
c.baseURL = url
}
}

// WithHTTPClient sets the HTTP client for the client.
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(c *Client) {
c.httpClient = httpClient
}
}

// WithTimeout sets the timeout for the client.
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *Client) {
c.httpClient.Timeout = timeout
}
Comment on lines +60 to +64

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The WithTimeout option directly modifies the Timeout field of the httpClient. If a user provides their own http.Client via WithHTTPClient, this option will mutate that client. This can be surprising behavior, especially since the order of options matters (WithHTTPClient followed by WithTimeout will modify the provided client, but the reverse order will not). Consider documenting this behavior or changing the implementation to avoid mutating a user-provided client. A safer approach might be to only set the timeout on the default client if no custom client is provided.

}

// doRequest performs an HTTP request.
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error {
u, err := url.Parse(c.baseURL + path)
if err != nil {
return fmt.Errorf("failed to parse URL: %w", err)
}
Comment on lines +69 to +72

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using string concatenation to build URLs can be fragile. For instance, it might not correctly handle cases where c.baseURL already has a path component or if the path component is not properly escaped. A more robust approach is to use url.Parse and url.URL.ResolveReference. This ensures that paths are joined correctly according to URL semantics and provides better error handling.

    u, err := url.Parse(c.baseURL)
	if err != nil {
		return fmt.Errorf("failed to parse base URL: %w", err)
	}
	pathURL, err := url.Parse(path)
	if err != nil {
		return fmt.Errorf("failed to parse request path: %w", err)
	}
	u = u.ResolveReference(pathURL)


var reqBody io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonBody)
}

req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Goog-Api-Key", c.apiKey)

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
Comment on lines +98 to +99

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The error returned from io.ReadAll is being ignored by using the blank identifier _. If reading the response body fails, the error message will be incomplete (e.g., showing an empty body string), making debugging harder. You should always handle errors returned from I/O operations.

		bodyBytes, err := io.ReadAll(resp.Body)
		if err != nil {
			return fmt.Errorf("API request failed with status %d, and could not read response body: %w", resp.StatusCode, err)
		}
		return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes))

}

if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("failed to decode response body: %w", err)
}
}

return nil
}
89 changes: 89 additions & 0 deletions go/jules/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package jules

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

func TestCreateSession(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
}
if r.URL.Path != "/v1alpha/sessions" {
t.Errorf("Expected path /v1alpha/sessions, got %s", r.URL.Path)
}
if r.Header.Get("X-Goog-Api-Key") != "test-key" {
t.Errorf("Expected X-Goog-Api-Key header to be test-key, got %s", r.Header.Get("X-Goog-Api-Key"))
}

var req Session
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("Failed to decode request body: %v", err)
}

if req.Prompt != "fix bug" {
t.Errorf("Expected prompt 'fix bug', got '%s'", req.Prompt)
}

resp := Session{
Name: "projects/p/locations/l/sessions/s1",
ID: "s1",
Prompt: req.Prompt,
SourceContext: req.SourceContext,
State: StateQueued,
}
json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()

client := NewClient("test-key", WithBaseURL(ts.URL+"/v1alpha"))
ctx := context.Background()

session, err := client.CreateSession(ctx, CreateSessionRequest{
Prompt: "fix bug",
Source: "sources/s1",
})
if err != nil {
t.Fatalf("CreateSession failed: %v", err)
}

if session.ID != "s1" {
t.Errorf("Expected session ID s1, got %s", session.ID)
}
}

func TestListSessions(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Errorf("Expected GET request, got %s", r.Method)
}
if r.URL.Path != "/v1alpha/sessions" {
t.Errorf("Expected path /v1alpha/sessions, got %s", r.URL.Path)
}

resp := SessionListResponse{
Sessions: []Session{
{ID: "s1"},
{ID: "s2"},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()

client := NewClient("test-key", WithBaseURL(ts.URL+"/v1alpha"))
ctx := context.Background()

resp, err := client.ListSessions(ctx, 0, "")
if err != nil {
t.Fatalf("ListSessions failed: %v", err)
}

if len(resp.Sessions) != 2 {
t.Errorf("Expected 2 sessions, got %d", len(resp.Sessions))
}
Comment on lines +86 to +88

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The assertion here only checks the number of sessions. For a more robust test, you should check the content of the sessions as well. This ensures that the response is not just the correct size, but also contains the expected data. Using more specific assertions provides better failure messages and test coverage.

	if len(resp.Sessions) != 2 {
		t.Fatalf("Expected 2 sessions, got %d", len(resp.Sessions))
	}
	if resp.Sessions[0].ID != "s1" {
		t.Errorf("Expected session 0 ID to be 's1', got %q", resp.Sessions[0].ID)
	}
	if resp.Sessions[1].ID != "s2" {
		t.Errorf("Expected session 1 ID to be 's2', got %q", resp.Sessions[1].ID)
	}

}
140 changes: 140 additions & 0 deletions go/jules/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package jules

// SessionState represents the state of a session.
type SessionState string

const (
StateUnspecified SessionState = "STATE_UNSPECIFIED"
StateQueued SessionState = "QUEUED"
StatePlanning SessionState = "PLANNING"
StateAwaitingApproval SessionState = "AWAITING_PLAN_APPROVAL"
StateAwaitingFeedback SessionState = "AWAITING_USER_FEEDBACK"
StateInProgress SessionState = "IN_PROGRESS"
StatePaused SessionState = "PAUSED"
StateFailed SessionState = "FAILED"
StateCompleted SessionState = "COMPLETED"
)

// GitHubBranch represents a GitHub branch.
type GitHubBranch struct {
DisplayName string `json:"displayName,omitempty"`
}

// GitHubRepo represents a GitHub repository.
type GitHubRepo struct {
Owner string `json:"owner,omitempty"`
Repo string `json:"repo,omitempty"`
IsPrivate bool `json:"isPrivate,omitempty"`
DefaultBranch *GitHubBranch `json:"defaultBranch,omitempty"`
Branches []GitHubBranch `json:"branches,omitempty"`
}

// Source represents an input source of data for a session.
type Source struct {
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
GitHubRepo *GitHubRepo `json:"githubRepo,omitempty"`
}

// GitHubRepoContext represents context to use a GitHubRepo in a session.
type GitHubRepoContext struct {
StartingBranch string `json:"startingBranch,omitempty"`
}

// SourceContext represents context for how to use a source in a session.
type SourceContext struct {
Source string `json:"source,omitempty"`
GitHubRepoContext *GitHubRepoContext `json:"githubRepoContext,omitempty"`
}

// PullRequest represents a pull request.
type PullRequest struct {
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
}

// SessionOutput represents an output of a session.
type SessionOutput struct {
PullRequest *PullRequest `json:"pullRequest,omitempty"`
}

// Session represents a contiguous amount of work within the same context.
type Session struct {
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
Prompt string `json:"prompt,omitempty"`
SourceContext *SourceContext `json:"sourceContext,omitempty"`
Title string `json:"title,omitempty"`
RequirePlanApproval bool `json:"requirePlanApproval,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
State SessionState `json:"state,omitempty"`
URL string `json:"url,omitempty"`
Outputs []SessionOutput `json:"outputs,omitempty"`
}

// PlanStep represents a step in a plan.
type PlanStep struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Index int `json:"index,omitempty"`
}

// Plan represents a sequence of steps that the agent will take to complete the task.
type Plan struct {
ID string `json:"id,omitempty"`
Steps []PlanStep `json:"steps,omitempty"`
CreateTime string `json:"createTime,omitempty"`
}

// GitPatch represents a patch in Git format.
type GitPatch struct {
UnidiffPatch string `json:"unidiffPatch,omitempty"`
BaseCommitID string `json:"baseCommitId,omitempty"`
SuggestedCommitMessage string `json:"suggestedCommitMessage,omitempty"`
}

// ChangeSet represents a change set artifact.
type ChangeSet struct {
Source string `json:"source,omitempty"`
GitPatch *GitPatch `json:"gitPatch,omitempty"`
}

// Media represents a media artifact.
type Media struct {
Data string `json:"data,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}

// BashOutput represents a bash output artifact.
type BashOutput struct {
Command string `json:"command,omitempty"`
Output string `json:"output,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
}

// Artifact represents a single unit of data produced by an activity step.
type Artifact struct {
ChangeSet *ChangeSet `json:"changeSet,omitempty"`
Media *Media `json:"media,omitempty"`
BashOutput *BashOutput `json:"bashOutput,omitempty"`
}

// Activity represents a single unit of work within a session.
type Activity struct {
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
Description string `json:"description,omitempty"`
CreateTime string `json:"createTime,omitempty"`
Originator string `json:"originator,omitempty"`
Artifacts []Artifact `json:"artifacts,omitempty"`
AgentMessaged map[string]string `json:"agentMessaged,omitempty"`
UserMessaged map[string]string `json:"userMessaged,omitempty"`
PlanGenerated map[string]any `json:"planGenerated,omitempty"`
PlanApproved map[string]string `json:"planApproved,omitempty"`
ProgressUpdated map[string]string `json:"progressUpdated,omitempty"`
SessionCompleted map[string]any `json:"sessionCompleted,omitempty"`
SessionFailed map[string]string `json:"sessionFailed,omitempty"`
}
Loading