Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions internal/provider/organization_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,75 @@ func (r *organizationResource) Update(ctx context.Context, req resource.UpdateRe
return
}

// Forgejo's PATCH /orgs/{org} endpoint can silently ignore visibility
// changes (see issue #141 — "Provider produced inconsistent result after
// apply" when changing visibility). Orgs share the underlying user schema
// in Forgejo, so the /admin/users/{user} endpoint handles visibility
// writes reliably. Mirror the user_resource pattern and re-apply
// visibility through that path when EditOrg's read-back disagrees with
// the planned value. Non-admin users for whom EditOrg works correctly
// are unaffected.
if !data.Visibility.IsNull() && !data.Visibility.IsUnknown() &&
org.Visibility != data.Visibility.ValueString() {
tflog.Warn(ctx, "EditOrg silently ignored visibility change; retrying via AdminEditUser", map[string]any{
"name": data.Name.ValueString(),
"planned": data.Visibility.ValueString(),
"observed": org.Visibility,
})

vt := forgejo.VisibleType(data.Visibility.ValueString())
userRes, userErr := r.client.AdminEditUser(
data.Name.ValueString(),
forgejo.EditUserOption{Visibility: &vt},
)
if userErr != nil {
var msg string
if userRes == nil {
msg = fmt.Sprintf("Unknown error with nil response: %s", userErr)
} else {
tflog.Error(ctx, "Error", map[string]any{
"status": userRes.Status,
})

switch userRes.StatusCode {
case 403:
msg = fmt.Sprintf(
"Updating organization visibility fell back to /admin/users/%s because PATCH /orgs/%s did not apply the change, but the API token lacks the write:admin scope required for that endpoint: %s",
data.Name.ValueString(),
data.Name.ValueString(),
userErr,
)
case 404:
msg = fmt.Sprintf(
"Organization with name %s not found: %s",
data.Name.String(),
userErr,
)
default:
msg = fmt.Sprintf(
"Unknown error (status %d): %s",
userRes.StatusCode,
userErr,
)
}
}
resp.Diagnostics.AddError("Unable to update organization visibility", msg)

return
}

// Re-read after the fallback so the state reflects the final value.
org, diags = getOrganizationByName(
ctx,
r.client,
data.Name.ValueString(),
)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}

// Map response body to model
data.from(org)

Expand Down
51 changes: 51 additions & 0 deletions internal/provider/organization_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,57 @@ resource "forgejo_organization" "test" {
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("repo_admin_change_team_access"), knownvalue.Bool(true)),
},
},
// Update and Read testing — private -> public regression
// for issue #141 ("Provider produced inconsistent result after
// apply" when changing visibility). EditOrg can silently ignore
// the visibility change; the AdminEditUser fallback in Update()
// ensures the state matches the plan.
{
Config: providerConfig + `
resource "forgejo_organization" "test" {
name = "tftest1"
description = "Purely for testing... 456"
location = "Mêlée Island"
visibility = "public"
website = "` + forgejoTestHost + `"
}`,
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("forgejo_organization.test", plancheck.ResourceActionUpdate),
},
},
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("avatar_url"), knownvalue.StringRegexp(regexp.MustCompile("^"+forgejoTestHost+"/avatars/[0-9a-z]{32}$"))),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("description"), knownvalue.StringExact("Purely for testing... 456")),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("full_name"), knownvalue.StringExact("")),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("id"), knownvalue.NotNull()),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("location"), knownvalue.StringExact("Mêlée Island")),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("name"), knownvalue.StringExact("tftest1")),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("visibility"), knownvalue.StringExact("public")),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("website"), knownvalue.StringExact(forgejoTestHost)),
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("repo_admin_change_team_access"), knownvalue.Bool(true)),
},
},
// Update and Read testing — back to private so the state-for-
// unknown check in the next step still proves that behavior.
{
Config: providerConfig + `
resource "forgejo_organization" "test" {
name = "tftest1"
description = "Purely for testing... 456"
location = "Mêlée Island"
visibility = "private"
website = "` + forgejoTestHost + `"
}`,
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction("forgejo_organization.test", plancheck.ResourceActionUpdate),
},
},
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue("forgejo_organization.test", tfjsonpath.New("visibility"), knownvalue.StringExact("private")),
},
},
// Update and Read testing
{
Config: providerConfig + `
Expand Down