Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/workflows/autoassign.yml
Original file line number Diff line number Diff line change
@@ -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
});
4 changes: 2 additions & 2 deletions internal/resources/appplatform/catalog-resource.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
22 changes: 22 additions & 0 deletions scripts/backstage-lookup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Development

Configure an access token for Backstage:

```console
export BACKSTAGE_TOKEN=<get access token defined on EngHub>
```

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)
```

185 changes: 185 additions & 0 deletions scripts/backstage-lookup/backstage.go
Original file line number Diff line number Diff line change
@@ -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/<org>/projects/<number>
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
}
Loading
Loading