// Gitea REPORT commit (optional, [gitea] commit_reports = true): right // after a REPORT file lands, the loop commits it to the configured repo via // the contents API (create, or update with the existing blob sha). All // endpoints come from config; nothing host-specific lives in code. package writeback import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "ukrrs.com/mopac/harness/internal/config" ) // GiteaWriter commits files to one configured repo. type GiteaWriter struct { url string key string owner string repo string branch string http *http.Client } // NewGiteaWriter builds the client from config and a resolved key. func NewGiteaWriter(cfg config.GiteaConfig, key string) *GiteaWriter { return &GiteaWriter{ url: strings.TrimSuffix(cfg.URL, "/"), key: key, owner: cfg.Owner, repo: cfg.Repo, branch: cfg.Branch, http: &http.Client{Timeout: 30 * time.Second}, } } // CommitFile creates or updates path in the repo with content. If the file // already exists its blob sha is fetched and the update carried out. func (w *GiteaWriter) CommitFile(ctx context.Context, path, content, message string) error { api := w.url + "/api/v1/repos/" + w.owner + "/" + w.repo + "/contents/" + path var sha string body, err := w.do(ctx, http.MethodGet, api+"?ref="+url.QueryEscape(w.branch), nil) if err == nil { var cur struct { SHA string `json:"sha"` Type string `json:"type"` } if json.Unmarshal(body, &cur) == nil && cur.Type == "file" { sha = cur.SHA } } else if !isNotFound(err) { return err // GET failed for a reason other than "does not exist" } payload := map[string]any{ "content": base64.StdEncoding.EncodeToString([]byte(content)), "message": message, } if w.branch != "" { payload["branch"] = w.branch } method := http.MethodPost if sha != "" { payload["sha"] = sha method = http.MethodPut } raw, err := json.Marshal(payload) if err != nil { return err } if _, err := w.do(ctx, method, api, raw); err != nil { return err } return nil } func (w *GiteaWriter) do(ctx context.Context, method, api string, body []byte) ([]byte, error) { var rd io.Reader if body != nil { rd = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, api, rd) if err != nil { return nil, err } req.Header.Set("Authorization", "token "+w.key) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := w.http.Do(req) if err != nil { return nil, fmt.Errorf("gitea request: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, fmt.Errorf("gitea read: %w", err) } if resp.StatusCode == http.StatusNotFound { return nil, errNotFound } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("gitea HTTP %d: %s", resp.StatusCode, truncateFor(string(raw), 300)) } return raw, nil } type notFoundError struct{} func (notFoundError) Error() string { return "gitea: not found" } var errNotFound error = notFoundError{} func isNotFound(err error) bool { _, ok := err.(notFoundError) return ok }