diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml new file mode 100644 index 000000000..9515908c2 --- /dev/null +++ b/.github/workflows/autoassign.yml @@ -0,0 +1,105 @@ +name: autoassign + +on: + issues: + types: [opened] + +permissions: + issues: write + contents: read + repository-projects: write + +jobs: + auto-assign: + runs-on: ubuntu-latest + if: contains(github.event.issue.body, 'Affected Resource(s)') + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.21' + + - name: Build backstage-lookup tool + run: | + cd scripts/backstage-lookup + go build -o ../../backstage-lookup + + - name: Parse issue form + uses: stefanbuck/github-issue-parser@2ea9b35a8c584529ed00891a8f7e41dc46d0441e # v3.2.1 + id: issue-parser + with: + template-path: .github/ISSUE_TEMPLATE/3-bug-report-enhanced.yml + + - uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10 + name: Auth with Backstage + id: gcloud-auth + with: + token_format: access_token + workload_identity_provider: "projects/304398677251/locations/global/workloadIdentityPools/github/providers/github-provider" + service_account: "github-terraform-provider-ci@grafanalabs-workload-identity.iam.gserviceaccount.com" + + - name: Get Secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@75804962c1ba608148988c1e2dc35fbb0ee21746 + with: + repo_secrets: | + BACKSTAGE_URL=backstage:backstage_url + AUDIENCE=backstage:audience + + - name: Lookup team ownership + id: backstage-lookup + env: + BACKSTAGE_URL: ${{ env.BACKSTAGE_URL }} # TODO: confirm this works + AUDIENCE: ${{ env.AUDIENCE }} # TODO: confirm this works + ACCESS_TOKEN: ${{ steps.gcloud-auth.outputs.access_token }} + run: | + RESOURCES=$(echo '${{ steps.issue-parser.outputs.jsonString }}' | jq -r '.["affected-resources"] // empty' | tr '\n' ' ') + + if [[ -z "$RESOURCES" ]]; then + echo "projects=" >> $GITHUB_OUTPUT + echo "teams=" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Resources: $RESOURCES" + ./backstage-lookup $RESOURCES >> $GITHUB_OUTPUT || { + echo "projects=" >> $GITHUB_OUTPUT + echo "teams=" >> $GITHUB_OUTPUT + } + + - name: Assign to projects and add labels + if: steps.backstage-lookup.outputs.teams != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HTML_URL: ${{ github.event.issue.html_url }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + # Add to projects + for project in ${{ steps.backstage-lookup.outputs.projects }}; do + [[ -n "$project" ]] && gh project item-add $project --url ${HTML_URL} + done + + # Add team labels + for team in ${{ steps.backstage-lookup.outputs.teams }}; do + [[ -n "$team" ]] && gh issue edit ${ISSUE_NUMBER} --add-label "team/$team" + done + + - name: Add comment + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const teams = '${{ steps.backstage-lookup.outputs.teams }}'.split(' ').filter(t => t); + const projects = '${{ steps.backstage-lookup.outputs.projects }}'.split(' ').filter(p => p); + + const message = teams.length > 0 + ? `🤖 **Auto-assigned to:** ${teams.map(t => `@grafana/${t}`).join(' ')}\n**Projects:** ${projects.join(', ') || 'none'}` + : `🔍 **Manual triage needed** - no team ownership found. Please mention \`@grafana/platform-monitoring\``; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: message + }); diff --git a/internal/resources/appplatform/catalog-resource.yaml b/internal/resources/appplatform/catalog-resource.yaml index c74d37727..75b91df11 100644 --- a/internal/resources/appplatform/catalog-resource.yaml +++ b/internal/resources/appplatform/catalog-resource.yaml @@ -9,7 +9,7 @@ metadata: spec: subcomponentOf: component:default/terraform-provider-grafana type: terraform-resource - owner: group:default/grafana-app-platform + owner: group:default/grafana-app-platform-squad lifecycle: production --- apiVersion: backstage.io/v1alpha1 @@ -22,5 +22,5 @@ metadata: spec: subcomponentOf: component:default/terraform-provider-grafana type: terraform-resource - owner: group:default/grafana-app-platform + owner: group:default/grafana-app-platform-squad lifecycle: production diff --git a/scripts/backstage-lookup/README.md b/scripts/backstage-lookup/README.md new file mode 100644 index 000000000..3f0ac513e --- /dev/null +++ b/scripts/backstage-lookup/README.md @@ -0,0 +1,22 @@ +## Development + +Configure an access token for Backstage: + +```console +export BACKSTAGE_TOKEN= +``` + +Set up a port-forward with kubectl or k9s: + +```console +kubectl port-forward -n backstage service/backstage-ingress 8080 +export BACKSTAGE_URL=http://localhost:8080 +``` + +Get a GitHub token with the 'project' scope: + +```console +gh auth login -s 'project' +export GITHUB_TOKEN=$(gh auth token) +``` + diff --git a/scripts/backstage-lookup/backstage.go b/scripts/backstage-lookup/backstage.go new file mode 100644 index 000000000..125313d75 --- /dev/null +++ b/scripts/backstage-lookup/backstage.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "regexp" + "strings" + + "github.com/datolabs-io/go-backstage/v3" + "github.com/mitchellh/mapstructure" + "golang.org/x/oauth2" +) + +type BackstageClient struct { + Client *backstage.Client + Filters func(string) []string +} + +func NewBackstageClient() (*BackstageClient, error) { + baseURL := os.Getenv("BACKSTAGE_URL") + if baseURL == "" { + return nil, fmt.Errorf("BACKSTAGE_URL required") + } + + accessToken := os.Getenv("BACKSTAGE_TOKEN") + if accessToken == "" { + return nil, fmt.Errorf("BACKSTAGE_TOKEN required") + } + + src := oauth2.StaticTokenSource( + &oauth2.Token{ + AccessToken: accessToken, + TokenType: "Bearer", + }) + ctx := context.Background() + httpClient := oauth2.NewClient(ctx, src) + + client, err := backstage.NewClient(baseURL, "default", httpClient) + if err != nil { + return nil, err + } + + return &BackstageClient{ + Client: client, + Filters: func(resourceName string) []string { + return []string{ + fmt.Sprintf("kind=Component,metadata.name=%s", resourceName), + } + }, + }, nil +} + +func (b *BackstageClient) FindProjectsForResource(resourceName, groupRef string) ([]string, error) { + if groupRef == "" { + resources, err := b.findComponents(resourceName) + if err != nil { + return nil, err + } + if len(resources) > 1 { + log.Printf("Multiple components found, using first %s.", resources[0].Metadata.Name) + } + groupRef = resources[0].Spec.Owner + } + + projects, err := b.findProjectsForGroup(groupRef) + if err != nil { + return nil, err + } + + if len(projects) == 0 { + return nil, fmt.Errorf("FindProjectForResource: no projects found") + } + + // URL must look like https://github.com/orgs//projects/ + re := regexp.MustCompile(`https://github.com/orgs/.*/projects/(\d+).*`) + + var ids []string + for _, project := range projects { + ids = append(ids, string(re.FindSubmatch([]byte(project))[1])) + } + return ids, nil +} + +func (b *BackstageClient) findComponents(resourceName string) ([]backstage.ComponentEntityV1alpha1, error) { + ctx := context.Background() + entities, _, err := b.Client.Catalog.Entities.List(ctx, &backstage.ListEntityOptions{ + Filters: b.Filters(resourceName), + }) + if err != nil { + return nil, err + } + if len(entities) == 0 { + return nil, fmt.Errorf("findComponents: No entities found.") + } + if len(entities) > 1 { + log.Printf("Multiple entities found.") + } + + components := make([]backstage.ComponentEntityV1alpha1, len(entities)) + if err := mapstructure.Decode(entities, &components); err != nil { + return nil, err + } + + return components, nil +} + +func (b *BackstageClient) findGroupByRef(ref string) (*backstage.GroupEntityV1alpha1, error) { + entityRef, err := parseEntityRef(ref) + if err != nil { + return nil, err + } + + ctx := context.Background() + entities, _, err := b.Client.Catalog.Entities.List(ctx, &backstage.ListEntityOptions{ + Filters: []string{ + fmt.Sprintf("kind=Group,metadata.name=%s,metadata.namespace=%s", entityRef.Name, entityRef.Namespace), + }, + }) + if err != nil { + return nil, err + } + if len(entities) == 0 { + return nil, fmt.Errorf("findGroupByRef: No entities found.") + } + if len(entities) > 1 { + return nil, fmt.Errorf("findGroupByRef: Multiple entities found.") + } + var group backstage.GroupEntityV1alpha1 + if err := mapstructure.Decode(entities[0], &group); err != nil { + return nil, err + } + + group.Metadata = entities[0].Metadata + group.Relations = entities[0].Relations + + return &group, nil +} + +func (b *BackstageClient) findProjectsForGroup(groupRef string) ([]string, error) { + group, err := b.findGroupByRef(groupRef) + if err != nil { + return nil, err + } + + var githubProjects []string + for _, link := range group.Metadata.Links { + if link.Type == "github_project" { + githubProjects = append(githubProjects, link.URL) + } + } + if len(githubProjects) == 0 { + for _, relation := range group.Relations { + if relation.Type == "parentOf" { + projects, _ := b.findProjectsForGroup(relation.TargetRef) + githubProjects = append(githubProjects, projects...) + } + } + } + return githubProjects, nil +} + +type EntityRef struct { + Kind string + Namespace string + Name string +} + +func parseEntityRef(ref string) (*EntityRef, error) { + kindParts := strings.Split(ref, ":") + if len(kindParts) != 2 { + return nil, fmt.Errorf("Could not parse entityRef.") + } + + parts := strings.Split(kindParts[1], "/") + if len(parts) != 2 { + return nil, fmt.Errorf("Could not parse entityRef.") + } + return &EntityRef{ + Kind: kindParts[0], + Namespace: parts[0], + Name: parts[1], + }, nil +} diff --git a/scripts/backstage-lookup/github.go b/scripts/backstage-lookup/github.go new file mode 100644 index 000000000..b54119fbb --- /dev/null +++ b/scripts/backstage-lookup/github.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/shurcooL/githubv4" + "golang.org/x/oauth2" +) + +type GitHubClient struct { + Client *githubv4.Client +} + +func NewGitHubClient() (*GitHubClient, error) { + accessToken := os.Getenv("GITHUB_TOKEN") + if accessToken == "" { + return nil, fmt.Errorf("GITHUB_TOKEN required") + } + src := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: accessToken}, + ) + ctx := context.Background() + httpClient := oauth2.NewClient(ctx, src) + + return &GitHubClient{ + Client: githubv4.NewClient(httpClient), + }, nil +} + +func (g *GitHubClient) AddIssueToProject(org, repo string, issueNumber, projectNumber int) error { + ctx := context.Background() + + contentId, err := g.findIssueId(ctx, org, repo, issueNumber) + if err != nil { + return err + } + + projectId, err := g.findProjectId(ctx, org, projectNumber) + if err != nil { + return err + } + + return g.addIssueToProject(ctx, contentId, projectId) +} + +func (g *GitHubClient) RemoveIssueFromProject(org, repo string, issueNumber, projectNumber int) error { + ctx := context.Background() + + itemId, projectId, err := g.findProjectItemId(ctx, org, repo, projectNumber, issueNumber) + if err != nil { + return err + } + + if itemId == "" { + return nil + } + + return g.removeIssueFromProject(ctx, itemId, projectId) +} + +func (g *GitHubClient) findIssueId(ctx context.Context, org, repo string, number int) (string, error) { + var query struct { + Repository struct { + Issue struct { + Id string + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + err := g.Client.Query(ctx, &query, map[string]any{ + "owner": githubv4.String(org), + "name": githubv4.String(repo), + "number": githubv4.Int(number), + }) + return query.Repository.Issue.Id, err +} + +func (g *GitHubClient) findProjectItemId(ctx context.Context, org, repo string, projectNumber, issueNumber int) (string, string, error) { + var query struct { + Repository struct { + Issue struct { + ProjectItems struct { + Edges []struct { + Node struct { + Id string + ProjectV2Item struct { + Project struct { + Id string + Number int + } + } `graphql:"... on ProjectV2Item"` + } + } + } `graphql:"projectItems(first: 100)"` // assumes issues don't have more than 100 projects + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + if err := g.Client.Query(ctx, &query, map[string]any{ + "owner": githubv4.String(org), + "name": githubv4.String(repo), + "number": githubv4.Int(issueNumber), + }); err != nil { + return "", "", err + } + + for _, edge := range query.Repository.Issue.ProjectItems.Edges { + if edge.Node.ProjectV2Item.Project.Number == projectNumber { + return edge.Node.Id, edge.Node.ProjectV2Item.Project.Id, nil + } + } + + // not sure if we should return an error, not finding a project could be expected + return "", "", nil //fmt.Errorf("findProjectItemId: issue not found on project") +} + +func (g *GitHubClient) findProjectId(ctx context.Context, org string, number int) (string, error) { + var query struct { + Organization struct { + ProjectV2 struct { + Id string + } `graphql:"projectV2(number: $number)"` + } `graphql:"organization(login: $owner)"` + } + + err := g.Client.Query(ctx, &query, map[string]any{ + "owner": githubv4.String(org), + "number": githubv4.Int(number), + }) + return query.Organization.ProjectV2.Id, err +} + +func (g *GitHubClient) addIssueToProject(ctx context.Context, contentId, projectId string) error { + var mutation struct { + AddProjectV2ItemById struct { + ClientMutationId string + } `graphql:"addProjectV2ItemById(input: $input)"` + } + input := githubv4.AddProjectV2ItemByIdInput{ + ContentID: githubv4.ID(contentId), + ProjectID: githubv4.ID(projectId), + } + + return g.Client.Mutate(ctx, &mutation, input, nil) +} + +func (g *GitHubClient) removeIssueFromProject(ctx context.Context, itemId, projectId string) error { + var mutation struct { + DeleteProjectV2Item struct { + ClientMutationId string + } `graphql:"deleteProjectV2Item(input: $input)"` + } + + input := githubv4.DeleteProjectV2ItemInput{ + ItemID: githubv4.ID(itemId), + ProjectID: githubv4.ID(projectId), + } + + return g.Client.Mutate(ctx, &mutation, input, nil) +} diff --git a/scripts/backstage-lookup/go.mod b/scripts/backstage-lookup/go.mod new file mode 100644 index 000000000..dac6ca302 --- /dev/null +++ b/scripts/backstage-lookup/go.mod @@ -0,0 +1,14 @@ +module backstage-lookup + +go 1.24.0 + +toolchain go1.24.4 + +require ( + github.com/datolabs-io/go-backstage/v3 v3.1.0 + github.com/mitchellh/mapstructure v1.5.0 + github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 + golang.org/x/oauth2 v0.30.0 +) + +require github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect diff --git a/scripts/backstage-lookup/go.sum b/scripts/backstage-lookup/go.sum new file mode 100644 index 000000000..af0d84cf6 --- /dev/null +++ b/scripts/backstage-lookup/go.sum @@ -0,0 +1,22 @@ +github.com/datolabs-io/go-backstage/v3 v3.1.0 h1:gkcYDsss1DAEpN3p/nIQCY9dpMmDsG3YgRJTZZje5j4= +github.com/datolabs-io/go-backstage/v3 v3.1.0/go.mod h1:8Xt7Q+A8dUQvgidXII+Wj6UmRmbuj/YPiAzUbzeRcvY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/backstage-lookup/main.go b/scripts/backstage-lookup/main.go new file mode 100644 index 000000000..46a8c72ce --- /dev/null +++ b/scripts/backstage-lookup/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "flag" + "fmt" + "log" + "slices" + "strconv" + "strings" +) + +func unique(slice []string) []string { + seen := make(map[string]bool) + result := make([]string, 0, len(slice)) + for _, item := range slice { + if !seen[item] { + seen[item] = true + result = append(result, item) + } + } + return result +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + dryRun := flag.Bool("dry-run", true, "Dry-run prints the intended actions.") + groupRef := flag.String("group-ref", "", "Assign to project for this Backstage groupRef.") + + flag.Parse() + + if *dryRun { + fmt.Println("Dry-run: use --dry-run=false to turn off") + } + + positionalArgs := flag.Args() + + if len(positionalArgs) < 2 { + log.Fatal("Usage: backstage-lookup [resource2] ...") + } + + issueNumber, err := strconv.Atoi(positionalArgs[0]) + if err != nil { + log.Fatal(err) + } + + do(issueNumber, positionalArgs[1:], *dryRun, *groupRef) +} + +func do(issueNumber int, resources []string, dryRun bool, groupRef string) { + backstage, err := NewBackstageClient() + if err != nil { + log.Fatal(err) + } + backstage.Filters = func(resourceName string) []string { + return []string{ + fmt.Sprintf("kind=Component,metadata.name=resource-%s", resourceName), + fmt.Sprintf("kind=Component,metadata.name=datasource-%s", resourceName), + } + } + + var allProjects []string + for _, resource := range resources { + if resource = strings.TrimSpace(resource); resource != "" { + fmt.Printf("Looking up resource: %s\n", resource) + projects, err := backstage.FindProjectsForResource(resource, groupRef) + if err != nil { + log.Printf("Warning: failed to find projects for resource %s: %v", resource, err) + continue + } + allProjects = append(allProjects, projects...) + } + } + + allProjects = unique(allProjects) + + fmt.Printf("Assigning issue #%d to projects=%s\n", issueNumber, strings.Join(allProjects, " ")) + + github, err := NewGitHubClient() + if err != nil { + log.Fatal(err) + } + + // If the resource is not owned by monitoring and there are other projects claiming ownership, then remove monitoring. + resourceIsOwnedByPlatformMonitoring := -1 != slices.IndexFunc(allProjects, func(p string) bool { return p == "513" }) + if len(allProjects) > 0 && !resourceIsOwnedByPlatformMonitoring { + fmt.Printf("Removing issue #%d from platform-monitoring project (513)\n", issueNumber) + if !dryRun { + if err := github.RemoveIssueFromProject("grafana", "terraform-provider-grafana", issueNumber, 513); err != nil { + log.Printf("Warning: failed to remove from platform-monitoring project: %v", err) + } + } + } + + for _, projectNumber := range allProjects { + projectNumberInt, err := strconv.Atoi(projectNumber) + if err != nil { + log.Printf("Warning: invalid project number %s: %v", projectNumber, err) + continue + } + fmt.Printf("Adding issue #%d to project %d\n", issueNumber, projectNumberInt) + if !dryRun { + if err := github.AddIssueToProject("grafana", "terraform-provider-grafana", issueNumber, projectNumberInt); err != nil { + log.Printf("Warning: failed to add to project %d: %v", projectNumberInt, err) + } + } + } +}