SHA256
1
0

Compare commits

..

1 Commits

Author SHA256 Message Date
0db0536436 Initial obs-groups-bot 2026-03-06 14:53:16 +01:00
4 changed files with 648 additions and 1 deletions

View File

@@ -1,4 +1,4 @@
MODULES := devel-importer utils/hujson utils/maintainer-update gitea-events-rabbitmq-publisher gitea_status_proxy group-review obs-forward-bot obs-staging-bot obs-status-service workflow-direct workflow-pr
MODULES := devel-importer utils/hujson utils/maintainer-update gitea-events-rabbitmq-publisher gitea_status_proxy group-review obs-forward-bot obs-groups-bot obs-staging-bot obs-status-service workflow-direct workflow-pr
build:
for m in $(MODULES); do go build -C $$m -buildmode=pie || exit 1 ; done

1
obs-groups-bot/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
obs-groups-bot

399
obs-groups-bot/main.go Normal file
View File

@@ -0,0 +1,399 @@
// Connect to the Open Build Service (OBS) API, retrieves a list of all groups,
// and exports their metadata (specifically member lists) into individual JSON files.
//
// The tool supports both command-line flags and environment variables for configuration,
// and includes a debug mode for verbose output. It handles different XML response formats
// from the OBS API and ensures that the output JSON files are properly sanitized and formatted.
//
// The accepted command-line flags are:
//
// -debug: Enable debug output showing API URLs and responses.
// -instance: Name of the OBS instance (used in metadata, default "openSUSE").
// -host: Base URL of the OBS API (default "http://localhost:3000").
// -user: OBS username (or set via OBS_USER environment variable).
// -password: OBS password (or set via OBS_PASSWORD environment variable).
// -output: Directory to save the JSON files (default "groups").
//
// Usage:
//
// # Using flags for credentials
// go run main.go -user "myuser" -password "mypass"
//
// # Using environment variables (OBS_USER, OBS_PASSWORD)
// go run main.go
//
// # Targeting a specific OBS instance and output directory
// go run main.go -host "https://api.opensuse.org" -output "./obs_groups"
//
// # Full command with debug mode
// go run main.go -host http://localhost:8000 -user "myuser" -password "mypass" -output "./obs_groups" -instance "OBS" -debug
package main
import (
"context"
"encoding/json"
"encoding/xml"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const (
envOBSUser = "OBS_USER"
envOBSPassword = "OBS_PASSWORD"
)
type ObsClient struct {
baseUrl *url.URL
client *http.Client
user string
password string
cookie string
debug bool
}
type groupsList struct {
XMLName xml.Name `xml:"groups"`
Groups []groupItem `xml:"group"`
}
type groupsListAlt struct {
XMLName xml.Name `xml:"directory"`
Entries []groupEntry `xml:"entry"`
}
type groupEntry struct {
Name string `xml:"name,attr,omitempty"`
Inner string `xml:",innerxml"`
}
func (e *groupEntry) getName() string {
if e.Name != "" {
return e.Name
}
return e.Inner
}
type groupItem struct {
GroupID string `xml:"groupid,attr"`
}
type personRepoMeta struct {
XMLName xml.Name `xml:"person"`
UserID string `xml:"userid,attr"`
Role string `xml:"role,attr,omitempty"`
}
type personGroup struct {
XMLName xml.Name `xml:"person"`
Persons []personRepoMeta `xml:"person"`
}
type groupMeta struct {
XMLName xml.Name `xml:"group"`
Title string `xml:"title"`
Persons personGroup `xml:"person"`
}
func NewObsClient(host, user, password string, debug bool) (*ObsClient, error) {
if host == "" {
return nil, fmt.Errorf("host URL cannot be empty")
}
if user == "" || password == "" {
return nil, fmt.Errorf("username and password are required")
}
baseUrl, err := url.Parse(host)
if err != nil {
return nil, fmt.Errorf("failed to parse host URL: %w", err)
}
if baseUrl.Scheme == "" || baseUrl.Host == "" {
return nil, fmt.Errorf("host URL must contain scheme (http:// or https://) and hostname")
}
return &ObsClient{
baseUrl: baseUrl,
client: &http.Client{Timeout: 30 * time.Second},
user: user,
password: password,
debug: debug,
}, nil
}
func (c *ObsClient) ObsRequest(ctx context.Context, method string, url_path []string, body io.Reader) (*http.Response, error) {
fullURL := c.baseUrl.JoinPath(url_path...).String()
return c.ObsRequestRaw(ctx, method, fullURL, body)
}
func (c *ObsClient) ObsRequestRaw(ctx context.Context, method string, url string, body io.Reader) (*http.Response, error) {
if c.debug {
log.Printf("[DEBUG] %s %s", method, url)
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if body != nil {
req.Body = io.NopCloser(body)
}
if c.cookie != "" {
req.Header.Add("Cookie", c.cookie)
}
if c.user != "" && c.password != "" {
req.SetBasicAuth(c.user, c.password)
}
res, err := c.client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode == 200 {
auth_cookie := res.Header.Get("set-cookie")
if auth_cookie != "" {
c.cookie = auth_cookie
}
}
return res, nil
}
func (c *ObsClient) GetAllGroups(ctx context.Context) ([]string, error) {
res, err := c.ObsRequest(ctx, "GET", []string{"group"}, nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
log.Printf("Response status: %d, body length: %d", res.StatusCode, len(data))
if res.StatusCode != 200 {
bodyStr := string(data)
if len(bodyStr) > 500 {
bodyStr = bodyStr[:500]
}
return nil, fmt.Errorf("Unexpected return code: %d, body: %s", res.StatusCode, bodyStr)
}
// Try parsing as <groups> format
var groupsList groupsList
err = xml.Unmarshal(data, &groupsList)
if err == nil && len(groupsList.Groups) > 0 {
groupIDs := make([]string, len(groupsList.Groups))
for i, g := range groupsList.Groups {
groupIDs[i] = g.GroupID
}
return groupIDs, nil
}
// Try parsing as <directory> format
var groupsAlt groupsListAlt
err = xml.Unmarshal(data, &groupsAlt)
if err == nil && len(groupsAlt.Entries) > 0 {
groupIDs := make([]string, len(groupsAlt.Entries))
for i, e := range groupsAlt.Entries {
groupIDs[i] = e.getName()
}
return groupIDs, nil
}
// Log what we got
bodyStr := string(data)
if len(bodyStr) > 1000 {
bodyStr = bodyStr[:1000]
}
log.Printf("Failed to parse XML, got: %s", bodyStr)
return nil, fmt.Errorf("Could not parse groups response")
}
func (c *ObsClient) GetGroupMeta(ctx context.Context, gid string) (*groupMeta, error) {
log.Printf("[DEBUG] gid: %s", gid)
groupPath := []string{"group", gid}
fullURL := c.baseUrl.JoinPath(groupPath...).String()
if c.debug {
log.Printf("[DEBUG] Fetching group: %s", fullURL)
}
res, err := c.ObsRequestRaw(ctx, "GET", fullURL, nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
switch res.StatusCode {
case 200:
break
case 404:
return nil, nil
default:
return nil, fmt.Errorf("Unexpected return code: %d", res.StatusCode)
}
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
var meta groupMeta
err = xml.Unmarshal(data, &meta)
if err != nil {
return nil, err
}
return &meta, nil
}
type GroupOutput struct {
Meta ImportMeta `json:"_meta,omitempty"`
Name string `json:"Name"`
Reviewers []string `json:"Reviewers"`
Silent bool `json:"Silent,omitempty"`
}
type ImportMeta struct {
ImportedFrom string `json:"imported_from"`
ReadOnly bool `json:"read_only"`
ImportTime time.Time `json:"import_time"`
}
func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, ":", "_")
name = strings.ReplaceAll(name, " ", "_")
return name
}
func processGroup(ctx context.Context, client *ObsClient, groupID, outputDir, instanceName string, importTime time.Time) error {
meta, err := client.GetGroupMeta(ctx, groupID)
if err != nil {
return fmt.Errorf("fetching group meta: %w", err)
}
if meta == nil {
return fmt.Errorf("group not found")
}
if client.debug {
log.Printf("[DEBUG] Group meta for %s: Title: %s, Persons: %d", groupID, meta.Title, len(meta.Persons.Persons))
}
reviewers := make([]string, 0, len(meta.Persons.Persons))
for _, p := range meta.Persons.Persons {
reviewers = append(reviewers, p.UserID)
}
output := GroupOutput{
Meta: ImportMeta{
ImportedFrom: instanceName,
ReadOnly: true,
ImportTime: importTime,
},
Name: groupID,
Reviewers: reviewers,
}
filename := sanitizeFilename(groupID) + ".json"
filePath := filepath.Join(outputDir, filename)
data, err := json.MarshalIndent(output, "", " ")
if err != nil {
return fmt.Errorf("marshaling json: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("writing file: %w", err)
}
if client.debug {
log.Printf("[DEBUG] Saved group %s to %s", groupID, filePath)
}
return nil
}
func main() {
debugModePtr := flag.Bool("debug", false, "Enable debug output showing API URLs")
obsInstance := flag.String("instance", "openSUSE", "OBS instance name (used in metadata)")
obsHost := flag.String("host", "http://localhost:3000", "OBS API host URL")
obsUser := flag.String("user", "", "OBS username (or set OBS_USER env)")
obsPassword := flag.String("password", "", "OBS password (or set OBS_PASSWORD env)")
outputDir := flag.String("output", "groups", "Output directory for JSON files")
flag.Parse()
obsUserVal := *obsUser
obsPasswordVal := *obsPassword
if obsUserVal == "" {
if *debugModePtr {
log.Printf("[DEBUG] No OBS user provided. Trying environment variables `%s`...", envOBSUser)
}
obsUserVal = os.Getenv(envOBSUser)
}
if obsPasswordVal == "" {
if *debugModePtr {
log.Printf("[DEBUG] No OBS password provided. Trying environment variables `%s`...", envOBSPassword)
}
obsPasswordVal = os.Getenv(envOBSPassword)
}
if obsUserVal == "" || obsPasswordVal == "" {
log.Fatalf("OBS credentials required. Set -user/-password flags or %s/%s environment variables", envOBSUser, envOBSPassword)
}
log.Printf("Connecting to OBS at %s (instance: %s)", *obsHost, *obsInstance)
client, err := NewObsClient(*obsHost, obsUserVal, obsPasswordVal, *debugModePtr)
if err != nil {
log.Fatalf("Failed to create OBS client: %v", err)
}
ctx := context.Background()
log.Println("Fetching list of all groups...")
groupIDs, err := client.GetAllGroups(ctx)
if err != nil {
log.Fatalf("Failed to get groups list: %v", err)
}
log.Printf("Found %d groups: %v", len(groupIDs), groupIDs)
log.Printf("Found %s ", groupIDs)
err = os.MkdirAll(*outputDir, 0755)
if err != nil {
log.Fatalf("Failed to create output directory: %v", err)
}
importTime := time.Now()
successCount := 0
errorCount := 0
for i, groupID := range groupIDs {
log.Printf("[%d/%d] Fetching group: %s", i+1, len(groupIDs), groupID)
if err := processGroup(ctx, client, groupID, *outputDir, *obsInstance, importTime); err != nil {
log.Printf("Error processing group %s: %v", groupID, err)
errorCount++
continue
}
successCount++
time.Sleep(100 * time.Millisecond)
}
log.Printf("Done! Success: %d, Errors: %d", successCount, errorCount)
log.Printf("JSON files saved to: %s", *outputDir)
}

247
obs-groups-bot/main_test.go Normal file
View File

@@ -0,0 +1,247 @@
package main
import (
"context"
"encoding/json"
"encoding/xml"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
func TestGroupsListParsing(t *testing.T) {
// Test <groups> format
groupsXML := `<?xml version="1.0" encoding="utf-8"?>
<groups>
<group groupid="group1"/>
<group groupid="group2"/>
<group groupid="group3"/>
</groups>`
var groupsList groupsList
err := xml.Unmarshal([]byte(groupsXML), &groupsList)
if err != nil {
t.Fatalf("Failed to unmarshal groups XML: %v", err)
}
if len(groupsList.Groups) != 3 {
t.Errorf("Expected 3 groups, got %d", len(groupsList.Groups))
}
expected := []string{"group1", "group2", "group3"}
for i, g := range groupsList.Groups {
if g.GroupID != expected[i] {
t.Errorf("Expected group %s, got %s", expected[i], g.GroupID)
}
}
}
func TestProcessGroup(t *testing.T) {
// 1. Mock the OBS API server for GetGroupMeta
groupID := "test:group"
mockGroupMetaResponse := `<?xml version="1.0" encoding="utf-8"?>
<group>
<title>Test Group Title</title>
<person>
<person userid="user1" role="maintainer"/>
<person userid="user2" role="reviewer"/>
</person>
</group>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/group/" + groupID
if r.URL.Path != expectedPath {
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockGroupMetaResponse))
}))
defer server.Close()
// 2. Create a temporary directory for output
outputDir := t.TempDir()
// 3. Initialize client pointing to mock server
client, err := NewObsClient(server.URL, "testuser", "testpass", false)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
// 4. Call processGroup
instanceName := "test-instance"
importTime := time.Now().UTC().Truncate(time.Second) // Truncate for stable comparison
err = processGroup(context.Background(), client, groupID, outputDir, instanceName, importTime)
if err != nil {
t.Fatalf("processGroup failed: %v", err)
}
// 5. Verify the output file
expectedFilename := sanitizeFilename(groupID) + ".json"
filePath := filepath.Join(outputDir, expectedFilename)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
t.Fatalf("Expected output file was not created: %s", filePath)
}
// Read and verify file content
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
var result GroupOutput
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("Failed to unmarshal output JSON: %v", err)
}
// Assertions
expectedReviewers := []string{"user1", "user2"}
expectedOutput := GroupOutput{
Meta: ImportMeta{
ImportedFrom: instanceName,
ReadOnly: true,
ImportTime: importTime,
},
Name: groupID,
Reviewers: expectedReviewers,
}
// Use reflect.DeepEqual for a robust comparison of the structs
if !reflect.DeepEqual(result, expectedOutput) {
t.Errorf("Output JSON does not match expected.\nGot: %+v\nWant: %+v", result, expectedOutput)
}
}
func TestObsClient_GetAllGroups(t *testing.T) {
// Mock the OBS API server
mockResponse := `<?xml version="1.0" encoding="utf-8"?>
<groups>
<group groupid="mock-group-1"/>
<group groupid="mock-group-2"/>
</groups>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the request path
if r.URL.Path != "/group" {
t.Errorf("Expected path /group, got %s", r.URL.Path)
}
// Verify method
if r.Method != "GET" {
t.Errorf("Expected method GET, got %s", r.Method)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockResponse))
}))
defer server.Close()
// Initialize client pointing to mock server
client, err := NewObsClient(server.URL, "testuser", "testpass", false)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
groups, err := client.GetAllGroups(context.Background())
if err != nil {
t.Fatalf("GetAllGroups failed: %v", err)
}
if len(groups) != 2 {
t.Errorf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "mock-group-1" {
t.Errorf("Expected first group to be mock-group-1, got %s", groups[0])
}
}
func TestGroupsListDirectoryFormat(t *testing.T) {
// Test <directory> format with name attribute
dirXML := `<?xml version="1.0" encoding="utf-8"?>
<directory>
<entry name="group-a"/>
<entry name="group-b"/>
<entry name="group-c"/>
</directory>`
var groupsAlt groupsListAlt
err := xml.Unmarshal([]byte(dirXML), &groupsAlt)
if err != nil {
t.Fatalf("Failed to unmarshal directory XML: %v", err)
}
if len(groupsAlt.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(groupsAlt.Entries))
}
expected := []string{"group-a", "group-b", "group-c"}
for i, e := range groupsAlt.Entries {
if e.getName() != expected[i] {
t.Errorf("Expected entry %s, got %s", expected[i], e.getName())
}
}
}
func TestGroupMetaParsing(t *testing.T) {
groupXML := `<?xml version="1.0" encoding="utf-8"?>
<group>
<title>Test Group Title</title>
<person>
<person userid="user1" role="maintainer"/>
<person userid="user2" role="reviewer"/>
<person userid="user3"/>
</person>
</group>`
var meta groupMeta
err := xml.Unmarshal([]byte(groupXML), &meta)
if err != nil {
t.Fatalf("Failed to unmarshal group meta XML: %v", err)
}
if meta.Title != "Test Group Title" {
t.Errorf("Expected title 'Test Group Title', got '%s'", meta.Title)
}
if len(meta.Persons.Persons) != 3 {
t.Errorf("Expected 3 persons, got %d", len(meta.Persons.Persons))
}
persons := meta.Persons.Persons
if persons[0].UserID != "user1" || persons[0].Role != "maintainer" {
t.Errorf("First person should be user1 with role maintainer")
}
if persons[1].UserID != "user2" || persons[1].Role != "reviewer" {
t.Errorf("Second person should be user2 with role reviewer")
}
if persons[2].UserID != "user3" || persons[2].Role != "" {
t.Errorf("Third person should be user3 with empty role")
}
}
func TestSanitizeFilename(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"simple", "simple"},
{"group/name", "group_name"},
{"project:group", "project_group"},
{"group with spaces", "group_with_spaces"},
{"group/name:space", "group_name_space"},
{"", ""},
{"multiple///slashes", "multiple___slashes"},
}
for _, tc := range tests {
result := sanitizeFilename(tc.input)
if result != tc.expected {
t.Errorf("sanitizeFilename(%q) = %q, expected %q", tc.input, result, tc.expected)
}
}
}