Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 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
104 changes: 104 additions & 0 deletions .github/workflows/autoassign.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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
TERRAFORM_AUTOMATION_TOKEN=backstage:terraform_automation_token

- name: Lookup team ownership
id: backstage-lookup
env:
BACKSTAGE_URL: ${{ env.BACKSTAGE_URL }}
TERRAFORM_AUTOMATION_TOKEN: ${{ env.TERRAFORM_AUTOMATION_TOKEN }} # TODO: confirm this works
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
3 changes: 3 additions & 0 deletions scripts/backstage-lookup/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module backstage-lookup

go 1.21
244 changes: 244 additions & 0 deletions scripts/backstage-lookup/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package main

import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
)

// Constants for API and configuration
const (
defaultURL = "https://enghub.grafana-ops.net"
defaultTimeout = 30 * time.Second
groupPrefix = "group:"
)

// Environment variable names
const (
EnvBackstageURL = "BACKSTAGE_URL"
EnvToken = "TERRAFORM_AUTOMATION_TOKEN"
)

// API response types
type Component struct {
Spec struct {
Owner string `json:"owner"`
} `json:"spec"`
}

type Group struct {
Metadata struct {
Links []struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"links"`
} `json:"metadata"`

Relations []struct {
Type string `json:"type"`
TargetRef string `json:"targetRef"`
} `json:"relations"`
}

// Result represents the lookup result
type Result struct {
Projects []string
Teams []string
}

// BackstageLookup handles API interactions with Backstage
type BackstageLookup struct {
client *http.Client
baseURL string
token string
projectRegex *regexp.Regexp
}

// NewBackstageLookup creates a new Backstage API client
func NewBackstageLookup(baseURL, token string) *BackstageLookup {
if baseURL == "" {
baseURL = defaultURL
}
return &BackstageLookup{
client: &http.Client{Timeout: defaultTimeout},
baseURL: baseURL,
token: token,
projectRegex: regexp.MustCompile(`/projects/(\d+)`),
}
}

// get performs an authenticated HTTP request to Backstage API
func (b *BackstageLookup) get(url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+b.token)
req.Header.Set("Accept", "application/json")

resp, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}

return io.ReadAll(resp.Body)
}

// findOwner retrieves the owner of a component by trying different prefixes and namespaces
func (b *BackstageLookup) findOwner(resource string) string {
endpoints := []string{
"default/resource-", "default/datasource-",
"backstage-catalog/resource-", "backstage-catalog/datasource-",
}

for _, endpoint := range endpoints {
url := fmt.Sprintf("%s/api/catalog/entities/by-name/component/%s%s", b.baseURL, endpoint, resource)

data, err := b.get(url)
if err != nil {
continue
}

var comp Component
if json.Unmarshal(data, &comp) == nil && comp.Spec.Owner != "" {
return comp.Spec.Owner
}
}
return ""
}

// findProject retrieves GitHub project information for a group
func (b *BackstageLookup) findProject(namespace, team string) string {
url := fmt.Sprintf("%s/api/catalog/entities/by-name/group/%s/%s", b.baseURL, namespace, team)

data, err := b.get(url)
if err != nil {
return ""
}

var group Group
if json.Unmarshal(data, &group) != nil {
return ""
}

for _, link := range group.Metadata.Links {
if link.Type == "github_project" {
if matches := b.projectRegex.FindStringSubmatch(link.URL); len(matches) >= 2 {
return matches[1]
}
}
}

// Walk through parentOf relations and return first match
// Background: the teams in group:default/ are synced from GitHub, we can't add arbitrary links to these, we have added the links to teams in group:backstage-catalog: instead. The teams in the backstage-catalog namespace refer to the GitHub teams as their parent. In theory multiple children could have a GitHub project, this loop returns the first match.
for _, relation := range group.Relations {
if relation.Type == "parentOf" {
fmt.Println(relation.TargetRef)
namespace, team := parseOwner(relation.TargetRef)
project := b.findProject(namespace, team)
if project != "" {
return project
}
}
}

return ""
}

// parseOwner parses a group owner string into namespace and name
func parseOwner(owner string) (namespace, team string) {
if !strings.HasPrefix(owner, groupPrefix) {
return "", ""
}

parts := strings.Split(strings.TrimPrefix(owner, groupPrefix), "/")
if len(parts) == 2 {
return parts[0], parts[1]
}
return "", ""
}

// LookupResource looks up project and team information for a Terraform resource
func (b *BackstageLookup) LookupResource(resource string) (projects, teams []string) {
if resource == "Other (please describe in the issue)" {
return nil, nil
}

log.Printf("Processing: %s", resource)

owner := b.findOwner(resource)
if owner == "" {
log.Printf("No owner found for %s - manual triage needed", resource)
return nil, nil
}

namespace, team := parseOwner(owner)
if namespace == "" || team == "" {
log.Printf("Invalid owner %s for %s - manual triage needed", owner, resource)
return nil, nil
}

log.Printf("Found owner %s for %s", owner, resource)

if project := b.findProject(namespace, team); project != "" {
log.Printf("Found project %s for team %s", project, team)
return []string{project}, []string{team}
}

log.Printf("No project found for team %s", team)
return nil, []string{team}
}

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)

if len(os.Args) < 2 {
log.Fatal("Usage: backstage-lookup <resource1> [resource2] ...")
}

baseURL := os.Getenv("BACKSTAGE_URL")
token := os.Getenv("TERRAFORM_AUTOMATION_TOKEN")
if token == "" {
log.Fatal("TERRAFORM_AUTOMATION_TOKEN required")
}

lookup := NewBackstageLookup(baseURL, token)

var allProjects, allTeams []string
for _, resource := range os.Args[1:] {
if resource = strings.TrimSpace(resource); resource != "" {
// Clean resource name
resource = strings.TrimSuffix(strings.TrimSuffix(resource, " (resource)"), " (data source)")
projects, teams := lookup.LookupResource(resource)
allProjects = append(allProjects, projects...)
allTeams = append(allTeams, teams...)
}
}

fmt.Printf("projects=%s\n", strings.Join(unique(allProjects), " "))
fmt.Printf("teams=%s\n", strings.Join(unique(allTeams), " "))
}
Loading