Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit 4be4658

Browse files
authored
dev/linearhooks: add POC (#62367)
1 parent e40c06b commit 4be4658

37 files changed

+24828
-5
lines changed

.gitattributes

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ cmd/repo-updater/repos/testdata/** linguist-generated=true
88
CHANGELOG.md merge=union
99
**/mocks*_*test.go linguist-generated=true
1010
.apko/range.sh linguist-generated=true
11+
dev/linearhooks/internal/lineargql/gqltest/fake_client.go

.prettierignore

+3
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,6 @@ client/browser/src/types/webextension-polyfill/index.d.ts
6262
graphql-operations.ts
6363

6464
wolfi-images/*.lock.json
65+
66+
# This is downloaded from upstream directly and should not be modified
67+
dev/linearhooks/internal/lineargql/schema.graphql

deps.bzl

+16-2
Original file line numberDiff line numberDiff line change
@@ -3142,8 +3142,8 @@ def go_dependencies():
31423142
name = "com_github_hashicorp_golang_lru_v2",
31433143
build_file_proto_mode = "disable_global",
31443144
importpath = "github.com/hashicorp/golang-lru/v2",
3145-
sum = "h1:Dwmkdr5Nc/oBiXgJS3CDHNhJtIHkuZ3DZF5twqnfBdU=",
3146-
version = "v2.0.2",
3145+
sum = "h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=",
3146+
version = "v2.0.7",
31473147
)
31483148
go_repository(
31493149
name = "com_github_hashicorp_hcl",
@@ -4188,6 +4188,13 @@ def go_dependencies():
41884188
sum = "h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=",
41894189
version = "v2.0.0",
41904190
)
4191+
go_repository(
4192+
name = "com_github_maxbrunsfeld_counterfeiter_v6",
4193+
build_file_proto_mode = "disable_global",
4194+
importpath = "github.com/maxbrunsfeld/counterfeiter/v6",
4195+
sum = "h1:NicmruxkeqHjDv03SfSxqmaLuisddudfP3h5wdXFbhM=",
4196+
version = "v6.8.1",
4197+
)
41914198
go_repository(
41924199
name = "com_github_mazznoer_csscolorparser",
41934200
build_file_proto_mode = "disable_global",
@@ -5240,6 +5247,13 @@ def go_dependencies():
52405247
sum = "h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM=",
52415248
version = "v2.2.0",
52425249
)
5250+
go_repository(
5251+
name = "com_github_sclevine_spec",
5252+
build_file_proto_mode = "disable_global",
5253+
importpath = "github.com/sclevine/spec",
5254+
sum = "h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=",
5255+
version = "v1.4.0",
5256+
)
52435257
go_repository(
52445258
name = "com_github_sean_seed",
52455259
build_file_proto_mode = "disable_global",

dev/linearhooks/.env.example

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export CONFIG_FILE_PATH=config.example.yaml
2+
export LINEAR_PERSONAL_API_KEY=
3+
export LINEAR_WEBHOOK_SIGNING_SECRET=
4+
export PORT=3000
5+
export SRC_DEVELOPMENT=true
6+
export SRC_LOG_FORMAT=console
7+
export SRC_LOG_LEVEL=debug

dev/linearhooks/BUILD.bazel

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
2+
load("//dev:oci_defs.bzl", "image_repository", "oci_image", "oci_push", "oci_tarball")
3+
load("@rules_pkg//:pkg.bzl", "pkg_tar")
4+
load("@container_structure_test//:defs.bzl", "container_structure_test")
5+
6+
go_library(
7+
name = "linearhooks_lib",
8+
srcs = ["main.go"],
9+
importpath = "github.com/sourcegraph/sourcegraph/dev/linearhooks",
10+
visibility = ["//visibility:private"],
11+
deps = [
12+
"//dev/linearhooks/internal/service",
13+
"//lib/managedservicesplatform/runtime",
14+
],
15+
)
16+
17+
go_binary(
18+
name = "linearhooks",
19+
embed = [":linearhooks_lib"],
20+
visibility = ["//visibility:public"],
21+
)
22+
23+
pkg_tar(
24+
name = "tar_linearhooks",
25+
srcs = [":linearhooks"],
26+
)
27+
28+
oci_image(
29+
name = "image",
30+
base = "@wolfi_base",
31+
entrypoint = [
32+
"/sbin/tini",
33+
"--",
34+
"/linearhooks",
35+
],
36+
tars = [":tar_linearhooks"],
37+
user = "sourcegraph",
38+
)
39+
40+
oci_tarball(
41+
name = "image_tarball",
42+
image = ":image",
43+
repo_tags = ["linearhooks:candidate"],
44+
)
45+
46+
container_structure_test(
47+
name = "image_test",
48+
timeout = "short",
49+
configs = ["image_test.yaml"],
50+
driver = "docker",
51+
image = ":image",
52+
tags = [
53+
"exclusive",
54+
"requires-network",
55+
],
56+
)
57+
58+
oci_push(
59+
name = "candidate_push",
60+
image = ":image",
61+
repository = image_repository("linearhooks"),
62+
)

dev/linearhooks/README.md

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Linear Webhooks
2+
3+
## Development
4+
5+
> [!CAUTION]
6+
> DO NOT commit your api key
7+
8+
First make a copy of the dotenv file and set the API key and webhook signing secrets in `.env` based on the [Linear API settings](https://linear.app/sourcegraph/settings/api). If you don't have access, reach out to #wg-linear-trial.
9+
10+
```sh
11+
cp .env.example .env
12+
```
13+
14+
```sh
15+
source .env
16+
```
17+
18+
```sh
19+
go run .
20+
```
21+
22+
Use [ngrok](https://ngrok.com/docs/getting-started/) to get a public URL for receiving webhook events:
23+
24+
```sh
25+
ngrok http 3000
26+
```
27+
28+
Set the [webhook URL in Linear](https://linear.app/sourcegraph/settings/api).
29+
30+
## Deployment
31+
32+
This service is deployed as a MSP service. Learn more from [go/msp](http://go/msp).
33+
34+
> [!CAUTION]
35+
> Keep your secret safe
36+
37+
In production, it's recommended to create a [Linear OAuth2 application](https://developers.linear.app/docs/oauth/authentication), and create a developer token using application identity as actor. Then, set the developer token as `LINEAR_PERSONAL_API_KEY` in the deployment. Othewise, your personal identity will be associated with all requests.
38+
39+
Unfortunately, Linear only supports `authorization_code` grant type, but not `client_credentials`. Authenticating through the web interface (e.g., OAuth callback) is a lot of added complexity for a simply webhook service. We will revisit in the future.
40+
41+
## Configuration
42+
43+
Refer to [config.example.yaml](./config.example.yaml)

dev/linearhooks/config.example.yaml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
spec:
2+
mover:
3+
# rules is a list of rules that define the conditions for moving issues between teams
4+
# Only the first rule that matches the issue will be applied, the rest will be ignored
5+
rules:
6+
- src:
7+
# teamId is the identifier of the source team. Only issues from this team will be evaluated for this rule
8+
# Either identifier (i.e. unique team prefix) or UUID is accepted. Use 'Any Team' to match any incoming team.
9+
teamId: FOO
10+
# labels is a list of labels that the issue must have to be evaluated for this rule
11+
labels: [team/bar]
12+
dst:
13+
# teamId is the identifier of the destination team. Issues that match the rule will be moved to this team.
14+
# Either identifier (i.e. unique team prefix) or UUID is accepted
15+
teamId: BAR

dev/linearhooks/image_test.yaml

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
schemaVersion: '2.0.0'
2+
3+
commandTests:
4+
- name: 'binary is runnable'
5+
command: '/linearhooks'
6+
args:
7+
- '--help'
8+
9+
- name: 'not running as root'
10+
command: '/usr/bin/id'
11+
args:
12+
- -u
13+
excludedOutput: ['^0']
14+
exitCode: 0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
load("//dev:go_defs.bzl", "go_test")
2+
load("@io_bazel_rules_go//go:def.bzl", "go_library")
3+
4+
go_library(
5+
name = "handlers",
6+
srcs = [
7+
"config.go",
8+
"handlers.go",
9+
"middleware.go",
10+
],
11+
importpath = "github.com/sourcegraph/sourcegraph/dev/linearhooks/internal/handlers",
12+
visibility = ["//dev/linearhooks:__subpackages__"],
13+
deps = [
14+
"//dev/linearhooks/internal/lineargql",
15+
"//dev/linearhooks/internal/linearschema",
16+
"//internal/collections",
17+
"//lib/errors",
18+
"@com_github_hashicorp_golang_lru_v2//expirable",
19+
"@com_github_khan_genqlient//graphql",
20+
"@com_github_sourcegraph_log//:log",
21+
"@io_k8s_sigs_yaml//:yaml",
22+
],
23+
)
24+
25+
go_test(
26+
name = "handlers_test",
27+
srcs = ["handlers_test.go"],
28+
embed = [":handlers"],
29+
deps = [
30+
"//dev/linearhooks/internal/lineargql",
31+
"//dev/linearhooks/internal/lineargql/gqltest",
32+
"//dev/linearhooks/internal/linearschema",
33+
"@com_github_hexops_autogold_v2//:autogold",
34+
"@com_github_sourcegraph_log//logtest",
35+
"@com_github_stretchr_testify//assert",
36+
"@com_github_stretchr_testify//require",
37+
],
38+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package handlers
2+
3+
import (
4+
"github.com/sourcegraph/sourcegraph/lib/errors"
5+
)
6+
7+
type Config struct {
8+
Spec Spec `json:"spec"`
9+
}
10+
11+
type Spec struct {
12+
Mover MoverSpec `json:"mover,omitempty"`
13+
}
14+
15+
type MoverSpec struct {
16+
Rules []RuleSpec `json:"rules"`
17+
}
18+
19+
type RuleSpec struct {
20+
// Src is the identifier of the source team. Only issues from this team will be evaluated for this rule.
21+
Src SrcSpec `json:"src"`
22+
// Dst is the identifier of the destination team. Issues that match the rule will be moved to this team.
23+
Dst DstSpec `json:"dst"`
24+
}
25+
26+
type SrcSpec struct {
27+
// TeamID is the identifier of the team that the issue must be in for the rule to match.
28+
// Use the keyword 'Any Issue' to match any source team.
29+
TeamID string `json:"teamId,omitempty"`
30+
// Labels is a list of labels that must be present on the issue for the rule to match.
31+
Labels []string `json:"labels"`
32+
}
33+
34+
type DstSpec struct {
35+
TeamID string `json:"teamId,omitempty"`
36+
}
37+
38+
func (c *Config) Validate() error {
39+
return c.Spec.Validate()
40+
}
41+
42+
func (s *Spec) Validate() error {
43+
return s.Mover.Validate()
44+
}
45+
46+
func (s *MoverSpec) Validate() error {
47+
var errs errors.MultiError
48+
if len(s.Rules) == 0 {
49+
errs = errors.Append(errs, errors.New("rules must contain at least one rule"))
50+
}
51+
for _, r := range s.Rules {
52+
if err := r.Validate(); err != nil {
53+
errs = errors.Append(errs, err)
54+
}
55+
}
56+
return errs
57+
}
58+
59+
func (rs RuleSpec) Validate() error {
60+
var errs errors.MultiError
61+
if rs.Src.TeamID == "" {
62+
errs = errors.Append(errs, errors.Newf("src.teamId must be set, or use %d to match any issues", WildcardTeamID))
63+
}
64+
if len(rs.Src.Labels) == 0 {
65+
errs = errors.Append(errs, errors.New("src.labels must contain at least one label"))
66+
}
67+
if rs.Dst.TeamID == "" {
68+
errs = errors.Append(errs, errors.New("dst.teamId must be set"))
69+
}
70+
return errs
71+
}

0 commit comments

Comments
 (0)