├── go.mod ├── SUPPORT.md ├── LICENSE.txt ├── config └── info.go ├── copilot ├── endpoints.go └── messages.go ├── agent ├── sse.go └── service.go ├── SECURITY.md ├── CONTRIBUTING.md ├── go.sum ├── oauth └── handler.go ├── main.go ├── CODE_OF_CONDUCT.md └── README.md /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/copilot-extensions/function-calling-extension 2 | 3 | go 1.21.6 4 | 5 | require ( 6 | github.com/google/go-github/v57 v57.0.0 7 | github.com/google/uuid v1.6.0 8 | github.com/invopop/jsonschema v0.12.0 9 | github.com/wk8/go-ordered-map/v2 v2.1.8 10 | golang.org/x/oauth2 v0.22.0 11 | ) 12 | 13 | require ( 14 | github.com/bahlo/generic-list-go v0.2.0 // indirect 15 | github.com/buger/jsonparser v1.1.1 // indirect 16 | github.com/google/go-querystring v1.1.0 // indirect 17 | github.com/mailru/easyjson v0.7.7 // indirect 18 | gopkg.in/yaml.v3 v3.0.1 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. 6 | 7 | - **THIS PROJECT NAME** is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner. 8 | 9 | ## GitHub Support Policy 10 | 11 | Support for this project is limited to the resources listed above. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright GitHub, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /config/info.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type Info struct { 9 | // Port is the local port on which the application will run 10 | Port string 11 | 12 | // FQDN (for Fully-Qualified Domain Name) is the internet facing host address 13 | // where application will live (e.g. https://example.com) 14 | FQDN string 15 | 16 | // ClientID comes from your configured GitHub app 17 | ClientID string 18 | 19 | // ClientSecret comes from your configured GitHub app 20 | ClientSecret string 21 | } 22 | 23 | const ( 24 | portEnv = "PORT" 25 | clientIdEnv = "CLIENT_ID" 26 | clientSecretEnv = "CLIENT_SECRET" 27 | fqdnEnv = "FQDN" 28 | ) 29 | 30 | func New() (*Info, error) { 31 | port := os.Getenv(portEnv) 32 | if port == "" { 33 | return nil, fmt.Errorf("%s environment variable required", portEnv) 34 | } 35 | 36 | fqdn := os.Getenv(fqdnEnv) 37 | if fqdn == "" { 38 | return nil, fmt.Errorf("%s environment variable required", fqdnEnv) 39 | } 40 | 41 | clientID := os.Getenv(clientIdEnv) 42 | if clientID == "" { 43 | return nil, fmt.Errorf("%s environment variable required", clientIdEnv) 44 | } 45 | 46 | clientSecret := os.Getenv(clientSecretEnv) 47 | if clientSecret == "" { 48 | return nil, fmt.Errorf("%s environment variable required", clientSecretEnv) 49 | } 50 | 51 | return &Info{ 52 | Port: port, 53 | FQDN: fqdn, 54 | ClientID: clientID, 55 | ClientSecret: clientSecret, 56 | }, nil 57 | } 58 | -------------------------------------------------------------------------------- /copilot/endpoints.go: -------------------------------------------------------------------------------- 1 | package copilot 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | func ChatCompletions(ctx context.Context, integrationID, apiKey string, req *ChatCompletionsRequest) (*ChatCompletionsResponse, error) { 13 | body, err := json.Marshal(req) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to marshal request: %w", err) 16 | } 17 | 18 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.githubcopilot.com/chat/completions", bytes.NewReader(body)) 19 | if err != nil { 20 | return nil, fmt.Errorf("failed to create request: %w", err) 21 | } 22 | httpReq.Header.Set("Content-Type", "application/json") 23 | httpReq.Header.Set("Accept", "application/json") 24 | httpReq.Header.Set("Authorization", "Bearer "+apiKey) 25 | if integrationID != "" { 26 | httpReq.Header.Set("Copilot-Integration-Id", integrationID) 27 | } 28 | 29 | resp, err := (&http.Client{}).Do(httpReq) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to send request: %w", err) 32 | } 33 | defer resp.Body.Close() 34 | 35 | if resp.StatusCode != http.StatusOK { 36 | b, _ := io.ReadAll(resp.Body) 37 | fmt.Println(string(b)) 38 | return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 39 | } 40 | 41 | var chatRes *ChatCompletionsResponse 42 | err = json.NewDecoder(resp.Body).Decode(&chatRes) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to unmarshal response body: %w", err) 45 | } 46 | 47 | return chatRes, nil 48 | } 49 | -------------------------------------------------------------------------------- /agent/sse.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | ) 7 | 8 | // Copilot extensions must stream back chat responses. sseWriter wraps an 9 | // io.Writer to help write sse formated data. 10 | type sseWriter struct { 11 | w io.Writer 12 | } 13 | 14 | func NewSSEWriter(w io.Writer) *sseWriter { 15 | return &sseWriter{ 16 | w: w, 17 | } 18 | } 19 | 20 | // writeSSEDone writes a [DONE] SSE message to the writer. 21 | func (w *sseWriter) writeDone() { 22 | _, _ = w.w.Write([]byte("data: [DONE]\n\n")) 23 | } 24 | 25 | // writeSSEData writes a data SSE message to the writer. 26 | func (w *sseWriter) writeData(v any) error { 27 | _, _ = w.w.Write([]byte("data: ")) 28 | if err := json.NewEncoder(w.w).Encode(v); err != nil { 29 | return err 30 | } 31 | _, _ = w.w.Write([]byte("\n")) // Encode() adds one newline, so add only one more here. 32 | return nil 33 | } 34 | 35 | // writeSSEEvent writes a data SSE message to the writer. 36 | func (w *sseWriter) writeEvent(name string) error { 37 | _, err := w.w.Write([]byte("event: " + name)) 38 | if err != nil { 39 | return err 40 | } 41 | _, err = w.w.Write([]byte("\n")) 42 | if err != nil { 43 | return err 44 | } 45 | return nil 46 | } 47 | 48 | type sseResponse struct { 49 | Choices []sseResponseChoice `json:"choices"` 50 | } 51 | 52 | type sseResponseChoice struct { 53 | Index int `json:"index"` 54 | Delta sseResponseMessage `json:"delta"` 55 | } 56 | 57 | type sseResponseMessage struct { 58 | Role string `json:"role"` 59 | Content string `json:"content"` 60 | } 61 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Thanks for helping make GitHub safe for everyone. 2 | 3 | # Security 4 | 5 | GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). 6 | 7 | Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. 8 | 9 | ## Reporting Security Issues 10 | 11 | If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. 12 | 13 | **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** 14 | 15 | Instead, please send an email to opensource-security[@]github.com. 16 | 17 | Please include as much of the information listed below as you can to help us better understand and resolve the issue: 18 | 19 | * The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) 20 | * Full paths of source file(s) related to the manifestation of the issue 21 | * The location of the affected source code (tag/branch/commit or direct URL) 22 | * Any special configuration required to reproduce the issue 23 | * Step-by-step instructions to reproduce the issue 24 | * Proof-of-concept or exploit code (if possible) 25 | * Impact of the issue, including how an attacker might exploit the issue 26 | 27 | This information will help us triage your report more quickly. 28 | 29 | ## Policy 30 | 31 | See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) -------------------------------------------------------------------------------- /copilot/messages.go: -------------------------------------------------------------------------------- 1 | package copilot 2 | 3 | import "github.com/invopop/jsonschema" 4 | 5 | type ChatRequest struct { 6 | Messages []ChatMessage `json:"messages"` 7 | } 8 | 9 | type ChatMessage struct { 10 | Role string `json:"role"` 11 | Content string `json:"content"` 12 | Confirmations []*ChatConfirmation `json:"copilot_confirmations"` 13 | ToolCalls []*ToolCall `json:"tool_calls"` 14 | } 15 | 16 | type ToolCall struct { 17 | Function *ChatMessageFunctionCall `json:"function"` 18 | } 19 | 20 | type ChatMessageFunctionCall struct { 21 | Name string `json:"name"` 22 | Arguments string `json:"arguments"` 23 | } 24 | 25 | type ChatConfirmation struct { 26 | State string `json:"state"` 27 | Confirmation *ConfirmationData `json:"confirmation"` 28 | } 29 | 30 | type ConfirmationData struct { 31 | Owner string `json:"owner"` 32 | Repo string `json:"repo"` 33 | Title string `json:"title"` 34 | Body string `json:"body"` 35 | } 36 | 37 | type ResponseConfirmation struct { 38 | Type string `json:"type"` 39 | Title string `json:"title"` 40 | Message string `json:"message"` 41 | Confirmation *ConfirmationData `json:"confirmation"` 42 | } 43 | 44 | type Model string 45 | 46 | const ( 47 | ModelGPT35 Model = "gpt-3.5-turbo" 48 | ModelGPT4 Model = "gpt-4" 49 | ) 50 | 51 | type ChatCompletionsRequest struct { 52 | Messages []ChatMessage `json:"messages"` 53 | Model Model `json:"model"` 54 | Tools []FunctionTool `json:"tools"` 55 | } 56 | 57 | type FunctionTool struct { 58 | Type string `json:"type"` 59 | Function Function `json:"function"` 60 | } 61 | 62 | type Function struct { 63 | Name string `json:"name"` 64 | Description string `json:"description,omitempty"` 65 | Parameters *jsonschema.Schema `json:"parameters"` 66 | } 67 | 68 | type ChatCompletionsResponse struct { 69 | Choices []ChatChoice `json:"choices"` 70 | } 71 | 72 | type ChatChoice struct { 73 | Index int `json:"index"` 74 | Message ChatMessage `json:"message"` 75 | } 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github/REPO/fork 4 | [pr]: https://github.com/github/REPO/compare 5 | [style]: https://github.com/github/REPO/blob/main/.golangci.yaml 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.txt). 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 12 | 13 | ## Prerequisites for running and testing code 14 | 15 | These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process. 16 | 17 | 1. install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) 18 | 1. [install golangci-lint](https://golangci-lint.run/usage/install/#local-installation) 19 | 20 | ## Submitting a pull request 21 | 22 | 1. [Fork][fork] and clone the repository 23 | 1. Configure and install the dependencies: `script/bootstrap` 24 | 1. Make sure the tests pass on your machine: `go test -v ./...` 25 | 1. Make sure linter passes on your machine: `golangci-lint run` 26 | 1. Create a new branch: `git checkout -b my-branch-name` 27 | 1. Make your change, add tests, and make sure the tests and linter still pass 28 | 1. Push to your fork and [submit a pull request][pr] 29 | 1. Pat yourself on the back and wait for your pull request to be reviewed and merged. 30 | 31 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 32 | 33 | - Follow the [style guide][style]. 34 | - Write tests. 35 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 36 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 37 | 38 | ## Resources 39 | 40 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 41 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 42 | - [GitHub Help](https://help.github.com) -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 2 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 3 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 4 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 8 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 9 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 10 | github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs= 11 | github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw= 12 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 13 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 14 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 15 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 16 | github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= 17 | github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 18 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 19 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 20 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 24 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 25 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 26 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 27 | golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= 28 | golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 29 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 31 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 33 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 34 | -------------------------------------------------------------------------------- /oauth/handler.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "fmt" 5 | "github.com/google/uuid" 6 | "golang.org/x/oauth2" 7 | "net/http" 8 | ) 9 | 10 | // Service provides endpoints to allow this agent to be authorized. 11 | type Service struct { 12 | conf *oauth2.Config 13 | } 14 | 15 | func NewService(clientID, clientSecret, callback string) *Service { 16 | return &Service{ 17 | conf: &oauth2.Config{ 18 | ClientID: clientID, 19 | ClientSecret: clientSecret, 20 | RedirectURL: callback, 21 | Endpoint: oauth2.Endpoint{ 22 | AuthURL: "https://github.com/login/oauth/authorize", 23 | TokenURL: "https://github.com/login/oauth/access_token", 24 | }, 25 | }, 26 | } 27 | } 28 | 29 | const ( 30 | STATE_COOKIE = "oauth_state" 31 | ) 32 | 33 | // PreAuth is the landing page that the user arrives at when they first attempt 34 | // to use the agent while unauthorized. You can do anything you want here, 35 | // including making sure the user has an account on your side. At some point, 36 | // you'll probably want to make a call to the authorize endpoint to authorize 37 | // the app. 38 | func (s *Service) PreAuth(w http.ResponseWriter, r *http.Request) { 39 | // In our example, we're not doing anything except going through the 40 | // authorization flow. This is standard Oauth2. 41 | 42 | verifier := oauth2.GenerateVerifier() 43 | state := uuid.New() 44 | 45 | url := s.conf.AuthCodeURL(state.String(), oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier)) 46 | stateCookie := &http.Cookie{ 47 | Name: STATE_COOKIE, 48 | Value: state.String(), 49 | MaxAge: 10 * 60, // 10 minutes in seconds 50 | Secure: true, 51 | HttpOnly: true, 52 | SameSite: http.SameSiteLaxMode, 53 | } 54 | 55 | http.SetCookie(w, stateCookie) 56 | w.Header().Set("location", url) 57 | w.WriteHeader(http.StatusFound) 58 | } 59 | 60 | // PostAuth is the landing page where the user lads after authorizing. As 61 | // above, you can do anything you want here. A common thing you might do is 62 | // get the user information and then perform some sort of account linking in 63 | // your database. 64 | func (s *Service) PostAuth(w http.ResponseWriter, r *http.Request) { 65 | state := r.URL.Query().Get("state") 66 | code := r.URL.Query().Get("code") 67 | 68 | stateCookie, err := r.Cookie(STATE_COOKIE) 69 | if err != nil { 70 | w.WriteHeader(http.StatusBadRequest) 71 | w.Write([]byte("state cookie not found")) 72 | return 73 | } 74 | 75 | // Important: Compare the state! This prevents CSRF attacks 76 | if state != stateCookie.Value { 77 | w.WriteHeader(http.StatusBadRequest) 78 | w.Write([]byte("invalid state")) 79 | return 80 | } 81 | 82 | _, err = s.conf.Exchange(r.Context(), code) 83 | if err != nil { 84 | w.WriteHeader(http.StatusBadRequest) 85 | w.Write([]byte(fmt.Sprintf("error exchange code for token: %v", err))) 86 | return 87 | } 88 | 89 | // Response contains an access token, now the world is your oyster. Get user information and perform account linking, or do whatever you want from here. 90 | 91 | w.WriteHeader(http.StatusOK) 92 | w.Write([]byte("All done! Please return to the app")) 93 | } 94 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/x509" 6 | "encoding/json" 7 | "encoding/pem" 8 | "fmt" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "strings" 13 | 14 | "github.com/copilot-extensions/function-calling-extension/agent" 15 | "github.com/copilot-extensions/function-calling-extension/config" 16 | "github.com/copilot-extensions/function-calling-extension/oauth" 17 | ) 18 | 19 | func main() { 20 | if err := run(); err != nil { 21 | fmt.Println(err) 22 | os.Exit(1) 23 | } 24 | } 25 | 26 | func run() error { 27 | pubKey, err := fetchPublicKey() 28 | if err != nil { 29 | return fmt.Errorf("failed to fetch public key: %w", err) 30 | } 31 | 32 | config, err := config.New() 33 | if err != nil { 34 | return fmt.Errorf("error fetching config: %w", err) 35 | } 36 | 37 | me, err := url.Parse(config.FQDN) 38 | if err != nil { 39 | return fmt.Errorf("unable to parse HOST environment variable: %w", err) 40 | } 41 | 42 | me.Path = "auth/callback" 43 | 44 | oauthService := oauth.NewService(config.ClientID, config.ClientSecret, me.String()) 45 | http.HandleFunc("/auth/authorization", oauthService.PreAuth) 46 | http.HandleFunc("/auth/callback", oauthService.PostAuth) 47 | 48 | agentService := agent.NewService(pubKey) 49 | 50 | http.HandleFunc("/agent", agentService.ChatCompletion) 51 | 52 | fmt.Println("Listening on port", config.Port) 53 | return http.ListenAndServe(":"+config.Port, nil) 54 | } 55 | 56 | // fetchPublicKey fetches the keys used to sign messages from copilot. Checking 57 | // the signature with one of these keys verifies that the request to the 58 | // completions API comes from GitHub and not elsewhere on the internet. 59 | func fetchPublicKey() (*ecdsa.PublicKey, error) { 60 | resp, err := http.Get("https://api.github.com/meta/public_keys/copilot_api") 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to fetch public key: %w", err) 63 | } 64 | defer resp.Body.Close() 65 | 66 | if resp.StatusCode != http.StatusOK { 67 | return nil, fmt.Errorf("failed to fetch public key: %s", resp.Status) 68 | } 69 | 70 | var respBody struct { 71 | PublicKeys []struct { 72 | Key string `json:"key"` 73 | IsCurrent bool `json:"is_current"` 74 | } `json:"public_keys"` 75 | } 76 | if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { 77 | return nil, fmt.Errorf("failed to decode public key: %w", err) 78 | } 79 | 80 | var rawKey string 81 | for _, pk := range respBody.PublicKeys { 82 | if pk.IsCurrent { 83 | rawKey = pk.Key 84 | break 85 | } 86 | } 87 | if rawKey == "" { 88 | return nil, fmt.Errorf("could not find current public key") 89 | } 90 | 91 | pubPemStr := strings.ReplaceAll(rawKey, "\\n", "\n") 92 | // Decode the Public Key 93 | block, _ := pem.Decode([]byte(pubPemStr)) 94 | if block == nil { 95 | return nil, fmt.Errorf("error parsing PEM block with GitHub public key") 96 | } 97 | 98 | // Create our ECDSA Public Key 99 | key, err := x509.ParsePKIXPublicKey(block.Bytes) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | // Because of documentation, we know it's a *ecdsa.PublicKey 105 | ecdsaKey, ok := key.(*ecdsa.PublicKey) 106 | if !ok { 107 | return nil, fmt.Errorf("GitHub key is not ECDSA") 108 | } 109 | 110 | return ecdsaKey, nil 111 | } 112 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Function Calling Extensions Sample 2 | 3 | ## Description 4 | This project is a Go application that demonstrates how to use function calling in an agent-based GitHub Copilot Extension. 5 | 6 | ## Prerequisites 7 | 8 | - Go 1.16 or higher 9 | - Set the following environment variables (example below): 10 | 11 | ``` 12 | export PORT=8080 13 | export CLIENT_ID=Iv1.0ae52273ad3193eb // the application id 14 | export CLIENT_SECRET="your_client_secret" // generate a new client secret for your application 15 | export FQDN=https://6de513480979.ngrok.app // use ngrok to expose a url 16 | ``` 17 | 18 | ## Installation: 19 | 1. Clone the repository: 20 | 21 | ``` 22 | git clone git@github.com:copilot-extensions/function-calling-extension.git 23 | cd function-calling-extension 24 | ``` 25 | 26 | 2. Install dependencies: 27 | 28 | ``` 29 | go mod tidy 30 | ``` 31 | 32 | ## Usage 33 | 34 | 1. Start up ngrok with the port provided: 35 | 36 | ``` 37 | ngrok http http://localhost:8080 38 | ``` 39 | 40 | 2. Set the environment variables (use the ngrok generated url for the `FDQN`) 41 | 3. Run the application: 42 | 43 | ``` 44 | go run . 45 | ``` 46 | 47 | ## Accessing the Agent in Chat: 48 | 49 | 1. In the `Copilot` tab of your Application settings (`https://github.com/settings/apps//agent`) 50 | - Set the URL that was set for your FQDN above with the endpoint `/agent` (e.g. `https://6de513480979.ngrok.app/agent`) 51 | - Set the Pre-Authorization URL with the endpoint `/auth/authorization` (e.g. `https://6de513480979.ngrok.app/auth/authorization`) 52 | 2. In the `General` tab of your application settings (`https://github.com/settings/apps/`) 53 | - Set the `Callback URL` with the `/auth/callback` endpoint (e.g. `https://6de513480979.ngrok.app/auth/callback`) 54 | - Set the `Homepage URL` with the base ngrok endpoint (e.g. `https://6de513480979.ngrok.app/auth/callback`) 55 | 3. Ensure your permissions are enabled in `Permissions & events` > 56 | - `Repository Permissions` > `Issues` > `Access: Read and Write` 57 | - `Account Permissions` > `Copilot Chat` > `Access: Read Only` 58 | 4. Ensure you install your application at (`https://github.com/apps/`) 59 | 5. Now if you go to `https://github.com/copilot` you can `@` your agent using the name of your application. 60 | 61 | ## What Can It Do 62 | 63 | Test out the agent with the following commands! 64 | 65 | | Description | Prompt | 66 | | --- |--- | 67 | | User asking `@agent` to create a GitHub issue | `@agent Create an issue in the repo (org/repo) with title "my first issue" and body "hooray I created an issue"` | 68 | | User asking `@agent` to list GitHub issues | `@agent list all issues in this repo (org/repo)` | 69 | 70 | ## Copilot Extensions Documentation 71 | - [Using Copilot Extensions](https://docs.github.com/en/copilot/using-github-copilot/using-extensions-to-integrate-external-tools-with-copilot-chat) 72 | - [About building Copilot Extensions](https://docs.github.com/en/copilot/building-copilot-extensions/about-building-copilot-extensions) 73 | - [Set up process](https://docs.github.com/en/copilot/building-copilot-extensions/setting-up-copilot-extensions) 74 | - [Communicating with the Copilot platform](https://docs.github.com/en/copilot/building-copilot-extensions/building-a-copilot-agent-for-your-copilot-extension/configuring-your-copilot-agent-to-communicate-with-the-copilot-platform) 75 | - [Communicating with GitHub](https://docs.github.com/en/copilot/building-copilot-extensions/building-a-copilot-agent-for-your-copilot-extension/configuring-your-copilot-agent-to-communicate-with-github) 76 | -------------------------------------------------------------------------------- /agent/service.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "crypto/sha256" 7 | "encoding/asn1" 8 | "encoding/base64" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "math/big" 13 | "net/http" 14 | 15 | "github.com/copilot-extensions/function-calling-extension/copilot" 16 | "github.com/google/go-github/v57/github" 17 | "github.com/invopop/jsonschema" 18 | "github.com/wk8/go-ordered-map/v2" 19 | ) 20 | 21 | var tools []copilot.FunctionTool 22 | 23 | func init() { 24 | listProperties := orderedmap.New[string, *jsonschema.Schema]() 25 | listProperties.Set("repository_owner", &jsonschema.Schema{ 26 | Type: "string", 27 | Description: "The owner of the repository", 28 | }) 29 | listProperties.Set("repository_name", &jsonschema.Schema{ 30 | Type: "string", 31 | Description: "The type of the repository", 32 | }) 33 | 34 | createProperties := orderedmap.New[string, *jsonschema.Schema]() 35 | createProperties.Set("repository_owner", &jsonschema.Schema{ 36 | Type: "string", 37 | Description: "The owner of the repository", 38 | }) 39 | createProperties.Set("repository_name", &jsonschema.Schema{ 40 | Type: "string", 41 | Description: "The name of the repository", 42 | }) 43 | createProperties.Set("issue_title", &jsonschema.Schema{ 44 | Type: "string", 45 | Description: "The title of the issue being created", 46 | }) 47 | createProperties.Set("issue_body", &jsonschema.Schema{ 48 | Type: "string", 49 | Description: "The content of the issue being created", 50 | }) 51 | 52 | tools = []copilot.FunctionTool{ 53 | { 54 | Type: "function", 55 | Function: copilot.Function{ 56 | Name: "list_issues", 57 | Description: "Fetch a list of issues from github.com for a given repository. Users may specify the repository owner and the repository name separately, or they may specify it in the form {repository_owner}/{repository_name}, or in the form github.com/{repository_owner}/{repository_name}.", 58 | Parameters: &jsonschema.Schema{ 59 | Type: "object", 60 | Properties: listProperties, 61 | Required: []string{"repository_owner", "repository_name"}, 62 | }, 63 | }, 64 | }, 65 | { 66 | Type: "function", 67 | Function: copilot.Function{ 68 | Name: "create_issue_dialog", 69 | Description: "Creates a confirmation dialog in which the user can interact with in order to create an issue on a github.com repository. Only one dialog should be created for each issue/repository combination. Users may specify the repository owner and the repository name separately, or they may specify it in the form {repository_owner}/{repository_name}, or in the form github.com/{repository_owner}/{repository_name}.", 70 | Parameters: &jsonschema.Schema{ 71 | Type: "object", 72 | Properties: createProperties, 73 | Required: []string{"repository_owner", "repository_name", "issue_title", "issue_body"}, 74 | }, 75 | }, 76 | }, 77 | } 78 | } 79 | 80 | // Service provides and endpoint for this agent to perform chat completions 81 | type Service struct { 82 | pubKey *ecdsa.PublicKey 83 | } 84 | 85 | func NewService(pubKey *ecdsa.PublicKey) *Service { 86 | return &Service{ 87 | pubKey: pubKey, 88 | } 89 | } 90 | 91 | func (s *Service) ChatCompletion(w http.ResponseWriter, r *http.Request) { 92 | sig := r.Header.Get("Github-Public-Key-Signature") 93 | 94 | body, err := io.ReadAll(r.Body) 95 | if err != nil { 96 | fmt.Println(fmt.Errorf("failed to read request body: %w", err)) 97 | w.WriteHeader(http.StatusInternalServerError) 98 | return 99 | } 100 | 101 | // Make sure the payload matches the signature. In this way, you can be sure 102 | // that an incoming request comes from github 103 | isValid, err := validPayload(body, sig, s.pubKey) 104 | if err != nil { 105 | fmt.Printf("failed to validate payload signature: %v\n", err) 106 | w.WriteHeader(http.StatusInternalServerError) 107 | return 108 | } 109 | if !isValid { 110 | http.Error(w, "invalid payload signature", http.StatusUnauthorized) 111 | return 112 | } 113 | 114 | apiToken := r.Header.Get("X-GitHub-Token") 115 | integrationID := r.Header.Get("Copilot-Integration-Id") 116 | 117 | var req *copilot.ChatRequest 118 | if err := json.Unmarshal(body, &req); err != nil { 119 | fmt.Printf("failed to unmarshal request: %v\n", err) 120 | w.WriteHeader(http.StatusBadRequest) 121 | return 122 | } 123 | if err := generateCompletion(r.Context(), integrationID, apiToken, req, NewSSEWriter(w)); err != nil { 124 | fmt.Printf("failed to execute agent: %v\n", err) 125 | w.WriteHeader(http.StatusInternalServerError) 126 | } 127 | } 128 | 129 | func generateCompletion(ctx context.Context, integrationID, apiToken string, req *copilot.ChatRequest, w *sseWriter) error { 130 | // If the user clicks a confirmation box, handle that, and ignore everything else. 131 | for _, conf := range req.Messages[len(req.Messages)-1].Confirmations { 132 | if conf.State != "accepted" { 133 | continue 134 | } 135 | 136 | err := createIssue(ctx, apiToken, conf.Confirmation.Owner, conf.Confirmation.Repo, conf.Confirmation.Title, conf.Confirmation.Body) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | w.writeData(sseResponse{ 142 | Choices: []sseResponseChoice{ 143 | { 144 | Index: 0, 145 | Delta: sseResponseMessage{ 146 | Role: "assistant", 147 | Content: fmt.Sprintf("Created issue %s on repository %s/%s", conf.Confirmation.Title, conf.Confirmation.Owner, conf.Confirmation.Repo), 148 | }, 149 | }, 150 | }, 151 | }) 152 | 153 | return nil 154 | } 155 | 156 | var messages []copilot.ChatMessage 157 | var confs []copilot.ConfirmationData 158 | messages = append(messages, req.Messages...) 159 | 160 | // The LLM will call functions when it wants more information. We loop here in 161 | // order to feed that information back into the LLM 162 | for i := 0; i < 5; i++ { 163 | 164 | chatReq := &copilot.ChatCompletionsRequest{ 165 | Model: copilot.ModelGPT35, 166 | Messages: messages, 167 | } 168 | 169 | // Only give the LLM tools if we're not on the last loop. On the final 170 | // iteration, we want to force a chat completion out of the LLM 171 | if i < 4 { 172 | chatReq.Tools = tools 173 | } 174 | 175 | res, err := copilot.ChatCompletions(ctx, integrationID, apiToken, chatReq) 176 | if err != nil { 177 | return fmt.Errorf("failed to get chat completions stream: %w", err) 178 | } 179 | 180 | function := getFunctionCall(res) 181 | 182 | // If there's no function call, send the completion back to the client 183 | if function == nil { 184 | choices := make([]sseResponseChoice, len(res.Choices)) 185 | for i, choice := range res.Choices { 186 | choices[i] = sseResponseChoice{ 187 | Index: choice.Index, 188 | Delta: sseResponseMessage{ 189 | Role: choice.Message.Role, 190 | Content: choice.Message.Content, 191 | }, 192 | } 193 | } 194 | 195 | w.writeData(sseResponse{ 196 | Choices: choices, 197 | }) 198 | w.writeDone() 199 | break 200 | } 201 | 202 | fmt.Println("found function!", function.Name) 203 | 204 | switch function.Name { 205 | 206 | case "list_issues": 207 | args := &struct { 208 | Owner string `json:"repository_owner"` 209 | Name string `json:"repository_name"` 210 | }{} 211 | err := json.Unmarshal([]byte(function.Arguments), &args) 212 | if err != nil { 213 | return fmt.Errorf("error unmarshalling function arguments: %w", err) 214 | } 215 | msg, err := listIssues(ctx, apiToken, args.Owner, args.Name) 216 | if err != nil { 217 | return err 218 | } 219 | messages = append(messages, *msg) 220 | case "create_issue_dialog": 221 | args := &struct { 222 | Owner string `json:"repository_owner"` 223 | Name string `json:"repository_name"` 224 | Title string `json:"issue_title"` 225 | Body string `json:"issue_body"` 226 | }{} 227 | err := json.Unmarshal([]byte(function.Arguments), &args) 228 | if err != nil { 229 | return fmt.Errorf("error unmarshalling function arguments: %w", err) 230 | } 231 | 232 | conf, msg := createIssueConfirmation(args.Owner, args.Name, args.Title, args.Body) 233 | 234 | found := false 235 | for _, existing_conf := range confs { 236 | if *conf.Confirmation == existing_conf { 237 | found = true 238 | break 239 | } 240 | } 241 | 242 | if !found { 243 | confs = append(confs, *conf.Confirmation) 244 | 245 | if err := w.writeEvent("copilot_confirmation"); err != nil { 246 | return fmt.Errorf("failed to write event: %w", err) 247 | } 248 | 249 | if err := w.writeData(conf); err != nil { 250 | return fmt.Errorf("failed to write data: %w", err) 251 | } 252 | 253 | messages = append(messages, *msg) 254 | } 255 | default: 256 | return fmt.Errorf("unknown function call: %s", function.Name) 257 | } 258 | } 259 | return nil 260 | } 261 | 262 | func listIssues(ctx context.Context, apiToken, owner, repo string) (*copilot.ChatMessage, error) { 263 | client := github.NewClient(nil).WithAuthToken(apiToken) 264 | issues, _, err := client.Issues.ListByRepo(ctx, owner, repo, nil) 265 | if err != nil { 266 | return nil, fmt.Errorf("error fetching issues: %w", err) 267 | } 268 | 269 | serializedIssues, err := json.Marshal(issues) 270 | if err != nil { 271 | return nil, fmt.Errorf("error serializing issues") 272 | } 273 | 274 | return &copilot.ChatMessage{ 275 | Role: "system", 276 | Content: fmt.Sprintf("The issues for the repository %s/%s are: %s", owner, repo, string(serializedIssues)), 277 | }, nil 278 | } 279 | 280 | func createIssueConfirmation(owner, repo, title, body string) (*copilot.ResponseConfirmation, *copilot.ChatMessage) { 281 | return &copilot.ResponseConfirmation{ 282 | Type: "action", 283 | Title: "Create Issue", 284 | Message: fmt.Sprintf("Are you sure you want to create an issue in repository %s/%s with the title \"%s\" and the content \"%s\"", owner, repo, title, body), 285 | Confirmation: &copilot.ConfirmationData{ 286 | Owner: owner, 287 | Repo: repo, 288 | Title: title, 289 | Body: body, 290 | }, 291 | }, &copilot.ChatMessage{ 292 | Role: "system", 293 | Content: fmt.Sprintf("Issue dialog created: {\"issue_title\": \"%s\", \"issue_body\": \"%s\", \"repository_owner\": \"%s\", \"repository_name\": \"%s\"}", title, body, owner, repo), 294 | } 295 | } 296 | 297 | func createIssue(ctx context.Context, apiToken, owner, repo, title, body string) error { 298 | client := github.NewClient(nil).WithAuthToken(apiToken) 299 | _, _, err := client.Issues.Create(ctx, owner, repo, &github.IssueRequest{ 300 | Title: &title, 301 | Body: &body, 302 | }) 303 | if err != nil { 304 | return fmt.Errorf("error creating issue: %w", err) 305 | } 306 | 307 | return nil 308 | } 309 | 310 | // asn1Signature is a struct for ASN.1 serializing/parsing signatures. 311 | type asn1Signature struct { 312 | R *big.Int 313 | S *big.Int 314 | } 315 | 316 | func validPayload(data []byte, sig string, publicKey *ecdsa.PublicKey) (bool, error) { 317 | asnSig, err := base64.StdEncoding.DecodeString(sig) 318 | parsedSig := asn1Signature{} 319 | if err != nil { 320 | return false, err 321 | } 322 | rest, err := asn1.Unmarshal(asnSig, &parsedSig) 323 | if err != nil || len(rest) != 0 { 324 | return false, err 325 | } 326 | 327 | // Verify the SHA256 encoded payload against the signature with GitHub's Key 328 | digest := sha256.Sum256(data) 329 | return ecdsa.Verify(publicKey, digest[:], parsedSig.R, parsedSig.S), nil 330 | } 331 | 332 | func getFunctionCall(res *copilot.ChatCompletionsResponse) *copilot.ChatMessageFunctionCall { 333 | if len(res.Choices) == 0 { 334 | return nil 335 | } 336 | 337 | if len(res.Choices[0].Message.ToolCalls) == 0 { 338 | return nil 339 | } 340 | 341 | funcCall := res.Choices[0].Message.ToolCalls[0].Function 342 | if funcCall == nil { 343 | return nil 344 | } 345 | return funcCall 346 | 347 | } 348 | --------------------------------------------------------------------------------