diff --git a/cmd/gitea-cli/cmd/auth.go b/cmd/gitea-cli/cmd/auth.go new file mode 100644 index 0000000000..d819aed24a --- /dev/null +++ b/cmd/gitea-cli/cmd/auth.go @@ -0,0 +1,165 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/term" + "gopkg.in/yaml.v3" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage authentication", + Long: `Manage authentication to Gitea servers.`, +} + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Login to a Gitea server", + Long: `Login to a Gitea server and save credentials. + +The credentials are stored in ~/.gitea-cli.yaml and used for subsequent commands.`, + RunE: runLogin, +} + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Logout from the current server", + RunE: runLogout, +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show authentication status", + RunE: runStatus, +} + +func init() { + loginCmd.Flags().String("server", "", "Gitea server URL (required)") + loginCmd.Flags().String("token", "", "API token (if not provided, will prompt)") + loginCmd.MarkFlagRequired("server") + + authCmd.AddCommand(loginCmd) + authCmd.AddCommand(logoutCmd) + authCmd.AddCommand(statusCmd) +} + +func runLogin(cmd *cobra.Command, args []string) error { + server, _ := cmd.Flags().GetString("server") + tokenFlag, _ := cmd.Flags().GetString("token") + + // Normalize server URL + server = strings.TrimSuffix(server, "/") + if !strings.HasPrefix(server, "http://") && !strings.HasPrefix(server, "https://") { + server = "https://" + server + } + + var apiToken string + if tokenFlag != "" { + apiToken = tokenFlag + } else { + // Prompt for token + fmt.Print("API Token: ") + byteToken, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + // Fallback if terminal not available + reader := bufio.NewReader(os.Stdin) + apiToken, _ = reader.ReadString('\n') + apiToken = strings.TrimSpace(apiToken) + } else { + apiToken = string(byteToken) + fmt.Println() // New line after password + } + } + + if apiToken == "" { + return fmt.Errorf("token is required") + } + + // Verify the token works + fmt.Printf("Verifying credentials with %s...\n", server) + + // TODO: Make actual API call to verify token + // For now, just save the config + + // Save configuration + config := map[string]string{ + "server": server, + "token": apiToken, + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + configPath := filepath.Join(home, ".gitea-cli.yaml") + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0600); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + fmt.Printf("✓ Logged in to %s\n", server) + fmt.Printf(" Configuration saved to %s\n", configPath) + + return nil +} + +func runLogout(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + configPath := filepath.Join(home, ".gitea-cli.yaml") + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + fmt.Println("Not currently logged in") + return nil + } + + if err := os.Remove(configPath); err != nil { + return fmt.Errorf("failed to remove config: %w", err) + } + + fmt.Println("✓ Logged out successfully") + return nil +} + +func runStatus(cmd *cobra.Command, args []string) error { + server := viper.GetString("server") + token := viper.GetString("token") + + if server == "" || token == "" { + fmt.Println("Not logged in") + fmt.Println("\nUse 'gitea-cli auth login --server ' to authenticate") + return nil + } + + // Mask token for display + maskedToken := token + if len(token) > 8 { + maskedToken = token[:4] + "..." + token[len(token)-4:] + } + + fmt.Printf("Server: %s\n", server) + fmt.Printf("Token: %s\n", maskedToken) + + // TODO: Verify token is still valid with API call + + return nil +} diff --git a/cmd/gitea-cli/cmd/root.go b/cmd/gitea-cli/cmd/root.go new file mode 100644 index 0000000000..fd4c9421f5 --- /dev/null +++ b/cmd/gitea-cli/cmd/root.go @@ -0,0 +1,121 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + cfgFile string + serverURL string + token string + verbose bool + + version string + buildTime string + gitCommit string +) + +// rootCmd represents the base command +var rootCmd = &cobra.Command{ + Use: "gitea-cli", + Short: "A CLI tool for Gitea", + Long: `gitea-cli is a command-line tool for interacting with Gitea instances. + +It provides efficient chunked uploads for large files, progress tracking, +and the ability to resume interrupted uploads. + +Example usage: + gitea-cli auth login --server https://gitea.example.com + gitea-cli upload release-asset --repo owner/repo --release v1.0.0 --file ./app.tar.gz + gitea-cli upload resume --session sess_abc123`, +} + +// Execute runs the root command +func Execute() error { + return rootCmd.Execute() +} + +// SetVersion sets version information +func SetVersion(v, bt, gc string) { + version = v + buildTime = bt + gitCommit = gc +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.gitea-cli.yaml)") + rootCmd.PersistentFlags().StringVarP(&serverURL, "server", "s", "", "Gitea server URL") + rootCmd.PersistentFlags().StringVarP(&token, "token", "t", "", "API token") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + + viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) + viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token")) + + // Add subcommands + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(authCmd) + rootCmd.AddCommand(uploadCmd) +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + + viper.AddConfigPath(home) + viper.AddConfigPath(filepath.Join(home, ".config", "gitea-cli")) + viper.SetConfigName(".gitea-cli") + viper.SetConfigType("yaml") + } + + viper.SetEnvPrefix("GITEA") + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err == nil { + if verbose { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } + } +} + +// versionCmd shows version information +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("gitea-cli version %s\n", version) + fmt.Printf(" Build time: %s\n", buildTime) + fmt.Printf(" Git commit: %s\n", gitCommit) + }, +} + +// getServer returns the configured server URL +func getServer() string { + if serverURL != "" { + return serverURL + } + return viper.GetString("server") +} + +// getToken returns the configured API token +func getToken() string { + if token != "" { + return token + } + return viper.GetString("token") +} diff --git a/cmd/gitea-cli/cmd/upload.go b/cmd/gitea-cli/cmd/upload.go new file mode 100644 index 0000000000..50ab8319d9 --- /dev/null +++ b/cmd/gitea-cli/cmd/upload.go @@ -0,0 +1,683 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/spf13/cobra" +) + +const ( + defaultChunkSize = 10 * 1024 * 1024 // 10MB + maxChunkSize = 100 * 1024 * 1024 // 100MB +) + +var uploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload files to Gitea", + Long: `Upload files to Gitea with chunked upload support for large files.`, +} + +var releaseAssetCmd = &cobra.Command{ + Use: "release-asset", + Short: "Upload a release asset", + Long: `Upload a release asset using chunked upload. + +This command supports large files with progress tracking and resume capability. +Interrupted uploads can be resumed using the session ID.`, + Example: ` # Basic upload + gitea-cli upload release-asset --repo owner/repo --release v1.0.0 --file ./app.tar.gz + + # With options + gitea-cli upload release-asset \ + --repo owner/repo \ + --release v1.0.0 \ + --file ./app.tar.gz \ + --chunk-size 50MB \ + --parallel 4 \ + --verify-checksum`, + RunE: runReleaseAssetUpload, +} + +var resumeCmd = &cobra.Command{ + Use: "resume", + Short: "Resume an interrupted upload", + Long: `Resume a previously interrupted chunked upload using its session ID.`, + Example: ` gitea-cli upload resume --session sess_abc123 --file ./app.tar.gz`, + RunE: runResumeUpload, +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List pending uploads", + Long: `List all pending upload sessions for a repository.`, + RunE: runListUploads, +} + +func init() { + // release-asset flags + releaseAssetCmd.Flags().StringP("repo", "r", "", "Repository (owner/repo)") + releaseAssetCmd.Flags().String("release", "", "Release tag or ID") + releaseAssetCmd.Flags().StringP("file", "f", "", "File to upload") + releaseAssetCmd.Flags().String("name", "", "Asset name (defaults to filename)") + releaseAssetCmd.Flags().String("chunk-size", "10MB", "Chunk size (e.g., 10MB, 50MB)") + releaseAssetCmd.Flags().IntP("parallel", "p", 4, "Number of parallel uploads") + releaseAssetCmd.Flags().Bool("verify-checksum", true, "Verify checksum after upload") + releaseAssetCmd.Flags().Bool("progress", true, "Show progress bar") + releaseAssetCmd.MarkFlagRequired("repo") + releaseAssetCmd.MarkFlagRequired("release") + releaseAssetCmd.MarkFlagRequired("file") + + // resume flags + resumeCmd.Flags().String("session", "", "Upload session ID") + resumeCmd.Flags().StringP("file", "f", "", "File to upload") + resumeCmd.MarkFlagRequired("session") + resumeCmd.MarkFlagRequired("file") + + // list flags + listCmd.Flags().StringP("repo", "r", "", "Repository (owner/repo)") + listCmd.MarkFlagRequired("repo") + + uploadCmd.AddCommand(releaseAssetCmd) + uploadCmd.AddCommand(resumeCmd) + uploadCmd.AddCommand(listCmd) +} + +// UploadSession represents a chunked upload session +type UploadSession struct { + ID string `json:"id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ChunkSize int64 `json:"chunk_size"` + TotalChunks int64 `json:"total_chunks"` + ChunksReceived int64 `json:"chunks_received"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + Checksum string `json:"checksum,omitempty"` +} + +// ProgressTracker tracks upload progress +type ProgressTracker struct { + totalBytes int64 + bytesWritten int64 + startTime time.Time + lastUpdate time.Time + mu sync.Mutex +} + +func (p *ProgressTracker) Add(n int64) { + atomic.AddInt64(&p.bytesWritten, n) +} + +func (p *ProgressTracker) Progress() (current, total int64, percent float64, speed float64, eta time.Duration) { + current = atomic.LoadInt64(&p.bytesWritten) + total = p.totalBytes + if total > 0 { + percent = float64(current) / float64(total) * 100 + } + elapsed := time.Since(p.startTime).Seconds() + if elapsed > 0 { + speed = float64(current) / elapsed + if speed > 0 { + remaining := total - current + eta = time.Duration(float64(remaining)/speed) * time.Second + } + } + return +} + +func runReleaseAssetUpload(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + release, _ := cmd.Flags().GetString("release") + filePath, _ := cmd.Flags().GetString("file") + assetName, _ := cmd.Flags().GetString("name") + chunkSizeStr, _ := cmd.Flags().GetString("chunk-size") + parallel, _ := cmd.Flags().GetInt("parallel") + verifyChecksum, _ := cmd.Flags().GetBool("verify-checksum") + showProgress, _ := cmd.Flags().GetBool("progress") + + server := getServer() + token := getToken() + + if server == "" || token == "" { + return fmt.Errorf("not logged in. Use 'gitea-cli auth login' first") + } + + // Parse repo + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repository format. Use owner/repo") + } + owner, repoName := parts[0], parts[1] + + // Parse chunk size + chunkSize, err := parseSize(chunkSizeStr) + if err != nil { + return fmt.Errorf("invalid chunk size: %w", err) + } + if chunkSize > maxChunkSize { + chunkSize = maxChunkSize + } + + // Open file + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + fileSize := stat.Size() + + if assetName == "" { + assetName = filepath.Base(filePath) + } + + fmt.Printf("Uploading %s (%s)\n", assetName, formatSize(fileSize)) + + // Calculate checksum if requested + var checksum string + if verifyChecksum { + fmt.Print("Calculating checksum... ") + checksum, err = calculateSHA256(file) + if err != nil { + return fmt.Errorf("failed to calculate checksum: %w", err) + } + fmt.Printf("done (%s)\n", checksum[:16]+"...") + file.Seek(0, 0) // Reset file position + } + + // Create upload session + fmt.Print("Creating upload session... ") + session, err := createUploadSession(server, token, owner, repoName, release, assetName, fileSize, chunkSize, checksum) + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + fmt.Printf("done (%s)\n", session.ID) + + // Upload chunks + tracker := &ProgressTracker{ + totalBytes: fileSize, + startTime: time.Now(), + } + + ctx := context.Background() + err = uploadChunks(ctx, server, token, session, file, parallel, tracker, showProgress) + if err != nil { + fmt.Printf("\n❌ Upload failed: %v\n", err) + fmt.Printf(" Resume with: gitea-cli upload resume --session %s --file %s\n", session.ID, filePath) + return err + } + + // Complete upload + fmt.Print("\nFinalizing... ") + result, err := completeUpload(server, token, session.ID) + if err != nil { + return fmt.Errorf("failed to complete upload: %w", err) + } + fmt.Println("done") + + if verifyChecksum && result.ChecksumVerified { + fmt.Println("Verifying checksum... ✓ SHA256 matches") + } + + elapsed := time.Since(tracker.startTime) + fmt.Printf("\n✅ Upload complete!\n") + fmt.Printf(" Asset ID: %d\n", result.ID) + fmt.Printf(" Time: %s\n", elapsed.Round(time.Second)) + fmt.Printf(" Speed: %s/s (avg)\n", formatSize(int64(float64(fileSize)/elapsed.Seconds()))) + if result.DownloadURL != "" { + fmt.Printf(" Download: %s\n", result.DownloadURL) + } + + return nil +} + +func runResumeUpload(cmd *cobra.Command, args []string) error { + sessionID, _ := cmd.Flags().GetString("session") + filePath, _ := cmd.Flags().GetString("file") + + server := getServer() + token := getToken() + + if server == "" || token == "" { + return fmt.Errorf("not logged in") + } + + // Get session status + session, err := getUploadSession(server, token, sessionID) + if err != nil { + return fmt.Errorf("failed to get session: %w", err) + } + + if session.Status == "complete" { + fmt.Println("Upload already completed") + return nil + } + + if session.Status == "expired" { + return fmt.Errorf("upload session has expired") + } + + // Open file + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + fmt.Printf("Resuming upload: %s\n", session.FileName) + fmt.Printf(" Chunks: %d/%d complete\n", session.ChunksReceived, session.TotalChunks) + + tracker := &ProgressTracker{ + totalBytes: session.FileSize, + bytesWritten: session.ChunksReceived * session.ChunkSize, + startTime: time.Now(), + } + + ctx := context.Background() + err = uploadChunks(ctx, server, token, session, file, 4, tracker, true) + if err != nil { + return err + } + + // Complete + fmt.Print("\nFinalizing... ") + result, err := completeUpload(server, token, session.ID) + if err != nil { + return fmt.Errorf("failed to complete: %w", err) + } + fmt.Println("done") + + fmt.Printf("\n✅ Upload complete!\n") + fmt.Printf(" Asset ID: %d\n", result.ID) + + return nil +} + +func runListUploads(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + + server := getServer() + token := getToken() + + if server == "" || token == "" { + return fmt.Errorf("not logged in") + } + + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repository format") + } + + sessions, err := listUploadSessions(server, token, parts[0], parts[1]) + if err != nil { + return err + } + + if len(sessions) == 0 { + fmt.Println("No pending uploads") + return nil + } + + fmt.Printf("Pending uploads for %s:\n\n", repo) + for _, s := range sessions { + progress := float64(s.ChunksReceived) / float64(s.TotalChunks) * 100 + fmt.Printf(" %s\n", s.ID) + fmt.Printf(" File: %s (%s)\n", s.FileName, formatSize(s.FileSize)) + fmt.Printf(" Progress: %.1f%% (%d/%d chunks)\n", progress, s.ChunksReceived, s.TotalChunks) + fmt.Printf(" Expires: %s\n", s.ExpiresAt.Format(time.RFC3339)) + fmt.Println() + } + + return nil +} + +func uploadChunks(ctx context.Context, server, token string, session *UploadSession, file *os.File, parallel int, tracker *ProgressTracker, showProgress bool) error { + totalChunks := session.TotalChunks + chunkSize := session.ChunkSize + + // Create worker pool + type chunkJob struct { + number int64 + data []byte + } + + jobs := make(chan chunkJob, parallel) + errors := make(chan error, totalChunks) + var wg sync.WaitGroup + + // Start workers + for i := 0; i < parallel; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + err := uploadChunk(server, token, session.ID, job.number, job.data) + if err != nil { + errors <- fmt.Errorf("chunk %d: %w", job.number, err) + return + } + tracker.Add(int64(len(job.data))) + } + }() + } + + // Progress display + done := make(chan struct{}) + if showProgress { + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + current, total, percent, speed, eta := tracker.Progress() + fmt.Printf("\r [%-50s] %5.1f%% %s/%s %s/s ETA %s ", + progressBar(percent, 50), + percent, + formatSize(current), + formatSize(total), + formatSize(int64(speed)), + formatDuration(eta)) + } + } + }() + } + + // Read and queue chunks + for chunkNum := session.ChunksReceived; chunkNum < totalChunks; chunkNum++ { + offset := chunkNum * chunkSize + file.Seek(offset, 0) + + size := chunkSize + if chunkNum == totalChunks-1 { + size = session.FileSize - offset + } + + data := make([]byte, size) + n, err := io.ReadFull(file, data) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + close(jobs) + close(done) + return fmt.Errorf("failed to read chunk %d: %w", chunkNum, err) + } + data = data[:n] + + select { + case err := <-errors: + close(jobs) + close(done) + return err + case jobs <- chunkJob{number: chunkNum, data: data}: + case <-ctx.Done(): + close(jobs) + close(done) + return ctx.Err() + } + } + + close(jobs) + wg.Wait() + close(done) + + // Check for errors + select { + case err := <-errors: + return err + default: + return nil + } +} + +func progressBar(percent float64, width int) string { + filled := int(percent / 100 * float64(width)) + if filled > width { + filled = width + } + return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) +} + +func formatSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return "<1s" + } + d = d.Round(time.Second) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + if h > 0 { + return fmt.Sprintf("%dh%dm", h, m) + } + if m > 0 { + return fmt.Sprintf("%dm%ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +func parseSize(s string) (int64, error) { + s = strings.ToUpper(strings.TrimSpace(s)) + multiplier := int64(1) + + if strings.HasSuffix(s, "GB") { + multiplier = 1024 * 1024 * 1024 + s = strings.TrimSuffix(s, "GB") + } else if strings.HasSuffix(s, "MB") { + multiplier = 1024 * 1024 + s = strings.TrimSuffix(s, "MB") + } else if strings.HasSuffix(s, "KB") { + multiplier = 1024 + s = strings.TrimSuffix(s, "KB") + } else if strings.HasSuffix(s, "B") { + s = strings.TrimSuffix(s, "B") + } + + var value int64 + _, err := fmt.Sscanf(s, "%d", &value) + if err != nil { + return 0, err + } + + return value * multiplier, nil +} + +func calculateSHA256(file *os.File) (string, error) { + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// API functions + +func createUploadSession(server, token, owner, repo, release, fileName string, fileSize, chunkSize int64, checksum string) (*UploadSession, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/%s/assets/upload-session", server, owner, repo, release) + + body := map[string]any{ + "name": fileName, + "size": fileSize, + "chunk_size": chunkSize, + } + if checksum != "" { + body["checksum"] = checksum + } + + jsonBody, _ := json.Marshal(body) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body)) + } + + var session UploadSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, err + } + + return &session, nil +} + +func getUploadSession(server, token, sessionID string) (*UploadSession, error) { + url := fmt.Sprintf("%s/api/v1/repos/uploads/%s", server, sessionID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("session not found") + } + + var session UploadSession + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return nil, err + } + + return &session, nil +} + +func uploadChunk(server, token, sessionID string, chunkNum int64, data []byte) error { + url := fmt.Sprintf("%s/api/v1/repos/uploads/%s/chunks/%d", server, sessionID, chunkNum) + + req, err := http.NewRequest("PUT", url, bytes.NewReader(data)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed: %s", string(body)) + } + + return nil +} + +type CompleteResult struct { + ID int64 `json:"id"` + DownloadURL string `json:"browser_download_url"` + ChecksumVerified bool `json:"checksum_verified"` +} + +func completeUpload(server, token, sessionID string) (*CompleteResult, error) { + url := fmt.Sprintf("%s/api/v1/repos/uploads/%s/complete", server, sessionID) + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed: %s", string(body)) + } + + var result CompleteResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +func listUploadSessions(server, token, owner, repo string) ([]*UploadSession, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/uploads", server, owner, repo) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list sessions") + } + + var sessions []*UploadSession + if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + return nil, err + } + + return sessions, nil +} diff --git a/cmd/gitea-cli/main.go b/cmd/gitea-cli/main.go new file mode 100644 index 0000000000..f313cb858d --- /dev/null +++ b/cmd/gitea-cli/main.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// gitea-cli is a command-line tool for interacting with Gitea instances. +// It provides efficient chunked uploads, progress tracking, and resume capability. +package main + +import ( + "fmt" + "os" + + "code.gitea.io/gitea/cmd/gitea-cli/cmd" +) + +var ( + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" +) + +func main() { + cmd.SetVersion(Version, BuildTime, GitCommit) + + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index a89c2a5c73..c1852d6600 100644 --- a/go.mod +++ b/go.mod @@ -104,6 +104,8 @@ require ( github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sassoftware/go-rpmutils v0.4.0 github.com/sergi/go-diff v1.4.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.3.2 github.com/stretchr/testify v1.11.1 github.com/syndtr/goleveldb v1.0.0 github.com/tstranex/u2f v1.0.0 @@ -123,6 +125,7 @@ require ( golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.18.0 golang.org/x/sys v0.38.0 + golang.org/x/term v0.37.0 golang.org/x/text v0.31.0 google.golang.org/grpc v1.75.0 google.golang.org/protobuf v1.36.8 @@ -219,12 +222,15 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/libdns/libdns v1.1.1 // indirect + github.com/magiconair/properties v1.8.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/markbates/going v1.0.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -248,6 +254,7 @@ require ( github.com/olekukonko/ll v0.1.0 // indirect github.com/olekukonko/tablewriter v1.0.9 // indirect github.com/onsi/ginkgo v1.16.5 // indirect + github.com/pelletier/go-toml v1.8.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect @@ -263,6 +270,9 @@ require ( github.com/skeema/knownhosts v1.3.1 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.3.0 // indirect + github.com/spf13/jwalterweatherman v1.0.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/tinylib/msgp v1.4.0 // indirect github.com/unknwon/com v1.0.1 // indirect diff --git a/go.sum b/go.sum index ac70239339..815ae22642 100644 --- a/go.sum +++ b/go.sum @@ -245,6 +245,7 @@ github.com/couchbase/goutils v0.1.2 h1:gWr8B6XNWPIhfalHNog3qQKfGiYyh4K4VhO3P2o9B github.com/couchbase/goutils v0.1.2/go.mod h1:h89Ek/tiOxxqjz30nPPlwZdQbdB8BwgnuBxeoUe/ViE= github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -485,6 +486,7 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= @@ -494,6 +496,8 @@ github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -550,6 +554,7 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= @@ -641,6 +646,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= +github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= @@ -719,10 +726,18 @@ github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/ github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= diff --git a/sdk/go/client.go b/sdk/go/client.go new file mode 100644 index 0000000000..1d12ce1935 --- /dev/null +++ b/sdk/go/client.go @@ -0,0 +1,283 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package gitea provides a Go SDK for the Gitea API. +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client is a Gitea API client +type Client struct { + baseURL string + token string + httpClient *http.Client + userAgent string +} + +// ClientOption is a function that configures the client +type ClientOption func(*Client) + +// NewClient creates a new Gitea API client +func NewClient(baseURL string, opts ...ClientOption) (*Client, error) { + // Normalize URL + baseURL = strings.TrimSuffix(baseURL, "/") + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "https://" + baseURL + } + + c := &Client{ + baseURL: baseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + userAgent: "gitea-sdk-go/1.0", + } + + for _, opt := range opts { + opt(c) + } + + return c, nil +} + +// SetToken sets the API token +func SetToken(token string) ClientOption { + return func(c *Client) { + c.token = token + } +} + +// SetHTTPClient sets a custom HTTP client +func SetHTTPClient(client *http.Client) ClientOption { + return func(c *Client) { + c.httpClient = client + } +} + +// SetUserAgent sets a custom user agent +func SetUserAgent(ua string) ClientOption { + return func(c *Client) { + c.userAgent = ua + } +} + +// APIError represents an API error response +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Status int `json:"status"` + Details map[string]any `json:"details,omitempty"` +} + +func (e *APIError) Error() string { + if e.Code != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Message) + } + return e.Message +} + +// doRequest performs an HTTP request +func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error { + fullURL := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", c.userAgent) + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var apiErr APIError + if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil { + body, _ := io.ReadAll(resp.Body) + return &APIError{ + Status: resp.StatusCode, + Message: string(body), + } + } + apiErr.Status = resp.StatusCode + return &apiErr + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + +// doRequestRaw performs an HTTP request with raw body +func (c *Client) doRequestRaw(ctx context.Context, method, path string, body io.Reader, contentType string, result interface{}) error { + fullURL := c.baseURL + path + + req, err := http.NewRequestWithContext(ctx, method, fullURL, body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", c.userAgent) + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var apiErr APIError + if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil { + body, _ := io.ReadAll(resp.Body) + return &APIError{ + Status: resp.StatusCode, + Message: string(body), + } + } + apiErr.Status = resp.StatusCode + return &apiErr + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + +// GetVersion returns the Gitea server version +func (c *Client) GetVersion(ctx context.Context) (string, error) { + var result struct { + Version string `json:"version"` + } + if err := c.doRequest(ctx, "GET", "/api/v1/version", nil, &result); err != nil { + return "", err + } + return result.Version, nil +} + +// User represents a Gitea user +type User struct { + ID int64 `json:"id"` + Login string `json:"login"` + FullName string `json:"full_name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` +} + +// GetCurrentUser returns the authenticated user +func (c *Client) GetCurrentUser(ctx context.Context) (*User, error) { + var user User + if err := c.doRequest(ctx, "GET", "/api/v1/user", nil, &user); err != nil { + return nil, err + } + return &user, nil +} + +// Repository represents a Gitea repository +type Repository struct { + ID int64 `json:"id"` + Owner *User `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + Fork bool `json:"fork"` + DefaultBranch string `json:"default_branch"` + Stars int `json:"stars_count"` + Forks int `json:"forks_count"` + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` +} + +// GetRepository returns a repository by owner and name +func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Repository, error) { + var repository Repository + path := fmt.Sprintf("/api/v1/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + if err := c.doRequest(ctx, "GET", path, nil, &repository); err != nil { + return nil, err + } + return &repository, nil +} + +// Release represents a Gitea release +type Release struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + PublishedAt time.Time `json:"published_at"` + Assets []Attachment `json:"assets"` +} + +// Attachment represents a release asset +type Attachment struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + DownloadCount int64 `json:"download_count"` + DownloadURL string `json:"browser_download_url"` + CreatedAt time.Time `json:"created_at"` +} + +// GetRelease returns a release by tag name +func (c *Client) GetRelease(ctx context.Context, owner, repo, tag string) (*Release, error) { + var release Release + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", + url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(tag)) + if err := c.doRequest(ctx, "GET", path, nil, &release); err != nil { + return nil, err + } + return &release, nil +} + +// ListReleases returns all releases for a repository +func (c *Client) ListReleases(ctx context.Context, owner, repo string) ([]*Release, error) { + var releases []*Release + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases", url.PathEscape(owner), url.PathEscape(repo)) + if err := c.doRequest(ctx, "GET", path, nil, &releases); err != nil { + return nil, err + } + return releases, nil +} diff --git a/sdk/go/upload.go b/sdk/go/upload.go new file mode 100644 index 0000000000..ce316a784f --- /dev/null +++ b/sdk/go/upload.go @@ -0,0 +1,349 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package gitea + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/url" + "sync" + "sync/atomic" + "time" +) + +// UploadSession represents a chunked upload session +type UploadSession struct { + ID string `json:"id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ChunkSize int64 `json:"chunk_size"` + TotalChunks int64 `json:"total_chunks"` + ChunksReceived int64 `json:"chunks_received"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + Checksum string `json:"checksum,omitempty"` +} + +// UploadResult represents the result of a completed upload +type UploadResult struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + DownloadURL string `json:"browser_download_url"` + ChecksumVerified bool `json:"checksum_verified"` +} + +// Progress represents upload progress +type Progress struct { + BytesDone int64 + BytesTotal int64 + ChunksDone int64 + ChunksTotal int64 + Percent float64 + Speed float64 // bytes per second + ETA time.Duration +} + +// ProgressFunc is called with progress updates +type ProgressFunc func(Progress) + +// ChunkedUploadOptions configures a chunked upload +type ChunkedUploadOptions struct { + FileName string + ChunkSize int64 + Parallel int + VerifyChecksum bool + OnProgress ProgressFunc +} + +// ChunkedUpload handles large file uploads +type ChunkedUpload struct { + client *Client + owner string + repo string + release string + session *UploadSession + options ChunkedUploadOptions + + mu sync.Mutex + bytesWritten int64 + startTime time.Time +} + +// CreateChunkedUpload starts a new chunked upload session +func (c *Client) CreateChunkedUpload(ctx context.Context, owner, repo string, releaseID int64, opts ChunkedUploadOptions) (*ChunkedUpload, error) { + if opts.ChunkSize == 0 { + opts.ChunkSize = 10 * 1024 * 1024 // 10MB default + } + if opts.Parallel == 0 { + opts.Parallel = 4 + } + + return &ChunkedUpload{ + client: c, + owner: owner, + repo: repo, + release: fmt.Sprintf("%d", releaseID), + options: opts, + }, nil +} + +// Upload uploads the file from the reader +func (cu *ChunkedUpload) Upload(ctx context.Context, reader io.Reader, size int64) (*UploadResult, error) { + cu.startTime = time.Now() + + // Calculate checksum if requested + var checksum string + var data []byte + var err error + + // Read all data into memory for checksum calculation + // For very large files, this should be optimized to stream + data, err = io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + if cu.options.VerifyChecksum { + hash := sha256.Sum256(data) + checksum = hex.EncodeToString(hash[:]) + } + + // Create session + session, err := cu.createSession(ctx, size, checksum) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + cu.session = session + + // Upload chunks + if err := cu.uploadChunks(ctx, bytes.NewReader(data)); err != nil { + return nil, fmt.Errorf("failed to upload chunks: %w", err) + } + + // Complete upload + result, err := cu.complete(ctx) + if err != nil { + return nil, fmt.Errorf("failed to complete upload: %w", err) + } + + return result, nil +} + +func (cu *ChunkedUpload) createSession(ctx context.Context, size int64, checksum string) (*UploadSession, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%s/assets/upload-session", + url.PathEscape(cu.owner), url.PathEscape(cu.repo), cu.release) + + body := map[string]any{ + "name": cu.options.FileName, + "size": size, + "chunk_size": cu.options.ChunkSize, + } + if checksum != "" { + body["checksum"] = checksum + } + + var session UploadSession + if err := cu.client.doRequest(ctx, "POST", path, body, &session); err != nil { + return nil, err + } + + return &session, nil +} + +func (cu *ChunkedUpload) uploadChunks(ctx context.Context, reader io.ReaderAt) error { + totalChunks := cu.session.TotalChunks + chunkSize := cu.session.ChunkSize + fileSize := cu.session.FileSize + + // Worker pool + type job struct { + number int64 + data []byte + } + + jobs := make(chan job, cu.options.Parallel) + errors := make(chan error, totalChunks) + var wg sync.WaitGroup + + // Start workers + for i := 0; i < cu.options.Parallel; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobs { + if err := cu.uploadChunk(ctx, j.number, j.data); err != nil { + errors <- err + return + } + atomic.AddInt64(&cu.bytesWritten, int64(len(j.data))) + cu.reportProgress() + } + }() + } + + // Queue chunks + for chunkNum := int64(0); chunkNum < totalChunks; chunkNum++ { + offset := chunkNum * chunkSize + size := chunkSize + if chunkNum == totalChunks-1 { + size = fileSize - offset + } + + data := make([]byte, size) + n, err := reader.ReadAt(data, offset) + if err != nil && err != io.EOF { + close(jobs) + return fmt.Errorf("failed to read chunk %d: %w", chunkNum, err) + } + data = data[:n] + + select { + case err := <-errors: + close(jobs) + return err + case jobs <- job{number: chunkNum, data: data}: + case <-ctx.Done(): + close(jobs) + return ctx.Err() + } + } + + close(jobs) + wg.Wait() + + select { + case err := <-errors: + return err + default: + return nil + } +} + +func (cu *ChunkedUpload) uploadChunk(ctx context.Context, chunkNum int64, data []byte) error { + path := fmt.Sprintf("/api/v1/repos/uploads/%s/chunks/%d", cu.session.ID, chunkNum) + return cu.client.doRequestRaw(ctx, "PUT", path, bytes.NewReader(data), "application/octet-stream", nil) +} + +func (cu *ChunkedUpload) complete(ctx context.Context) (*UploadResult, error) { + path := fmt.Sprintf("/api/v1/repos/uploads/%s/complete", cu.session.ID) + + var result UploadResult + if err := cu.client.doRequest(ctx, "POST", path, nil, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (cu *ChunkedUpload) reportProgress() { + if cu.options.OnProgress == nil { + return + } + + bytesWritten := atomic.LoadInt64(&cu.bytesWritten) + elapsed := time.Since(cu.startTime).Seconds() + + var speed float64 + var eta time.Duration + if elapsed > 0 { + speed = float64(bytesWritten) / elapsed + if speed > 0 { + remaining := cu.session.FileSize - bytesWritten + eta = time.Duration(float64(remaining)/speed) * time.Second + } + } + + var percent float64 + if cu.session.FileSize > 0 { + percent = float64(bytesWritten) / float64(cu.session.FileSize) * 100 + } + + chunksTotal := cu.session.TotalChunks + chunksDone := bytesWritten / cu.session.ChunkSize + if chunksDone > chunksTotal { + chunksDone = chunksTotal + } + + cu.options.OnProgress(Progress{ + BytesDone: bytesWritten, + BytesTotal: cu.session.FileSize, + ChunksDone: chunksDone, + ChunksTotal: chunksTotal, + Percent: percent, + Speed: speed, + ETA: eta, + }) +} + +// GetSession returns the current session +func (cu *ChunkedUpload) GetSession() *UploadSession { + return cu.session +} + +// Cancel cancels the upload +func (cu *ChunkedUpload) Cancel(ctx context.Context) error { + if cu.session == nil { + return nil + } + + path := fmt.Sprintf("/api/v1/repos/uploads/%s", cu.session.ID) + return cu.client.doRequest(ctx, "DELETE", path, nil, nil) +} + +// UploadReleaseAsset is a convenience method for uploading a release asset +func (c *Client) UploadReleaseAsset(ctx context.Context, owner, repo string, releaseID int64, fileName string, reader io.Reader, size int64, opts ...UploadOption) (*UploadResult, error) { + uploadOpts := ChunkedUploadOptions{ + FileName: fileName, + ChunkSize: 10 * 1024 * 1024, + Parallel: 4, + VerifyChecksum: true, + } + + for _, opt := range opts { + opt(&uploadOpts) + } + + upload, err := c.CreateChunkedUpload(ctx, owner, repo, releaseID, uploadOpts) + if err != nil { + return nil, err + } + + return upload.Upload(ctx, reader, size) +} + +// UploadOption configures an upload +type UploadOption func(*ChunkedUploadOptions) + +// WithChunkSize sets the chunk size +func WithChunkSize(size int64) UploadOption { + return func(o *ChunkedUploadOptions) { + o.ChunkSize = size + } +} + +// WithParallel sets the number of parallel uploads +func WithParallel(n int) UploadOption { + return func(o *ChunkedUploadOptions) { + o.Parallel = n + } +} + +// WithProgress sets the progress callback +func WithProgress(fn ProgressFunc) UploadOption { + return func(o *ChunkedUploadOptions) { + o.OnProgress = fn + } +} + +// WithChecksum enables checksum verification +func WithChecksum(verify bool) UploadOption { + return func(o *ChunkedUploadOptions) { + o.VerifyChecksum = verify + } +} diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 0000000000..759090dc54 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,124 @@ +# Gitea Python SDK + +Official Python SDK for the Gitea API with chunked upload support for large files. + +## Installation + +```bash +pip install gitea-sdk +``` + +## Quick Start + +```python +from gitea import GiteaClient + +# Create client +client = GiteaClient("https://gitea.example.com", token="your_token") + +# Get current user +user = client.get_current_user() +print(f"Logged in as {user.login}") + +# Get a repository +repo = client.get_repository("owner", "repo") +print(f"Repository: {repo.full_name}") +``` + +## Chunked Upload + +Upload large files with progress tracking and resume capability: + +```python +from gitea import GiteaClient + +client = GiteaClient("https://gitea.example.com", token="your_token") + +# Upload a release asset with progress callback +def on_progress(p): + print(f"\rProgress: {p.percent:.1f}% ({p.speed_formatted}) ETA: {p.eta}", end="") + +with open("large-file.tar.gz", "rb") as f: + result = client.upload_release_asset( + owner="myorg", + repo="myrepo", + release_id=123, + file=f, + filename="large-file.tar.gz", + chunk_size=50 * 1024 * 1024, # 50MB chunks + parallel=4, + verify_checksum=True, + progress_callback=on_progress, + ) + +print(f"\nUploaded: {result.download_url}") +``` + +## Resume Interrupted Uploads + +```python +# Resume an interrupted upload +result = client.resume_upload( + session_id="sess_abc123", + file=open("large-file.tar.gz", "rb"), + progress_callback=on_progress, +) +``` + +## API Reference + +### GiteaClient + +#### Constructor + +```python +client = GiteaClient( + base_url="https://gitea.example.com", + token="your_api_token", + timeout=30, # Request timeout in seconds +) +``` + +#### User Methods + +- `get_current_user()` - Get authenticated user +- `get_user(username)` - Get user by username + +#### Repository Methods + +- `get_repository(owner, repo)` - Get repository +- `list_user_repos(username)` - List user's repositories + +#### Release Methods + +- `get_release(owner, repo, release_id)` - Get release by ID +- `get_release_by_tag(owner, repo, tag)` - Get release by tag +- `list_releases(owner, repo)` - List all releases + +#### Upload Methods + +- `upload_release_asset(...)` - Upload release asset with chunked upload +- `resume_upload(session_id, file)` - Resume interrupted upload +- `get_upload_session(session_id)` - Get upload session status +- `cancel_upload(session_id)` - Cancel upload session + +## Error Handling + +```python +from gitea import GiteaClient, APIError, AuthenticationError, NotFoundError + +client = GiteaClient("https://gitea.example.com", token="your_token") + +try: + repo = client.get_repository("owner", "nonexistent") +except NotFoundError as e: + print(f"Repository not found: {e}") +except AuthenticationError as e: + print(f"Authentication failed: {e}") +except APIError as e: + print(f"API error [{e.code}]: {e.message}") +``` + +## License + +MIT License - See LICENSE file for details. diff --git a/sdk/python/gitea/__init__.py b/sdk/python/gitea/__init__.py new file mode 100644 index 0000000000..79c5204904 --- /dev/null +++ b/sdk/python/gitea/__init__.py @@ -0,0 +1,51 @@ +# Copyright 2026 The Gitea Authors. All rights reserved. +# SPDX-License-Identifier: MIT + +""" +Gitea Python SDK + +A Python client library for the Gitea API with chunked upload support. + +Example usage: + + from gitea import GiteaClient + + client = GiteaClient("https://gitea.example.com", token="your_token") + + # Get current user + user = client.get_current_user() + print(f"Logged in as {user.login}") + + # Upload a release asset with progress + with open("app.tar.gz", "rb") as f: + result = client.upload_release_asset( + owner="myorg", + repo="myrepo", + release_id=123, + file=f, + filename="app.tar.gz", + progress_callback=lambda p: print(f"{p.percent:.1f}%") + ) + print(f"Uploaded: {result.download_url}") +""" + +from .client import GiteaClient +from .models import User, Repository, Release, Attachment, UploadSession, UploadResult, Progress +from .exceptions import GiteaError, APIError, AuthenticationError, NotFoundError, ValidationError + +__version__ = "1.0.0" +__all__ = [ + "GiteaClient", + "User", + "Repository", + "Release", + "Attachment", + "UploadSession", + "UploadResult", + "Progress", + "GiteaError", + "APIError", + "AuthenticationError", + "NotFoundError", + "ValidationError", +] diff --git a/sdk/python/gitea/client.py b/sdk/python/gitea/client.py new file mode 100644 index 0000000000..4b6b263904 --- /dev/null +++ b/sdk/python/gitea/client.py @@ -0,0 +1,420 @@ +# Copyright 2026 The Gitea Authors. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Gitea API client with chunked upload support.""" + +import hashlib +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from io import BytesIO +from typing import BinaryIO, Callable, Dict, Any, List, Optional +from urllib.parse import urljoin, quote + +import requests + +from .exceptions import APIError, AuthenticationError, NotFoundError, UploadError +from .models import ( + User, + Repository, + Release, + UploadSession, + UploadResult, + Progress, +) + + +class GiteaClient: + """Client for the Gitea API. + + Args: + base_url: The base URL of the Gitea instance (e.g., "https://gitea.example.com") + token: API token for authentication + timeout: Request timeout in seconds (default: 30) + """ + + DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024 # 10MB + MAX_PARALLEL = 8 + + def __init__( + self, + base_url: str, + token: Optional[str] = None, + timeout: int = 30, + ): + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({ + "User-Agent": "gitea-sdk-python/1.0", + }) + if token: + self.session.headers["Authorization"] = f"token {token}" + + def _url(self, path: str) -> str: + """Build full URL for API path.""" + return urljoin(self.base_url, path) + + def _request( + self, + method: str, + path: str, + json: Optional[Dict[str, Any]] = None, + data: Optional[bytes] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, Any]: + """Make an API request.""" + url = self._url(path) + req_headers = headers or {} + + try: + if data is not None: + resp = self.session.request( + method, + url, + data=data, + headers=req_headers, + timeout=self.timeout, + ) + else: + resp = self.session.request( + method, + url, + json=json, + timeout=self.timeout, + ) + except requests.RequestException as e: + raise APIError(f"Request failed: {e}") + + if resp.status_code == 401: + raise AuthenticationError() + elif resp.status_code == 404: + raise NotFoundError("Resource") + elif resp.status_code >= 400: + try: + error_data = resp.json() + raise APIError( + message=error_data.get("message", resp.text), + code=error_data.get("code"), + status=resp.status_code, + details=error_data.get("details"), + ) + except ValueError: + raise APIError(resp.text, status=resp.status_code) + + if resp.status_code == 204 or not resp.content: + return {} + + try: + return resp.json() + except ValueError: + return {"content": resp.text} + + # User methods + + def get_current_user(self) -> User: + """Get the currently authenticated user.""" + data = self._request("GET", "/api/v1/user") + return User.from_dict(data) + + def get_user(self, username: str) -> User: + """Get a user by username.""" + data = self._request("GET", f"/api/v1/users/{quote(username)}") + return User.from_dict(data) + + # Repository methods + + def get_repository(self, owner: str, repo: str) -> Repository: + """Get a repository by owner and name.""" + data = self._request("GET", f"/api/v1/repos/{quote(owner)}/{quote(repo)}") + return Repository.from_dict(data) + + def list_user_repos(self, username: str) -> List[Repository]: + """List repositories for a user.""" + data = self._request("GET", f"/api/v1/users/{quote(username)}/repos") + return [Repository.from_dict(r) for r in data] + + # Release methods + + def get_release(self, owner: str, repo: str, release_id: int) -> Release: + """Get a release by ID.""" + data = self._request( + "GET", + f"/api/v1/repos/{quote(owner)}/{quote(repo)}/releases/{release_id}", + ) + return Release.from_dict(data) + + def get_release_by_tag(self, owner: str, repo: str, tag: str) -> Release: + """Get a release by tag name.""" + data = self._request( + "GET", + f"/api/v1/repos/{quote(owner)}/{quote(repo)}/releases/tags/{quote(tag)}", + ) + return Release.from_dict(data) + + def list_releases(self, owner: str, repo: str) -> List[Release]: + """List all releases for a repository.""" + data = self._request( + "GET", + f"/api/v1/repos/{quote(owner)}/{quote(repo)}/releases", + ) + return [Release.from_dict(r) for r in data] + + # Chunked upload methods + + def upload_release_asset( + self, + owner: str, + repo: str, + release_id: int, + file: BinaryIO, + filename: str, + chunk_size: int = DEFAULT_CHUNK_SIZE, + parallel: int = 4, + verify_checksum: bool = True, + progress_callback: Optional[Callable[[Progress], None]] = None, + ) -> UploadResult: + """Upload a release asset using chunked upload. + + Args: + owner: Repository owner + repo: Repository name + release_id: Release ID + file: File-like object to upload + filename: Name for the uploaded file + chunk_size: Size of each chunk in bytes (default: 10MB) + parallel: Number of parallel upload workers (default: 4) + verify_checksum: Whether to verify file checksum (default: True) + progress_callback: Callback function for progress updates + + Returns: + UploadResult with details of the uploaded asset + """ + # Read file content + file.seek(0, 2) # Seek to end + file_size = file.tell() + file.seek(0) # Seek back to start + + # Calculate checksum if needed + checksum = None + if verify_checksum: + hasher = hashlib.sha256() + while True: + chunk = file.read(65536) + if not chunk: + break + hasher.update(chunk) + checksum = hasher.hexdigest() + file.seek(0) + + # Create upload session + session = self._create_upload_session( + owner, repo, release_id, filename, file_size, chunk_size, checksum + ) + + # Upload chunks + try: + self._upload_chunks( + session, file, parallel, progress_callback + ) + except Exception as e: + raise UploadError( + f"Upload failed: {e}", + session_id=session.id, + ) + + # Complete upload + result = self._complete_upload(session.id) + return result + + def _create_upload_session( + self, + owner: str, + repo: str, + release_id: int, + filename: str, + file_size: int, + chunk_size: int, + checksum: Optional[str] = None, + ) -> UploadSession: + """Create a chunked upload session.""" + body = { + "name": filename, + "size": file_size, + "chunk_size": chunk_size, + } + if checksum: + body["checksum"] = checksum + + data = self._request( + "POST", + f"/api/v1/repos/{quote(owner)}/{quote(repo)}/releases/{release_id}/assets/upload-session", + json=body, + ) + return UploadSession.from_dict(data) + + def _upload_chunks( + self, + session: UploadSession, + file: BinaryIO, + parallel: int, + progress_callback: Optional[Callable[[Progress], None]] = None, + ) -> None: + """Upload file chunks in parallel.""" + total_chunks = session.total_chunks + chunk_size = session.chunk_size + file_size = session.file_size + + bytes_uploaded = 0 + start_time = time.time() + + def upload_chunk(chunk_num: int, chunk_data: bytes) -> int: + """Upload a single chunk.""" + self._request( + "PUT", + f"/api/v1/repos/uploads/{session.id}/chunks/{chunk_num}", + data=chunk_data, + headers={"Content-Type": "application/octet-stream"}, + ) + return len(chunk_data) + + # Prepare chunks + chunks = [] + for chunk_num in range(total_chunks): + offset = chunk_num * chunk_size + size = min(chunk_size, file_size - offset) + file.seek(offset) + chunk_data = file.read(size) + chunks.append((chunk_num, chunk_data)) + + # Upload in parallel + parallel = min(parallel, self.MAX_PARALLEL) + with ThreadPoolExecutor(max_workers=parallel) as executor: + futures = { + executor.submit(upload_chunk, num, data): num + for num, data in chunks + } + + for future in as_completed(futures): + chunk_num = futures[future] + try: + bytes_done = future.result() + bytes_uploaded += bytes_done + + if progress_callback: + elapsed = time.time() - start_time + speed = bytes_uploaded / elapsed if elapsed > 0 else 0 + remaining = file_size - bytes_uploaded + eta = remaining / speed if speed > 0 else 0 + + progress = Progress( + bytes_done=bytes_uploaded, + bytes_total=file_size, + chunks_done=chunk_num + 1, + chunks_total=total_chunks, + percent=bytes_uploaded / file_size * 100, + speed=speed, + eta_seconds=eta, + ) + progress_callback(progress) + + except Exception as e: + raise UploadError( + f"Failed to upload chunk {chunk_num}: {e}", + session_id=session.id, + chunk=chunk_num, + ) + + def _complete_upload(self, session_id: str) -> UploadResult: + """Complete a chunked upload.""" + data = self._request( + "POST", + f"/api/v1/repos/uploads/{session_id}/complete", + ) + return UploadResult.from_dict(data) + + def cancel_upload(self, session_id: str) -> None: + """Cancel an upload session.""" + self._request("DELETE", f"/api/v1/repos/uploads/{session_id}") + + def get_upload_session(self, session_id: str) -> UploadSession: + """Get the status of an upload session.""" + data = self._request("GET", f"/api/v1/repos/uploads/{session_id}") + return UploadSession.from_dict(data) + + def resume_upload( + self, + session_id: str, + file: BinaryIO, + progress_callback: Optional[Callable[[Progress], None]] = None, + ) -> UploadResult: + """Resume an interrupted upload. + + Args: + session_id: ID of the upload session to resume + file: File-like object to upload + progress_callback: Callback function for progress updates + + Returns: + UploadResult with details of the uploaded asset + """ + session = self.get_upload_session(session_id) + + if session.status == "complete": + raise UploadError("Upload already completed", session_id=session_id) + if session.status == "expired": + raise UploadError("Upload session has expired", session_id=session_id) + + # Upload remaining chunks + self._upload_remaining_chunks(session, file, progress_callback) + + # Complete upload + return self._complete_upload(session_id) + + def _upload_remaining_chunks( + self, + session: UploadSession, + file: BinaryIO, + progress_callback: Optional[Callable[[Progress], None]] = None, + ) -> None: + """Upload remaining chunks for a resumed upload.""" + # Similar to _upload_chunks but starts from chunks_received + total_chunks = session.total_chunks + chunk_size = session.chunk_size + file_size = session.file_size + start_chunk = session.chunks_received + + bytes_uploaded = start_chunk * chunk_size + start_time = time.time() + + for chunk_num in range(start_chunk, total_chunks): + offset = chunk_num * chunk_size + size = min(chunk_size, file_size - offset) + file.seek(offset) + chunk_data = file.read(size) + + self._request( + "PUT", + f"/api/v1/repos/uploads/{session.id}/chunks/{chunk_num}", + data=chunk_data, + headers={"Content-Type": "application/octet-stream"}, + ) + + bytes_uploaded += len(chunk_data) + + if progress_callback: + elapsed = time.time() - start_time + speed = (bytes_uploaded - start_chunk * chunk_size) / elapsed if elapsed > 0 else 0 + remaining = file_size - bytes_uploaded + eta = remaining / speed if speed > 0 else 0 + + progress = Progress( + bytes_done=bytes_uploaded, + bytes_total=file_size, + chunks_done=chunk_num + 1, + chunks_total=total_chunks, + percent=bytes_uploaded / file_size * 100, + speed=speed, + eta_seconds=eta, + ) + progress_callback(progress) diff --git a/sdk/python/gitea/exceptions.py b/sdk/python/gitea/exceptions.py new file mode 100644 index 0000000000..efa1ed4607 --- /dev/null +++ b/sdk/python/gitea/exceptions.py @@ -0,0 +1,90 @@ +# Copyright 2026 The Gitea Authors. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Exception classes for the Gitea SDK.""" + +from typing import Optional, Dict, Any + + +class GiteaError(Exception): + """Base exception for all Gitea SDK errors.""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class APIError(GiteaError): + """Raised when the API returns an error response.""" + + def __init__( + self, + message: str, + code: Optional[str] = None, + status: int = 0, + details: Optional[Dict[str, Any]] = None, + ): + super().__init__(message) + self.code = code + self.status = status + self.details = details or {} + + def __str__(self) -> str: + if self.code: + return f"[{self.code}] {self.message}" + return self.message + + +class AuthenticationError(APIError): + """Raised when authentication fails.""" + + def __init__(self, message: str = "Authentication failed", code: str = "AUTH_FAILED"): + super().__init__(message, code=code, status=401) + + +class NotFoundError(APIError): + """Raised when a resource is not found.""" + + def __init__(self, resource: str, identifier: str = ""): + message = f"{resource} not found" + if identifier: + message = f"{resource} '{identifier}' not found" + super().__init__(message, code="NOT_FOUND", status=404) + self.resource = resource + self.identifier = identifier + + +class ValidationError(APIError): + """Raised when request validation fails.""" + + def __init__(self, message: str, field: Optional[str] = None): + super().__init__(message, code="VALIDATION_FAILED", status=400) + self.field = field + + +class UploadError(GiteaError): + """Raised when an upload fails.""" + + def __init__(self, message: str, session_id: Optional[str] = None, chunk: Optional[int] = None): + super().__init__(message) + self.session_id = session_id + self.chunk = chunk + + def __str__(self) -> str: + parts = [self.message] + if self.session_id: + parts.append(f"session={self.session_id}") + if self.chunk is not None: + parts.append(f"chunk={self.chunk}") + return " ".join(parts) + + +class RateLimitError(APIError): + """Raised when rate limit is exceeded.""" + + def __init__(self, retry_after: Optional[int] = None): + message = "Rate limit exceeded" + if retry_after: + message += f". Retry after {retry_after} seconds" + super().__init__(message, code="RATE_LIMIT_EXCEEDED", status=429) + self.retry_after = retry_after diff --git a/sdk/python/gitea/models.py b/sdk/python/gitea/models.py new file mode 100644 index 0000000000..2a8277b0a9 --- /dev/null +++ b/sdk/python/gitea/models.py @@ -0,0 +1,222 @@ +# Copyright 2026 The Gitea Authors. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Data models for the Gitea SDK.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, List, Dict, Any + + +@dataclass +class User: + """Represents a Gitea user.""" + id: int + login: str + full_name: str = "" + email: str = "" + avatar_url: str = "" + is_admin: bool = False + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "User": + return cls( + id=data.get("id", 0), + login=data.get("login", ""), + full_name=data.get("full_name", ""), + email=data.get("email", ""), + avatar_url=data.get("avatar_url", ""), + is_admin=data.get("is_admin", False), + ) + + +@dataclass +class Repository: + """Represents a Gitea repository.""" + id: int + name: str + full_name: str + owner: Optional[User] = None + description: str = "" + private: bool = False + fork: bool = False + default_branch: str = "main" + stars_count: int = 0 + forks_count: int = 0 + clone_url: str = "" + html_url: str = "" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Repository": + owner = None + if data.get("owner"): + owner = User.from_dict(data["owner"]) + return cls( + id=data.get("id", 0), + name=data.get("name", ""), + full_name=data.get("full_name", ""), + owner=owner, + description=data.get("description", ""), + private=data.get("private", False), + fork=data.get("fork", False), + default_branch=data.get("default_branch", "main"), + stars_count=data.get("stars_count", 0), + forks_count=data.get("forks_count", 0), + clone_url=data.get("clone_url", ""), + html_url=data.get("html_url", ""), + ) + + +@dataclass +class Attachment: + """Represents a release attachment/asset.""" + id: int + name: str + size: int + download_count: int = 0 + download_url: str = "" + created_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Attachment": + created_at = None + if data.get("created_at"): + try: + created_at = datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")) + except (ValueError, AttributeError): + pass + return cls( + id=data.get("id", 0), + name=data.get("name", ""), + size=data.get("size", 0), + download_count=data.get("download_count", 0), + download_url=data.get("browser_download_url", ""), + created_at=created_at, + ) + + +@dataclass +class Release: + """Represents a Gitea release.""" + id: int + tag_name: str + name: str = "" + body: str = "" + draft: bool = False + prerelease: bool = False + published_at: Optional[datetime] = None + assets: List[Attachment] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Release": + published_at = None + if data.get("published_at"): + try: + published_at = datetime.fromisoformat(data["published_at"].replace("Z", "+00:00")) + except (ValueError, AttributeError): + pass + + assets = [] + for asset_data in data.get("assets", []): + assets.append(Attachment.from_dict(asset_data)) + + return cls( + id=data.get("id", 0), + tag_name=data.get("tag_name", ""), + name=data.get("name", ""), + body=data.get("body", ""), + draft=data.get("draft", False), + prerelease=data.get("prerelease", False), + published_at=published_at, + assets=assets, + ) + + +@dataclass +class UploadSession: + """Represents a chunked upload session.""" + id: str + file_name: str + file_size: int + chunk_size: int + total_chunks: int + chunks_received: int = 0 + status: str = "pending" + expires_at: Optional[datetime] = None + checksum: str = "" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "UploadSession": + expires_at = None + if data.get("expires_at"): + try: + expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) + except (ValueError, AttributeError): + pass + return cls( + id=data.get("id", ""), + file_name=data.get("file_name", ""), + file_size=data.get("file_size", 0), + chunk_size=data.get("chunk_size", 0), + total_chunks=data.get("total_chunks", 0), + chunks_received=data.get("chunks_received", 0), + status=data.get("status", "pending"), + expires_at=expires_at, + checksum=data.get("checksum", ""), + ) + + +@dataclass +class UploadResult: + """Represents the result of a completed upload.""" + id: int + name: str + size: int + download_url: str + checksum_verified: bool = False + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "UploadResult": + return cls( + id=data.get("id", 0), + name=data.get("name", ""), + size=data.get("size", 0), + download_url=data.get("browser_download_url", ""), + checksum_verified=data.get("checksum_verified", False), + ) + + +@dataclass +class Progress: + """Represents upload progress.""" + bytes_done: int + bytes_total: int + chunks_done: int + chunks_total: int + percent: float + speed: float # bytes per second + eta_seconds: float + + @property + def eta(self) -> str: + """Format ETA as a human-readable string.""" + seconds = int(self.eta_seconds) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + seconds = seconds % 60 + if minutes < 60: + return f"{minutes}m{seconds}s" + hours = minutes // 60 + minutes = minutes % 60 + return f"{hours}h{minutes}m" + + @property + def speed_formatted(self) -> str: + """Format speed as a human-readable string.""" + if self.speed < 1024: + return f"{self.speed:.0f} B/s" + elif self.speed < 1024 * 1024: + return f"{self.speed / 1024:.1f} KB/s" + else: + return f"{self.speed / 1024 / 1024:.1f} MB/s" diff --git a/sdk/python/setup.py b/sdk/python/setup.py new file mode 100644 index 0000000000..9ad3e038cd --- /dev/null +++ b/sdk/python/setup.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# Copyright 2026 The Gitea Authors. All rights reserved. +# SPDX-License-Identifier: MIT + +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="gitea-sdk", + version="1.0.0", + author="The Gitea Authors", + author_email="contact@gitea.io", + description="Python SDK for the Gitea API with chunked upload support", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://gitea.io", + project_urls={ + "Bug Tracker": "https://github.com/go-gitea/gitea/issues", + "Documentation": "https://docs.gitea.io/sdk/python", + "Source Code": "https://github.com/go-gitea/gitea/tree/main/sdk/python", + }, + packages=find_packages(), + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Version Control :: Git", + ], + python_requires=">=3.8", + install_requires=[ + "requests>=2.25.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0", + "pytest-cov>=4.0", + "black>=23.0", + "mypy>=1.0", + "types-requests>=2.25", + ], + }, +) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000000..022e741b2e --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,176 @@ +# Gitea TypeScript/JavaScript SDK + +Official TypeScript/JavaScript SDK for the Gitea API with chunked upload support for large files. + +## Installation + +```bash +npm install @gitea/sdk +# or +yarn add @gitea/sdk +# or +pnpm add @gitea/sdk +``` + +## Quick Start + +```typescript +import { GiteaClient } from '@gitea/sdk'; + +// Create client +const client = new GiteaClient({ + baseUrl: 'https://gitea.example.com', + token: 'your_token' +}); + +// Get current user +const user = await client.getCurrentUser(); +console.log(`Logged in as ${user.login}`); + +// Get a repository +const repo = await client.getRepository('owner', 'repo'); +console.log(`Repository: ${repo.full_name}`); +``` + +## Chunked Upload + +Upload large files with progress tracking: + +```typescript +import { GiteaClient } from '@gitea/sdk'; + +const client = new GiteaClient({ + baseUrl: 'https://gitea.example.com', + token: 'your_token' +}); + +// Upload a release asset with progress +const file = new File([...], 'large-file.tar.gz'); + +const result = await client.uploadReleaseAsset({ + owner: 'myorg', + repo: 'myrepo', + releaseId: 123, + file, + chunkSize: 50 * 1024 * 1024, // 50MB chunks + parallel: 4, + verifyChecksum: true, + onProgress: (p) => { + console.log(`Progress: ${p.percent.toFixed(1)}%`); + console.log(`Speed: ${formatBytes(p.speed)}/s`); + console.log(`ETA: ${formatDuration(p.eta)}`); + } +}); + +console.log(`Uploaded: ${result.browser_download_url}`); +``` + +## Using ChunkedUpload Directly + +For more control over the upload process: + +```typescript +import { GiteaClient, ChunkedUpload } from '@gitea/sdk'; + +const client = new GiteaClient({ + baseUrl: 'https://gitea.example.com', + token: 'your_token' +}); + +const upload = client.createChunkedUpload('owner', 'repo', 123, { + chunkSize: 50 * 1024 * 1024, + parallel: 4, + onProgress: (p) => console.log(`${p.percent.toFixed(1)}%`) +}); + +try { + const result = await upload.upload(file, 'filename.tar.gz'); + console.log(`Success: ${result.browser_download_url}`); +} catch (error) { + // Upload failed, can retry later + const session = upload.getSession(); + console.log(`Resume with session: ${session?.id}`); +} +``` + +## API Reference + +### GiteaClient + +#### Constructor + +```typescript +const client = new GiteaClient({ + baseUrl: 'https://gitea.example.com', + token: 'your_api_token', + timeout: 30000, // Request timeout in milliseconds +}); +``` + +#### User Methods + +- `getCurrentUser()` - Get authenticated user +- `getUser(username)` - Get user by username + +#### Repository Methods + +- `getRepository(owner, repo)` - Get repository +- `listUserRepos(username)` - List user's repositories + +#### Release Methods + +- `getRelease(owner, repo, releaseId)` - Get release by ID +- `getReleaseByTag(owner, repo, tag)` - Get release by tag +- `listReleases(owner, repo)` - List all releases + +#### Upload Methods + +- `uploadReleaseAsset(options)` - Upload release asset with chunked upload +- `createChunkedUpload(owner, repo, releaseId, options)` - Create upload handler +- `getUploadSession(sessionId)` - Get upload session status +- `cancelUpload(sessionId)` - Cancel upload session + +## Error Handling + +```typescript +import { + GiteaClient, + APIError, + AuthenticationError, + NotFoundError, + UploadError +} from '@gitea/sdk'; + +const client = new GiteaClient({ + baseUrl: 'https://gitea.example.com', + token: 'your_token' +}); + +try { + const repo = await client.getRepository('owner', 'nonexistent'); +} catch (error) { + if (error instanceof NotFoundError) { + console.log(`Repository not found: ${error.message}`); + } else if (error instanceof AuthenticationError) { + console.log(`Authentication failed: ${error.message}`); + } else if (error instanceof UploadError) { + console.log(`Upload failed: ${error.message}`); + console.log(`Session: ${error.sessionId}, Chunk: ${error.chunk}`); + } else if (error instanceof APIError) { + console.log(`API error [${error.code}]: ${error.message}`); + } +} +``` + +## Browser Support + +This SDK works in modern browsers that support: +- `fetch` API +- `crypto.subtle` for SHA-256 checksum +- `File` and `Blob` APIs + +For Node.js, you may need to polyfill `fetch` for versions < 18. + +## License + +MIT License - See LICENSE file for details. diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000000..86f423cb42 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,49 @@ +{ + "name": "@gitea/sdk", + "version": "1.0.0", + "description": "Official TypeScript/JavaScript SDK for the Gitea API with chunked upload support", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "lint": "eslint src --ext .ts", + "test": "vitest", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "gitea", + "git", + "api", + "sdk", + "client", + "upload", + "chunked" + ], + "author": "The Gitea Authors", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/go-gitea/gitea.git", + "directory": "sdk/typescript" + }, + "bugs": { + "url": "https://github.com/go-gitea/gitea/issues" + }, + "homepage": "https://docs.gitea.io/sdk/typescript", + "engines": { + "node": ">=16.0.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "eslint": "^8.55.0", + "tsup": "^8.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 0000000000..3931536e8b --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,291 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +import { + User, + Repository, + Release, + UploadSession, + UploadResult, + APIErrorResponse, +} from './types'; +import { + APIError, + AuthenticationError, + NotFoundError, +} from './errors'; +import { ChunkedUpload, ChunkedUploadOptions } from './upload'; + +/** + * Options for creating a GiteaClient. + */ +export interface GiteaClientOptions { + /** Base URL of the Gitea instance */ + baseUrl: string; + /** API token for authentication */ + token?: string; + /** Request timeout in milliseconds (default: 30000) */ + timeout?: number; +} + +/** + * Client for the Gitea API. + * + * @example + * ```typescript + * const client = new GiteaClient({ + * baseUrl: 'https://gitea.example.com', + * token: 'your_token' + * }); + * + * const user = await client.getCurrentUser(); + * console.log(`Logged in as ${user.login}`); + * ``` + */ +export class GiteaClient { + private baseUrl: string; + private token?: string; + private timeout: number; + + constructor(options: GiteaClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ''); + this.token = options.token; + this.timeout = options.timeout ?? 30000; + } + + private async request( + method: string, + path: string, + body?: unknown + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + 'User-Agent': 'gitea-sdk-typescript/1.0', + }; + + if (this.token) { + headers['Authorization'] = `token ${this.token}`; + } + + if (body) { + headers['Content-Type'] = 'application/json'; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.status === 401) { + throw new AuthenticationError(); + } + + if (response.status === 404) { + throw new NotFoundError('Resource'); + } + + if (!response.ok) { + let errorData: APIErrorResponse; + try { + errorData = await response.json(); + } catch { + throw new APIError(await response.text(), undefined, response.status); + } + throw new APIError( + errorData.message, + errorData.code, + response.status, + errorData.details + ); + } + + if (response.status === 204) { + return {} as T; + } + + return response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new APIError('Request timeout', 'TIMEOUT', 0); + } + throw error; + } + } + + // User methods + + /** + * Get the currently authenticated user. + */ + async getCurrentUser(): Promise { + return this.request('GET', '/api/v1/user'); + } + + /** + * Get a user by username. + */ + async getUser(username: string): Promise { + return this.request( + 'GET', + `/api/v1/users/${encodeURIComponent(username)}` + ); + } + + // Repository methods + + /** + * Get a repository by owner and name. + */ + async getRepository(owner: string, repo: string): Promise { + return this.request( + 'GET', + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` + ); + } + + /** + * List repositories for a user. + */ + async listUserRepos(username: string): Promise { + return this.request( + 'GET', + `/api/v1/users/${encodeURIComponent(username)}/repos` + ); + } + + // Release methods + + /** + * Get a release by ID. + */ + async getRelease( + owner: string, + repo: string, + releaseId: number + ): Promise { + return this.request( + 'GET', + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repo + )}/releases/${releaseId}` + ); + } + + /** + * Get a release by tag name. + */ + async getReleaseByTag( + owner: string, + repo: string, + tag: string + ): Promise { + return this.request( + 'GET', + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repo + )}/releases/tags/${encodeURIComponent(tag)}` + ); + } + + /** + * List all releases for a repository. + */ + async listReleases(owner: string, repo: string): Promise { + return this.request( + 'GET', + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repo + )}/releases` + ); + } + + // Upload methods + + /** + * Create a chunked upload handler. + */ + createChunkedUpload( + owner: string, + repo: string, + releaseId: number, + options?: ChunkedUploadOptions + ): ChunkedUpload { + return new ChunkedUpload( + this.baseUrl, + this.token ?? '', + owner, + repo, + releaseId, + options + ); + } + + /** + * Upload a release asset using chunked upload. + * + * @example + * ```typescript + * const file = new File([...], 'app.tar.gz'); + * const result = await client.uploadReleaseAsset({ + * owner: 'myorg', + * repo: 'myrepo', + * releaseId: 123, + * file, + * onProgress: (p) => console.log(`${p.percent.toFixed(1)}%`) + * }); + * console.log(`Uploaded: ${result.browser_download_url}`); + * ``` + */ + async uploadReleaseAsset(options: { + owner: string; + repo: string; + releaseId: number; + file: File | Blob; + filename?: string; + chunkSize?: number; + parallel?: number; + verifyChecksum?: boolean; + onProgress?: (progress: import('./types').Progress) => void; + }): Promise { + const filename = options.filename ?? (options.file as File).name ?? 'file'; + const upload = this.createChunkedUpload( + options.owner, + options.repo, + options.releaseId, + { + chunkSize: options.chunkSize, + parallel: options.parallel, + verifyChecksum: options.verifyChecksum, + onProgress: options.onProgress, + } + ); + + return upload.upload(options.file, filename); + } + + /** + * Get the status of an upload session. + */ + async getUploadSession(sessionId: string): Promise { + return this.request( + 'GET', + `/api/v1/repos/uploads/${sessionId}` + ); + } + + /** + * Cancel an upload session. + */ + async cancelUpload(sessionId: string): Promise { + await this.request('DELETE', `/api/v1/repos/uploads/${sessionId}`); + } +} diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 0000000000..20b37b0f79 --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,132 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Base error class for all Gitea SDK errors. + */ +export class GiteaError extends Error { + constructor(message: string) { + super(message); + this.name = 'GiteaError'; + Object.setPrototypeOf(this, GiteaError.prototype); + } +} + +/** + * Raised when the API returns an error response. + */ +export class APIError extends GiteaError { + public readonly code?: string; + public readonly status: number; + public readonly details?: Record; + + constructor( + message: string, + code?: string, + status: number = 0, + details?: Record + ) { + super(message); + this.name = 'APIError'; + this.code = code; + this.status = status; + this.details = details; + Object.setPrototypeOf(this, APIError.prototype); + } + + toString(): string { + if (this.code) { + return `[${this.code}] ${this.message}`; + } + return this.message; + } +} + +/** + * Raised when authentication fails. + */ +export class AuthenticationError extends APIError { + constructor(message: string = 'Authentication failed') { + super(message, 'AUTH_FAILED', 401); + this.name = 'AuthenticationError'; + Object.setPrototypeOf(this, AuthenticationError.prototype); + } +} + +/** + * Raised when a resource is not found. + */ +export class NotFoundError extends APIError { + public readonly resource: string; + public readonly identifier?: string; + + constructor(resource: string, identifier?: string) { + const message = identifier + ? `${resource} '${identifier}' not found` + : `${resource} not found`; + super(message, 'NOT_FOUND', 404); + this.name = 'NotFoundError'; + this.resource = resource; + this.identifier = identifier; + Object.setPrototypeOf(this, NotFoundError.prototype); + } +} + +/** + * Raised when request validation fails. + */ +export class ValidationError extends APIError { + public readonly field?: string; + + constructor(message: string, field?: string) { + super(message, 'VALIDATION_FAILED', 400); + this.name = 'ValidationError'; + this.field = field; + Object.setPrototypeOf(this, ValidationError.prototype); + } +} + +/** + * Raised when an upload fails. + */ +export class UploadError extends GiteaError { + public readonly sessionId?: string; + public readonly chunk?: number; + + constructor(message: string, sessionId?: string, chunk?: number) { + super(message); + this.name = 'UploadError'; + this.sessionId = sessionId; + this.chunk = chunk; + Object.setPrototypeOf(this, UploadError.prototype); + } + + toString(): string { + const parts = [this.message]; + if (this.sessionId) { + parts.push(`session=${this.sessionId}`); + } + if (this.chunk !== undefined) { + parts.push(`chunk=${this.chunk}`); + } + return parts.join(' '); + } +} + +/** + * Raised when rate limit is exceeded. + */ +export class RateLimitError extends APIError { + public readonly retryAfter?: number; + + constructor(retryAfter?: number) { + let message = 'Rate limit exceeded'; + if (retryAfter) { + message += `. Retry after ${retryAfter} seconds`; + } + super(message, 'RATE_LIMIT_EXCEEDED', 429); + this.name = 'RateLimitError'; + this.retryAfter = retryAfter; + Object.setPrototypeOf(this, RateLimitError.prototype); + } +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000000..e491bc79ed --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Gitea TypeScript/JavaScript SDK + * + * A client library for the Gitea API with chunked upload support. + * + * @example + * ```typescript + * import { GiteaClient } from '@gitea/sdk'; + * + * const client = new GiteaClient({ + * baseUrl: 'https://gitea.example.com', + * token: 'your_token' + * }); + * + * const user = await client.getCurrentUser(); + * console.log(`Logged in as ${user.login}`); + * ``` + * + * @packageDocumentation + */ + +export { GiteaClient, GiteaClientOptions } from './client'; +export { + User, + Repository, + Release, + Attachment, + UploadSession, + UploadResult, + Progress, +} from './types'; +export { + GiteaError, + APIError, + AuthenticationError, + NotFoundError, + ValidationError, + UploadError, +} from './errors'; +export { + ChunkedUpload, + ChunkedUploadOptions, + ProgressCallback, +} from './upload'; diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts new file mode 100644 index 0000000000..6ebf78b7a5 --- /dev/null +++ b/sdk/typescript/src/types.ts @@ -0,0 +1,114 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Represents a Gitea user. + */ +export interface User { + id: number; + login: string; + full_name: string; + email: string; + avatar_url: string; + is_admin: boolean; +} + +/** + * Represents a Gitea repository. + */ +export interface Repository { + id: number; + name: string; + full_name: string; + owner?: User; + description: string; + private: boolean; + fork: boolean; + default_branch: string; + stars_count: number; + forks_count: number; + clone_url: string; + html_url: string; +} + +/** + * Represents a release attachment/asset. + */ +export interface Attachment { + id: number; + name: string; + size: number; + download_count: number; + browser_download_url: string; + created_at: string; +} + +/** + * Represents a Gitea release. + */ +export interface Release { + id: number; + tag_name: string; + name: string; + body: string; + draft: boolean; + prerelease: boolean; + published_at: string; + assets: Attachment[]; +} + +/** + * Represents a chunked upload session. + */ +export interface UploadSession { + id: string; + file_name: string; + file_size: number; + chunk_size: number; + total_chunks: number; + chunks_received: number; + status: 'pending' | 'uploading' | 'complete' | 'expired' | 'failed'; + expires_at: string; + checksum?: string; +} + +/** + * Represents the result of a completed upload. + */ +export interface UploadResult { + id: number; + name: string; + size: number; + browser_download_url: string; + checksum_verified: boolean; +} + +/** + * Represents upload progress. + */ +export interface Progress { + /** Bytes uploaded so far */ + bytesDone: number; + /** Total bytes to upload */ + bytesTotal: number; + /** Number of chunks uploaded */ + chunksDone: number; + /** Total number of chunks */ + chunksTotal: number; + /** Percentage complete (0-100) */ + percent: number; + /** Upload speed in bytes per second */ + speed: number; + /** Estimated time remaining in milliseconds */ + eta: number; +} + +/** + * API error response from the server. + */ +export interface APIErrorResponse { + code?: string; + message: string; + status?: number; + details?: Record; +} diff --git a/sdk/typescript/src/upload.ts b/sdk/typescript/src/upload.ts new file mode 100644 index 0000000000..2a218577d4 --- /dev/null +++ b/sdk/typescript/src/upload.ts @@ -0,0 +1,299 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +import { UploadSession, UploadResult, Progress } from './types'; +import { UploadError } from './errors'; + +/** + * Progress callback function type. + */ +export type ProgressCallback = (progress: Progress) => void; + +/** + * Options for chunked upload. + */ +export interface ChunkedUploadOptions { + /** Size of each chunk in bytes (default: 10MB) */ + chunkSize?: number; + /** Number of parallel upload workers (default: 4) */ + parallel?: number; + /** Whether to verify file checksum (default: true) */ + verifyChecksum?: boolean; + /** Callback function for progress updates */ + onProgress?: ProgressCallback; +} + +const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_PARALLEL = 8; + +/** + * Handles chunked file uploads. + */ +export class ChunkedUpload { + private baseUrl: string; + private token: string; + private owner: string; + private repo: string; + private releaseId: number; + private options: Required> & { + onProgress?: ProgressCallback; + }; + private session?: UploadSession; + private bytesUploaded = 0; + private startTime = 0; + + constructor( + baseUrl: string, + token: string, + owner: string, + repo: string, + releaseId: number, + options: ChunkedUploadOptions = {} + ) { + this.baseUrl = baseUrl; + this.token = token; + this.owner = owner; + this.repo = repo; + this.releaseId = releaseId; + this.options = { + chunkSize: options.chunkSize ?? DEFAULT_CHUNK_SIZE, + parallel: Math.min(options.parallel ?? 4, MAX_PARALLEL), + verifyChecksum: options.verifyChecksum ?? true, + onProgress: options.onProgress, + }; + } + + /** + * Upload a file. + */ + async upload(file: File | Blob, filename: string): Promise { + this.startTime = Date.now(); + const fileSize = file.size; + + // Calculate checksum if needed + let checksum: string | undefined; + if (this.options.verifyChecksum) { + checksum = await this.calculateSHA256(file); + } + + // Create session + this.session = await this.createSession(filename, fileSize, checksum); + + // Upload chunks + try { + await this.uploadChunks(file); + } catch (error) { + throw new UploadError( + `Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + this.session.id + ); + } + + // Complete upload + return this.completeUpload(); + } + + private async calculateSHA256(file: File | Blob): Promise { + const buffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + private async createSession( + filename: string, + fileSize: number, + checksum?: string + ): Promise { + const url = `${this.baseUrl}/api/v1/repos/${encodeURIComponent( + this.owner + )}/${encodeURIComponent(this.repo)}/releases/${ + this.releaseId + }/assets/upload-session`; + + const body: Record = { + name: filename, + size: fileSize, + chunk_size: this.options.chunkSize, + }; + if (checksum) { + body.checksum = checksum; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `token ${this.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create session: ${error}`); + } + + return response.json(); + } + + private async uploadChunks(file: File | Blob): Promise { + if (!this.session) { + throw new Error('No session'); + } + + const { total_chunks, chunk_size, file_size } = this.session; + const chunks: Array<{ number: number; data: Blob }> = []; + + // Prepare chunks + for (let i = 0; i < total_chunks; i++) { + const start = i * chunk_size; + const end = Math.min(start + chunk_size, file_size); + const data = file.slice(start, end); + chunks.push({ number: i, data }); + } + + // Upload in parallel batches + const parallel = this.options.parallel; + for (let i = 0; i < chunks.length; i += parallel) { + const batch = chunks.slice(i, i + parallel); + await Promise.all( + batch.map((chunk) => this.uploadChunk(chunk.number, chunk.data)) + ); + } + } + + private async uploadChunk(chunkNumber: number, data: Blob): Promise { + if (!this.session) { + throw new Error('No session'); + } + + const url = `${this.baseUrl}/api/v1/repos/uploads/${this.session.id}/chunks/${chunkNumber}`; + + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: `token ${this.token}`, + 'Content-Type': 'application/octet-stream', + }, + body: data, + }); + + if (!response.ok) { + const error = await response.text(); + throw new UploadError( + `Failed to upload chunk ${chunkNumber}: ${error}`, + this.session.id, + chunkNumber + ); + } + + this.bytesUploaded += data.size; + this.reportProgress(); + } + + private reportProgress(): void { + if (!this.options.onProgress || !this.session) { + return; + } + + const elapsed = (Date.now() - this.startTime) / 1000; + const speed = elapsed > 0 ? this.bytesUploaded / elapsed : 0; + const remaining = this.session.file_size - this.bytesUploaded; + const eta = speed > 0 ? (remaining / speed) * 1000 : 0; + + const progress: Progress = { + bytesDone: this.bytesUploaded, + bytesTotal: this.session.file_size, + chunksDone: Math.floor( + this.bytesUploaded / this.session.chunk_size + ), + chunksTotal: this.session.total_chunks, + percent: (this.bytesUploaded / this.session.file_size) * 100, + speed, + eta, + }; + + this.options.onProgress(progress); + } + + private async completeUpload(): Promise { + if (!this.session) { + throw new Error('No session'); + } + + const url = `${this.baseUrl}/api/v1/repos/uploads/${this.session.id}/complete`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `token ${this.token}`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to complete upload: ${error}`); + } + + return response.json(); + } + + /** + * Cancel the upload. + */ + async cancel(): Promise { + if (!this.session) { + return; + } + + const url = `${this.baseUrl}/api/v1/repos/uploads/${this.session.id}`; + + await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `token ${this.token}`, + }, + }); + } + + /** + * Get the current session. + */ + getSession(): UploadSession | undefined { + return this.session; + } +} + +/** + * Format bytes as a human-readable string. + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + } else { + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; + } +} + +/** + * Format duration in milliseconds as a human-readable string. + */ +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return `${minutes}m${remainingSeconds}s`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h${remainingMinutes}m`; +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000000..2a647d3fb7 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "lib": ["ES2020", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}