Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260729-114501.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: BUG FIXES
body: "Delete permissions now resolve correctly for resources with non-standard API paths (e.g. `explorer-saved-queries`, `run-tasks`)"
time: 2026-07-29T11:45:01.000000-06:00
5 changes: 4 additions & 1 deletion internal/commands/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,10 @@ func RunAPI(ctx context.Context, opts *Opts) error {
// session can authorize a noninteractive delete; otherwise a human must
// confirm at an interactive terminal.
if method == http.MethodDelete {
class := execsession.ClassFromPath(opts.URL.Path)
class := opts.ResourceType
if class == "" {
class = execsession.ClassFromPath(opts.URL.Path)
}

decision := execsession.Decision{}
if opts.Authorizer != nil {
Expand Down
5 changes: 4 additions & 1 deletion internal/pkg/execsession/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ func ClassFromPath(p string) string {
}

// The class is the collection segment immediately preceding the final id.
return segments[len(segments)-2]
// Map it to the actual resource type (handles non-standard paths where
// the URL segment differs from the type name, e.g. "views" → "explorer-saved-queries").
segment := segments[len(segments)-2]
return resource.TypeFromPathSegment(segment)
}

func isAllDigits(s string) bool {
Expand Down
4 changes: 4 additions & 0 deletions internal/pkg/execsession/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ func TestClassFromPath(t *testing.T) {
{name: "short path collection only", path: "/workspaces", want: ""},
{name: "empty", path: "", want: ""},
{name: "root", path: "/", want: ""},
{name: "non-standard explorer-saved-queries", path: "/organizations/my-org/explorer/views/sq-abc", want: "explorer-saved-queries"},
{name: "non-standard explorer-saved-queries with api prefix", path: "/api/v2/organizations/my-org/explorer/views/sq-abc", want: "explorer-saved-queries"},
{name: "non-standard run-tasks", path: "/tasks/task-abc", want: "run-tasks"},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one more non-standard one in the registry: /github-app/installation/{id}

Can you add a test for that?

{name: "non-standard github-app-installations", path: "/github-app/installation/ghain-abc", want: "github-app-installations"},
}

for _, tc := range cases {
Expand Down
34 changes: 34 additions & 0 deletions internal/pkg/resource/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,3 +560,37 @@ func IsResolvableType(typeName string) bool {
}
return r.Resolvable
}

// TypeFromPathSegment maps a URL path segment to its resource type. For most
// resources the segment matches the type (e.g. "workspaces" → "workspaces"),
// but some have non-standard URL structures where the path segment differs
// from the type name (e.g. "views" → "explorer-saved-queries", "tasks" → "run-tasks").
// Returns the segment unchanged if no mapping is found.
func TypeFromPathSegment(segment string) string {
// Check all registered resources for a PathGet that uses this segment
// in a non-standard way (segment != type).
for _, r := range registry {
if r.PathGet == "" {
continue
}
// Extract the collection segment immediately before {id} in PathGet
parts := strings.Split(strings.Trim(r.PathGet, "/"), "/")
if len(parts) < 2 {
continue
}
// Find the last non-placeholder segment before the trailing {id}
var collectionSegment string
for i := len(parts) - 1; i >= 0; i-- {
if strings.HasPrefix(parts[i], "{") {
continue
}
collectionSegment = parts[i]
break
}
if collectionSegment == segment && collectionSegment != r.Type {
return r.Type
}
}
// No non-standard mapping found, return the segment as-is
return segment
}
32 changes: 32 additions & 0 deletions internal/pkg/resource/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,35 @@ func TestRegistryInvariants(t *testing.T) {
}
})
}

func TestTypeFromPathSegment(t *testing.T) {
t.Parallel()

cases := []struct {
segment string
want string
}{
// Standard cases where segment == type
{segment: "workspaces", want: "workspaces"},
{segment: "projects", want: "projects"},
{segment: "runs", want: "runs"},
{segment: "vars", want: "vars"},
{segment: "organizations", want: "organizations"},

// Non-standard cases where the PathGet uses a different segment
{segment: "views", want: "explorer-saved-queries"},
{segment: "tasks", want: "run-tasks"},
{segment: "installation", want: "github-app-installations"},

// Unknown segment returns as-is
{segment: "unknown-segment", want: "unknown-segment"},
{segment: "", want: ""},
}

for _, tc := range cases {
t.Run(tc.segment, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, TypeFromPathSegment(tc.segment))
})
}
}