feat(deploy): commit-status reporting to Git providers
Report deploy status back to the Git provider as a commit status (pending/success/failure) for git-sourced workloads (static + dockerfile). - GitProvider.SetCommitStatus on gitea/github/gitlab over the existing SSRF-safe client; fixed "tinyforge" context so redeploys update one row. postJSON returns status-code-only errors (never echoes the upstream body, which a hostile provider could use to reflect the auth token into the best-effort log line). - Best-effort deploy hook: pending on deploy start, success/failure on outcome, gated on a per-workload report_commit_status flag. Never fails or blocks a deploy; emits nothing on the unchanged-SHA short-circuit. - UI ToggleSwitch (create + edit) + reportCommitStatus in sourceForms.ts + en/ru i18n. - Tests: per-provider state mapping + request shape; reporter gating (enabled/disabled/empty-SHA/nil/error-swallow). Reviewed via go-reviewer + security-reviewer (0 CRITICAL/HIGH; one MEDIUM body-echo log-leak fixed).
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── State mapping (pure) ────────────────────────────────────────────
|
||||
//
|
||||
// Each provider maps the provider-agnostic CommitStatus onto its own API
|
||||
// vocabulary. Gitea/GitHub accept the same four words; GitLab collapses
|
||||
// failure+error into "failed".
|
||||
|
||||
func TestGiteaState_Mapping(t *testing.T) {
|
||||
cases := map[CommitStatus]string{
|
||||
CommitStatusPending: "pending",
|
||||
CommitStatusSuccess: "success",
|
||||
CommitStatusFailure: "failure",
|
||||
CommitStatusError: "error",
|
||||
CommitStatus("???"): "pending", // unknown -> pending fallback
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := giteaState(in); got != want {
|
||||
t.Errorf("giteaState(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubState_Mapping(t *testing.T) {
|
||||
cases := map[CommitStatus]string{
|
||||
CommitStatusPending: "pending",
|
||||
CommitStatusSuccess: "success",
|
||||
CommitStatusFailure: "failure",
|
||||
CommitStatusError: "error",
|
||||
CommitStatus("???"): "pending",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := githubState(in); got != want {
|
||||
t.Errorf("githubState(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabState_Mapping(t *testing.T) {
|
||||
cases := map[CommitStatus]string{
|
||||
CommitStatusPending: "pending",
|
||||
CommitStatusSuccess: "success",
|
||||
CommitStatusFailure: "failed", // GitLab has no "failure"
|
||||
CommitStatusError: "failed", // error also collapses to "failed"
|
||||
CommitStatus("???"): "pending",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := gitlabState(in); got != want {
|
||||
t.Errorf("gitlabState(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDescription(t *testing.T) {
|
||||
short := "Tinyforge: deploying"
|
||||
if got := truncateDescription(short); got != short {
|
||||
t.Errorf("short description mutated: %q", got)
|
||||
}
|
||||
long := strings.Repeat("x", 200)
|
||||
got := truncateDescription(long)
|
||||
if len([]rune(got)) > maxCommitStatusDescription {
|
||||
t.Errorf("truncated length = %d runes, want <= %d", len([]rune(got)), maxCommitStatusDescription)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("missing ellipsis on truncation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Endpoint + body construction (httptest) ─────────────────────────
|
||||
//
|
||||
// The SSRF-safe client refuses loopback, so for these tests we swap the
|
||||
// provider's httpClient for a plain one pointed at httptest. This still
|
||||
// exercises the real URL/body construction inside each SetCommitStatus.
|
||||
|
||||
type capturedRequest struct {
|
||||
method string
|
||||
path string // r.URL.EscapedPath() — preserves %2F so GitLab's encoded project path is observable
|
||||
rawQ string
|
||||
body map[string]string
|
||||
auth string
|
||||
token string // PRIVATE-TOKEN (GitLab)
|
||||
}
|
||||
|
||||
func newCaptureServer(t *testing.T, capture *capturedRequest) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capture.method = r.Method
|
||||
capture.path = r.URL.EscapedPath()
|
||||
capture.rawQ = r.URL.RawQuery
|
||||
capture.auth = r.Header.Get("Authorization")
|
||||
capture.token = r.Header.Get("PRIVATE-TOKEN")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &capture.body)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
}
|
||||
|
||||
func TestGitea_SetCommitStatus_Request(t *testing.T) {
|
||||
var cap capturedRequest
|
||||
srv := newCaptureServer(t, &cap)
|
||||
defer srv.Close()
|
||||
|
||||
f := NewGiteaContentFetcher(srv.URL, "tok123")
|
||||
f.httpClient = srv.Client() // bypass SSRF guard for loopback test server
|
||||
|
||||
err := f.SetCommitStatus(context.Background(), "owner", "repo", "abc123",
|
||||
CommitStatusSuccess, "https://app.example.com", "deployed")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCommitStatus: %v", err)
|
||||
}
|
||||
|
||||
if cap.method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", cap.method)
|
||||
}
|
||||
if want := "/api/v1/repos/owner/repo/statuses/abc123"; cap.path != want {
|
||||
t.Errorf("path = %q, want %q", cap.path, want)
|
||||
}
|
||||
if cap.body["state"] != "success" {
|
||||
t.Errorf("state = %q, want success", cap.body["state"])
|
||||
}
|
||||
if cap.body["context"] != "tinyforge" {
|
||||
t.Errorf("context = %q, want tinyforge", cap.body["context"])
|
||||
}
|
||||
if cap.body["target_url"] != "https://app.example.com" {
|
||||
t.Errorf("target_url = %q", cap.body["target_url"])
|
||||
}
|
||||
if cap.body["description"] != "deployed" {
|
||||
t.Errorf("description = %q, want deployed", cap.body["description"])
|
||||
}
|
||||
if cap.auth != "token tok123" {
|
||||
t.Errorf("auth = %q, want 'token tok123'", cap.auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHub_SetCommitStatus_Request(t *testing.T) {
|
||||
var cap capturedRequest
|
||||
srv := newCaptureServer(t, &cap)
|
||||
defer srv.Close()
|
||||
|
||||
// Force GHE-style apiBase so we hit the server's path; the github.com
|
||||
// branch hard-codes api.github.com which the SSRF client would block.
|
||||
g := NewGitHubProvider(srv.URL, "ghp_tok")
|
||||
g.apiBase = srv.URL + "/api/v3"
|
||||
g.httpClient = srv.Client()
|
||||
|
||||
err := g.SetCommitStatus(context.Background(), "octo", "cat", "deadbeef",
|
||||
CommitStatusFailure, "", "failed")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCommitStatus: %v", err)
|
||||
}
|
||||
|
||||
if want := "/api/v3/repos/octo/cat/statuses/deadbeef"; cap.path != want {
|
||||
t.Errorf("path = %q, want %q", cap.path, want)
|
||||
}
|
||||
if cap.body["state"] != "failure" {
|
||||
t.Errorf("state = %q, want failure", cap.body["state"])
|
||||
}
|
||||
if cap.body["context"] != "tinyforge" {
|
||||
t.Errorf("context = %q, want tinyforge", cap.body["context"])
|
||||
}
|
||||
if cap.auth != "Bearer ghp_tok" {
|
||||
t.Errorf("auth = %q, want 'Bearer ghp_tok'", cap.auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLab_SetCommitStatus_Request(t *testing.T) {
|
||||
var cap capturedRequest
|
||||
srv := newCaptureServer(t, &cap)
|
||||
defer srv.Close()
|
||||
|
||||
g := NewGitLabProvider(srv.URL, "glpat-xyz")
|
||||
g.httpClient = srv.Client()
|
||||
|
||||
err := g.SetCommitStatus(context.Background(), "grp", "proj", "cafe01",
|
||||
CommitStatusError, "https://app.example.com", "boom")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCommitStatus: %v", err)
|
||||
}
|
||||
|
||||
// GitLab uses the URL-encoded project path + sha in the path, and the
|
||||
// status metadata as query params.
|
||||
if want := "/api/v4/projects/grp%2Fproj/statuses/cafe01"; cap.path != want {
|
||||
t.Errorf("path = %q, want %q", cap.path, want)
|
||||
}
|
||||
q, err := parseQuery(cap.rawQ)
|
||||
if err != nil {
|
||||
t.Fatalf("parse query %q: %v", cap.rawQ, err)
|
||||
}
|
||||
if q["state"] != "failed" { // error -> failed
|
||||
t.Errorf("state = %q, want failed", q["state"])
|
||||
}
|
||||
if q["name"] != "tinyforge" {
|
||||
t.Errorf("name = %q, want tinyforge", q["name"])
|
||||
}
|
||||
if q["target_url"] != "https://app.example.com" {
|
||||
t.Errorf("target_url = %q", q["target_url"])
|
||||
}
|
||||
if q["description"] != "boom" {
|
||||
t.Errorf("description = %q, want boom", q["description"])
|
||||
}
|
||||
if cap.token != "glpat-xyz" {
|
||||
t.Errorf("PRIVATE-TOKEN = %q, want glpat-xyz", cap.token)
|
||||
}
|
||||
}
|
||||
|
||||
// parseQuery is a tiny wrapper so the test reads the first value of each
|
||||
// query key without dragging net/url into every assertion.
|
||||
func parseQuery(raw string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if raw == "" {
|
||||
return out, nil
|
||||
}
|
||||
for _, pair := range strings.Split(raw, "&") {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
k := urlDecode(kv[0])
|
||||
v := ""
|
||||
if len(kv) == 2 {
|
||||
v = urlDecode(kv[1])
|
||||
}
|
||||
if _, ok := out[k]; !ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func urlDecode(s string) string {
|
||||
dec, err := decodeQueryComponent(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
// decodeQueryComponent decodes one application/x-www-form-urlencoded
|
||||
// component (handles %XX and '+'-as-space) without importing net/url here.
|
||||
func decodeQueryComponent(s string) (string, error) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '+':
|
||||
b.WriteByte(' ')
|
||||
case '%':
|
||||
if i+2 >= len(s) {
|
||||
return s, errPercent
|
||||
}
|
||||
hi, lo := fromHex(s[i+1]), fromHex(s[i+2])
|
||||
if hi < 0 || lo < 0 {
|
||||
return s, errPercent
|
||||
}
|
||||
b.WriteByte(byte(hi<<4 | lo))
|
||||
i += 2
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
var errPercent = &decodeErr{}
|
||||
|
||||
type decodeErr struct{}
|
||||
|
||||
func (*decodeErr) Error() string { return "bad percent-encoding" }
|
||||
|
||||
func fromHex(c byte) int {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return int(c - '0')
|
||||
case c >= 'a' && c <= 'f':
|
||||
return int(c-'a') + 10
|
||||
case c >= 'A' && c <= 'F':
|
||||
return int(c-'A') + 10
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TestSetCommitStatus_NonOK_ReturnsError verifies a non-2xx provider
|
||||
// response surfaces as an error (the deploy hook logs + swallows it, but
|
||||
// the provider method itself must report it).
|
||||
func TestSetCommitStatus_NonOK_ReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"message":"bad token"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := NewGiteaContentFetcher(srv.URL, "tok")
|
||||
f.httpClient = srv.Client()
|
||||
|
||||
err := f.SetCommitStatus(context.Background(), "o", "r", "sha", CommitStatusPending, "", "x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 401, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "401") {
|
||||
t.Errorf("error missing status code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetCommitStatus_RespectsContext ensures the call honours context
|
||||
// cancellation (defensive — the deploy hook passes the deploy ctx).
|
||||
func TestSetCommitStatus_RespectsContext(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := NewGiteaContentFetcher(srv.URL, "")
|
||||
f.httpClient = srv.Client()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := f.SetCommitStatus(ctx, "o", "r", "sha", CommitStatusPending, "", "x"); err == nil {
|
||||
t.Fatal("expected context-deadline error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -304,6 +305,54 @@ func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo st
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCommitStatus reports a deploy status on a commit via Gitea's commit-
|
||||
// status API (also serves Forgejo/Gogs). The "context" field is fixed to
|
||||
// "tinyforge" so repeated deploys update one status row.
|
||||
func (f *GiteaContentFetcher) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
|
||||
state := giteaState(status)
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"state": state,
|
||||
"target_url": targetURL,
|
||||
"description": truncateDescription(description),
|
||||
"context": commitStatusContext,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal status: %w", err)
|
||||
}
|
||||
// Path-escape each identifier so the URL shape matches the other
|
||||
// provider methods and a hostile owner/repo/sha can't break out of
|
||||
// the intended path. The SSRF-safe client guards the host.
|
||||
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/statuses/%s",
|
||||
f.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
||||
if err := postJSON(ctx, f.httpClient, apiURL, body, f.setAuth); err != nil {
|
||||
return fmt.Errorf("set commit status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAuth applies the Gitea token header (no-op when the token is empty).
|
||||
func (f *GiteaContentFetcher) setAuth(req *http.Request) {
|
||||
if f.token != "" {
|
||||
req.Header.Set("Authorization", "token "+f.token)
|
||||
}
|
||||
}
|
||||
|
||||
// giteaState maps a provider-agnostic CommitStatus onto Gitea's API
|
||||
// vocabulary. Gitea accepts the same four words Tinyforge uses, so this is
|
||||
// a 1:1 mapping with a "pending" fallback for any unknown value.
|
||||
func giteaState(status CommitStatus) string {
|
||||
switch status {
|
||||
case CommitStatusSuccess:
|
||||
return "success"
|
||||
case CommitStatusFailure:
|
||||
return "failure"
|
||||
case CommitStatusError:
|
||||
return "error"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
// doGet performs an authenticated GET request and returns the response body.
|
||||
func (f *GiteaContentFetcher) doGet(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -115,6 +116,43 @@ func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCommitStatus reports a deploy status on a commit via GitHub's commit-
|
||||
// status API (works for github.com and GitHub Enterprise — apiBase already
|
||||
// carries the /api/v3 suffix for GHE). The "context" field is fixed to
|
||||
// "tinyforge" so repeated deploys update one status row.
|
||||
func (g *GitHubProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"state": githubState(status),
|
||||
"target_url": targetURL,
|
||||
"description": truncateDescription(description),
|
||||
"context": commitStatusContext,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal status: %w", err)
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s/repos/%s/%s/statuses/%s",
|
||||
g.apiBase, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
||||
if err := postJSON(ctx, g.httpClient, apiURL, body, g.setAuth); err != nil {
|
||||
return fmt.Errorf("set commit status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// githubState maps a provider-agnostic CommitStatus onto GitHub's API
|
||||
// vocabulary. GitHub accepts the same four words Tinyforge uses.
|
||||
func githubState(status CommitStatus) string {
|
||||
switch status {
|
||||
case CommitStatusSuccess:
|
||||
return "success"
|
||||
case CommitStatusFailure:
|
||||
return "failure"
|
||||
case CommitStatusError:
|
||||
return "error"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
|
||||
var allBranches []string
|
||||
page := 1
|
||||
|
||||
@@ -95,6 +95,45 @@ func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCommitStatus reports a deploy status on a commit via GitLab's commit-
|
||||
// status API. GitLab's state vocabulary differs (pending/running/success/
|
||||
// failed/canceled), so failure AND error both map to "failed". The status
|
||||
// metadata (name/target_url/description) is passed as query parameters,
|
||||
// which is how GitLab's POST .../statuses/{sha} endpoint accepts them.
|
||||
func (g *GitLabProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error {
|
||||
q := url.Values{}
|
||||
q.Set("state", gitlabState(status))
|
||||
q.Set("name", commitStatusContext)
|
||||
if targetURL != "" {
|
||||
q.Set("target_url", targetURL)
|
||||
}
|
||||
if description != "" {
|
||||
q.Set("description", truncateDescription(description))
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s/projects/%s/statuses/%s?%s",
|
||||
g.apiBase, projectPath(owner, repo), url.PathEscape(sha), q.Encode())
|
||||
// No JSON body — all fields ride as query params. Reuse postJSON for
|
||||
// the SSRF-safe POST + 2xx handling; an empty body is valid here.
|
||||
if err := postJSON(ctx, g.httpClient, apiURL, nil, g.setAuth); err != nil {
|
||||
return fmt.Errorf("set commit status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitlabState maps a provider-agnostic CommitStatus onto GitLab's API
|
||||
// vocabulary. GitLab has no "failure"/"error" split — both map to
|
||||
// "failed".
|
||||
func gitlabState(status CommitStatus) string {
|
||||
switch status {
|
||||
case CommitStatusSuccess:
|
||||
return "success"
|
||||
case CommitStatusFailure, CommitStatusError:
|
||||
return "failed"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) {
|
||||
var allBranches []string
|
||||
page := 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -21,6 +22,40 @@ type RepoInfo struct {
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// CommitStatus is the deploy outcome reported back to the git provider as
|
||||
// a commit status. The values are provider-agnostic; each implementation
|
||||
// maps them onto its own API vocabulary (Gitea/GitHub use the same four
|
||||
// words, GitLab collapses failure/error into "failed").
|
||||
type CommitStatus string
|
||||
|
||||
const (
|
||||
CommitStatusPending CommitStatus = "pending"
|
||||
CommitStatusSuccess CommitStatus = "success"
|
||||
CommitStatusFailure CommitStatus = "failure"
|
||||
CommitStatusError CommitStatus = "error"
|
||||
)
|
||||
|
||||
// commitStatusContext is the status "context"/"name" key reported to every
|
||||
// provider so repeated deploys update the same status row rather than
|
||||
// piling up new ones.
|
||||
const commitStatusContext = "tinyforge"
|
||||
|
||||
// maxCommitStatusDescription caps the human-readable description so a
|
||||
// provider can't reject the request for an over-long field.
|
||||
const maxCommitStatusDescription = 140
|
||||
|
||||
// truncateDescription clamps a status description to the provider-safe
|
||||
// length, appending an ellipsis when it had to cut.
|
||||
func truncateDescription(s string) string {
|
||||
if len(s) <= maxCommitStatusDescription {
|
||||
return s
|
||||
}
|
||||
// Reserve room for the ellipsis rune; cut on a byte boundary that
|
||||
// stays under the cap. Descriptions are short ASCII strings in
|
||||
// practice, so a simple byte cut is fine here.
|
||||
return s[:maxCommitStatusDescription-1] + "…"
|
||||
}
|
||||
|
||||
// GitProvider abstracts Git hosting API operations.
|
||||
// Implementations exist for Gitea/Forgejo/Gogs, GitHub, and GitLab.
|
||||
type GitProvider interface {
|
||||
@@ -45,6 +80,12 @@ type GitProvider interface {
|
||||
|
||||
// DownloadFolder downloads all files from a folder path to a local directory.
|
||||
DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error
|
||||
|
||||
// SetCommitStatus reports a deploy status on a commit. Best-effort;
|
||||
// callers ignore errors beyond logging. targetURL and description are
|
||||
// optional (pass "" to omit); description is truncated to a provider-
|
||||
// safe length by the implementation.
|
||||
SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error
|
||||
}
|
||||
|
||||
// ProviderType identifies a Git hosting provider.
|
||||
@@ -135,6 +176,36 @@ func httpGet(ctx context.Context, client *http.Client, url string) (int, error)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// postJSON is a shared helper for POSTing a JSON body to a provider API
|
||||
// endpoint with the caller's auth applied. It accepts any 2xx as success
|
||||
// (status APIs return 201 Created on Gitea/GitHub, 200/201 on GitLab) and
|
||||
// returns a status-code-only error on non-2xx — it must NOT echo the
|
||||
// response body: the deploy hook logs this error best-effort, and a
|
||||
// hostile/misconfigured provider could reflect the request's auth token
|
||||
// back in its body. The body bytes must already be marshalled by the caller.
|
||||
func postJSON(ctx context.Context, client *http.Client, url string, body []byte, authHeader func(r *http.Request)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if authHeader != nil {
|
||||
authHeader(req)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFileHTTP is a shared helper for downloading a file from a URL.
|
||||
func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
Reference in New Issue
Block a user