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
24 changes: 16 additions & 8 deletions docs/plan.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
connectrpc.com/connect/v2 v2.0.2
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/github/copilot-sdk/go v1.0.28
github.com/go-chi/chi/v5 v5.5.0
github.com/jackc/pgx/v5 v5.11.0
github.com/modelcontextprotocol/go-sdk v1.8.0
Expand All @@ -15,9 +16,15 @@ require (
)

require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
Expand All @@ -26,6 +33,11 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sethvargo/go-retry v0.5.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.47.0 // indirect
go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/metric v1.47.0 // indirect
go.opentelemetry.io/otel/trace v1.47.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/sync v0.24.0 // indirect
Expand Down
23 changes: 23 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,28 @@ connectrpc.com/connect/v2 v2.0.2 h1:PoPDmOlhc46elsuCdKCKTxjNBb3HJlKF+s/sP2oucPw=
connectrpc.com/connect/v2 v2.0.2/go.mod h1:N32OLrstcPUzkykFynnefFro3+xzSdOrZlK2jHmS/xo=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/github/copilot-sdk/go v1.0.28 h1:p9205uladSUScyM7B+AbdaGLbcQGSTbx3U+bSH1YXZ4=
github.com/github/copilot-sdk/go v1.0.28/go.mod h1:U0STg9Jdv9LDwtkUtcUJCzPQ4KTnKakRfP+1LhEx248=
github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg=
github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM=
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
Expand Down Expand Up @@ -56,6 +69,16 @@ github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUm
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
Expand Down
20 changes: 20 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ jactionlint = "latest"
shellcheck = "latest"
buf = "latest"
process-compose = "latest"
# The planner's Copilot CLI, pinned at the version that the Copilot SDK in go.mod names, which
# copilot:check checks (decision 74). Lazy, it installs when first run, so CI, which never runs
# it, doesn't download it.
copilot = { version = "1.0.105", lazy = true }

[env]
# process-compose's API server listens on localhost:8080 by default, the gateway's address.
Expand Down Expand Up @@ -72,6 +76,21 @@ if ! diff -r "${fresh}/internal/gen" internal/gen; then
fi
'''

[tasks."copilot:check"]
description = "Fail if mise's copilot isn't the CLI version that the Copilot SDK in go.mod names"
shell = "bash -c"
run = '''
set -euo pipefail
sdk=$(go list -m -json github.com/github/copilot-sdk/go | jq -er .Version)
named=$(curl -sSf "https://raw.githubusercontent.com/github/copilot-sdk/go/${sdk}/nodejs/package.json" |
jq -er .copilotCliVersion)
pinned=$(mise config get --file mise.toml tools.copilot.version)
if [[ "${pinned}" != "${named}" ]]; then
echo "error: mise.toml pins copilot ${pinned}, but the Copilot SDK ${sdk} names ${named}" >&2
exit 1
fi
'''

[tasks."lint:compose"]
description = "Check process-compose.yaml (process-compose up --dry-run)"
run = "process-compose up --dry-run"
Expand Down Expand Up @@ -102,6 +121,7 @@ depends = [
"lint:actions",
"lint:compose",
"gen:check",
"copilot:check",
"cover",
"vuln",
"//apps/web:check",
Expand Down
11 changes: 11 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
"description": ["pnpm refuses npm releases younger than a day, so Renovate waits as long (docs/plan.md §6.4)"],
"matchDatasources": ["npm"],
"minimumReleaseAge": "1 day"
},
{
"description": ["The Copilot CLI follows the version that the Copilot SDK names, not its newest release (docs/plan.md decision 74)"],
"matchManagers": ["mise"],
"matchDepNames": ["copilot"],
"enabled": false
},
{
"description": ["Each Copilot SDK release names its Copilot CLI version, which mise.toml pins (docs/plan.md decision 74)"],
"matchDepNames": ["github.com/github/copilot-sdk/go"],
"prBodyNotes": ["Set `copilot` in `mise.toml` to the `copilotCliVersion` in the SDK's `nodejs/package.json` at this release; `mise run copilot:check` fails until it matches."]
}
]
}
88 changes: 88 additions & 0 deletions services/planner/internal/copilot/copilot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Package copilot works out plans with the Copilot CLI, through the Copilot SDK.
package copilot

import (
"context"
"fmt"
"slices"
"strings"

sdk "github.com/github/copilot-sdk/go"
)

// Settings say where the Copilot CLI is and which account it works for.
type Settings struct {
// CLI is the path of the copilot executable.
CLI string
// Home is the CLI's own folder for its state, the plans' sessions among it.
Home string
// Work is an empty folder, outside any git repository, that the CLI works in.
Work string
// Host is the account's GitHub host.
Host string
// Token is the account's fine-grained token with the Copilot Requests permission.
Token string
}

// Client works out plans on a Copilot CLI of its own.
type Client struct {
copilot *sdk.Client
work string
}

// New returns a client for the CLI that settings name. The CLI inherits environ, less the
// variables that set up Copilot or GitHub, so only settings decide whom it works for.
func New(settings Settings, environ []string) *Client {
return newClient(clientOptions(settings, environ), settings.Work)
}

func newClient(options *sdk.ClientOptions, work string) *Client {
return &Client{copilot: sdk.NewClient(options), work: work}
}

func clientOptions(settings Settings, environ []string) *sdk.ClientOptions {
return &sdk.ClientOptions{
Connection: sdk.StdioConnection{
Path: settings.CLI,
Env: cliEnv(environ, settings.Host),
},
WorkingDirectory: settings.Work,
BaseDirectory: settings.Home,
GitHubToken: settings.Token,
UseLoggedInUser: new(false),
}
}

// cliEnv returns environ without the variables that set up Copilot or GitHub, such as another
// account's token, and with host as the CLI's GitHub host.
func cliEnv(environ []string, host string) []string {
env := slices.DeleteFunc(slices.Clone(environ), setsUpCopilot)

return append(env, "COPILOT_GH_HOST="+host)
}

// setsUpCopilot reports whether variable, a NAME=value pair, sets up Copilot or GitHub.
func setsUpCopilot(variable string) bool {
name, _, _ := strings.Cut(variable, "=")

return strings.HasPrefix(name, "COPILOT_") || strings.HasPrefix(name, "GH_") ||
name == "GITHUB_TOKEN"
}

// Start starts the CLI and connects to it.
func (c *Client) Start(ctx context.Context) error {
if err := c.copilot.Start(ctx); err != nil {
return fmt.Errorf("start the copilot CLI: %w", err)
}

return nil
}

// Stop leaves the plans' sessions, which the CLI keeps, and stops the CLI.
func (c *Client) Stop() error {
if err := c.copilot.Stop(); err != nil {
return fmt.Errorf("stop the copilot CLI: %w", err)
}

return nil
}
106 changes: 106 additions & 0 deletions services/planner/internal/copilot/copilot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package copilot

import (
"errors"
"io/fs"
"path/filepath"
"reflect"
"slices"
"testing"

sdk "github.com/github/copilot-sdk/go"
)

func TestClientOptions(t *testing.T) {
settings := Settings{
CLI: "/tools/copilot", Home: "/planner/home", Work: "/planner/work",
Host: "example.ghe.com", Token: "secret-token",
}

options := clientOptions(settings, []string{"HOME=/home/user", "GH_TOKEN=personal-token"})

want := sdk.StdioConnection{
Path: "/tools/copilot", Env: []string{"HOME=/home/user", "COPILOT_GH_HOST=example.ghe.com"},
}
if !reflect.DeepEqual(options.Connection, want) {
t.Errorf("Connection = %#v, want %#v", options.Connection, want)
}

if options.WorkingDirectory != "/planner/work" || options.BaseDirectory != "/planner/home" {
t.Errorf("folders = %q, %q, want the work and home folders",
options.WorkingDirectory, options.BaseDirectory)
}

if options.GitHubToken != "secret-token" {
t.Errorf("GitHubToken = %q, want the settings' token", options.GitHubToken)
}

if options.UseLoggedInUser == nil || *options.UseLoggedInUser {
t.Error("UseLoggedInUser isn't false, so the CLI may sign in as the logged-in user")
}
}

func TestCLIEnv(t *testing.T) {
tests := []struct {
name string
environ, want []string
}{
{
name: "keeps the rest",
environ: []string{
"PATH=/usr/bin", "HOME=/home/user", "GITHUB_ACTIONS=true", "GH=x", "NOT_GH_TOKEN=x",
},
want: []string{
"PATH=/usr/bin", "HOME=/home/user", "GITHUB_ACTIONS=true", "GH=x", "NOT_GH_TOKEN=x",
"COPILOT_GH_HOST=example.ghe.com",
},
},
{
name: "drops tokens",
environ: []string{
"COPILOT_GITHUB_TOKEN=a", "PATH=/usr/bin", "GH_TOKEN=b", "GITHUB_TOKEN=c",
"GH_ENTERPRISE_TOKEN=d",
},
want: []string{"PATH=/usr/bin", "COPILOT_GH_HOST=example.ghe.com"},
},
{
name: "drops settings",
environ: []string{
"COPILOT_ALLOW_ALL=true",
"COPILOT_GH_HOST=github.com",
"GH_HOST=github.com",
"COPILOT_HOME=/home/user/.copilot",
"COPILOT_PROVIDER_BASE_URL=http://x",
"COPILOT_X",
},
want: []string{"COPILOT_GH_HOST=example.ghe.com"},
},
{
name: "from nothing",
want: []string{"COPILOT_GH_HOST=example.ghe.com"},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
environ := slices.Clone(test.environ)

if got := cliEnv(environ, "example.ghe.com"); !slices.Equal(got, test.want) {
t.Errorf("cliEnv = %q, want %q", got, test.want)
}

if !slices.Equal(environ, test.environ) {
t.Errorf("cliEnv changed its environ to %q", environ)
}
})
}
}

func TestStartWithoutCLI(t *testing.T) {
work := t.TempDir()
client := New(Settings{CLI: filepath.Join(work, "copilot"), Work: work, Token: "x"}, nil)

if err := client.Start(t.Context()); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Start = %v, want an error for the missing CLI", err)
}
}
Loading
Loading