Return write evidence from Gitea operations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Implementation plan: neutral forge delivery and credential preflight
|
||||
|
||||
Status: implementation in progress; Stages 1–4 are complete and Stages 5–10
|
||||
Status: implementation in progress; Stages 1–5 are complete and Stages 6–10
|
||||
are pending.
|
||||
|
||||
## Purpose and execution
|
||||
@@ -327,7 +327,7 @@ evidence. It uses no extra account/admin credentials or destructive probe.
|
||||
|
||||
## Stage 5: Extract Gitea write operations and evidence
|
||||
|
||||
Status: pending.
|
||||
Status: complete.
|
||||
|
||||
1. Implement `CreateRelease` from the current release POST and `UploadAsset`
|
||||
from multipart upload. Keep explicit target commit, stable flags, full note,
|
||||
|
||||
@@ -2,6 +2,7 @@ package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -75,6 +76,92 @@ func (b *Backend) Preflight(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Backend) CreateRelease(ctx context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) {
|
||||
if strings.TrimSpace(request.Tag) == "" || !validObjectID(request.Commit) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureInvalid, Err: errors.New("a full tag and lowercase Git commit ID are required"),
|
||||
}
|
||||
}
|
||||
_, apiRoot, err := b.destinationAPI("create release")
|
||||
if err != nil {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
body, err := json.Marshal(createReleaseRequest{
|
||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||
Name: request.Title, Body: request.Body,
|
||||
Draft: request.Draft, Prerelease: request.Prerelease,
|
||||
})
|
||||
if err != nil {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
|
||||
}
|
||||
created, status, err := b.client.postRelease(ctx, b.client.httpClient(), b.destination.Token, apiRoot+"/releases", body)
|
||||
if err == nil && created.ID <= 0 {
|
||||
err = failure(FailureMalformed, "verify created release", status, errors.New("release response does not contain a positive ID"))
|
||||
}
|
||||
evidence := writeEvidence(status, err)
|
||||
if err != nil {
|
||||
return forge.Release{}, evidence, neutralFailure(err)
|
||||
}
|
||||
return forge.Release{
|
||||
URL: created.URL,
|
||||
Tag: created.TagName,
|
||||
Title: created.Title,
|
||||
Body: created.Body,
|
||||
Draft: created.Draft,
|
||||
Prerelease: created.Prerelease,
|
||||
Handle: releaseHandle{id: created.ID},
|
||||
}, evidence, nil
|
||||
}
|
||||
|
||||
func (b *Backend) UploadAsset(ctx context.Context, handle any, filename string, content io.Reader) (forge.WriteEvidence, error) {
|
||||
release, ok := handle.(releaseHandle)
|
||||
if !ok || release.id <= 0 {
|
||||
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
|
||||
}
|
||||
if strings.TrimSpace(filename) == "" || content == nil {
|
||||
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset filename and content reader are required")}
|
||||
}
|
||||
_, apiRoot, err := b.destinationAPI("upload release asset")
|
||||
if err != nil {
|
||||
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
status, err := b.client.uploadAsset(ctx, b.client.httpClient(), b.destination.Token, apiRoot, release.id, filename, content)
|
||||
evidence := writeEvidence(status, err)
|
||||
if err != nil {
|
||||
return evidence, neutralFailure(err)
|
||||
}
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
func writeEvidence(status int, err error) forge.WriteEvidence {
|
||||
evidence := forge.WriteEvidence{Attempted: status != 0, Disposition: forge.WriteNotAttempted}
|
||||
var backendFailure *Failure
|
||||
if err != nil && errors.As(err, &backendFailure) && backendFailure.Kind == FailureTransport {
|
||||
evidence.Attempted = true
|
||||
}
|
||||
if !evidence.Attempted {
|
||||
return evidence
|
||||
}
|
||||
if err == nil {
|
||||
evidence.Disposition = forge.WriteAccepted
|
||||
return evidence
|
||||
}
|
||||
if status == http.StatusConflict {
|
||||
evidence.Disposition = forge.WriteConflict
|
||||
return evidence
|
||||
}
|
||||
if status >= http.StatusInternalServerError || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
evidence.Disposition = forge.WriteUncertain
|
||||
return evidence
|
||||
}
|
||||
if errors.As(err, &backendFailure) && (backendFailure.Kind == FailureTransport || backendFailure.Kind == FailureMalformed) {
|
||||
evidence.Disposition = forge.WriteUncertain
|
||||
return evidence
|
||||
}
|
||||
evidence.Disposition = forge.WriteRejected
|
||||
return evidence
|
||||
}
|
||||
|
||||
func (repository repositoryResponse) matches(destination Destination) bool {
|
||||
if repository.ID <= 0 || !strings.EqualFold(repository.Name, destination.Repository) {
|
||||
return false
|
||||
@@ -237,3 +324,5 @@ func neutralFailure(err error) error {
|
||||
}
|
||||
return &forge.Failure{Kind: kind, Err: err}
|
||||
}
|
||||
|
||||
var _ forge.Backend = (*Backend)(nil)
|
||||
|
||||
@@ -245,3 +245,171 @@ func TestBackendPreflightPreservesCancellation(t *testing.T) {
|
||||
t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseSendsRequestedFieldsAndAcceptsProtocolAcknowledgment(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases" {
|
||||
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
var request createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if request.TagName != "v1.2.3" || request.TargetCommitish != strings.Repeat("a", 40) || request.Name != "Release title" || request.Body != "# Release title\n\nComplete notes." || !request.Draft || !request.Prerelease {
|
||||
t.Errorf("release request = %#v", request)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "server tag", Name: "server title", Body: "server body", Draft: true, Prerelease: true})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
release, evidence, err := backend.CreateRelease(context.Background(), forge.CreateReleaseRequest{
|
||||
Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release title",
|
||||
Body: "# Release title\n\nComplete notes.", Draft: true, Prerelease: true,
|
||||
})
|
||||
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
if release.Handle == nil || release.Tag != "server tag" || release.Title != "server title" || release.Body != "server body" || !release.Draft || !release.Prerelease {
|
||||
t.Fatalf("CreateRelease() release = %#v", release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseMarksInvalidAcknowledgmentUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendCreateReleaseClassifiesConflictAndServerFailure(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
disposition forge.WriteDisposition
|
||||
}{
|
||||
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||
{status: http.StatusServiceUnavailable, disposition: forge.WriteUncertain},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
||||
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetBorrowsReaderAndValidatesAcknowledgment(t *testing.T) {
|
||||
var uploaded string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases/12/assets" || r.URL.Query().Get("name") != "app.bin" {
|
||||
t.Errorf("request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
file, header, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
t.Errorf("attachment: %v", err)
|
||||
} else {
|
||||
defer file.Close()
|
||||
content, readErr := io.ReadAll(file)
|
||||
if readErr != nil {
|
||||
t.Errorf("read attachment: %v", readErr)
|
||||
}
|
||||
if header.Filename != "app.bin" {
|
||||
t.Errorf("multipart filename = %q", header.Filename)
|
||||
}
|
||||
uploaded = string(content)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
reader := &trackedReader{Reader: strings.NewReader("streamed bytes")}
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", reader)
|
||||
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
if uploaded != "streamed bytes" || reader.closed {
|
||||
t.Fatalf("uploaded = %q, caller reader closed = %v", uploaded, reader.closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetMarksBadAcknowledgmentUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "different.bin"})
|
||||
}))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
var failure *forge.Failure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetClassifiesKnownRejectionAndConflict(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
disposition forge.WriteDisposition
|
||||
}{
|
||||
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
||||
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
||||
{status: http.StatusInternalServerError, disposition: forge.WriteUncertain},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
||||
defer server.Close()
|
||||
backend := NewBackend(testDestination(server.URL), server.Client())
|
||||
|
||||
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
evidence, err := NewBackend(testDestination(server.URL), server.Client()).UploadAsset(ctx, releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
||||
if evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validCreateReleaseRequest() forge.CreateReleaseRequest {
|
||||
return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"}
|
||||
}
|
||||
|
||||
type trackedReader struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (reader *trackedReader) Close() error {
|
||||
reader.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,7 +146,12 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item)
|
||||
content, openErr := assetContent(item)
|
||||
if openErr != nil {
|
||||
return result, failure(FailureInvalid, "open local release asset", 0, openErr)
|
||||
}
|
||||
status, uploadErr := c.uploadAsset(ctx, client, destination.Token, baseAPI, releaseID, item.Filename, content)
|
||||
_ = content.Close()
|
||||
if uploadErr == nil {
|
||||
result.Uploaded = append(result.Uploaded, identity(item))
|
||||
result.Pending = removeIdentity(result.Pending, item.Filename)
|
||||
@@ -198,36 +203,25 @@ func (c Client) uploadMissing(ctx context.Context, destination Destination, rele
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, asset artifact.ExpectedAsset) (int, error) {
|
||||
func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, apiRoot string, releaseID int64, filename string, content io.Reader) (int, error) {
|
||||
if content == nil {
|
||||
return 0, failure(FailureInvalid, "upload release asset", 0, errors.New("asset content reader is required"))
|
||||
}
|
||||
uploadURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||
parsed, err := url.Parse(uploadURL)
|
||||
if err != nil {
|
||||
return 0, failure(FailureInvalid, "prepare asset upload", 0, err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("name", asset.Filename)
|
||||
query.Set("name", filename)
|
||||
parsed.RawQuery = query.Encode()
|
||||
reader, writer := io.Pipe()
|
||||
multipartWriter := multipart.NewWriter(writer)
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
part, partErr := multipartWriter.CreateFormFile("attachment", asset.Filename)
|
||||
part, partErr := multipartWriter.CreateFormFile("attachment", filename)
|
||||
if partErr == nil {
|
||||
var source io.Reader
|
||||
if len(asset.Content) > 0 {
|
||||
source = bytes.NewReader(asset.Content)
|
||||
} else {
|
||||
file, openErr := os.Open(asset.Path)
|
||||
if openErr != nil {
|
||||
partErr = openErr
|
||||
} else {
|
||||
defer file.Close()
|
||||
source = file
|
||||
}
|
||||
}
|
||||
if partErr == nil {
|
||||
_, partErr = io.Copy(part, source)
|
||||
}
|
||||
_, partErr = io.Copy(part, content)
|
||||
}
|
||||
closeErr := multipartWriter.Close()
|
||||
if partErr == nil {
|
||||
@@ -261,7 +255,7 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
if err := decodeJSON(response.Body, &uploaded); err != nil {
|
||||
return response.StatusCode, failure(FailureMalformed, "decode uploaded asset", response.StatusCode, err)
|
||||
}
|
||||
if uploaded.ID <= 0 || uploaded.Name != asset.Filename {
|
||||
if uploaded.ID <= 0 || uploaded.Name != filename {
|
||||
return response.StatusCode, failure(FailureMalformed, "verify uploaded asset", response.StatusCode, errors.New("upload response does not identify the selected asset"))
|
||||
}
|
||||
if writeErr != nil {
|
||||
@@ -270,6 +264,13 @@ func (c Client) uploadAsset(ctx context.Context, client *http.Client, token, api
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) {
|
||||
if len(asset.Content) > 0 {
|
||||
return io.NopCloser(bytes.NewReader(asset.Content)), nil
|
||||
}
|
||||
return os.Open(asset.Path)
|
||||
}
|
||||
|
||||
func orderedExpected(observations []AssetInspection) []artifact.ExpectedAsset {
|
||||
result := make([]artifact.ExpectedAsset, 0, len(observations))
|
||||
for _, observation := range observations {
|
||||
|
||||
Reference in New Issue
Block a user