246 lines
6.9 KiB
Go
246 lines
6.9 KiB
Go
// Copyright 2025 Dolthub, Inc.
|
|
//
|
|
// 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 main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
query = `extension:sql pg_dump`
|
|
downloadCount = 110
|
|
)
|
|
|
|
// RepoName simply contains the name of the repository.
|
|
type RepoName struct {
|
|
FullName string `json:"full_name"`
|
|
}
|
|
|
|
// Item is a SQL file (hopefully) containing a pg_dump.
|
|
type Item struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
HtmlURL string `json:"html_url"`
|
|
ContentsURL string `json:"url"`
|
|
Repository RepoName `json:"repository"`
|
|
}
|
|
|
|
// CodeSearchResult contains the result of a code search.
|
|
type CodeSearchResult struct {
|
|
TotalCount int `json:"total_count"`
|
|
IncompleteResults bool `json:"incomplete_results"`
|
|
Items []Item `json:"items"`
|
|
Message string `json:"message"` // Only used when there's an error
|
|
}
|
|
|
|
// ContentFile is all of the information about a SQL file, including how to retrieve it.
|
|
type ContentFile struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
SHA string `json:"sha"`
|
|
Size int64 `json:"size"`
|
|
HTMLURL string `json:"html_url"`
|
|
DownloadURL string `json:"download_url"`
|
|
}
|
|
|
|
func main() {
|
|
ctx := context.Background()
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
token := os.Getenv("GITHUB_TOKEN")
|
|
if len(token) == 0 {
|
|
fmt.Println("Must provide a GITHUB_TOKEN as an environment variable")
|
|
os.Exit(1)
|
|
}
|
|
|
|
_, currentFileLocation, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
fmt.Println("Unable to find the folder where this file is located")
|
|
os.Exit(1)
|
|
}
|
|
dumpsFolder := filepath.Clean(filepath.Join(filepath.Dir(currentFileLocation), "../sql"))
|
|
|
|
var saved int
|
|
page := 1
|
|
|
|
OuterLoop:
|
|
for {
|
|
remaining := downloadCount - saved
|
|
items, err := SearchCode(ctx, httpClient, token, page, min(50, remaining))
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
if len(items) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, item := range items {
|
|
cf, err := GetContent(ctx, httpClient, token, item.ContentsURL)
|
|
if err != nil {
|
|
fmt.Printf("warn: %s/%s: %v\n", item.Repository.FullName, item.Path, err)
|
|
continue
|
|
}
|
|
if cf.Type != "file" || cf.DownloadURL == "" {
|
|
continue
|
|
}
|
|
|
|
dest := filepath.Join(dumpsFolder, SanitizePath(item.Repository.FullName)+filepath.Ext(cf.Path))
|
|
if _, err = os.Stat(dest); err == nil {
|
|
continue
|
|
}
|
|
if err = DownloadFile(ctx, httpClient, item, cf.DownloadURL, dest); err != nil {
|
|
fmt.Printf("download error: %s -> %v\n", dest, err)
|
|
continue
|
|
}
|
|
fmt.Printf("saved: %s (%d bytes)\n", dest, cf.Size)
|
|
|
|
saved++
|
|
if saved >= downloadCount {
|
|
break OuterLoop
|
|
}
|
|
time.Sleep(6500 * time.Millisecond) // We sleep to mitigate rate limits
|
|
}
|
|
page++
|
|
}
|
|
}
|
|
|
|
// SearchCode executes the query against the API, returning all items that were found.
|
|
func SearchCode(ctx context.Context, hc *http.Client, token string, page int, perPage int) ([]Item, error) {
|
|
params := url.Values{}
|
|
params.Set("q", query)
|
|
params.Set("page", strconv.Itoa(page))
|
|
params.Set("per_page", strconv.Itoa(perPage))
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/search/code?"+params.Encode(), nil)
|
|
SetHeaders(req, token)
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if HandleRate(resp) {
|
|
return SearchCode(ctx, hc, token, page, perPage)
|
|
}
|
|
var sr CodeSearchResult
|
|
if err = json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
if sr.Message != "" {
|
|
return nil, fmt.Errorf("search error: %s (HTTP %d)", sr.Message, resp.StatusCode)
|
|
}
|
|
return nil, fmt.Errorf("search error: HTTP %d", resp.StatusCode)
|
|
}
|
|
return sr.Items, nil
|
|
}
|
|
|
|
// GetContent gets the ContentFile from the given URL.
|
|
func GetContent(ctx context.Context, hc *http.Client, token string, contentsURL string) (*ContentFile, error) {
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", contentsURL, nil)
|
|
SetHeaders(req, token)
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if HandleRate(resp) {
|
|
return GetContent(ctx, hc, token, contentsURL)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("contents error: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
var cf ContentFile
|
|
if err = json.NewDecoder(resp.Body).Decode(&cf); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cf, nil
|
|
}
|
|
|
|
// DownloadFile downloads the given SQL file to the destination.
|
|
func DownloadFile(ctx context.Context, hc *http.Client, item Item, rawURL string, dest string) error {
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
|
req.Header.Set("User-Agent", "gh-pg-dump-finder/1.0")
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("download HTTP %d", resp.StatusCode)
|
|
}
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, _ = io.WriteString(out, fmt.Sprintf("-- Downloaded from: %s\n", item.HtmlURL))
|
|
_, err = io.Copy(out, resp.Body)
|
|
return err
|
|
}
|
|
|
|
// SetHeaders sets the appropriate headers for a request.
|
|
func SetHeaders(req *http.Request, token string) {
|
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
|
req.Header.Set("User-Agent", "gh-pg-dump-finder/1.0")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
// HandleRate handles potential rate limits.
|
|
func HandleRate(resp *http.Response) bool {
|
|
if resp.StatusCode == 403 {
|
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
|
if secs, _ := strconv.Atoi(ra); secs > 0 {
|
|
sleepTime := time.Duration(secs) * time.Second
|
|
fmt.Printf("rate limited (%s), retrying\n", sleepTime.String())
|
|
time.Sleep(sleepTime)
|
|
return true
|
|
}
|
|
}
|
|
if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" {
|
|
if ts, _ := strconv.ParseInt(reset, 10, 64); ts > 0 {
|
|
wait := time.Until(time.Unix(ts+5, 0))
|
|
if wait > 0 && wait < 5*time.Minute {
|
|
fmt.Printf("rate limited (%s), retrying\n", wait.String())
|
|
time.Sleep(wait)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SanitizePath removes potentially invalid file system characters.
|
|
func SanitizePath(s string) string {
|
|
illegal := `<>:"\|/?*`
|
|
for _, r := range illegal {
|
|
s = strings.ReplaceAll(s, string(r), "_")
|
|
}
|
|
return s
|
|
}
|