~emersion/yojo

298157b69bff843d71e39e2ca3e6c2d071cc8b46 — Simon Ser 9 months ago cd4219b
Add support for user hooks
4 files changed, 147 insertions(+), 32 deletions(-)

M go.mod
M go.sum
M main.go
M templates/dashboard.html
M go.mod => go.mod +2 -2
@@ 3,7 3,7 @@ module git.sr.ht/~emersion/yojo
go 1.20

require (
	code.gitea.io/sdk/gitea v0.17.0
	code.gitea.io/sdk/gitea v0.17.2-0.20240125160400-53f735b9110e
	git.sr.ht/~emersion/go-oauth2 v0.0.0-20231107122354-9bc3ac409ebf
	git.sr.ht/~emersion/go-scfg v0.0.0-20231211181832-0b4e72d8ec3c
	git.sr.ht/~emersion/gqlclient v0.0.0-20230820050442-8873fe0204b9


@@ 20,6 20,6 @@ require (
	github.com/hashicorp/go-version v1.6.0 // indirect
	github.com/stretchr/testify v1.8.1 // indirect
	github.com/vektah/gqlparser/v2 v2.5.10 // indirect
	golang.org/x/crypto v0.16.0 // indirect
	golang.org/x/crypto v0.17.0 // indirect
	golang.org/x/sys v0.15.0 // indirect
)

M go.sum => go.sum +31 -9
@@ 1,5 1,5 @@
code.gitea.io/sdk/gitea v0.17.0 h1:8JPBss4+Jf7AE1YcfyiGrngTXE8dFSG3si/bypsTH34=
code.gitea.io/sdk/gitea v0.17.0/go.mod h1:ndkDk99BnfiUCCYEUhpNzi0lpmApXlwRFqClBlOlEBg=
code.gitea.io/sdk/gitea v0.17.2-0.20240125160400-53f735b9110e h1:QY6Ce4kLEUy478ufVu5OhLldJzzrKkxq3j7W7vyeblo=
code.gitea.io/sdk/gitea v0.17.2-0.20240125160400-53f735b9110e/go.mod h1:aCnBqhHpoEWA180gMbaCtdX9Pl6BWBAuuP2miadoTNM=
git.sr.ht/~emersion/go-oauth2 v0.0.0-20231107122354-9bc3ac409ebf h1:JCWWRdr1xVC9kw5oKcjACyxcWq19UF0U6kdX0GcWfJY=
git.sr.ht/~emersion/go-oauth2 v0.0.0-20231107122354-9bc3ac409ebf/go.mod h1:VHj0jSCLIkrfEwmOvJ4+ykpoVbD/YLN7BM523oKKBHc=
git.sr.ht/~emersion/go-scfg v0.0.0-20231211181832-0b4e72d8ec3c h1:Cjy9/qASF8hogbKbWXgEQZxbYHrM9ksl76sGzsP8Zqo=


@@ 24,7 24,6 @@ github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=


@@ 42,28 41,51 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/vektah/gqlparser/v2 v2.5.10 h1:6zSM4azXC9u4Nxy5YmdmGu4uKamfwsdKTwp5zsEealU=
github.com/vektah/gqlparser/v2 v2.5.10/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

M main.go => main.go +106 -21
@@ 44,7 44,7 @@ const (

var (
	srhtAuthScope  = []string{"builds.sr.ht/PROFILE:RO", "builds.sr.ht/JOBS:RW"}
	giteaAuthScope = []string{"repo:status", "admin:repo_hook", "admin:org_hook"}
	giteaAuthScope = []string{"repo:status", "admin:repo_hook", "admin:user_hook", "admin:org_hook"}
)

var (


@@ 219,6 219,12 @@ func main() {
			return
		}

		userHooks, err := listUserHooks(giteaClient, getOrigin(r))
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		var (
			repos   []*gitea.Repository
			page    int = 1


@@ 257,6 263,7 @@ func main() {
			GiteaUsername string
			GiteaHostname string
			GiteaName     string
			UserEnabled   bool
			Repositories  []*gitea.Repository
		}{
			SrhtUsername:  sessionData.SrhtToken.Username,


@@ 264,6 271,7 @@ func main() {
			GiteaUsername: sessionData.GiteaToken.Username,
			GiteaHostname: giteaHostname,
			GiteaName:     config.Gitea.Name,
			UserEnabled:   len(userHooks) > 0,
			Repositories:  repos,
		}
		if err := tpl.ExecuteTemplate(w, "dashboard.html", &data); err != nil {


@@ 280,6 288,23 @@ func main() {
		}
	})

	r.Post("/", func(w http.ResponseWriter, r *http.Request) {
		r.ParseForm()

		var err error
		if r.Form.Has("disable_user") {
			err = disableUser(r.Context(), getOrigin(r))
		} else {
			err = enableUser(r.Context(), getOrigin(r))
		}
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
	})

	r.Get("/logout", func(w http.ResponseWriter, r *http.Request) {
		sessionFromContext(r.Context()).Delete()
		http.Redirect(w, r, "/", http.StatusTemporaryRedirect)


@@ 389,7 414,7 @@ func main() {
			return
		}

		hooks, err := listHooks(giteaClient, repo.Owner.UserName, repo.Name, getOrigin(r))
		hooks, err := listRepoHooks(giteaClient, repo.Owner.UserName, repo.Name, getOrigin(r))
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return


@@ 586,12 611,7 @@ func getHookEndpoint(origin string) string {
	return origin + "/webhook"
}

func listHooks(giteaClient *gitea.Client, owner, repo, origin string) ([]*gitea.Hook, error) {
	hooks, _, err := giteaClient.ListRepoHooks(owner, repo, gitea.ListHooksOptions{})
	if err != nil {
		return nil, fmt.Errorf("failed to fetch Gitea repo webhooks: %v", err)
	}

func filterHooks(hooks []*gitea.Hook, origin string) []*gitea.Hook {
	hookURLPrefix := getHookEndpoint(origin) + "?"
	var ourHooks []*gitea.Hook
	for _, hook := range hooks {


@@ 600,26 620,19 @@ func listHooks(giteaClient *gitea.Client, owner, repo, origin string) ([]*gitea.
			ourHooks = append(ourHooks, hook)
		}
	}

	return ourHooks, nil
	return ourHooks
}

func enableRepo(ctx context.Context, owner, repo, origin string) error {
	sessionData := sessionFromContext(ctx).Load()
	giteaClient, err := newGiteaClient(ctx, sessionData.GiteaToken)
	if err != nil {
		return fmt.Errorf("failed to create Gitea client: %v", err)
	}

func prepareHook(ctx context.Context, sessionData *sessionData, origin string) (*gitea.CreateHookOption, error) {
	installation := &Installation{
		SrhtToken:  *sessionData.SrhtToken,
		GiteaToken: *sessionData.GiteaToken,
	}
	if err := db.StoreInstallation(ctx, installation); err != nil {
		return fmt.Errorf("failed to store installation into DB: %v", err)
		return nil, fmt.Errorf("failed to store installation into DB: %v", err)
	}

	_, _, err = giteaClient.CreateRepoHook(owner, repo, gitea.CreateHookOption{
	return &gitea.CreateHookOption{
		Type: gitea.HookTypeGitea,
		Config: map[string]string{
			"url":          getHookEndpoint(origin) + "?installation_token=" + installation.WebhookToken,


@@ 629,7 642,30 @@ func enableRepo(ctx context.Context, owner, repo, origin string) error {
		Events:       []string{"push", "pull_request_only", "pull_request_sync"},
		BranchFilter: "*",
		Active:       true,
	})
	}, nil
}

func listRepoHooks(giteaClient *gitea.Client, owner, repo, origin string) ([]*gitea.Hook, error) {
	hooks, _, err := giteaClient.ListRepoHooks(owner, repo, gitea.ListHooksOptions{})
	if err != nil {
		return nil, fmt.Errorf("failed to fetch Gitea repo webhooks: %v", err)
	}
	return filterHooks(hooks, origin), nil
}

func enableRepo(ctx context.Context, owner, repo, origin string) error {
	sessionData := sessionFromContext(ctx).Load()
	giteaClient, err := newGiteaClient(ctx, sessionData.GiteaToken)
	if err != nil {
		return fmt.Errorf("failed to create Gitea client: %v", err)
	}

	hookOptions, err := prepareHook(ctx, sessionData, origin)
	if err != nil {
		return err
	}

	_, _, err = giteaClient.CreateRepoHook(owner, repo, *hookOptions)
	if err != nil {
		return fmt.Errorf("failed to create repo hook: %v", err)
	}


@@ 644,7 680,7 @@ func disableRepo(ctx context.Context, owner, repo, origin string) error {
		return fmt.Errorf("failed to create Gitea client: %v", err)
	}

	hooks, err := listHooks(giteaClient, owner, repo, origin)
	hooks, err := listRepoHooks(giteaClient, owner, repo, origin)
	if err != nil {
		return err
	}


@@ 659,6 695,55 @@ func disableRepo(ctx context.Context, owner, repo, origin string) error {
	return nil
}

func listUserHooks(giteaClient *gitea.Client, origin string) ([]*gitea.Hook, error) {
	hooks, _, err := giteaClient.ListMyHooks(gitea.ListHooksOptions{})
	if err != nil {
		return nil, fmt.Errorf("failed to fetch Gitea user webhooks: %v", err)
	}
	return filterHooks(hooks, origin), nil
}

func enableUser(ctx context.Context, origin string) error {
	sessionData := sessionFromContext(ctx).Load()
	giteaClient, err := newGiteaClient(ctx, sessionData.GiteaToken)
	if err != nil {
		return fmt.Errorf("failed to create Gitea client: %v", err)
	}

	hookOptions, err := prepareHook(ctx, sessionData, origin)
	if err != nil {
		return err
	}

	_, _, err = giteaClient.CreateMyHook(*hookOptions)
	if err != nil {
		return fmt.Errorf("failed to create user hook: %v", err)
	}

	return nil
}

func disableUser(ctx context.Context, origin string) error {
	sessionData := sessionFromContext(ctx).Load()
	giteaClient, err := newGiteaClient(ctx, sessionData.GiteaToken)
	if err != nil {
		return fmt.Errorf("failed to create Gitea client: %v", err)
	}

	hooks, err := listUserHooks(giteaClient, origin)
	if err != nil {
		return err
	}

	for _, hook := range hooks {
		_, err := giteaClient.DeleteMyHook(hook.ID)
		if err != nil {
			return fmt.Errorf("failed to delete Gitea user webhook: %v", err)
		}
	}

	return nil
}
func sanityCheckEndpoint(ctx context.Context, endpoint string) error {
	u, err := url.Parse(endpoint)
	if err != nil {

M templates/dashboard.html => templates/dashboard.html +8 -0
@@ 11,6 11,14 @@
<strong>{{.SrhtUsername}}</strong> and to {{.GiteaName}} as
<strong>{{.GiteaUsername}}</strong>.</p>

<form method="post" action="">
{{if .UserEnabled}}
	<button type="submit" name="disable_user" class="install-btn">Disable for all my repositories</button>
{{else}}
	<button type="submit" name="enable_user" class="install-btn">Enable for all my repositories</button>
{{end}}
</form>

<p>Please select a repository to configure:</p>

<ul class="codeberg-repos">