-
Notifications
You must be signed in to change notification settings - Fork 3
Add Go SDK for Jules Agent API #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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= |
| 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 | ||
| } |
| 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 | ||
| } | ||
| } | ||
|
|
||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using string concatenation to build URLs can be fragile. For instance, it might not correctly handle cases where 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error returned from 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 | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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)
} |
||
| } | ||
| 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"` | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
WithTimeoutoption directly modifies theTimeoutfield of thehttpClient. If a user provides their ownhttp.ClientviaWithHTTPClient, this option will mutate that client. This can be surprising behavior, especially since the order of options matters (WithHTTPClientfollowed byWithTimeoutwill 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.