Skip to content
Draft
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
33 changes: 33 additions & 0 deletions internal/forge/shamhub/admin.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package shamhub

import (
"cmp"
"context"
json "encoding/json/v2"
"errors"
Expand Down Expand Up @@ -73,6 +74,10 @@ var (
"GET /_shamhub/admin/dump/changes/{number}",
(*ShamHub).handleAdminDumpChange,
)
_ = shamhubRESTHandler(
"GET /_shamhub/admin/dump/stacks/{owner}/{repo}",
(*ShamHub).handleAdminDumpStacks,
)
_ = shamhubHTTPHandler(
"GET /_shamhub/admin/dump/comments",
(*ShamHub).handleAdminDumpComments,
Expand Down Expand Up @@ -631,6 +636,34 @@ type adminDumpCommentsResponse struct {
Comments []*ChangeComment `json:"comments"`
}

type adminDumpStacksRequest struct {
Owner string `path:"owner" json:"-"`
Repo string `path:"repo" json:"-"`
}

type adminDumpStacksResponse struct {
Changes []stackChange `json:"changes"`
}

// Stack dumps return stored native relationships for script assertions.
func (sh *ShamHub) handleAdminDumpStacks(
_ context.Context,
req adminDumpStacksRequest,
) (*adminDumpStacksResponse, error) {
sh.mu.RLock()
defer sh.mu.RUnlock()

bases := sh.stackBases[repoID{Owner: req.Owner, Name: req.Repo}]
changes := make([]stackChange, 0, len(bases))
for number, base := range bases {
changes = append(changes, stackChange{Number: number, Base: base})
}
slices.SortFunc(changes, func(a, b stackChange) int {
return cmp.Compare(a.Number, b.Number)
})
return &adminDumpStacksResponse{Changes: changes}, nil
}

// Comment dumps keep repeated change query parameters for script ergonomics.
func (sh *ShamHub) handleAdminDumpComments(
w http.ResponseWriter,
Expand Down
19 changes: 19 additions & 0 deletions internal/forge/shamhub/cli_main.go
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,25 @@ func (c *shamhubCLI) dump(args []string) error {
}
return encodeJSON(c.stdout, res.Change)

case "stacks":
if len(args) != 2 {
return errors.New("usage: shamhub dump stacks <owner/repo>")
}
owner, repo, err := parseOwnerRepo(args[1])
if err != nil {
return err
}

var res adminDumpStacksResponse
if err := c.client.Get(
c.ctx,
"/_shamhub/admin/dump/stacks/"+owner+"/"+repo,
&res,
); err != nil {
return err
}
return encodeJSON(c.stdout, res.Changes)

default:
return fmt.Errorf("unknown dump command: %s", args[0])
}
Expand Down
49 changes: 47 additions & 2 deletions internal/forge/shamhub/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,48 @@ import (
"io"
"net/http"
"net/url"
"strings"

"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/git/giturl"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/silog"
)

// stacksMode controls whether ShamHub exposes native stack capabilities.
// Its zero value disables them.
type stacksMode uint8

const (
stacksOff stacksMode = iota
stacksOn
)

// UnmarshalText accepts the case-insensitive configuration values `on`, `off`,
// `1`, and `0`.
func (m *stacksMode) UnmarshalText(text []byte) error {
switch strings.ToLower(string(text)) {
case "off", "0":
*m = stacksOff
case "on", "1":
*m = stacksOn
default:
return fmt.Errorf("invalid value %q: expected on or off", text)
}
return nil
}

func (m stacksMode) String() string {
switch m {
case stacksOff:
return "off"
case stacksOn:
return "on"
default:
return fmt.Sprintf("stacksMode(%d)", m)
}
}

// Options defines CLI options for the ShamHub forge.
type Options struct {
// URL is the base URL for Git repositories
Expand All @@ -24,6 +59,12 @@ type Options struct {

// APIURL is the base URL for the ShamHub API.
APIURL string `name:"shamhub-api-url" hidden:"" env:"SHAMHUB_API_URL" help:"Base URL for ShamHub API requests"`

// Stacks controls whether ShamHub exposes native stack operations.
// The default is `off`.
// Opened repositories expose optional native stack capabilities only when
// the value is `on`.
Stacks stacksMode `name:"shamhub-stacks" hidden:"" config:"forge.shamhub.stacks" default:"off" help:"Whether to expose ShamHub native stack operations. One of 'on' and 'off'."`
}

// Definition configures ShamHub forge instances.
Expand Down Expand Up @@ -135,14 +176,18 @@ func newRepository(f *Forge, token *AuthenticationToken, rid *RepositoryID, http
return nil, fmt.Errorf("parse API URL: %w", err)
}

return &forgeRepository{
repo := &forgeRepository{
forge: f,
owner: rid.owner,
repo: rid.repo,
apiURL: apiURL,
log: f.Log,
client: client,
}, nil
}
if f.Stacks == stacksOn {
return &stackRepository{forgeRepository: repo}, nil
}
return repo, nil
}

// RepositoryID is a unique identifier for a ShamHub repository.
Expand Down
61 changes: 61 additions & 0 deletions internal/forge/shamhub/forge_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,74 @@
package shamhub

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/git/giturl"
)

func TestNewRepository_stacksCapability(t *testing.T) {
tests := []struct {
name string
mode stacksMode
want bool
}{
{name: "Default"},
{name: "Off", mode: stacksOff},
{name: "On", mode: stacksOn, want: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo, err := newRepository(
&Forge{
URL: "https://shamhub.example",
APIURL: "https://api.shamhub.example",
Stacks: tt.mode,
},
&AuthenticationToken{tok: "test"},
&RepositoryID{
url: "https://shamhub.example/acme/repo.git",
owner: "acme",
repo: "repo",
},
http.DefaultClient,
)
require.NoError(t, err)

_, got := repo.(forge.StackRepository)
assert.Equal(t, tt.want, got)
})
}
}

func TestStacksMode_UnmarshalText(t *testing.T) {
tests := []struct {
name string
give string
want stacksMode
}{
{name: "Off", give: "off", want: stacksOff},
{name: "Zero", give: "0", want: stacksOff},
{name: "On", give: "on", want: stacksOn},
{name: "One", give: "1", want: stacksOn},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got stacksMode
require.NoError(t, got.UnmarshalText([]byte(tt.give)))
assert.Equal(t, tt.want, got)
})
}

var got stacksMode
assert.Error(t, got.UnmarshalText([]byte("invalid")))
}

func TestForge_ParseRepositoryPath_knownForge(t *testing.T) {
f := &Forge{URL: "https://shamhub.example"}
remoteURL, err := giturl.Parse("git@shamhub-alias:example/repo.git")
Expand Down
9 changes: 6 additions & 3 deletions internal/forge/shamhub/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func TestIntegration(t *testing.T) {
shamForge := &Forge{
URL: gitURL,
APIURL: apiURL,
Stacks: stacksOn,
Log: silogtest.New(t),
}

Expand All @@ -157,7 +158,7 @@ func TestIntegration(t *testing.T) {
},
MergeChange: func(t *testing.T, repo forge.Repository, changeID forge.ChangeID) {
if forgetest.Update() {
r := repo.(*forgeRepository)
r := repo.(*stackRepository).forgeRepository
require.NoError(t, shamhub.MergeChange(MergeChangeRequest{
Owner: r.owner,
Repo: r.repo,
Expand All @@ -167,7 +168,7 @@ func TestIntegration(t *testing.T) {
},
CloseChange: func(t *testing.T, repo forge.Repository, changeID forge.ChangeID) {
if forgetest.Update() {
r := repo.(*forgeRepository)
r := repo.(*stackRepository).forgeRepository
require.NoError(t, shamhub.RejectChange(RejectChangeRequest{
Owner: r.owner,
Repo: r.repo,
Expand All @@ -184,7 +185,7 @@ func TestIntegration(t *testing.T) {
check forge.ChangeCheck,
) {
require.NoError(t,
repo.(*forgeRepository).setChangeCheck(
repo.(*stackRepository).setChangeCheck(
t.Context(),
changeID,
check,
Expand All @@ -196,5 +197,7 @@ func TestIntegration(t *testing.T) {
ReviewThreadCommitHash: true,
Reviewers: []string{"reviewer1", "reviewer2"},
Assignees: []string{"assignee1", "assignee2"},
TestStacks: true,
TestMergeRange: true,
})
}
Loading
Loading