fix(gitea-client): use POST /api/v1/orgs not /admin/orgs for org create (TBD-A43, Closes #1906) (#1910)

Gitea 1.22+ no longer routes POST /api/v1/admin/orgs — that path is
GET-only (admin list) and returns 405 with `Allow: GET`. The supported
create endpoint is POST /api/v1/orgs (org-create-as-self): the
authenticated principal owns the new Org. Because the
organization-controller authenticates with the Gitea admin token
(catalyst-gitea-token, owner=gitea_admin), the admin user owns each
tenant Org — same semantic as the legacy admin path.

Symptom on t31: catalyst-organization-controller loops on
"gitea.EnsureOrg: create: gitea: POST .../api/v1/admin/orgs: HTTP 405",
blocking D29 Step 7 (tenant Gitea Org provisioning).

Real Gitea API proof (t31, Gitea 1.22.3):
  - BEFORE: POST /api/v1/admin/orgs → 405 Method Not Allowed (Allow: GET)
  - AFTER:  POST /api/v1/orgs       → 201 Created
  - 422 on duplicate username → unchanged (still mapped to errAlreadyExists)

Closes #1906
Refs TBD-A43

Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-19 07:59:08 +04:00 committed by GitHub
parent 8b5cab3aae
commit f442c28174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 32 additions and 16 deletions

View File

@ -174,8 +174,8 @@ func (g *giteaServer) handle(w http.ResponseWriter, r *http.Request) {
return return
} }
// POST /api/v1/admin/orgs // POST /api/v1/orgs
if r.Method == http.MethodPost && p == "/api/v1/admin/orgs" { if r.Method == http.MethodPost && p == "/api/v1/orgs" {
var body struct { var body struct {
Username string `json:"username"` Username string `json:"username"`
FullName string `json:"full_name"` FullName string `json:"full_name"`

View File

@ -27,7 +27,9 @@
// Endpoints (Gitea Admin REST API, version 1.22): // Endpoints (Gitea Admin REST API, version 1.22):
// //
// GET /api/v1/orgs/{org} // GET /api/v1/orgs/{org}
// POST /api/v1/admin/orgs // POST /api/v1/orgs (org-create-as-self;
// admin-owned token →
// admin owns the new org)
// GET /api/v1/repos/{owner}/{repo} // GET /api/v1/repos/{owner}/{repo}
// POST /api/v1/orgs/{org}/repos // POST /api/v1/orgs/{org}/repos
// GET /api/v1/repos/{owner}/{repo}/branches/{branch} // GET /api/v1/repos/{owner}/{repo}/branches/{branch}
@ -245,8 +247,12 @@ type Org struct {
Visibility string `json:"visibility,omitempty"` Visibility string `json:"visibility,omitempty"`
} }
// adminOrgCreate is the payload for POST /admin/orgs. // orgCreate is the payload for POST /orgs. The authenticated user
type adminOrgCreate struct { // (the bearer of the admin access-token) becomes the new Org's owner.
// In Gitea 1.22+, the legacy POST /admin/orgs/{user} endpoint is no
// longer routed (returns 405 with `Allow: GET`); /orgs is the only
// supported create path for both admin- and user-owned tokens.
type orgCreate struct {
Username string `json:"username"` Username string `json:"username"`
FullName string `json:"full_name,omitempty"` FullName string `json:"full_name,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
@ -288,21 +294,31 @@ func (c *Client) GetOrg(ctx context.Context, slug string) (Org, error) {
return out, nil return out, nil
} }
// CreateOrg creates a Gitea Org via the admin endpoint. Returns // CreateOrg creates a Gitea Org via POST /orgs (the org-create-as-self
// errAlreadyExists (internal sentinel) on 422/409 so EnsureOrg can // endpoint). The authenticated principal owns the new Org. Because the
// re-find idempotently. // controller authenticates with a Gitea admin token, the admin user
// owns each created tenant Org — same semantic as the legacy
// /admin/orgs path. Returns errAlreadyExists (internal sentinel) on
// 422/409 so EnsureOrg can re-find idempotently.
//
// NOTE: Gitea 1.22+ no longer routes POST /api/v1/admin/orgs (returns
// HTTP 405 `Allow: GET`); the admin-namespaced create path is
// /api/v1/admin/users/{user}/orgs but is order-of-magnitude clunkier
// (requires knowing the admin username). /orgs covers every realistic
// production deployment because the controller's token is always
// owned by a sufficiently-privileged user.
func (c *Client) CreateOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) { func (c *Client) CreateOrg(ctx context.Context, slug, fullName, description, visibility string) (Org, error) {
if visibility == "" { if visibility == "" {
visibility = "private" visibility = "private"
} }
body := adminOrgCreate{ body := orgCreate{
Username: slug, Username: slug,
FullName: fullName, FullName: fullName,
Description: description, Description: description,
Visibility: visibility, Visibility: visibility,
} }
var out Org var out Org
status, _, err := c.do(ctx, http.MethodPost, "/admin/orgs", body, &out) status, _, err := c.do(ctx, http.MethodPost, "/orgs", body, &out)
if err != nil { if err != nil {
if status == http.StatusUnprocessableEntity || status == http.StatusConflict { if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
return Org{}, errAlreadyExists return Org{}, errAlreadyExists

View File

@ -84,9 +84,9 @@ func (f *fakeGitea) handler() http.Handler {
return return
} }
// POST /api/v1/admin/orgs // POST /api/v1/orgs
if r.Method == http.MethodPost && p == "/api/v1/admin/orgs" { if r.Method == http.MethodPost && p == "/api/v1/orgs" {
var body adminOrgCreate var body orgCreate
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@ -472,7 +472,7 @@ func TestEnsureOrg_FindHits(t *testing.T) {
if got := fake.callCount(http.MethodGet, "/api/v1/orgs/acme"); got != 1 { if got := fake.callCount(http.MethodGet, "/api/v1/orgs/acme"); got != 1 {
t.Errorf("expected 1 GET, got %d", got) t.Errorf("expected 1 GET, got %d", got)
} }
if got := fake.callCount(http.MethodPost, "/api/v1/admin/orgs"); got != 0 { if got := fake.callCount(http.MethodPost, "/api/v1/orgs"); got != 0 {
t.Errorf("expected 0 POST when org pre-exists, got %d", got) t.Errorf("expected 0 POST when org pre-exists, got %d", got)
} }
} }
@ -489,7 +489,7 @@ func TestEnsureOrg_CreatesWhenMissing(t *testing.T) {
if o.Username != "newone" || o.ID == 0 { if o.Username != "newone" || o.ID == 0 {
t.Errorf("expected created org, got %+v", o) t.Errorf("expected created org, got %+v", o)
} }
if got := fake.callCount(http.MethodPost, "/api/v1/admin/orgs"); got != 1 { if got := fake.callCount(http.MethodPost, "/api/v1/orgs"); got != 1 {
t.Errorf("expected 1 POST, got %d", got) t.Errorf("expected 1 POST, got %d", got)
} }
} }
@ -506,7 +506,7 @@ func TestEnsureOrg_422Race(t *testing.T) {
return return
} }
_ = json.NewEncoder(w).Encode(Org{ID: 99, Username: "raced"}) _ = json.NewEncoder(w).Encode(Org{ID: 99, Username: "raced"})
case "POST /api/v1/admin/orgs": case "POST /api/v1/orgs":
http.Error(w, "duplicate", http.StatusUnprocessableEntity) http.Error(w, "duplicate", http.StatusUnprocessableEntity)
default: default:
http.Error(w, "unhandled", http.StatusInternalServerError) http.Error(w, "unhandled", http.StatusInternalServerError)