fix(create_project): handle gitea generate-async branch race (#42)
Live e2e surfaced a race unit tests couldn't (mocks are instant): gitea's /generate returns and serves reads before the branch ref is writable, so the first content writes 404 "branch does not exist" for a beat — aborting the whole substitution pass. Add waitForBranch (poll BranchExists after generate) + upsertRetry (retry writes on the transient not-found). Test fake now serves the branch readiness probe. Refs #42. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
@@ -150,6 +151,12 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
}
|
}
|
||||||
result.DefaultBranch = branch
|
result.DefaultBranch = branch
|
||||||
|
|
||||||
|
// Wait for the generated branch to become writable. gitea's /generate is
|
||||||
|
// async: it returns (and serves reads) before the branch ref is committed, so
|
||||||
|
// the first writes 404 "branch does not exist" for a beat. Poll until the
|
||||||
|
// branch is present.
|
||||||
|
t.waitForBranch(ctx, args.Owner, args.Name, branch)
|
||||||
|
|
||||||
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
// Substitute across the WHOLE tree: content in every blob, plus a path rename
|
||||||
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
// for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go).
|
||||||
// A fixed known-files list can't rename directories or cover every templated
|
// A fixed known-files list can't rename directories or cover every templated
|
||||||
@@ -185,6 +192,42 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
return textOK(result)
|
return textOK(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForBranch polls until the freshly-generated branch is present, or a short
|
||||||
|
// budget elapses. Best-effort — upsertRetry covers the remaining read-vs-write
|
||||||
|
// consistency window where the branch reads but does not yet write.
|
||||||
|
func (t *CreateProjectFromTemplate) waitForBranch(ctx context.Context, owner, name, branch string) {
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if ok, err := t.c.BranchExists(ctx, owner, name, branch); ok && err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertRetry retries UpsertFile through the brief post-generate window where the
|
||||||
|
// branch is readable but writes still 404 "branch does not exist".
|
||||||
|
func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gitea.ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return err
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// substituteEntry substitutes placeholders in one blob. If the path carries a
|
// substituteEntry substitutes placeholders in one blob. If the path carries a
|
||||||
// placeholder it renames the file (write new + delete old); otherwise it rewrites
|
// placeholder it renames the file (write new + delete old); otherwise it rewrites
|
||||||
// content in place when changed. Returns a human-readable description of what was
|
// content in place when changed. Returns a human-readable description of what was
|
||||||
@@ -212,7 +255,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
|
|||||||
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
enc := base64.StdEncoding.EncodeToString([]byte(newContent))
|
||||||
|
|
||||||
if renamed {
|
if renamed {
|
||||||
if _, err := t.c.UpsertFile(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
|
||||||
Branch: branch,
|
Branch: branch,
|
||||||
Content: enc,
|
Content: enc,
|
||||||
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
|
Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath),
|
||||||
@@ -229,7 +272,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner,
|
|||||||
return path + " -> " + newPath, ""
|
return path + " -> " + newPath, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := t.c.UpsertFile(ctx, owner, name, path, gitea.UpsertFileArgs{
|
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
|
||||||
Branch: branch,
|
Branch: branch,
|
||||||
Content: enc,
|
Content: enc,
|
||||||
Message: "template: substitute placeholders",
|
Message: "template: substitute placeholders",
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl
|
|||||||
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
_, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
|
||||||
dest, dest, f.genBranch, dest, dest)
|
dest, dest, f.genBranch, dest, dest)
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/branches/"):
|
||||||
|
// waitForBranch readiness probe — branch is present.
|
||||||
|
_, _ = fmt.Fprint(w, `{"name":"main","commit":{"id":"c"}}`)
|
||||||
|
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"):
|
||||||
var entries []string
|
var entries []string
|
||||||
for path := range f.files {
|
for path := range f.files {
|
||||||
|
|||||||
Reference in New Issue
Block a user