├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── aicontext.go ├── bigquery.go ├── cmd └── labeler │ ├── main.go │ ├── test.go │ └── test_results.md ├── ghapi ├── ghapi.go └── page.go ├── go.mod ├── go.sum ├── httpjson ├── error.go └── json.go ├── img └── example-label.png ├── indexer.go └── webhook.go /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Cloud Run 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # This ensures only one deployment job runs at a time 9 | # If a second deployment is triggered, it will cancel the first one 10 | concurrency: 11 | group: deploy-to-cloud-run 12 | cancel-in-progress: true 13 | 14 | env: 15 | PROJECT_ID: coder-labeler 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | environment: prod 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v3 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: "1.21" # Adjust this to your Go version 30 | 31 | - name: Google Auth 32 | id: auth 33 | uses: "google-github-actions/auth@v1" 34 | with: 35 | credentials_json: "${{ secrets.GCP_SA_KEY }}" 36 | 37 | - name: Set up Cloud SDK 38 | uses: "google-github-actions/setup-gcloud@v1" 39 | 40 | - name: Configure Docker 41 | run: gcloud auth configure-docker us-central1-docker.pkg.dev 42 | 43 | - name: Deploy using Makefile 44 | run: make deploy 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | bin/** 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21 2 | 3 | ADD ./bin/labeler /bin/labeler 4 | ENTRYPOINT [ "/bin/labeler" ] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | 3 | PROJECT := coder-labeler 4 | COMMIT_SHA := $(shell git rev-parse --short HEAD) 5 | DOCKER_TAG := us-central1-docker.pkg.dev/$(PROJECT)/labeler/labeler:$(COMMIT_SHA) 6 | 7 | .PHONY: build push deploy 8 | 9 | build: 10 | # May need to run: 11 | # gcloud auth configure-docker \ 12 | # us-central1-docker.pkg.dev 13 | mkdir -p bin 14 | GOOS=linux GOARCH=amd64 go build -o bin/labeler ./cmd/labeler 15 | docker build -t $(DOCKER_TAG) . 16 | 17 | push: build 18 | docker push $(DOCKER_TAG) 19 | 20 | deploy: push 21 | # we keep CPU always allocated for background processing issue 22 | # indexing (WIP) and to eventually set labels outside of the 23 | # request-response cycle (escaping 10s webhook timeout) 24 | gcloud run deploy labeler --project $(PROJECT) --image $(DOCKER_TAG) --region us-central1 \ 25 | --allow-unauthenticated --memory=512Mi \ 26 | --min-instances=1 --no-cpu-throttling \ 27 | --set-secrets=OPENAI_API_KEY=openai-key:latest,GITHUB_APP_PEM=github-app-key:latest -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # labeler 2 | 3 | `labeler` is a GitHub app that automatically labels newly created issues for you 4 | based on your past labelling decisions. 5 | 6 | 7 | > [!IMPORTANT] 8 | > You can install coder-labeler on your repo 9 | > [**here**](https://github.com/marketplace/coder-labeler). 10 | 11 | ![img](./img/example-label.png) 12 | 13 | We currently use it on [`coder/coder`](https://github.com/coder/coder) and 14 | [`coder/code-server`](https://github.com/coder/code-server). 15 | 16 | The labeler is well-suited to manage labels that _describe_ or _reduce_ the 17 | semantic information of an issue. For example, labels like `bug`, `enhancement`, 18 | are self-evident from the contents of an issue. Often, a tracker will use labels 19 | that add information to an issue, e.g. `wontfix`, `roadmap`. These _inscriptive_ 20 | labels should be disabled in your configuration. 21 | 22 | [#5](https://github.com/coder/labeler/issues/5) tracks the automatic disabling 23 | of inscriptive labels. 24 | 25 | ## Configuration 26 | 27 | The labeler's primary configuration is your label descriptions. This way, the labeler interprets your label system in the same way a human would. 28 | 29 | Additionally, the `labeler` reads your `.github/labeler.yml` 30 | file for a list of Regex exclusions. Here's an example: 31 | 32 | ```yaml 33 | # .github/labeler.yml 34 | exclude: 35 | - good first issue 36 | - customer.*$ 37 | ``` 38 | 39 | [#4](https://github.com/coder/labeler/issues/4) tracks the creation 40 | of a dashboard for debugging configuration. 41 | 42 | ## Architecture 43 | 44 | ```mermaid 45 | sequenceDiagram 46 | participant GitHub 47 | participant Labeler as @coder-labeler 48 | participant AI as OpenAI 49 | GitHub->>Labeler: [Create|Reopen] Issue event 50 | note over Labeler: Load repo data, all labeling is stateless 51 | Labeler->GitHub: Get all repo issue labels 52 | Labeler->GitHub: Get last 100 repo issues 53 | Labeler->AI: Generate setLabels via GPT completion 54 | Labeler ->> GitHub: Add labels to issue 55 | ``` 56 | 57 | The labeler uses a GPT-4o completion with the past 100 opened issues instead of 58 | a more complex vector DB / embedding system. This is because of the proven 59 | accuracy of @cdr-bot on coder/coder and the fact that the completion approach lets us remove 60 | the need for a DB. 61 | 62 | ### Context construction 63 | 64 | See [aicontext.go](./aicontext.go) for the code that constructs the GPT context. 65 | -------------------------------------------------------------------------------- /aicontext.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ammario/prefixsuffix" 8 | "github.com/google/go-github/v59/github" 9 | "github.com/sashabaranov/go-openai" 10 | "github.com/sashabaranov/go-openai/jsonschema" 11 | "github.com/tiktoken-go/tokenizer" 12 | ) 13 | 14 | // aiContext contains and generates the GPT-4 aiContext used for label generation. 15 | type aiContext struct { 16 | allLabels []*github.Label 17 | lastIssues []*github.Issue 18 | targetIssue *github.Issue 19 | } 20 | 21 | func issueToText(issue *github.Issue) string { 22 | var sb strings.Builder 23 | fmt.Fprintf(&sb, "=== ISSUE %v ===\n", issue.GetNumber()) 24 | fmt.Fprintf(&sb, "author: %s (%s)\n", issue.GetUser().GetLogin(), issue.GetAuthorAssociation()) 25 | var labels []string 26 | for _, label := range issue.Labels { 27 | labels = append(labels, label.GetName()) 28 | } 29 | fmt.Fprintf(&sb, "labels: %s\n", labels) 30 | sb.WriteString("title: " + issue.GetTitle()) 31 | sb.WriteString("\n") 32 | 33 | saver := prefixsuffix.Saver{ 34 | // Max 1000 characters per issue. 35 | N: 500, 36 | } 37 | saver.Write([]byte(issue.GetBody())) 38 | sb.Write(saver.Bytes()) 39 | fmt.Fprintf(&sb, "\n=== END ISSUE %v ===\n", issue.GetNumber()) 40 | 41 | return sb.String() 42 | } 43 | 44 | func countTokens(msgs ...openai.ChatCompletionMessage) int { 45 | enc, err := tokenizer.Get(tokenizer.Cl100kBase) 46 | if err != nil { 47 | panic("oh oh") 48 | } 49 | 50 | var tokens int 51 | for _, msg := range msgs { 52 | ts, _, _ := enc.Encode(msg.Content) 53 | tokens += len(ts) 54 | 55 | for _, call := range msg.ToolCalls { 56 | ts, _, _ = enc.Encode(call.Function.Arguments) 57 | tokens += len(ts) 58 | } 59 | } 60 | return tokens 61 | } 62 | 63 | // magicDisableString is deprecated as the original recommendation 64 | // for disabling inscriptive labels. 65 | const magicDisableString = "Only humans may set this" 66 | 67 | // Request generates the messages to be used in the GPT-4 context. 68 | func (c *aiContext) Request( 69 | model string, 70 | ) openai.ChatCompletionRequest { 71 | var labelsDescription strings.Builder 72 | for _, label := range c.allLabels { 73 | labelsDescription.WriteString(label.GetName()) 74 | labelsDescription.WriteString(": ") 75 | labelsDescription.WriteString(label.GetDescription()) 76 | labelsDescription.WriteString("\n") 77 | } 78 | 79 | const labelFuncName = "setLabels" 80 | request := openai.ChatCompletionRequest{ 81 | Model: model, 82 | // We use LogProbs to determine level of confidence. 83 | LogProbs: true, 84 | // Want high determinism. 85 | Temperature: 0, 86 | ResponseFormat: &openai.ChatCompletionResponseFormat{ 87 | Type: openai.ChatCompletionResponseFormatTypeJSONSchema, 88 | JSONSchema: &openai.ChatCompletionResponseFormatJSONSchema{ 89 | Name: labelFuncName, 90 | Description: `Label the GitHub issue with the given labels.`, 91 | Schema: jsonschema.Definition{ 92 | Type: jsonschema.Object, 93 | Properties: map[string]jsonschema.Definition{ 94 | "reasoning": { 95 | Description: "The reasoning for the labels. Maximum of one sentence per label.", 96 | Type: jsonschema.String, 97 | }, 98 | "labels": { 99 | Type: jsonschema.Array, 100 | Items: &jsonschema.Definition{Type: jsonschema.String}, 101 | }, 102 | }, 103 | Required: []string{"reasoning", "labels"}, 104 | AdditionalProperties: false, 105 | }, 106 | Strict: true, 107 | }, 108 | }, 109 | } 110 | 111 | constructMsgs: 112 | var msgs []openai.ChatCompletionMessage 113 | 114 | // System message with instructions 115 | msgs = append(msgs, openai.ChatCompletionMessage{ 116 | Role: "system", 117 | Content: `You are a bot that helps label issues on GitHub using the "setLabels" 118 | function. Do not apply labels that are meant for Pull Requests. Avoid applying labels when 119 | the label description says something like "` + magicDisableString + `". 120 | Only apply labels when absolutely certain they are correct. An accidental 121 | omission of a label is better than an accidental addition. 122 | Multiple labels can be applied to a single issue if appropriate.`, 123 | }) 124 | 125 | // System message with label descriptions 126 | msgs = append(msgs, 127 | openai.ChatCompletionMessage{ 128 | Role: "system", 129 | Content: "The labels available are: \n" + labelsDescription.String(), 130 | }, 131 | ) 132 | 133 | // Create a single blob of past issues 134 | var pastIssuesBlob strings.Builder 135 | pastIssuesBlob.WriteString("Here are some examples of past issues and their labels:\n\n") 136 | 137 | for _, issue := range c.lastIssues { 138 | pastIssuesBlob.WriteString(issueToText(issue)) 139 | pastIssuesBlob.WriteString("\n\n") 140 | } 141 | 142 | // Add past issues blob as a system message 143 | msgs = append(msgs, openai.ChatCompletionMessage{ 144 | Role: "system", 145 | Content: pastIssuesBlob.String(), 146 | }) 147 | 148 | // Add the target issue 149 | msgs = append(msgs, openai.ChatCompletionMessage{ 150 | Role: openai.ChatMessageRoleUser, 151 | Content: issueToText(c.targetIssue), 152 | }) 153 | 154 | modelTokenLimit := 128000 155 | 156 | // Check token limit and adjust if necessary 157 | if countTokens(msgs...) > modelTokenLimit && len(c.lastIssues) > 1 { 158 | // Reduce the number of past issues 159 | c.lastIssues = c.lastIssues[:len(c.lastIssues)/2] 160 | goto constructMsgs 161 | } 162 | 163 | request.Messages = msgs 164 | return request 165 | } 166 | 167 | func tokenize(text string) []string { 168 | enc, err := tokenizer.Get(tokenizer.Cl100kBase) 169 | if err != nil { 170 | panic(err) 171 | } 172 | 173 | _, strs, err := enc.Encode(text) 174 | if err != nil { 175 | panic(err) 176 | } 177 | 178 | return strs 179 | } 180 | -------------------------------------------------------------------------------- /bigquery.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import "time" 4 | 5 | // BqIssue represents a GitHub issue in BigQuery. 6 | // The schema is defined here: 7 | // https://console.cloud.google.com/bigquery?authuser=1&folder=297399687849&organizationId=867596835188&orgonly=true&project=coder-labeler&supportedpurview=organizationId&ws=!1m5!1m4!4m3!1scoder-labeler!2sghindex!3sissues. 8 | // 9 | // CREATE VECTOR INDEX my_index ON my_dataset.my_table(embedding) 10 | // OPTIONS(index_type = 'IVF', distance_type = 'COSINE', 11 | // ivf_options = '{"num_lists": 2500}') 12 | type BqIssue struct { 13 | ID int64 `bigquery:"id"` 14 | InstallID int64 `bigquery:"install_id"` 15 | User string `bigquery:"user"` 16 | Repo string `bigquery:"repo"` 17 | Number int `bigquery:"number"` 18 | Title string `bigquery:"title"` 19 | State string `bigquery:"state"` 20 | Body string `bigquery:"body"` 21 | CreatedAt time.Time `bigquery:"created_at"` 22 | UpdatedAt time.Time `bigquery:"updated_at"` 23 | InsertedAt time.Time `bigquery:"inserted_at"` 24 | Embedding []float64 `bigquery:"embedding"` 25 | PullRequest bool `bigquery:"pull_request"` 26 | } 27 | -------------------------------------------------------------------------------- /cmd/labeler/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "cloud.google.com/go/bigquery" 15 | "cloud.google.com/go/compute/metadata" 16 | "github.com/beatlabs/github-auth/app" 17 | appkey "github.com/beatlabs/github-auth/key" 18 | "github.com/coder/labeler" 19 | "github.com/coder/retry" 20 | "github.com/coder/serpent" 21 | "github.com/go-chi/chi/v5" 22 | "github.com/jussi-kalliokoski/slogdriver" 23 | "github.com/lmittmann/tint" 24 | "github.com/sashabaranov/go-openai" 25 | ) 26 | 27 | func newLogger() *slog.Logger { 28 | gcpProjectID, err := metadata.ProjectID() 29 | if err != nil { 30 | logOpts := &tint.Options{ 31 | AddSource: true, 32 | Level: slog.LevelDebug, 33 | TimeFormat: time.Kitchen + " 05.000", 34 | } 35 | return slog.New(tint.NewHandler(os.Stderr, logOpts)) 36 | } 37 | 38 | return slog.New( 39 | slogdriver.NewHandler( 40 | os.Stderr, 41 | slogdriver.Config{ 42 | ProjectID: gcpProjectID, 43 | Level: slog.LevelDebug, 44 | }, 45 | ), 46 | ) 47 | } 48 | 49 | type rootCmd struct { 50 | appPEMFile string 51 | appPEMEnv string 52 | appID string 53 | openAIKey string 54 | openAIModel string 55 | bindAddr string 56 | googleProjectID string 57 | indexInterval time.Duration 58 | } 59 | 60 | func (r *rootCmd) appConfig() (*app.Config, error) { 61 | var ( 62 | err error 63 | appKey *rsa.PrivateKey 64 | ) 65 | if r.appPEMEnv != "" { 66 | appKey, err = appkey.Parse([]byte(r.appPEMEnv)) 67 | if err != nil { 68 | return nil, fmt.Errorf("parse app key: %w", err) 69 | } 70 | } else { 71 | appKey, err = appkey.FromFile(r.appPEMFile) 72 | if err != nil { 73 | return nil, fmt.Errorf("load app key: %w", err) 74 | } 75 | } 76 | 77 | appConfig, err := app.NewConfig(r.appID, appKey) 78 | if err != nil { 79 | return nil, fmt.Errorf("create app config: %w", err) 80 | } 81 | 82 | return appConfig, nil 83 | } 84 | 85 | func (r *rootCmd) ai(ctx context.Context) (*openai.Client, error) { 86 | openAIKey := strings.TrimSpace(r.openAIKey) 87 | 88 | if openAIKey == "" { 89 | return nil, fmt.Errorf("openai-key is required") 90 | } 91 | 92 | oai := openai.NewClient(openAIKey) 93 | 94 | // Validate the OpenAI API key. 95 | _, err := oai.ListModels(ctx) 96 | if err != nil { 97 | return nil, fmt.Errorf("list models: %w", err) 98 | } 99 | 100 | return openai.NewClient(openAIKey), nil 101 | } 102 | 103 | func main() { 104 | log := newLogger() 105 | var root rootCmd 106 | cmd := &serpent.Command{ 107 | Use: "labeler", 108 | Short: "labeler is the GitHub labeler backend service", 109 | Children: []*serpent.Command{ 110 | root.testCmd(), 111 | }, 112 | Handler: func(inv *serpent.Invocation) error { 113 | log.Debug("starting labeler") 114 | if root.appPEMFile == "" { 115 | return fmt.Errorf("app-pem-file is required") 116 | } 117 | 118 | ctx, cancel := context.WithCancel(context.Background()) 119 | defer cancel() 120 | 121 | oai, err := root.ai(ctx) 122 | if err != nil { 123 | return fmt.Errorf("openai: %w", err) 124 | } 125 | 126 | appConfig, err := root.appConfig() 127 | if err != nil { 128 | return fmt.Errorf("app config: %w", err) 129 | } 130 | 131 | bindAddr := root.bindAddr 132 | // support Cloud Run 133 | port := os.Getenv("PORT") 134 | if port != "" { 135 | bindAddr = ":" + port 136 | } 137 | 138 | listener, err := net.Listen("tcp", bindAddr) 139 | if err != nil { 140 | return fmt.Errorf("listen: %w", err) 141 | } 142 | log.Info("listening", "addr", listener.Addr()) 143 | 144 | go func() { 145 | <-ctx.Done() 146 | listener.Close() 147 | }() 148 | 149 | wh := &labeler.Webhook{ 150 | Log: log, 151 | OpenAI: oai, 152 | Model: root.openAIModel, 153 | AppConfig: appConfig, 154 | } 155 | 156 | mux := chi.NewMux() 157 | 158 | wh.Init(mux) 159 | 160 | bqClient, err := bigquery.NewClient(ctx, root.googleProjectID) 161 | if err != nil { 162 | return fmt.Errorf("bigquery: %w", err) 163 | } 164 | defer bqClient.Close() 165 | 166 | idx := &labeler.Indexer{ 167 | Log: log, 168 | OpenAI: oai, 169 | AppConfig: appConfig, 170 | BigQuery: bqClient, 171 | IndexInterval: root.indexInterval, 172 | } 173 | 174 | go func() { 175 | if root.indexInterval == 0 { 176 | return 177 | } 178 | ret := retry.New(time.Second, time.Minute) 179 | 180 | retry: 181 | err := idx.Run(ctx) 182 | if err != nil { 183 | log.Error("indexer run", "err", err) 184 | if ret.Wait(ctx) { 185 | goto retry 186 | } 187 | } 188 | }() 189 | 190 | return http.Serve(listener, mux) 191 | }, 192 | Options: []serpent.Option{ 193 | { 194 | Flag: "app-pem-file", 195 | Default: "./app.pem", 196 | Description: "Path to the GitHub App PEM file.", 197 | Required: true, 198 | Value: serpent.StringOf(&root.appPEMFile), 199 | }, 200 | { 201 | Flag: "app-id", 202 | Default: "843202", 203 | Description: "GitHub App ID.", 204 | Required: true, 205 | Value: serpent.StringOf(&root.appID), 206 | }, 207 | { 208 | Flag: "bind-addr", 209 | Description: "Address to bind to.", 210 | Default: "localhost:8080", 211 | Value: serpent.StringOf(&root.bindAddr), 212 | }, 213 | { 214 | Flag: "openai-model", 215 | Default: openai.GPT4oMini, 216 | Description: "OpenAI model to use.", 217 | Value: serpent.StringOf(&root.openAIModel), 218 | }, 219 | // SECRETS: only configurable via environment variables. 220 | { 221 | Description: "OpenAI API key.", 222 | Env: "OPENAI_API_KEY", 223 | Required: true, 224 | Value: serpent.StringOf(&root.openAIKey), 225 | }, 226 | { 227 | Env: "GITHUB_APP_PEM", 228 | Description: "APP PEM in raw form.", 229 | Value: serpent.StringOf(&root.appPEMEnv), 230 | }, 231 | { 232 | Flag: "google-project-id", 233 | Env: "GOOGLE_PROJECT_ID", 234 | Value: serpent.StringOf(&root.googleProjectID), 235 | Default: "coder-labeler", 236 | }, 237 | { 238 | Flag: "index-interval", 239 | Description: "Interval to run the indexer.", 240 | Value: serpent.DurationOf(&root.indexInterval), 241 | Default: "1h", 242 | }, 243 | }, 244 | } 245 | 246 | err := cmd.Invoke().WithOS().Run() 247 | if err != nil { 248 | os.Stderr.WriteString(err.Error() + "\n") 249 | os.Exit(1) 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /cmd/labeler/test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "slices" 9 | "sort" 10 | "sync" 11 | "text/tabwriter" 12 | "time" 13 | 14 | "github.com/coder/labeler" 15 | "github.com/coder/labeler/ghapi" 16 | "github.com/coder/serpent" 17 | "github.com/go-chi/chi/v5" 18 | "github.com/google/go-github/v59/github" 19 | ) 20 | 21 | type testStats struct { 22 | nIssues int 23 | 24 | hits []string 25 | 26 | // falseAdds is the number of false-adds, i.e. labels that were added but shouldn't have been. 27 | // These are worst than falseRms because it causes two issue events in the GitHub UI. 28 | // where-as falseRms only causes one. 29 | falseAdds []string 30 | 31 | // falseRms is the number of false-removes. 32 | falseRms []string 33 | 34 | tokens int 35 | tooks []time.Duration 36 | } 37 | 38 | func (s *testStats) process( 39 | w io.Writer, 40 | start time.Time, 41 | wantLabels []string, 42 | infResp *labeler.InferResponse, 43 | ) { 44 | s.nIssues++ 45 | 46 | slices.Sort(wantLabels) 47 | slices.Sort(infResp.SetLabels) 48 | 49 | fmt.Fprintf(w, "want: %v\n", wantLabels) 50 | fmt.Fprintf(w, "infer: %v\n", infResp.SetLabels) 51 | for _, label := range wantLabels { 52 | if !slices.Contains(infResp.SetLabels, label) { 53 | s.falseRms = append(s.falseRms, label) 54 | } else { 55 | s.hits = append(s.hits, label) 56 | } 57 | } 58 | for _, label := range infResp.SetLabels { 59 | if !slices.Contains(wantLabels, label) { 60 | s.falseAdds = append(s.falseAdds, label) 61 | } 62 | } 63 | s.tokens += infResp.TokensUsed 64 | s.tooks = append(s.tooks, time.Since(start)) 65 | } 66 | 67 | func uniqCount(ss []string) map[string]int { 68 | m := make(map[string]int) 69 | for _, s := range ss { 70 | m[s]++ 71 | } 72 | return m 73 | } 74 | 75 | type KV[Key any, Value any] struct { 76 | Key Key 77 | Value Value 78 | } 79 | 80 | func topN(m map[string]int, n int) []KV[string, int] { 81 | var kvs []KV[string, int] 82 | for k, v := range m { 83 | kvs = append(kvs, KV[string, int]{k, v}) 84 | } 85 | sort.Slice(kvs, func(i, j int) bool { 86 | return kvs[i].Value > kvs[j].Value 87 | }) 88 | if len(kvs) < n { 89 | n = len(kvs) 90 | } 91 | return kvs[:n] 92 | } 93 | 94 | func (kv *KV[Key, Value]) String() string { 95 | return fmt.Sprintf("%v: %v", kv.Key, kv.Value) 96 | } 97 | 98 | func (s *testStats) print(w io.Writer) error { 99 | twr := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0) 100 | 101 | fmt.Fprintf(twr, "Total issues:\t%d\n", s.nIssues) 102 | fmt.Fprintf(twr, "False adds:\t%d\t%.2f%%\n", len(s.falseAdds), float64(len(s.falseAdds))/float64(s.nIssues)*100) 103 | fmt.Fprintf(twr, "Top false adds:\t%v\n", topN(uniqCount(s.falseAdds), 20)) 104 | 105 | fmt.Fprintf(twr, "False removes:\t%d\t%.2f%%\n", len(s.falseRms), float64(len(s.falseRms))/float64(s.nIssues)*100) 106 | fmt.Fprintf(twr, "Top false removes:\t%v\n", topN(uniqCount(s.falseRms), 20)) 107 | 108 | fmt.Fprintf(twr, "Hits:\t%d\t%.2f%%\n", len(s.hits), float64(len(s.hits))/float64(s.nIssues)*100) 109 | fmt.Fprintf(twr, "Top hit labels:\t%v\n", topN(uniqCount(s.hits), 20)) 110 | 111 | fmt.Fprintf(twr, "Tokens used:\t%d\n", s.tokens) 112 | return twr.Flush() 113 | } 114 | 115 | func (r *rootCmd) testCmd() *serpent.Command { 116 | var ( 117 | installID string 118 | user string 119 | repo string 120 | nIssues int64 121 | ) 122 | return &serpent.Command{ 123 | Use: "test", 124 | Short: "Test performance and accuracy of a given repo", 125 | Handler: func(inv *serpent.Invocation) error { 126 | log := newLogger() 127 | 128 | appConfig, err := r.appConfig() 129 | if err != nil { 130 | return err 131 | } 132 | 133 | ctx := inv.Context() 134 | 135 | ai, err := r.ai(ctx) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | srv := &labeler.Webhook{ 141 | Log: log, 142 | OpenAI: ai, 143 | Model: r.openAIModel, 144 | AppConfig: appConfig, 145 | } 146 | mux := chi.NewMux() 147 | srv.Init(mux) 148 | 149 | instConfig, err := appConfig.InstallationConfig(installID) 150 | if err != nil { 151 | return fmt.Errorf("get installation config: %w", err) 152 | } 153 | 154 | githubClient := github.NewClient(instConfig.Client(ctx)) 155 | 156 | testIssues, err := ghapi.Page( 157 | ctx, 158 | githubClient, 159 | func(ctx context.Context, opt *github.ListOptions) ([]*github.Issue, *github.Response, error) { 160 | log.Info("load issues page from GitHub") 161 | issues, resp, err := githubClient.Issues.ListByRepo( 162 | ctx, 163 | user, 164 | repo, 165 | &github.IssueListByRepoOptions{ 166 | State: "all", 167 | ListOptions: *opt, 168 | }, 169 | ) 170 | 171 | return ghapi.OnlyTrueIssues(issues), resp, err 172 | }, 173 | int(nIssues), 174 | ) 175 | if err != nil { 176 | return fmt.Errorf("list issues: %w", err) 177 | } 178 | 179 | var ( 180 | st testStats 181 | stMu sync.Mutex 182 | semaphore = make(chan struct{}, 4) 183 | ) 184 | 185 | for i, issue := range testIssues { 186 | wantLabels := make([]string, 0, len(issue.Labels)) 187 | for _, label := range issue.Labels { 188 | wantLabels = append(wantLabels, label.GetName()) 189 | } 190 | 191 | var ( 192 | issue = issue 193 | i = i 194 | ) 195 | 196 | semaphore <- struct{}{} 197 | go func() { 198 | defer func() { 199 | <-semaphore 200 | }() 201 | 202 | ctx, cancel := context.WithTimeout(ctx, time.Minute) 203 | defer cancel() 204 | 205 | start := time.Now() 206 | 207 | resp, err := srv.Infer(ctx, &labeler.InferRequest{ 208 | InstallID: installID, 209 | User: user, 210 | Repo: repo, 211 | Issue: issue.GetNumber(), 212 | }) 213 | if err != nil { 214 | // It's typical of OpenAI to take a long time. 215 | log.Error("infer", "err", err) 216 | return 217 | } 218 | 219 | stMu.Lock() 220 | defer stMu.Unlock() 221 | 222 | log.Info("inferred issue", 223 | "i", i, 224 | "title", issue.GetTitle(), 225 | "url", issue.GetHTMLURL(), 226 | "took", time.Since(start).Truncate(time.Millisecond/10), 227 | "num", issue.GetNumber(), 228 | ) 229 | st.process(os.Stdout, start, wantLabels, resp) 230 | }() 231 | } 232 | 233 | for len(semaphore) > 0 { 234 | time.Sleep(time.Second) 235 | } 236 | 237 | return st.print(inv.Stdout) 238 | }, 239 | Options: []serpent.Option{ 240 | { 241 | Flag: "install-id", 242 | Value: serpent.StringOf(&installID), 243 | }, 244 | { 245 | Flag: "user", 246 | Value: serpent.StringOf(&user), 247 | }, 248 | { 249 | Flag: "repo", 250 | Value: serpent.StringOf(&repo), 251 | }, 252 | { 253 | Flag: "n-issues", 254 | Description: "Number of issues to test.", 255 | Value: serpent.Int64Of(&nIssues), 256 | Default: "10", 257 | }, 258 | }, 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /cmd/labeler/test_results.md: -------------------------------------------------------------------------------- 1 | @ammario collected these results on 2024-08-15 by running `labeler test` on 2 | `coder/coder`. 3 | 4 | 5 | ### gpt-4om 6 | 7 | ``` 8 | Total issues: 100 9 | False adds: 15 15.00% 10 | Top false adds: [{chore 2} {bug 2} {feature 2} {s3 2} {cli 1} {enterprise 1} {customer-reported 1} {regression 1} {need-backend 1} {waiting-for-info 1} {need-help 1}] 11 | False removes: 37 37.00% 12 | Top false removes: [{site 14} {s2 3} {design 3} {feature 3} {enterprise 3} {customer-requested 3} {cli 2} {roadmap-maybe 1} {security 1} {chore 1} {s1 1} {roadmap 1} {s3 1}] 13 | Hits: 250 250.00% 14 | Top hit labels: [{site 39} {bug 39} {feature 36} {enterprise 26} {design 26} {s3 24} {s4 8} {flake 8} {waiting-for-info 7} {good first issue 5} {customer-requested 4} {cli 4} {chore 4} {security 4} {need-backend 4} {observability 3} {regression 3} {api 2} {release/experimental 1} {customer-reported 1}] 15 | Tokens used: 2029452 16 | ``` 17 | 18 | -------------------------------------------------------------------------------- /ghapi/ghapi.go: -------------------------------------------------------------------------------- 1 | package ghapi 2 | -------------------------------------------------------------------------------- /ghapi/page.go: -------------------------------------------------------------------------------- 1 | package ghapi 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | 8 | "github.com/google/go-github/v59/github" 9 | ) 10 | 11 | // Page returns at most n items from a paginated list. 12 | func Page[T any]( 13 | ctx context.Context, 14 | client *github.Client, 15 | get func(context.Context, *github.ListOptions) ([]T, *github.Response, error), 16 | n int, 17 | ) ([]T, error) { 18 | var all []T 19 | if n == 0 { 20 | return all, nil 21 | } 22 | if n < 0 { 23 | n = math.MaxInt64 24 | } 25 | opt := &github.ListOptions{PerPage: 100} 26 | for { 27 | items, resp, err := get(ctx, opt) 28 | if err != nil { 29 | return nil, fmt.Errorf("list: %w", err) 30 | } 31 | for _, item := range items { 32 | all = append(all, item) 33 | if len(all) == n { 34 | return all, nil 35 | } 36 | } 37 | if resp.NextPage == 0 { 38 | break 39 | } 40 | opt.Page = resp.NextPage 41 | } 42 | return all, nil 43 | } 44 | 45 | func OnlyTrueIssues( 46 | slice []*github.Issue, 47 | ) []*github.Issue { 48 | var result []*github.Issue 49 | for _, item := range slice { 50 | if item.IsPullRequest() { 51 | continue 52 | } 53 | result = append(result, item) 54 | } 55 | return result 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/coder/labeler 2 | 3 | go 1.21.4 4 | 5 | require ( 6 | github.com/coder/serpent v0.7.1-0.20240425193715-6e887893bb6a 7 | github.com/google/go-github/v59 v59.0.0 8 | github.com/lmittmann/tint v1.0.4 9 | ) 10 | 11 | require ( 12 | cloud.google.com/go/compute/metadata v0.3.0 13 | github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd 14 | github.com/ammario/tlru v0.4.0 15 | github.com/jussi-kalliokoski/slogdriver v1.0.0 16 | google.golang.org/api v0.176.1 17 | ) 18 | 19 | require ( 20 | cloud.google.com/go v0.112.2 // indirect 21 | cloud.google.com/go/auth v0.3.0 // indirect 22 | cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect 23 | cloud.google.com/go/iam v1.1.7 // indirect 24 | github.com/apache/arrow/go/v15 v15.0.2 // indirect 25 | github.com/armon/go-radix v1.0.0 // indirect 26 | github.com/dlclark/regexp2 v1.11.0 // indirect 27 | github.com/felixge/httpsnoop v1.0.4 // indirect 28 | github.com/go-logr/logr v1.4.1 // indirect 29 | github.com/go-logr/stdr v1.2.2 // indirect 30 | github.com/goccy/go-json v0.10.2 // indirect 31 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 32 | github.com/google/flatbuffers v24.3.25+incompatible // indirect 33 | github.com/google/s2a-go v0.1.7 // indirect 34 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 35 | github.com/googleapis/gax-go/v2 v2.12.3 // indirect 36 | github.com/jussi-kalliokoski/goldjson v1.0.0 // indirect 37 | github.com/klauspost/compress v1.17.8 // indirect 38 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 39 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 40 | github.com/zeebo/xxh3 v1.0.2 // indirect 41 | go.opencensus.io v0.24.0 // indirect 42 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect 43 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect 44 | go.opentelemetry.io/otel/metric v1.26.0 // indirect 45 | golang.org/x/mod v0.17.0 // indirect 46 | golang.org/x/net v0.24.0 // indirect 47 | golang.org/x/sync v0.7.0 // indirect 48 | golang.org/x/text v0.14.0 // indirect 49 | golang.org/x/time v0.5.0 // indirect 50 | golang.org/x/tools v0.20.0 // indirect 51 | google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be // indirect 52 | google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be // indirect 53 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect 54 | google.golang.org/grpc v1.63.2 // indirect 55 | ) 56 | 57 | replace github.com/jussi-kalliokoski/slogdriver => github.com/ammario/slogdriver v0.0.0-20240312193005-cef0f67904ac 58 | 59 | require ( 60 | cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 // indirect 61 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 62 | github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect 63 | github.com/golang/protobuf v1.5.4 // indirect 64 | github.com/google/uuid v1.6.0 // indirect 65 | github.com/hashicorp/errwrap v1.1.0 // indirect 66 | github.com/hashicorp/go-multierror v1.1.1 // indirect 67 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 68 | github.com/mattn/go-isatty v0.0.20 // indirect 69 | github.com/mattn/go-runewidth v0.0.15 // indirect 70 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 71 | github.com/muesli/termenv v0.15.2 // indirect 72 | github.com/pion/transport/v2 v2.0.0 // indirect 73 | github.com/pion/udp v0.1.4 // indirect 74 | github.com/rivo/uniseg v0.4.4 // indirect 75 | github.com/spf13/pflag v1.0.5 // indirect 76 | go.opentelemetry.io/otel v1.26.0 // indirect 77 | go.opentelemetry.io/otel/trace v1.26.0 // indirect 78 | golang.org/x/crypto v0.22.0 // indirect 79 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f 80 | golang.org/x/oauth2 v0.19.0 // indirect 81 | golang.org/x/sys v0.19.0 // indirect 82 | golang.org/x/term v0.19.0 // indirect 83 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 84 | google.golang.org/protobuf v1.33.0 // indirect 85 | gopkg.in/yaml.v3 v3.0.1 86 | ) 87 | 88 | require ( 89 | cloud.google.com/go/bigquery v1.61.0 90 | github.com/beatlabs/github-auth v0.0.0-20240407205602-7a8272e15f92 91 | github.com/coder/retry v1.5.1 92 | github.com/go-chi/chi/v5 v5.0.12 93 | github.com/go-playground/webhooks/v6 v6.3.0 94 | github.com/google/go-querystring v1.1.0 // indirect 95 | github.com/sashabaranov/go-openai v1.28.2 96 | github.com/tiktoken-go/tokenizer v0.1.0 97 | ) 98 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 h1:KHblWIE/KHOwQ6lEbMZt6YpcGve2FEZ1sDtrW1Am5UI= 2 | cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ= 3 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 | cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw= 5 | cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms= 6 | cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs= 7 | cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w= 8 | cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= 9 | cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= 10 | cloud.google.com/go/bigquery v1.61.0 h1:w2Goy9n6gh91LVi6B2Sc+HpBl8WbWhIyzdvVvrAuEIw= 11 | cloud.google.com/go/bigquery v1.61.0/go.mod h1:PjZUje0IocbuTOdq4DBOJLNYB0WF3pAKBHzAYyxCwFo= 12 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 13 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 14 | cloud.google.com/go/datacatalog v1.20.0 h1:BGDsEjqpAo0Ka+b9yDLXnE5k+jU3lXGMh//NsEeDMIg= 15 | cloud.google.com/go/datacatalog v1.20.0/go.mod h1:fSHaKjIroFpmRrYlwz9XBB2gJBpXufpnxyAKaT4w6L0= 16 | cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM= 17 | cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA= 18 | cloud.google.com/go/logging v1.9.0 h1:iEIOXFO9EmSiTjDmfpbRjOxECO7R8C7b8IXUGOj7xZw= 19 | cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE= 20 | cloud.google.com/go/longrunning v0.5.6 h1:xAe8+0YaWoCKr9t1+aWe+OeQgN/iJK1fEgZSXmjuEaE= 21 | cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA= 22 | cloud.google.com/go/storage v1.40.0 h1:VEpDQV5CJxFmJ6ueWNsKxcr1QAYOXEgxDa+sBbJahPw= 23 | cloud.google.com/go/storage v1.40.0/go.mod h1:Rrj7/hKlG87BLqDJYtwR0fbPld8uJPbQ2ucUMY7Ir0g= 24 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 25 | github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd h1:WOzjyD34+0vVw3wzE7js8Yvzo08ljzvK1jG6wL8elVU= 26 | github.com/ammario/prefixsuffix v0.0.0-20200405191514-5a0456bf2cfd/go.mod h1:VM1c/0Tl3O26UkHMbU32VFqLwLvi2FA40b6s5vPOpoo= 27 | github.com/ammario/slogdriver v0.0.0-20240312193005-cef0f67904ac h1:qrVjHs1SLwMGomDLz3jc/EhuleikdJJlSX0negoWzxo= 28 | github.com/ammario/slogdriver v0.0.0-20240312193005-cef0f67904ac/go.mod h1:Oy7AdhJjHHFxlDlr3sNg5a5lgfoa1NC6FvHDH1rotbs= 29 | github.com/ammario/tlru v0.4.0 h1:sJ80I0swN3KOX2YxC6w8FbCqpQucWdbb+J36C05FPuU= 30 | github.com/ammario/tlru v0.4.0/go.mod h1:aYzRFu0XLo4KavE9W8Lx7tzjkX+pAApz+NgcKYIFUBQ= 31 | github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE= 32 | github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= 33 | github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= 34 | github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 35 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 36 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 37 | github.com/beatlabs/github-auth v0.0.0-20240407205602-7a8272e15f92 h1:gEfN/meEREQ0A3OrJ0MuqoYgwJO2vFjPw60hdyXoEgc= 38 | github.com/beatlabs/github-auth v0.0.0-20240407205602-7a8272e15f92/go.mod h1:RasAIhNyZCIZCBZLoUxvPFN+daPVJlWd8Xof3yhgDtw= 39 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 40 | github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= 41 | github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= 42 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 43 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 44 | github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= 45 | github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= 46 | github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= 47 | github.com/coder/retry v1.5.1/go.mod h1:blHMk9vs6LkoRT9ZHyuZo360cufXEhrxqvEzeMtRGoY= 48 | github.com/coder/serpent v0.7.1-0.20240425193715-6e887893bb6a h1:DPeHDdijMzYZ+xp0JDmUi1KdjC7Fy1mSwG2TiGcl9Qc= 49 | github.com/coder/serpent v0.7.1-0.20240425193715-6e887893bb6a/go.mod h1:REkJ5ZFHQUWFTPLExhXYZ1CaHFjxvGNRlLXLdsI08YA= 50 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 51 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 52 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 53 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 54 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 55 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 56 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 57 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 58 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 59 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 60 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 61 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 62 | github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= 63 | github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 64 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 65 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 66 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 67 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 68 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 69 | github.com/go-playground/webhooks/v6 v6.3.0 h1:zBLUxK1Scxwi97TmZt5j/B/rLlard2zY7P77FHg58FE= 70 | github.com/go-playground/webhooks/v6 v6.3.0/go.mod h1:GCocmfMtpJdkEOM1uG9p2nXzg1kY5X/LtvQgtPHUaaA= 71 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 72 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 73 | github.com/gogits/go-gogs-client v0.0.0-20200905025246-8bb8a50cb355/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0= 74 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 75 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 76 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 77 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 78 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 79 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 80 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 81 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 82 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 83 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 84 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 85 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 86 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 87 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 88 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 89 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 90 | github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= 91 | github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 92 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 93 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 94 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 95 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 96 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 97 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 98 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 99 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 100 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 101 | github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= 102 | github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM= 103 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 104 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 105 | github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= 106 | github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= 107 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= 108 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 109 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 110 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 111 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 112 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= 113 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 114 | github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= 115 | github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= 116 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 117 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 118 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 119 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 120 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 121 | github.com/jussi-kalliokoski/goldjson v1.0.0 h1:XqiUNujQ3e9mjFPsqEBTzaMVPNnMUlXa+yDEVT4Xla0= 122 | github.com/jussi-kalliokoski/goldjson v1.0.0/go.mod h1:KHjhomAO4vlPukhBzc5nwIJ2nNL39TLnEgoIsBd8bnY= 123 | github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= 124 | github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 125 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 126 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 127 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 128 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 129 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 130 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 131 | github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= 132 | github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 133 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 134 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 135 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 136 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 137 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 138 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 139 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 140 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 141 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 142 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 143 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 144 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 145 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 146 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 147 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= 148 | github.com/pion/transport/v2 v2.0.0 h1:bsMYyqHCbkvHwj+eNCFBuxtlKndKfyGI2vaQmM3fIE4= 149 | github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc= 150 | github.com/pion/udp v0.1.4 h1:OowsTmu1Od3sD6i3fQUJxJn2fEvJO6L1TidgadtbTI8= 151 | github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us= 152 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 153 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 154 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 155 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 156 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 157 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 158 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 159 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 160 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 161 | github.com/sashabaranov/go-openai v1.28.2 h1:Q3pi34SuNYNN7YrqpHlHbpeYlf75ljgHOAVM/r1yun0= 162 | github.com/sashabaranov/go-openai v1.28.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 163 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 164 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 165 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 166 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 167 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 168 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 169 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 170 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 171 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 172 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 173 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 174 | github.com/tiktoken-go/tokenizer v0.1.0 h1:c1fXriHSR/NmhMDTwUDLGiNhHwTV+ElABGvqhCWLRvY= 175 | github.com/tiktoken-go/tokenizer v0.1.0/go.mod h1:7SZW3pZUKWLJRilTvWCa86TOVIiiJhYj3FQ5V3alWcg= 176 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 177 | github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= 178 | github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 179 | github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= 180 | github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 181 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 182 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 183 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= 184 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= 185 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= 186 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= 187 | go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= 188 | go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= 189 | go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= 190 | go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= 191 | go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= 192 | go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= 193 | go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= 194 | go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= 195 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 196 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 197 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 198 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 199 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 200 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 201 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= 202 | golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 203 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 204 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 205 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 206 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 207 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 208 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 209 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 210 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 211 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 212 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 213 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 214 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 215 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 216 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 217 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 218 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 219 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 220 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 221 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 222 | golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= 223 | golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= 224 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 225 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 226 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 227 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 228 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 229 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 230 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 231 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 232 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 233 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 236 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 237 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 238 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 239 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 240 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 241 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 242 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 243 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 244 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 245 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 246 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 247 | golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= 248 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 249 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 250 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 251 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 252 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 253 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 254 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 255 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 256 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 257 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 258 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 259 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 260 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 261 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 262 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 263 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 264 | golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= 265 | golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= 266 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 267 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 268 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 269 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 270 | gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= 271 | gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= 272 | google.golang.org/api v0.176.1 h1:DJSXnV6An+NhJ1J+GWtoF2nHEuqB1VNoTfnIbjNvwD4= 273 | google.golang.org/api v0.176.1/go.mod h1:j2MaSDYcvYV1lkZ1+SMW4IeF90SrEyFA+tluDYWRrFg= 274 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 275 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 276 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 277 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 278 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 279 | google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be h1:g4aX8SUFA8V5F4LrSY5EclyGYw1OZN4HS1jTyjB9ZDc= 280 | google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be/go.mod h1:FeSdT5fk+lkxatqJP38MsUicGqHax5cLtmy/6TAuxO4= 281 | google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be h1:Zz7rLWqp0ApfsR/l7+zSHhY3PMiH2xqgxlfYfAfNpoU= 282 | google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be/go.mod h1:dvdCTIoAGbkWbcIKBniID56/7XHTt6WfxXNMxuziJ+w= 283 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A= 284 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= 285 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 286 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 287 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 288 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 289 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 290 | google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= 291 | google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= 292 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 293 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 294 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 295 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 296 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 297 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 298 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 299 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 300 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 301 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 302 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 303 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 304 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 305 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 306 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 307 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 308 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 309 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 310 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 311 | -------------------------------------------------------------------------------- /httpjson/error.go: -------------------------------------------------------------------------------- 1 | package httpjson 2 | 3 | // ErrorMessage is a helper to create a JSON error response 4 | // with a status code and a message. 5 | func ErrorMessage(status int, err error) *Response { 6 | return &Response{ 7 | Status: status, 8 | Body: M{ 9 | // In the future, we can extend this 10 | // logic to present structured errors. 11 | "error": err.Error(), 12 | }, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /httpjson/json.go: -------------------------------------------------------------------------------- 1 | package httpjson 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type Response struct { 9 | Status int 10 | Body any 11 | } 12 | 13 | // M is a helper type to create a map[string]interface{} 14 | type M map[string]interface{} 15 | 16 | type Handler func(w http.ResponseWriter, r *http.Request) *Response 17 | 18 | func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 19 | resp := (h)(w, r) 20 | Write(w, resp.Status, resp.Body) 21 | } 22 | 23 | func Write(w http.ResponseWriter, statusCode int, v interface{}) { 24 | enc := json.NewEncoder(w) 25 | enc.SetIndent("", " ") 26 | w.Header().Set("Content-Type", "application/json") 27 | w.WriteHeader(statusCode) 28 | _ = enc.Encode(v) 29 | } 30 | -------------------------------------------------------------------------------- /img/example-label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coder/labeler/db82312e8f02128e89f6c3b677581d24b138efb8/img/example-label.png -------------------------------------------------------------------------------- /indexer.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "math/rand" 8 | "strings" 9 | "time" 10 | 11 | "cloud.google.com/go/bigquery" 12 | "github.com/beatlabs/github-auth/app" 13 | "github.com/coder/labeler/ghapi" 14 | "github.com/google/go-github/v59/github" 15 | "github.com/sashabaranov/go-openai" 16 | "google.golang.org/api/iterator" 17 | ) 18 | 19 | type Indexer struct { 20 | Log *slog.Logger 21 | OpenAI *openai.Client 22 | AppConfig *app.Config 23 | BigQuery *bigquery.Client 24 | IndexInterval time.Duration 25 | } 26 | 27 | func (s *Indexer) findRandInstall(ctx context.Context) (*github.Installation, error) { 28 | client := github.NewClient(s.AppConfig.Client()) 29 | installations, err := ghapi.Page[*github.Installation]( 30 | ctx, 31 | client, 32 | func(ctx context.Context, opt *github.ListOptions) ([]*github.Installation, *github.Response, error) { 33 | return client.Apps.ListInstallations(ctx, opt) 34 | }, 35 | 1e6, 36 | ) 37 | if err != nil { 38 | return nil, fmt.Errorf("list installations: %w", err) 39 | } 40 | 41 | // We get a random installation because we have no guarantee 42 | // the labeler process will run for a long time and we want 43 | // to fairly index all organizations. This 44 | // avoids having to store some kind of index state. 45 | toIndex := installations[rand.Intn(len(installations))] 46 | return toIndex, nil 47 | } 48 | 49 | const embeddingDimensions = 256 50 | 51 | func f32to64(f []float32) []float64 { 52 | out := make([]float64, len(f)) 53 | for i, v := range f { 54 | out[i] = float64(v) 55 | } 56 | return out 57 | } 58 | 59 | func (s *Indexer) embedIssue(ctx context.Context, issue *github.Issue) ([]float64, error) { 60 | var buf strings.Builder 61 | fmt.Fprintf(&buf, "Title: %s\n", issue.GetTitle()) 62 | fmt.Fprintf(&buf, "State: %s\n", issue.GetState()) 63 | fmt.Fprintf(&buf, "Author: %s\n", issue.GetUser().GetLogin()) 64 | var labelNames []string 65 | for _, label := range issue.Labels { 66 | labelNames = append(labelNames, label.GetName()) 67 | } 68 | fmt.Fprintf(&buf, "Labels: %s\n", strings.Join(labelNames, ", ")) 69 | fmt.Fprintf(&buf, "Body: %s\n", issue.GetBody()) 70 | 71 | tokens := tokenize(buf.String()) 72 | if len(tokens) > 8191 { 73 | tokens = tokens[:8191] 74 | } 75 | resp, err := s.OpenAI.CreateEmbeddings( 76 | ctx, 77 | &openai.EmbeddingRequestStrings{ 78 | Model: openai.SmallEmbedding3, 79 | Input: []string{strings.Join(tokens, "")}, 80 | Dimensions: embeddingDimensions, 81 | }, 82 | ) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if len(resp.Data) != 1 { 88 | return nil, fmt.Errorf("expected 1 embedding, got %d", len(resp.Data)) 89 | } 90 | 91 | return f32to64(resp.Data[0].Embedding), nil 92 | } 93 | 94 | // issuesTableName is incremented with major schema changes since DML on 95 | // active tables is very slow. 96 | const issuesTableName = "issues_v2" 97 | 98 | func (s *Indexer) issuesTable() *bigquery.Table { 99 | return s.BigQuery.Dataset("ghindex").Table(issuesTableName) 100 | } 101 | 102 | // getUpdatedAts helps avoid duplicate inserts by letting the caller skip over 103 | // issues that have already been indexed. 104 | func (s *Indexer) getUpdatedAts(ctx context.Context, installID int64) (map[int64]time.Time, error) { 105 | queryStr := ` 106 | WITH RankedIssues AS ( 107 | SELECT 108 | id, 109 | updated_at, 110 | inserted_at, 111 | ROW_NUMBER() OVER (PARTITION BY inserted_at, id ORDER BY inserted_at DESC) AS rn 112 | FROM 113 | ` + "`coder-labeler.ghindex." + issuesTableName + "`" + ` 114 | WHERE install_id = @install_id 115 | ) 116 | SELECT 117 | id, 118 | updated_at 119 | FROM 120 | RankedIssues 121 | WHERE 122 | rn = 1 123 | ORDER BY 124 | inserted_at DESC; 125 | ` 126 | 127 | q := s.BigQuery.Query(queryStr) 128 | q.Parameters = []bigquery.QueryParameter{ 129 | { 130 | Name: "install_id", 131 | Value: installID, 132 | }, 133 | } 134 | 135 | job, err := q.Run(ctx) 136 | if err != nil { 137 | return nil, fmt.Errorf("run query: %w", err) 138 | } 139 | iter, err := job.Read(ctx) 140 | if err != nil { 141 | return nil, fmt.Errorf("read query: %w", err) 142 | } 143 | 144 | issues := make(map[int64]time.Time) 145 | for { 146 | var i BqIssue 147 | err := iter.Next(&i) 148 | if err == iterator.Done { 149 | break 150 | } 151 | if err != nil { 152 | s.Log.Error("read issue", "error", err) 153 | break 154 | } 155 | issues[i.ID] = i.UpdatedAt 156 | } 157 | return issues, nil 158 | } 159 | 160 | // indexInstall indexes all the issues for an installation. 161 | func (s *Indexer) indexInstall(ctx context.Context, install *github.Installation) error { 162 | idstr := fmt.Sprintf("%d", install.GetID()) 163 | 164 | config, err := s.AppConfig.InstallationConfig(idstr) 165 | if err != nil { 166 | return fmt.Errorf("get installation config: %w", err) 167 | } 168 | 169 | client := github.NewClient(config.Client(ctx)) 170 | 171 | // List all repos 172 | repos, err := ghapi.Page(ctx, 173 | client, 174 | func(ctx context.Context, opt *github.ListOptions) ([]*github.Repository, *github.Response, error) { 175 | lr, resp, err := client.Apps.ListRepos(ctx, opt) 176 | if err != nil { 177 | return nil, resp, fmt.Errorf("list repos: %w", err) 178 | } 179 | return lr.Repositories, resp, nil 180 | }, 181 | -1, 182 | ) 183 | if err != nil { 184 | return fmt.Errorf("list repos: %w", err) 185 | } 186 | 187 | log := s.Log.With("install", install.GetID()) 188 | log.Debug("indexing install", "repos", len(repos)) 189 | 190 | table := s.issuesTable() 191 | inserter := table.Inserter() 192 | 193 | cachedIssues, err := s.getUpdatedAts(ctx, install.GetID()) 194 | if err != nil { 195 | return fmt.Errorf("get cached issues: %w", err) 196 | } 197 | log.Debug("got cached issues", "count", len(cachedIssues)) 198 | 199 | for _, repo := range repos { 200 | // List all issues 201 | issues, err := ghapi.Page(ctx, 202 | client, 203 | func(ctx context.Context, opt *github.ListOptions) ([]*github.Issue, *github.Response, error) { 204 | issues, resp, err := client.Issues.ListByRepo(ctx, repo.GetOwner().GetLogin(), repo.GetName(), &github.IssueListByRepoOptions{ 205 | State: "all", 206 | ListOptions: *opt, 207 | Sort: "updated", 208 | Direction: "asc", 209 | }) 210 | return issues, resp, err 211 | }, 212 | -1, 213 | ) 214 | if err != nil { 215 | return fmt.Errorf("list issues: %w", err) 216 | } 217 | log := s.Log.With("repo", repo.GetFullName()) 218 | log.Debug("found issues", "count", len(issues)) 219 | 220 | for _, issue := range issues { 221 | if uat, ok := cachedIssues[issue.GetID()]; ok { 222 | if issue.UpdatedAt.Time.Equal(uat) { 223 | log.Debug("skipping issue due to cache", "num", issue.GetNumber()) 224 | continue 225 | } 226 | } 227 | emb, err := s.embedIssue(ctx, issue) 228 | if err != nil { 229 | return fmt.Errorf("embed issue %v: %w", issue.ID, err) 230 | } 231 | err = inserter.Put(ctx, BqIssue{ 232 | ID: issue.GetID(), 233 | InstallID: install.GetID(), 234 | User: repo.GetOwner().GetLogin(), 235 | Repo: repo.GetName(), 236 | Title: issue.GetTitle(), 237 | Number: issue.GetNumber(), 238 | State: issue.GetState(), 239 | Body: issue.GetBody(), 240 | CreatedAt: issue.GetCreatedAt().Time, 241 | UpdatedAt: issue.GetUpdatedAt().Time, 242 | InsertedAt: time.Now(), 243 | PullRequest: issue.IsPullRequest(), 244 | Embedding: emb, 245 | }) 246 | if err != nil { 247 | return fmt.Errorf("insert issue: %w", err) 248 | } 249 | updateAge := time.Since(issue.GetUpdatedAt().Time).Truncate(time.Minute) 250 | log.Debug( 251 | "indexed issue", "num", issue.GetNumber(), 252 | "update_age", updateAge.String(), 253 | ) 254 | } 255 | } 256 | log.Debug("finished indexing") 257 | return nil 258 | } 259 | 260 | func (s *Indexer) runIndex(ctx context.Context) error { 261 | install, err := s.findRandInstall(ctx) 262 | if err != nil { 263 | return fmt.Errorf("find random install: %w", err) 264 | } 265 | 266 | if err := s.indexInstall(ctx, install); err != nil { 267 | return fmt.Errorf("index install %v: %w", install.GetID(), err) 268 | } 269 | 270 | return nil 271 | } 272 | 273 | // Run starts the indexer and blocks until it's done. 274 | func (s *Indexer) Run(ctx context.Context) error { 275 | ticker := time.NewTicker(s.IndexInterval) 276 | s.Log.Info("indexer started", "interval", s.IndexInterval) 277 | defer ticker.Stop() 278 | for { 279 | err := s.runIndex(ctx) 280 | if err != nil { 281 | s.Log.Error("indexer", "error", err) 282 | } 283 | select { 284 | case <-ctx.Done(): 285 | return nil 286 | case <-ticker.C: 287 | continue 288 | } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /webhook.go: -------------------------------------------------------------------------------- 1 | package labeler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "net/http" 10 | "regexp" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/ammario/tlru" 17 | "github.com/beatlabs/github-auth/app" 18 | "github.com/coder/labeler/ghapi" 19 | "github.com/coder/labeler/httpjson" 20 | "github.com/coder/retry" 21 | "github.com/go-chi/chi/v5" 22 | githook "github.com/go-playground/webhooks/v6/github" 23 | "github.com/google/go-github/v59/github" 24 | "github.com/sashabaranov/go-openai" 25 | "golang.org/x/exp/maps" 26 | "gopkg.in/yaml.v3" 27 | ) 28 | 29 | type repoAddr struct { 30 | InstallID, User, Repo string 31 | } 32 | 33 | type Webhook struct { 34 | Log *slog.Logger 35 | OpenAI *openai.Client 36 | AppConfig *app.Config 37 | Model string 38 | 39 | router *chi.Mux 40 | 41 | // These caches are primarily useful in the test system, where there are 42 | // many inference requests to the same repo in a short period of time. 43 | // 44 | // That will rarely happen in production. 45 | repoLabelsCache *tlru.Cache[repoAddr, []*github.Label] 46 | 47 | recentIssuesCache *tlru.Cache[repoAddr, []*github.Issue] 48 | } 49 | 50 | func (s *Webhook) Init(r *chi.Mux) { 51 | s.router = r 52 | s.router.Mount("/infer", httpjson.Handler(s.infer)) 53 | s.router.Mount("/webhook", httpjson.Handler(s.webhook)) 54 | 55 | s.repoLabelsCache = tlru.New[repoAddr](func(ls []*github.Label) int { 56 | return len(ls) 57 | }, 4096) 58 | s.recentIssuesCache = tlru.New[repoAddr](func(ls []*github.Issue) int { 59 | return len(ls) 60 | }, 4096) 61 | } 62 | 63 | func filterIssues(slice []*github.Issue, f func(*github.Issue) bool) []*github.Issue { 64 | var result []*github.Issue 65 | for _, item := range slice { 66 | if f(item) { 67 | result = append(result, item) 68 | } 69 | } 70 | return result 71 | } 72 | 73 | type InferRequest struct { 74 | InstallID, User, Repo string 75 | Issue int `json:"issue"` 76 | // TestMode determines whether the target issue's existing labels 77 | // are stripped before inference. 78 | TestMode bool `json:"test_mode"` 79 | } 80 | 81 | type InferResponse struct { 82 | SetLabels []string `json:"set_labels,omitempty"` 83 | TokensUsed int `json:"tokens_used,omitempty"` 84 | DisabledLabels []string `json:"disabled_labels,omitempty"` 85 | } 86 | 87 | type repoConfig struct { 88 | Exclude []regexp.Regexp `json:"exclude"` 89 | } 90 | 91 | func (c *repoConfig) checkLabel(label string) bool { 92 | for _, re := range c.Exclude { 93 | if re.MatchString(label) { 94 | return false 95 | } 96 | } 97 | return true 98 | } 99 | 100 | func (s *Webhook) getRepoConfig(ctx context.Context, client *github.Client, 101 | owner, repo string, 102 | ) (*repoConfig, error) { 103 | fileContent, _, _, err := client.Repositories.GetContents( 104 | ctx, 105 | owner, 106 | repo, 107 | ".github/labeler.yml", 108 | &github.RepositoryContentGetOptions{}, 109 | ) 110 | if err != nil { 111 | var githubErr *github.ErrorResponse 112 | if errors.As(err, &githubErr) && githubErr.Response.StatusCode == http.StatusNotFound { 113 | return &repoConfig{}, nil 114 | } 115 | return nil, fmt.Errorf("get contents: %w", err) 116 | } 117 | 118 | content, err := fileContent.GetContent() 119 | if err != nil { 120 | return nil, fmt.Errorf("unmarshal content: %w", err) 121 | } 122 | 123 | var config repoConfig 124 | err = yaml.Unmarshal( 125 | []byte(content), 126 | &config, 127 | ) 128 | 129 | return &config, err 130 | } 131 | 132 | func filterSlice[T any](slice []T, f func(T) bool) []T { 133 | var result []T 134 | for _, item := range slice { 135 | if f(item) { 136 | result = append(result, item) 137 | } 138 | } 139 | return result 140 | } 141 | 142 | func (s *Webhook) Infer(ctx context.Context, req *InferRequest) (*InferResponse, error) { 143 | instConfig, err := s.AppConfig.InstallationConfig(req.InstallID) 144 | if err != nil { 145 | return nil, fmt.Errorf("get installation config: %w", err) 146 | } 147 | 148 | githubClient := github.NewClient(instConfig.Client(ctx)) 149 | 150 | config, err := s.getRepoConfig(ctx, githubClient, req.User, req.Repo) 151 | if err != nil { 152 | return nil, fmt.Errorf("get repo config: %w", err) 153 | } 154 | 155 | lastIssues, err := s.recentIssuesCache.Do(repoAddr{ 156 | InstallID: req.InstallID, 157 | User: req.User, 158 | Repo: req.Repo, 159 | }, func() ([]*github.Issue, error) { 160 | return ghapi.Page( 161 | ctx, 162 | githubClient, 163 | func(ctx context.Context, opt *github.ListOptions) ([]*github.Issue, *github.Response, error) { 164 | issues, resp, err := githubClient.Issues.ListByRepo( 165 | ctx, 166 | req.User, 167 | req.Repo, 168 | &github.IssueListByRepoOptions{ 169 | State: "all", 170 | ListOptions: *opt, 171 | }, 172 | ) 173 | 174 | return ghapi.OnlyTrueIssues(issues), resp, err 175 | }, 176 | 100, 177 | ) 178 | }, time.Minute) 179 | if err != nil { 180 | return nil, fmt.Errorf("list issues: %w", err) 181 | } 182 | 183 | repoLabels, err := s.repoLabelsCache.Do(repoAddr{ 184 | InstallID: req.InstallID, 185 | User: req.User, 186 | Repo: req.Repo, 187 | }, func() ([]*github.Label, error) { 188 | return ghapi.Page( 189 | ctx, 190 | githubClient, 191 | func(ctx context.Context, opt *github.ListOptions) ([]*github.Label, *github.Response, error) { 192 | return githubClient.Issues.ListLabels(ctx, req.User, req.Repo, opt) 193 | }, 194 | // We use the coder/customers label count as a reasonable maximum. 195 | 300, 196 | ) 197 | }, time.Minute) 198 | if err != nil { 199 | return nil, fmt.Errorf("list labels: %w", err) 200 | } 201 | 202 | targetIssue, _, err := githubClient.Issues.Get(ctx, req.User, req.Repo, req.Issue) 203 | if err != nil { 204 | return nil, fmt.Errorf("get target issue: %w", err) 205 | } 206 | 207 | // Take out target issue from the list of issues 208 | lastIssues = filterIssues(lastIssues, func(i *github.Issue) bool { 209 | return i.GetNumber() != targetIssue.GetNumber() 210 | }) 211 | 212 | // Sort by created at. 213 | sort.Slice(lastIssues, func(i, j int) bool { 214 | iTime := lastIssues[i].GetCreatedAt().Time 215 | jTime := lastIssues[j].GetCreatedAt().Time 216 | return iTime.Before(jTime) 217 | }) 218 | 219 | if req.TestMode { 220 | targetIssue.Labels = nil 221 | } 222 | 223 | aiContext := &aiContext{ 224 | allLabels: repoLabels, 225 | lastIssues: lastIssues, 226 | targetIssue: targetIssue, 227 | } 228 | 229 | retryAI: 230 | ret := retry.New(time.Second, time.Second*10) 231 | resp, err := s.OpenAI.CreateChatCompletion( 232 | ctx, 233 | aiContext.Request(s.Model), 234 | ) 235 | if err != nil { 236 | var aiErr *openai.APIError 237 | if errors.As(err, &aiErr) { 238 | if (aiErr.HTTPStatusCode >= 500 || aiErr.HTTPStatusCode == 429) && ret.Wait(ctx) { 239 | s.Log.Warn("retrying AI call", "error", err) 240 | goto retryAI 241 | } 242 | } 243 | return nil, fmt.Errorf("create chat completion: %w", err) 244 | } 245 | if len(resp.Choices) != 1 { 246 | return nil, fmt.Errorf("expected one choice") 247 | } 248 | 249 | choice := resp.Choices[0] 250 | 251 | content := choice.Message.Content 252 | var setLabels struct { 253 | Reasoning string `json:"reasoning"` 254 | Labels []string `json:"labels"` 255 | } 256 | 257 | err = json.Unmarshal([]byte(content), &setLabels) 258 | if err != nil { 259 | return nil, fmt.Errorf("unmarshal setLabels: %w, content: %q", err, content) 260 | } 261 | s.Log.Info("set labels", "labels", setLabels.Labels, "reasoning", setLabels.Reasoning) 262 | 263 | disabledLabels := make(map[string]struct{}) 264 | for _, label := range repoLabels { 265 | if strings.Contains(label.GetDescription(), magicDisableString) { 266 | disabledLabels[label.GetName()] = struct{}{} 267 | } 268 | if !config.checkLabel(label.GetName()) { 269 | disabledLabels[label.GetName()] = struct{}{} 270 | } 271 | } 272 | 273 | // Remove any labels that are disabled. 274 | newLabels := filterSlice(setLabels.Labels, func(label string) bool { 275 | _, ok := disabledLabels[label] 276 | return !ok 277 | }) 278 | 279 | repoLabelsMap := make(map[string]struct{}) 280 | for _, label := range repoLabels { 281 | repoLabelsMap[label.GetName()] = struct{}{} 282 | } 283 | 284 | log := s.Log.With( 285 | "repo", req.User+"/"+req.Repo, 286 | "issue", req.Issue, 287 | ) 288 | // Remove any labels that are not defined by the repo. 289 | // Sometimes the model returns labels in a 290 | // space delimited string. For example, "bug critical" instead of 291 | // ["bug", "critical"]. It's better to be safe and not accidentally 292 | // create new labels. 293 | newLabels = filterSlice(newLabels, func(label string) bool { 294 | _, ok := repoLabelsMap[label] 295 | if !ok { 296 | log.Warn("label not found", "label", label) 297 | } 298 | return ok 299 | }) 300 | 301 | return &InferResponse{ 302 | SetLabels: newLabels, 303 | TokensUsed: resp.Usage.TotalTokens, 304 | DisabledLabels: maps.Keys(disabledLabels), 305 | }, nil 306 | } 307 | 308 | func (s *Webhook) infer(w http.ResponseWriter, r *http.Request) *httpjson.Response { 309 | var ( 310 | installID = r.URL.Query().Get("install_id") 311 | user = r.URL.Query().Get("user") 312 | repo = r.URL.Query().Get("repo") 313 | issue = r.URL.Query().Get("issue") 314 | ) 315 | 316 | if user == "" || repo == "" || issue == "" || installID == "" { 317 | return &httpjson.Response{ 318 | Status: http.StatusBadRequest, 319 | Body: httpjson.M{"error": "install_id, user, repo, and issue are required"}, 320 | } 321 | } 322 | 323 | issueNum, err := strconv.Atoi(issue) 324 | if err != nil { 325 | return &httpjson.Response{ 326 | Status: http.StatusBadRequest, 327 | Body: httpjson.M{"error": "issue must be a number"}, 328 | } 329 | } 330 | 331 | resp, err := s.Infer(r.Context(), &InferRequest{ 332 | InstallID: installID, 333 | User: user, 334 | Repo: repo, 335 | Issue: issueNum, 336 | }) 337 | if err != nil { 338 | return httpjson.ErrorMessage(http.StatusInternalServerError, err) 339 | } 340 | 341 | return &httpjson.Response{ 342 | Status: http.StatusOK, 343 | Body: resp, 344 | } 345 | } 346 | 347 | func (s *Webhook) serverError(msg error) *httpjson.Response { 348 | s.Log.Error("server error", "error", msg) 349 | return &httpjson.Response{ 350 | Status: http.StatusInternalServerError, 351 | Body: httpjson.M{"error": msg.Error()}, 352 | } 353 | } 354 | 355 | func (s *Webhook) webhook(w http.ResponseWriter, r *http.Request) *httpjson.Response { 356 | hook, err := githook.New() 357 | if err != nil { 358 | if errors.Is(err, githook.ErrEventNotSpecifiedToParse) { 359 | return &httpjson.Response{ 360 | Status: http.StatusOK, 361 | Body: httpjson.M{"msg": "ignoring event: not specified to parse"}, 362 | } 363 | } 364 | return s.serverError(err) 365 | } 366 | 367 | payloadAny, err := hook.Parse( 368 | r, githook.IssuesEvent, 369 | ) 370 | if err != nil { 371 | return s.serverError(err) 372 | } 373 | 374 | payload, ok := payloadAny.(githook.IssuesPayload) 375 | if !ok { 376 | return s.serverError(fmt.Errorf("expected issues payload: %T", payloadAny)) 377 | } 378 | 379 | if payload.Action != "opened" && payload.Action != "reopened" { 380 | return &httpjson.Response{ 381 | Status: http.StatusOK, 382 | Body: httpjson.M{"message": "not an opened issue"}, 383 | } 384 | } 385 | 386 | repo := payload.Repository 387 | 388 | resp, err := s.Infer(r.Context(), &InferRequest{ 389 | InstallID: strconv.FormatInt(payload.Installation.ID, 10), 390 | User: repo.Owner.Login, 391 | Repo: repo.Name, 392 | Issue: int(payload.Issue.Number), 393 | }) 394 | if err != nil { 395 | return s.serverError(fmt.Errorf("infer: %w, issue: %+v", err, payload.Issue.URL)) 396 | } 397 | 398 | if len(resp.SetLabels) == 0 { 399 | return &httpjson.Response{ 400 | Status: http.StatusOK, 401 | Body: httpjson.M{"message": "no labels to set"}, 402 | } 403 | } 404 | 405 | // Set the labels. 406 | instConfig, err := s.AppConfig.InstallationConfig(strconv.FormatInt(payload.Installation.ID, 10)) 407 | if err != nil { 408 | return s.serverError(err) 409 | } 410 | 411 | githubClient := github.NewClient(instConfig.Client(r.Context())) 412 | _, _, err = githubClient.Issues.AddLabelsToIssue( 413 | r.Context(), 414 | repo.Owner.Login, 415 | repo.Name, 416 | int(payload.Issue.Number), 417 | resp.SetLabels, 418 | ) 419 | if err != nil { 420 | return s.serverError(fmt.Errorf("set %v: %w", resp.SetLabels, err)) 421 | } 422 | 423 | log := s.Log.With( 424 | "install_id", payload.Installation.ID, 425 | "user", repo.Owner.Login, 426 | "repo", repo.Name, 427 | "issue_num", payload.Issue.Number, 428 | "issue_url", payload.Issue.HTMLURL, 429 | ) 430 | 431 | log.Info("labels set", 432 | "labels", resp.SetLabels, 433 | "tokens_used", resp.TokensUsed, 434 | ) 435 | 436 | return &httpjson.Response{ 437 | Status: http.StatusOK, 438 | Body: httpjson.M{"message": "labels set", "labels": resp.SetLabels}, 439 | } 440 | } 441 | --------------------------------------------------------------------------------