diff --git a/go-util/.env.example b/go-util/.env.example new file mode 100644 index 0000000..6bb4e4f --- /dev/null +++ b/go-util/.env.example @@ -0,0 +1,3 @@ +GITEA_URL=https://gitea.example.com +GITEA_TOKEN=your-api-token-here +GITEA_KEEP_ORGS=org1,org2 diff --git a/go-util/README.md b/go-util/README.md index a8de5b4..219769a 100644 --- a/go-util/README.md +++ b/go-util/README.md @@ -1,2 +1,35 @@ # go-util -Some useful (or useless) scripts written in Go + +Some useful (or useless) scripts written in Go. + +## Structure + +Each script lives in its own directory under `cmd/`, so multiple `main` packages coexist without conflicts. + +``` +cmd/ + gitea-leave-orgs/ # Leave all Gitea orgs except a keep-list +``` + +## Usage + +```bash +# Run any script +go run ./cmd/ + +# Example: leave Gitea orgs +export GITEA_URL=https://gitea.example.com +export GITEA_TOKEN=your-token +export GITEA_KEEP_ORGS=org1,org2 +go run ./cmd/gitea-leave-orgs +``` + +## Adding a new script + +Create a new directory under `cmd/` with its own `main.go`: + +```bash +mkdir cmd/my-new-script +# write cmd/my-new-script/main.go with `package main` +go run ./cmd/my-new-script +``` diff --git a/go-util/cmd/gitea-leave-orgs/main.go b/go-util/cmd/gitea-leave-orgs/main.go new file mode 100644 index 0000000..1500849 --- /dev/null +++ b/go-util/cmd/gitea-leave-orgs/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" +) + +// org represents a Gitea organization. +type org struct { + ID int `json:"id"` + Username string `json:"username"` +} + +func main() { + baseURL := os.Getenv("GITEA_URL") + token := os.Getenv("GITEA_TOKEN") + keepList := os.Getenv("GITEA_KEEP_ORGS") // comma-separated org names to keep + + if baseURL == "" || token == "" { + log.Fatal("GITEA_URL and GITEA_TOKEN env vars are required") + } + + baseURL = strings.TrimRight(baseURL, "/") + + keep := make(map[string]bool) + for _, name := range strings.Split(keepList, ",") { + name = strings.TrimSpace(name) + if name != "" { + keep[name] = true + } + } + + orgs, err := listOrgs(baseURL, token) + if err != nil { + log.Fatalf("failed to list orgs: %v", err) + } + + fmt.Printf("Found %d orgs, keeping: %v\n", len(orgs), mapsKeys(keep)) + + for _, o := range orgs { + if keep[o.Username] { + fmt.Printf(" KEEP %s\n", o.Username) + continue + } + fmt.Printf(" LEAVE %s ... ", o.Username) + if err := leaveOrg(baseURL, token, o.Username); err != nil { + fmt.Printf("FAILED: %v\n", err) + } else { + fmt.Println("OK") + } + } +} + +// listOrgs fetches all organizations the authenticated user belongs to. +func listOrgs(baseURL, token string) ([]org, error) { + var allOrgs []org + page := 1 + + for { + url := fmt.Sprintf("%s/api/v1/user/orgs?page=%d&limit=50", baseURL, page) + body, err := doRequest(http.MethodGet, url, token) + if err != nil { + return nil, err + } + + var orgs []org + if err := json.Unmarshal(body, &orgs); err != nil { + return nil, fmt.Errorf("decode orgs: %w", err) + } + if len(orgs) == 0 { + break + } + + allOrgs = append(allOrgs, orgs...) + page++ + } + + return allOrgs, nil +} + +// leaveOrg removes the authenticated user from the given organization. +func leaveOrg(baseURL, token, orgName string) error { + url := fmt.Sprintf("%s/api/v1/orgs/%s/members/me", baseURL, orgName) + + // First try the /members/me endpoint + _, err := doRequest(http.MethodDelete, url, token) + if err != nil { + // Fallback: get username and use explicit member removal + username, userErr := getUsername(baseURL, token) + if userErr != nil { + return fmt.Errorf("leave org: %w (also failed to get username: %v)", err, userErr) + } + url = fmt.Sprintf("%s/api/v1/orgs/%s/members/%s", baseURL, orgName, username) + _, err = doRequest(http.MethodDelete, url, token) + } + return err +} + +// getUsername returns the authenticated user's username. +func getUsername(baseURL, token string) (string, error) { + body, err := doRequest(http.MethodGet, baseURL+"/api/v1/user", token) + if err != nil { + return "", err + } + var user struct { + Login string `json:"login"` + } + if err := json.Unmarshal(body, &user); err != nil { + return "", err + } + return user.Login, nil +} + +// doRequest executes an HTTP request with token auth and returns the response body. +func doRequest(method, url, token string) ([]byte, error) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +func mapsKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/go-util/go.mod b/go-util/go.mod new file mode 100644 index 0000000..9e03d03 --- /dev/null +++ b/go-util/go.mod @@ -0,0 +1,3 @@ +module github.com/tiennm99/go-util + +go 1.26.1