Skip to content
Open
129 changes: 129 additions & 0 deletions cmd/internal/org/org.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright 2025 OpenSSF Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package org

import (
"context"
"errors"
"fmt"
"net/http"
"strings"

"github.com/google/go-github/v53/github"

"github.com/ossf/scorecard/v5/clients/githubrepo/roundtripper"
"github.com/ossf/scorecard/v5/log"
)

// ErrNilResponse indicates the GitHub API returned a nil response object.
var ErrNilResponse = errors.New("nil response from GitHub API")

// ListOrgRepos lists all non-archived repositories for a GitHub organization.
// The caller should provide an http.RoundTripper (rt). If rt is nil, the
// default transport will be created via roundtripper.NewTransport.
func ListOrgRepos(ctx context.Context, orgName string, rt http.RoundTripper) ([]string, error) {
// Parse org name if needed.
if len(orgName) > 0 {
if parsed := parseOrgName(orgName); parsed != "" {
orgName = parsed
}
}

// Use the centralized transport so we respect token rotation, GitHub App
// auth, rate limiting and instrumentation already implemented in
// clients/githubrepo/roundtripper.
logger := log.NewLogger(log.DefaultLevel)
if rt == nil {
rt = roundtripper.NewTransport(ctx, logger)
}
httpClient := &http.Client{Transport: rt}
client := github.NewClient(httpClient)

opt := &github.RepositoryListByOrgOptions{
Type: "all",
}

var urls []string
for {
repos, resp, err := client.Repositories.ListByOrg(ctx, orgName, opt)
if err != nil {
return nil, fmt.Errorf("failed to list repos: %w", err)
}

for _, r := range repos {
if r.GetArchived() {
continue
}
urls = append(urls, r.GetHTMLURL())
}

if resp == nil {
return nil, ErrNilResponse
}
if resp.NextPage == 0 {
break
}
opt.Page = resp.NextPage
}

return urls, nil
}

// parseOrgName extracts the GitHub organization from a supported input.
// Supported:
// - owner > owner
// - github.com/owner > owner
// - http://github.com/owner > owner
// - https://github.com/owner > owner
//
// Returns "" if no org can be parsed.
func parseOrgName(input string) string {
s := strings.TrimSpace(input)
if s == "" {
return ""
}

// Strip optional scheme or leading "//".
switch {
case strings.HasPrefix(s, "https://"):
s = strings.TrimPrefix(s, "https://")
case strings.HasPrefix(s, "http://"):
s = strings.TrimPrefix(s, "http://")
case strings.HasPrefix(s, "//"):
s = strings.TrimPrefix(s, "//")
}

// If it's exactly the host, there's no org.
if s == "github.com" {
return ""
}

// Strip host prefix if present.
if after, ok := strings.CutPrefix(s, "github.com/"); ok {
s = after
}

// Keep only the first path segment (the org).
if i := strings.IndexByte(s, '/'); i >= 0 {
s = s[:i]
}

// Basic sanity: org shouldn't contain dots (to avoid host-like values).
if s == "" || strings.Contains(s, ".") {
return ""
}

return s
}
91 changes: 91 additions & 0 deletions cmd/internal/org/org_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2025 OpenSSF Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package org

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestParseOrgName(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"http://github.com/owner", "owner"},
{"https://github.com/owner", "owner"},
{"github.com/owner", "owner"},
{"owner", "owner"},
{"", ""},
}
for _, c := range cases {
if got := parseOrgName(c.in); got != c.want {
t.Fatalf("parseOrgName(%q) = %q; want %q", c.in, got, c.want)
}
}
}

// Test ListOrgRepos handles pagination and filters archived repos.
func TestListOrgRepos_PaginationAndArchived(t *testing.T) {
t.Parallel()
// Single page: one archived repo and two active repos; expect active ones returned.
body := `[
{"html_url": "https://github.com/owner/repo1", "archived": true},
{"html_url": "https://github.com/owner/repo2", "archived": false},
{"html_url": "https://github.com/owner/repo3", "archived": false}
]`

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}))
defer srv.Close()

// Override TransportFactory to redirect requests to our test server.
rt := roundTripperToServer(srv.URL)

repos, err := ListOrgRepos(context.Background(), "owner", rt)
if err != nil {
t.Fatalf("ListOrgRepos returned error: %v", err)
}
// Expect repo2 and repo3 (repo1 archived)
if len(repos) != 2 {
t.Fatalf("expected 2 repos, got %d: %v", len(repos), repos)
}
if !strings.Contains(repos[0], "repo2") || !strings.Contains(repos[1], "repo3") {
t.Fatalf("unexpected repos: %v", repos)
}
}

// roundTripperToServer returns an http.RoundTripper that rewrites requests
// to the given serverURL, keeping the path and query intact.
func roundTripperToServer(serverURL string) http.RoundTripper {
return http.RoundTripper(httpTransportFunc(func(req *http.Request) (*http.Response, error) {
// rewrite target
req.URL.Scheme = "http"
req.URL.Host = strings.TrimPrefix(serverURL, "http://")
return http.DefaultTransport.RoundTrip(req)
}))
}

// httpTransportFunc converts a function into an http.RoundTripper.
type httpTransportFunc func(*http.Request) (*http.Response, error)

func (f httpTransportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
Loading