diff --git a/.gitignore b/.gitignore index 768d589ce59..03713a01ca9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ __debug_bin* # Output of the go coverage tool, specifically when used with LiteIDE *.out +# Generated GraphQL client code +server/forge/sourcehut/*/gql.go + ### Frontend ### web/dist/** !web/dist/.gitkeep diff --git a/Makefile b/Makefile index d8910306c36..0c825c6dde6 100644 --- a/Makefile +++ b/Makefile @@ -118,7 +118,7 @@ clean-all: clean ## Clean all artifacts rm -rf docs/docs/40-cli.md docs/openapi.json .PHONY: generate -generate: install-mockery generate-openapi ## Run all code generations +generate: install-mockery generate-openapi generate-sourcehut ## Run all code generations mockery CGO_ENABLED=0 go generate ./... @@ -126,6 +126,9 @@ generate-openapi: ## Run openapi code generation and format it CGO_ENABLED=0 go run github.com/swaggo/swag/cmd/swag fmt --exclude pipeline/rpc/proto CGO_ENABLED=0 go generate cmd/server/openapi.go +generate-sourcehut: ## Run sourcehut GraphQL client generation + CGO_ENABLED=0 go generate ./server/forge/sourcehut/... + generate-license-header: install-addlicense addlicense -c "Woodpecker Authors" -ignore "vendor/**" **/*.go diff --git a/cmd/server/flags.go b/cmd/server/flags.go index ac5a3fd666f..33e566639d8 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -390,7 +390,7 @@ var flags = append([]cli.Flag{ &cli.StringFlag{ Name: "forge-url", Usage: "url of the forge", - Sources: cli.EnvVars("WOODPECKER_FORGE_URL", "WOODPECKER_GITHUB_URL", "WOODPECKER_GITLAB_URL", "WOODPECKER_GITEA_URL", "WOODPECKER_FORGEJO_URL", "WOODPECKER_BITBUCKET_URL", "WOODPECKER_BITBUCKET_DC_URL"), + Sources: cli.EnvVars("WOODPECKER_FORGE_URL", "WOODPECKER_GITHUB_URL", "WOODPECKER_GITLAB_URL", "WOODPECKER_GITEA_URL", "WOODPECKER_FORGEJO_URL", "WOODPECKER_BITBUCKET_URL", "WOODPECKER_BITBUCKET_DC_URL", "WOODPECKER_SOURCEHUT_URL"), }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( @@ -401,14 +401,16 @@ var flags = append([]cli.Flag{ "WOODPECKER_GITEA_CLIENT_FILE", "WOODPECKER_FORGEJO_CLIENT_FILE", "WOODPECKER_BITBUCKET_CLIENT_FILE", - "WOODPECKER_BITBUCKET_DC_CLIENT_ID_FILE")), + "WOODPECKER_BITBUCKET_DC_CLIENT_ID_FILE", + "WOODPECKER_SOURCEHUT_CLIENT_FILE")), cli.EnvVar("WOODPECKER_FORGE_CLIENT"), cli.EnvVar("WOODPECKER_GITHUB_CLIENT"), cli.EnvVar("WOODPECKER_GITLAB_CLIENT"), cli.EnvVar("WOODPECKER_GITEA_CLIENT"), cli.EnvVar("WOODPECKER_FORGEJO_CLIENT"), cli.EnvVar("WOODPECKER_BITBUCKET_CLIENT"), - cli.EnvVar("WOODPECKER_BITBUCKET_DC_CLIENT_ID")), + cli.EnvVar("WOODPECKER_BITBUCKET_DC_CLIENT_ID"), + cli.EnvVar("WOODPECKER_SOURCEHUT_CLIENT")), Name: "forge-oauth-client", Usage: "oauth2 client id", Config: cli.StringConfig{ @@ -425,6 +427,7 @@ var flags = append([]cli.Flag{ "WOODPECKER_FORGEJO_SECRET_FILE", "WOODPECKER_BITBUCKET_SECRET_FILE", "WOODPECKER_BITBUCKET_DC_CLIENT_SECRET_FILE", + "WOODPECKER_SOURCEHUT_SECRET_FILE", )), cli.EnvVar("WOODPECKER_FORGE_SECRET"), cli.EnvVar("WOODPECKER_GITHUB_SECRET"), @@ -432,7 +435,8 @@ var flags = append([]cli.Flag{ cli.EnvVar("WOODPECKER_GITEA_SECRET"), cli.EnvVar("WOODPECKER_FORGEJO_SECRET"), cli.EnvVar("WOODPECKER_BITBUCKET_SECRET"), - cli.EnvVar("WOODPECKER_BITBUCKET_DC_CLIENT_SECRET")), + cli.EnvVar("WOODPECKER_BITBUCKET_DC_CLIENT_SECRET"), + cli.EnvVar("WOODPECKER_SOURCEHUT_SECRET")), Name: "forge-oauth-secret", Usage: "oauth2 client secret", Config: cli.StringConfig{ @@ -448,7 +452,8 @@ var flags = append([]cli.Flag{ "WOODPECKER_GITLAB_SKIP_VERIFY", "WOODPECKER_GITEA_SKIP_VERIFY", "WOODPECKER_FORGEJO_SKIP_VERIFY", - "WOODPECKER_BITBUCKET_SKIP_VERIFY"), + "WOODPECKER_BITBUCKET_SKIP_VERIFY", + "WOODPECKER_SOURCEHUT_SKIP_VERIFY"), }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_EXPERT_FORGE_OAUTH_HOST"), @@ -549,6 +554,29 @@ var flags = append([]cli.Flag{ Usage: "Bitbucket DataCenter/Server oauth2 scope should be configured to include PROJECT_ADMIN configuration.", }, // + // SourceHut + // + &cli.BoolFlag{ + Sources: cli.EnvVars("WOODPECKER_SOURCEHUT"), + Name: "sourcehut", + Usage: "sourcehut driver is enabled", + }, + &cli.StringFlag{ + Sources: cli.EnvVars("WOODPECKER_SOURCEHUT_META_URL"), + Name: "sourcehut-meta-url", + Usage: "sourcehut meta.sr.ht URL", + }, + &cli.StringFlag{ + Sources: cli.EnvVars("WOODPECKER_SOURCEHUT_GIT_URL"), + Name: "sourcehut-git-url", + Usage: "sourcehut git.sr.ht URL", + }, + &cli.StringFlag{ + Sources: cli.EnvVars("WOODPECKER_SOURCEHUT_LISTS_URL"), + Name: "sourcehut-lists-url", + Usage: "sourcehut lists.sr.ht URL", + }, + // // development flags // &cli.StringFlag{ diff --git a/go.mod b/go.mod index a73fbd96b10..09ee4f0db1f 100644 --- a/go.mod +++ b/go.mod @@ -77,10 +77,12 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + git.sr.ht/~emersion/gqlclient v0.0.0-20250318184027-d4a003529bba // indirect github.com/42wim/httpsig v1.2.3 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/agnivade/levenshtein v1.1.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -101,6 +103,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dave/jennifer v1.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect @@ -197,6 +200,7 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect github.com/urfave/cli/v2 v2.25.3 // indirect github.com/valyala/fastjson v1.6.4 // indirect + github.com/vektah/gqlparser/v2 v2.5.8 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect diff --git a/go.sum b/go.sum index 4f460ce70cd..546d2ad8dc5 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jv codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.sr.ht/~emersion/gqlclient v0.0.0-20250318184027-d4a003529bba h1:Rl2ylhbDFXZ5LfT43Usf/8WTWOG01FQ+1uPTuLiH34k= +git.sr.ht/~emersion/gqlclient v0.0.0-20250318184027-d4a003529bba/go.mod h1:kvl/JK0Z3VRmtbBxdOJR4ydyXVouUIcFIXgv4H6rVAY= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE= @@ -32,8 +34,11 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -110,6 +115,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= +github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -120,6 +127,7 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= @@ -575,6 +583,8 @@ github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4= +github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -787,6 +797,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/server/forge/setup/setup.go b/server/forge/setup/setup.go index 95c4caa67c9..085be5aa4c9 100644 --- a/server/forge/setup/setup.go +++ b/server/forge/setup/setup.go @@ -15,6 +15,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea" "go.woodpecker-ci.org/woodpecker/v3/server/forge/github" "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitlab" + "go.woodpecker-ci.org/woodpecker/v3/server/forge/sourcehut" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) @@ -34,6 +35,8 @@ func Forge(forge *model.Forge) (forge.Forge, error) { return setupForgejo(forge) case model.ForgeTypeBitbucketDatacenter: return setupBitbucketDatacenter(forge) + case model.ForgeTypeSourceHut: + return setupSourceHut(forge) default: return nil, fmt.Errorf("forge not configured") } @@ -188,6 +191,54 @@ func setupBitbucketDatacenter(forge *model.Forge) (forge.Forge, error) { return bitbucketdatacenter.New(forge.ID, opts) } +func setupSourceHut(forge *model.Forge) (forge.Forge, error) { + server, err := url.Parse(forge.URL) + if err != nil { + return nil, err + } + + metaURL, ok := forge.AdditionalOptions["meta-url"].(string) + if !ok { + return nil, fmt.Errorf("missing meta-url") + } + + gitURL, ok := forge.AdditionalOptions["git-url"].(string) + if !ok { + return nil, fmt.Errorf("missing git-url") + } + + listsURL, ok := forge.AdditionalOptions["lists-url"].(string) + if !ok { + return nil, fmt.Errorf("missing lists-url") + } + + opts := sourcehut.Opts{ + URL: strings.TrimRight(server.String(), "/"), + MetaURL: metaURL, + GitURL: gitURL, + ListsURL: listsURL, + OAuthClientID: forge.OAuthClientID, + OAuthClientSecret: forge.OAuthClientSecret, + SkipVerify: forge.SkipVerify, + OAuth2URL: metaURL, + } + if len(opts.URL) == 0 { + return nil, fmt.Errorf("WOODPECKER_SOURCEHUT_URL must be set") + } + log.Debug(). + Str("url", opts.URL). + Str("meta-url", opts.MetaURL). + Str("git-url", opts.GitURL). + Str("lists-url", opts.ListsURL). + Str("oauth2-url", opts.OAuth2URL). + Bool("skip-verify", opts.SkipVerify). + Bool("oauth-client-id-set", opts.OAuthClientID != ""). + Bool("oauth-client-secret-set", opts.OAuthClientSecret != ""). + Str("type", string(forge.Type)). + Msg("setting up forge") + return sourcehut.New(forge.ID, opts) +} + func setupAddon(forge *model.Forge) (forge.Forge, error) { executable, ok := forge.AdditionalOptions["executable"].(string) if !ok { diff --git a/server/forge/sourcehut/git/gen.go b/server/forge/sourcehut/git/gen.go new file mode 100644 index 00000000000..bf532dc2a2e --- /dev/null +++ b/server/forge/sourcehut/git/gen.go @@ -0,0 +1,3 @@ +package git + +//go:generate go run git.sr.ht/~emersion/gqlclient/cmd/gqlclientgen -s schema.graphqls -q queries.graphql -o gql.go -n git diff --git a/server/forge/sourcehut/git/queries.graphql b/server/forge/sourcehut/git/queries.graphql new file mode 100644 index 00000000000..9acff59c605 --- /dev/null +++ b/server/forge/sourcehut/git/queries.graphql @@ -0,0 +1,138 @@ +query GetRepos($cursor: Cursor) { + me { + repositories(cursor: $cursor) { + results { + ... repoDetails + } + cursor + } + } + + version { ... versionDetails } +} + +query GetRepo($owner: String!, $repoName: String!) { + user(username: $owner) { + repository(name: $repoName) { + ... repoDetails + } + } + + version { ... versionDetails } +} + +query GetReferences($owner: String!, $repoName: String!, $cursor: Cursor) { + user(username: $owner) { + repository(name: $repoName) { + references(cursor: $cursor) { + results { + name + } + cursor + } + } + } +} + +query GetHead($owner: String!, $repoName: String!, $ref: String!) { + user(username: $owner) { + repository(name: $repoName) { + reference(name: $ref) { + follow { + id + } + } + } + } +} + +query GetDir($owner: String!, $repoName: String!, $path: String!) { + user(username: $owner) { + repository(name: $repoName) { + path(path: $path) { + name + + object { + __typename + ... on Tree { + entries { + results { + name + object { + __typename + ... on Blob { + content + } + } + } + cursor + } + } + } + } + } + } +} + +query GetFile($owner: String!, $repoName: String!, $path: String!) { + user(username: $owner) { + repository(name: $repoName) { + path(path: $path) { + name + + object { + __typename + ... on Blob { + content + } + } + } + } + } +} + +query GetPushWebhooks($repoID: Int!) { + gitWebhooks(repositoryID: $repoID) { + results { + id + } + } +} + +mutation RegisterPushWebhook($repoID: Int!, $payload: String!, $url: String!) { + createGitWebhook(config: { + repositoryID: $repoID, + url: $url, + events: [GIT_POST_RECEIVE], + query: $payload, + }) { + id + } +} + +mutation UnregisterPushWebhook($id: Int!) { + deleteGitWebhook(id: $id) { + id + } +} + +fragment repoDetails on Repository { + id + name + visibility + owner { + canonicalName + } + access + + HEAD { + name + } +} + +# Needed to construct SSH clone URLs for git repositories +fragment versionDetails on Version { + settings { + sshUser + } +} diff --git a/server/forge/sourcehut/git/schema.graphqls b/server/forge/sourcehut/git/schema.graphqls new file mode 100644 index 00000000000..76fc6248daf --- /dev/null +++ b/server/forge/sourcehut/git/schema.graphqls @@ -0,0 +1,760 @@ +# This schema definition is available in the public domain, or under the terms +# of CC-0, at your choice. +scalar Cursor +scalar Time +scalar Upload + +""" +URL from which some secondary data may be retrieved. You must provide the +same Authentication header to this address as you did to the GraphQL resolver +which provided it. The URL is not guaranteed to be consistent for an extended +length of time; applications should submit a new GraphQL query each time they +wish to access the data at the provided URL. +""" +scalar URL + +"Used to provide a human-friendly description of an access scope" +directive @scopehelp(details: String!) on ENUM_VALUE + +""" +This is used to decorate fields which are only accessible with a personal +access token, and are not available to clients using OAuth 2.0 access tokens. +""" +directive @private on FIELD_DEFINITION + +# For internal use +directive @internal on FIELD_DEFINITION +directive @anoninternal on FIELD_DEFINITION + +enum AccessScope { + PROFILE @scopehelp(details: "profile information") + REPOSITORIES @scopehelp(details: "repository metadata") + OBJECTS @scopehelp(details: "git objects & references") + ACLS @scopehelp(details: "access control lists") +} + +enum AccessKind { + RO @scopehelp(details: "read") + RW @scopehelp(details: "read and write") +} + +""" +Decorates fields for which access requires a particular OAuth 2.0 scope with +read or write access. +""" +directive @access(scope: AccessScope!, kind: AccessKind!) on FIELD_DEFINITION + +# https://semver.org +type Version { + major: Int! + minor: Int! + patch: Int! + + """ + If this API version is scheduled for deprecation, this is the date on which + it will stop working; or null if this API version is not scheduled for + deprecation. + """ + deprecationDate: Time + + "Optional features" + features: Features! + + "Config settings" + settings: Settings! +} + +"Describes the status of optional features" +type Features { + artifacts: Boolean! +} + +"Instance specific settings" +type Settings { + sshUser: String! +} + +enum AccessMode { + "Read-only" + RO + "Read/write" + RW +} + +enum Visibility { + "Visible to everyone, listed on your profile" + PUBLIC + "Visible to everyone (if they know the URL), not listed on your profile" + UNLISTED + "Not visible to anyone except those explicitly added to the access list" + PRIVATE +} + +interface Entity { + id: Int! + created: Time! + updated: Time! + """ + The canonical name of this entity. For users, this is their username + prefixed with '~'. Additional entity types will be supported in the future. + """ + canonicalName: String! + + "Returns a specific repository owned by the entity." + repository(name: String!): Repository @access(scope: REPOSITORIES, kind: RO) + + "Returns a list of repositories owned by the entity." + repositories(cursor: Cursor, filter: Filter): RepositoryCursor! @access(scope: REPOSITORIES, kind: RO) +} + +type User implements Entity { + id: Int! + created: Time! + updated: Time! + canonicalName: String! + username: String! + email: String! + url: String + location: String + bio: String + + repository(name: String!): Repository @access(scope: REPOSITORIES, kind: RO) + repositories(cursor: Cursor, filter: Filter): RepositoryCursor! @access(scope: REPOSITORIES, kind: RO) +} + +type Repository { + id: Int! + created: Time! + updated: Time! + owner: Entity! @access(scope: PROFILE, kind: RO) + name: String! + description: String + visibility: Visibility! + + """ + The repository's custom README, if set. + + NOTICE: This returns unsanitized HTML. It is the client's responsibility to + sanitize this for display on the web, if so desired. + """ + readme: String + + "The access that applies to this user for this repository" + access: AccessMode! @access(scope: ACLS, kind: RO) + + # Only available to the repository owner + acls(cursor: Cursor): ACLCursor! @access(scope: ACLS, kind: RO) + + ## Plumbing API: + + """ + Returns any number of git objects by their IDs. + """ + objects(ids: [String!]): [Object]! @access(scope: OBJECTS, kind: RO) + """ + Returns a specific git object by its ID. + """ + object(id: String!): Object @access(scope: OBJECTS, kind: RO) + + """ + Enumerates references on this repository. + """ + references(cursor: Cursor): ReferenceCursor! @access(scope: OBJECTS, kind: RO) + """ + Returns a reference by its fully qualified name, e.g. refs/heads/master. + """ + reference(name: String!): Reference @access(scope: OBJECTS, kind: RO) + + ## Porcelain API: + + # NOTE: revspecs are git-compatible, e.g. "HEAD~4", "master", "9790b10") + + "The HEAD reference for this repository (equivalent to the default branch)" + HEAD: Reference @access(scope: OBJECTS, kind: RO) + + """ + Returns a list of comments sorted by committer time (similar to `git log`'s + default ordering). + + If `from` is specified, it is interpreted as a revspec to start logging + from. A clever reader may notice that using commits[-1].from + "^" as the + from parameter is equivalent to passing the cursor to the next call. + """ + log(cursor: Cursor, from: String): CommitCursor! @access(scope: OBJECTS, kind: RO) + + "Returns a tree entry for a given path, at the given revspec." + path(revspec: String = "HEAD", path: String!): TreeEntry @access(scope: OBJECTS, kind: RO) + + "Returns the commit for a given revspec." + revparse_single(revspec: String!): Commit @access(scope: OBJECTS, kind: RO) + + ## For internal use + repoPath: String! @anoninternal +} + +# For internal use +type Redirect { + created: Time! + name: String! + owner: Entity! + originalPath: String! + repository: Repository +} + +type OAuthClient { + uuid: String! +} + +enum WebhookEvent { + # User webhooks only + REPO_CREATED + REPO_UPDATE + REPO_DELETED + + # Git webhooks only + GIT_PRE_RECEIVE + GIT_POST_RECEIVE +} + +interface WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + + """ + If this webhook was registered by an authorized OAuth 2.0 client, this + field is non-null. + """ + client: OAuthClient @private + + "All deliveries which have been sent to this webhook." + deliveries(cursor: Cursor): WebhookDeliveryCursor! + + "Returns a sample payload for this subscription, for testing purposes" + sample(event: WebhookEvent!): String! +} + +type UserWebhookSubscription implements WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + client: OAuthClient @private + deliveries(cursor: Cursor): WebhookDeliveryCursor! + sample(event: WebhookEvent!): String! +} + +type GitWebhookSubscription implements WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + client: OAuthClient @private + deliveries(cursor: Cursor): WebhookDeliveryCursor! + sample(event: WebhookEvent!): String! +} + +type WebhookDelivery { + uuid: String! + date: Time! + event: WebhookEvent! + subscription: WebhookSubscription! + requestBody: String! + + """ + These details are provided only after a response is received from the + remote server. If a response is sent whose Content-Type is not text/*, or + cannot be decoded as UTF-8, the response body will be null. It will be + truncated after 64 KiB. + """ + responseBody: String + responseHeaders: String + responseStatus: Int +} + +interface WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! +} + +type RepositoryEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + repository: Repository! +} + +type UpdatedRef { + # Is null when pushing a new reference + ref: Reference + # Is null when pushing a new reference + old: Object + # Is null when deleting a reference + new: Object + + """ + Note: this only returns up to the most recent 50 commits included in the + update, i.e. old..new or new~50..new, whichever has fewer commits. + """ + log: CommitCursor + + """ + Difference from old..new in the unified diff format. + + This field is null if the diff requires more than one second to prepare. + """ + diff: String +} + +""" +This event is used for pre-receive and post-receive git hooks. +""" +type GitEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + repository: Repository! @access(scope: REPOSITORIES, kind: RO) + pusher: Entity! @access(scope: PROFILE, kind: RO) + updates: [UpdatedRef]! @access(scope: OBJECTS, kind: RO) +} + +""" +A cursor for enumerating a list of repositories + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type RepositoryCursor { + results: [Repository!]! + cursor: Cursor +} + +""" +A cursor for enumerating access control list entries + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type ACLCursor { + results: [ACL!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of references + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type ReferenceCursor { + results: [Reference!]! + cursor: Cursor +} + +""" +A cursor for enumerating commits + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type CommitCursor { + results: [Commit!]! + cursor: Cursor +} + +""" +A cursor for enumerating tree entries + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type TreeEntryCursor { + results: [TreeEntry!]! + cursor: Cursor +} + +""" +A cursor for enumerating artifacts + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type ArtifactCursor { + results: [Artifact!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of webhook deliveries + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type WebhookDeliveryCursor { + results: [WebhookDelivery!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of webhook subscriptions + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type WebhookSubscriptionCursor { + results: [WebhookSubscription!]! + cursor: Cursor +} + +type ACL { + id: Int! + created: Time! + repository: Repository! + entity: Entity! @access(scope: PROFILE, kind: RO) + mode: AccessMode +} + +"Arbitrary file attached to a git repository" +type Artifact { + id: Int! + created: Time! + filename: String! + checksum: String! + size: Int! + url: URL! +} + +type Reference { + name: String! + target: String! + follow: Object + + artifact(filename: String!): Artifact + artifacts(cursor: Cursor): ArtifactCursor! +} + +enum ObjectType { + COMMIT + TREE + BLOB + TAG +} + +interface Object { + type: ObjectType! + id: String! + shortId: String! +} + +type Signature { + name: String! + email: String! + time: Time! +} + +type Trailer { + name: String! + value: String! +} + +type Commit implements Object { + type: ObjectType! + id: String! + shortId: String! + author: Signature! + committer: Signature! + message: String! + tree: Tree! + parents: [Commit!]! + diff: String! + trailers: [Trailer!]! +} + +type Tree implements Object { + type: ObjectType! + id: String! + shortId: String! + # TODO: add globbing + entries(cursor: Cursor): TreeEntryCursor! + + entry(path: String): TreeEntry +} + +type TreeEntry { + id: String! + name: String! + object: Object! + "Unix-style file mode, i.e. 0755 or 0644 (octal)" + mode: Int! +} + +interface Blob { + id: String! + type: ObjectType! + content: URL! + "Size of the blob in bytes" + size: Int! +} + +type TextBlob implements Object & Blob { + type: ObjectType! + id: String! + shortId: String! + content: URL! + "Size of the blob in bytes" + size: Int! + + """ + Returns up to 128 KiB of text content. Use the content field if you need more. + """ + text: String! +} + +type BinaryBlob implements Object & Blob { + type: ObjectType! + id: String! + shortId: String! + content: URL! + "Size of the blob in bytes" + size: Int! + + """ + Returns up to 32 KiB of binary content. + + Notice: this field is deprecated. Prefer to use the content field. + """ + base64: String! +} + +""" +An annotated git tag. +""" +type Tag implements Object { + type: ObjectType! + id: String! + shortId: String! + target: Object! + name: String! + tagger: Signature! + message: String! +} + +input Filter { + "Number of results to return." + count: Int = 20 + + """ + Search terms. The exact meaning varies by usage, but generally these are + compatible with the web UI's search syntax. + """ + search: String +} + +type Query { + "Returns API version information." + version: Version! + + "Returns the authenticated user." + me: User! @access(scope: PROFILE, kind: RO) + + "Returns a specific user." + user(username: String!): User @access(scope: PROFILE, kind: RO) + + """ + Returns repositories that the authenticated user has access to. + + NOTE: in this version of the API, only repositories owned by the + authenticated user are returned, but in the future the default behavior + will be to return all repositories that the user either (1) has been given + explicit access to via ACLs or (2) has implicit access to either by + ownership or group membership. + """ + repositories(cursor: Cursor, filter: Filter): RepositoryCursor @access(scope: REPOSITORIES, kind: RO) + + """ + Returns a list of user webhook subscriptions. For clients + authenticated with a personal access token, this returns all webhooks + configured by all GraphQL clients for your account. For clients + authenticated with an OAuth 2.0 access token, this returns only webhooks + registered for your client. + """ + userWebhooks(cursor: Cursor): WebhookSubscriptionCursor! + + "Returns details of a user webhook subscription by its ID." + userWebhook(id: Int!): WebhookSubscription + + """ + Returns a list of git webhook subscriptions associated with a repository. For + clients authenticated with a personal access token, this returns all webhooks + configured by all GraphQL clients for your account. For clients authenticated + with an OAuth 2.0 access token, this returns only webhooks registered for + your client. + """ + gitWebhooks(repositoryID: Int!, cursor: Cursor): WebhookSubscriptionCursor! + + "Returns details of a git webhook subscription by its ID." + gitWebhook(id: Int!): WebhookSubscription + + """ + Returns information about the webhook currently being processed. This is + not valid during normal queries over HTTP, and will return an error if used + outside of a webhook context. + """ + webhook: WebhookPayload! + + ## Internal use + + # Looks up a repository by its path on disk + repositoryByDiskPath(path: String!): Repository @anoninternal + + # Looks up a redirect by its (original) path on disk + redirectByDiskPath(path: String!): Redirect @anoninternal +} + +input RepoInput { + # Omit these fields to leave them unchanged, or set them to null to clear + # their value. + name: String + description: String + visibility: Visibility + + """ + Updates the custom README associated with this repository. Note that the + provided HTML will be sanitized when displayed on the web; see + https://man.sr.ht/markdown/#post-processing + """ + readme: String + + """ + Updates the repository HEAD reference, which serves as the default branch. + Must be a valid branch name. + """ + HEAD: String +} + +input UserWebhookInput { + url: String! + events: [WebhookEvent!]! + query: String! +} + +input GitWebhookInput { + repositoryID: Int! + + url: String! + events: [WebhookEvent!]! + query: String! +} + +input UpdatedRefInput { + ref: String! + old: String! + new: String! +} + +input GitEventInput { + repositoryID: Int! + event: WebhookEvent! + updates: [UpdatedRefInput]! +} + +type Mutation { + """ + Creates a new git repository. If the cloneUrl parameter is specified, the + repository will be cloned from the given URL. + """ + createRepository(name: String!, visibility: Visibility!, description: String, cloneUrl: String): Repository @access(scope: REPOSITORIES, kind: RW) + + "Updates the metadata for a git repository" + updateRepository(id: Int!, input: RepoInput!): Repository @access(scope: REPOSITORIES, kind: RW) + + "Deletes a git repository" + deleteRepository(id: Int!): Repository @access(scope: REPOSITORIES, kind: RW) + + "Adds or updates a user in the access control list" + updateACL(repoId: Int!, mode: AccessMode!, entity: ID!): ACL! @access(scope: ACLS, kind: RW) + + "Deletes an entry from the access control list" + deleteACL(id: Int!): ACL @access(scope: ACLS, kind: RW) + + """ + Uploads an artifact. revspec must match a specific git tag, and the + filename must be unique among artifacts for this repository. + """ + uploadArtifact(repoId: Int!, revspec: String!, file: Upload!): Artifact! @access(scope: OBJECTS, kind: RW) + + "Deletes an artifact." + deleteArtifact(id: Int!): Artifact @access(scope: OBJECTS, kind: RW) + + """ + Creates a new user webhook subscription. When an event from the + provided list of events occurs, the 'query' parameter (a GraphQL query) + will be evaluated and the results will be sent to the provided URL as the + body of an HTTP POST request. The list of events must include at least one + event, and no duplicates. + + This query is evaluated in the webhook context, such that query { webhook } + may be used to access details of the event which trigged the webhook. The + query may not make any mutations. + """ + createUserWebhook(config: UserWebhookInput!): WebhookSubscription! + + """ + Deletes a user webhook. Any events already queued may still be + delivered after this request completes. Clients authenticated with a + personal access token may delete any webhook registered for their account, + but authorized OAuth 2.0 clients may only delete their own webhooks. + Manually deleting a webhook configured by a third-party client may cause + unexpected behavior with the third-party integration. + """ + deleteUserWebhook(id: Int!): WebhookSubscription! + + """ + Creates a git webhook. When an event from the provided list of events occurs, + the 'query' parameter (a GraphQL query) will be evaluated and the results + will be sent to the provided URL as the body of an HTTP POST request. The + list of events must include at least one event, and no duplicates. + + This query is evaluated in the webhook context, such that query { webhook } + may be used to access details of the event which trigged the webhook. The + query may not make any mutations. + """ + createGitWebhook(config: GitWebhookInput!): WebhookSubscription! + + """ + Deletes a git webhook. Any events already queued may still be delivered after + this request completes. Clients authenticated with a personal access token + may delete any webhook registered for their account, but authorized OAuth 2.0 + clients may only delete their own webhooks. Manually deleting a webhook + configured by a third-party client may cause unexpected behavior with the + third-party integration. + """ + deleteGitWebhook(id: Int!): WebhookSubscription! + + ### ### + ### The following resolvers are for internal use. ### + ### ### + + """ + Deletes the authenticated user's account. Internal use only. + """ + deleteUser: Int! @internal + + """ + Delivers git webhooks. If it returns false, the push event should be + rejected. Invoked by git.sr.ht-update-hook. + """ + deliverGitHook(input: GitEventInput!): Boolean! @internal +} diff --git a/server/forge/sourcehut/helper.go b/server/forge/sourcehut/helper.go new file mode 100644 index 00000000000..1c9b80ed68c --- /dev/null +++ b/server/forge/sourcehut/helper.go @@ -0,0 +1,80 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sourcehut + +import ( + "fmt" + "net/url" + "strings" + "time" + + "go.woodpecker-ci.org/woodpecker/v3/server/forge/sourcehut/git" + "go.woodpecker-ci.org/woodpecker/v3/server/model" +) + +// toRepo converts a SourceHut repository to a Woodpecker repository. +func (c *SourceHut) toRepo(from *git.Repository, ver *git.Version) *model.Repo { + u, _ := url.Parse(c.gitURL) + fullName := fmt.Sprintf("%s/%s", from.Owner.CanonicalName, from.Name) + cloneURL := fmt.Sprintf("%s/%s", c.gitURL, fullName) + sshURL := fmt.Sprintf("%s@%s:%s", ver.Settings.SshUser, u.Hostname(), fullName) + return &model.Repo{ + ForgeRemoteID: model.ForgeRemoteID(fullName), + Name: from.Name, + Owner: from.Owner.CanonicalName, + FullName: fullName, + ForgeURL: fmt.Sprintf("%s/%s", c.gitURL, fullName), + IsSCMPrivate: from.Visibility == git.VisibilityPrivate, + Clone: cloneURL, + CloneSSH: sshURL, + Branch: from.HEAD.Name, + Perm: &model.Perm{ + Pull: true, + Push: from.Access == git.AccessModeRw, + Admin: from.Access == git.AccessModeRw, + }, + PREnabled: false, // TODO + } +} + +func (c *SourceHut) toPushPipeline(from *git.GitEvent) *model.Pipeline { + // TODO: Figure out what to do with several updates pushed at once + update := from.Updates[0] + if update.New == nil { + return nil + } + + commit, ok := update.New.Value.(*git.Commit) + if !ok { + return nil + } + + forgeUrl := fmt.Sprintf("%s/%s/%s/commit/%s", + c.gitURL, from.Repository.Owner.CanonicalName, from.Repository.Name, + update.New.Id) + + return &model.Pipeline{ + Event: model.EventPush, + Commit: update.New.Id, + Ref: strings.TrimPrefix(update.Ref.Name, "refs/heads/"), + ForgeURL: forgeUrl, + Branch: strings.TrimPrefix(update.Ref.Name, "refs/heads/"), + Message: commit.Message, + Author: commit.Author.Name, + Email: commit.Author.Email, + Timestamp: time.Now().UTC().Unix(), + Sender: from.Pusher.CanonicalName[1:], + } +} diff --git a/server/forge/sourcehut/lists/schema.graphqls b/server/forge/sourcehut/lists/schema.graphqls new file mode 100644 index 00000000000..6f7b38cda8d --- /dev/null +++ b/server/forge/sourcehut/lists/schema.graphqls @@ -0,0 +1,878 @@ +# This schema definition is available in the public domain, or under the terms +# of CC-0, at your choice. + +"String of the format %Y-%m-%dT%H:%M:%SZ" +scalar Time +"Opaque string" +scalar Cursor +""" +URL from which some secondary data may be retrieved. You must provide the +same Authentication header to this address as you did to the GraphQL resolver +which provided it. The URL is not guaranteed to be consistent for an extended +length of time; applications should submit a new GraphQL query each time they +wish to access the data at the provided URL. +""" +scalar URL +scalar Upload + +"Confirmation token associated with a subscription request" +scalar ConfirmationToken + +"Used to provide a human-friendly description of an access scope" +directive @scopehelp(details: String!) on ENUM_VALUE + +""" +This is used to decorate fields which are only accessible with a personal +access token, and are not available to clients using OAuth 2.0 access tokens. +""" +directive @private on FIELD_DEFINITION + +""" +This is used to decorate fields which are for internal use, and are not +available to normal API users. +""" +directive @internal on FIELD_DEFINITION + +""" +Used to decorate fields which are for internal use, and are not available to +normal API users. +""" +directive @anoninternal on FIELD_DEFINITION + +enum AccessScope { + ACLS @scopehelp(details: "access control lists") + EMAILS @scopehelp(details: "emails") + LISTS @scopehelp(details: "mailing lists") + PATCHES @scopehelp(details: "patches") + PROFILE @scopehelp(details: "profile information") + SUBSCRIPTIONS @scopehelp(details: "tracker & ticket subscriptions") +} + +enum AccessKind { + RO @scopehelp(details: "read") + RW @scopehelp(details: "read and write") +} + +""" +Decorates fields for which access requires a particular OAuth 2.0 scope with +read or write access. +""" +directive @access(scope: AccessScope!, kind: AccessKind!) on FIELD_DEFINITION + +# https://semver.org +type Version { + major: Int! + minor: Int! + patch: Int! + + """ + If this API version is scheduled for deprecation, this is the date on which + it will stop working; or null if this API version is not scheduled for + deprecation. + """ + deprecationDate: Time +} + +interface Entity { + canonicalName: String! +} + +"A registered user" +type User implements Entity { + id: Int! + created: Time! + updated: Time! + canonicalName: String! + username: String! + email: String! + url: String + location: String + bio: String + + list(name: String!): MailingList @access(scope: LISTS, kind: RO) + lists(cursor: Cursor): MailingListCursor! @access(scope: LISTS, kind: RO) + emails(cursor: Cursor): EmailCursor! @access(scope: EMAILS, kind: RO) + threads(cursor: Cursor): ThreadCursor! @access(scope: EMAILS, kind: RO) + patches(cursor: Cursor): PatchsetCursor! @access(scope: PATCHES, kind: RO) +} + +"A mailbox not associated with a registered user" +type Mailbox implements Entity { + canonicalName: String! + name: String! + address: String! +} + +enum Visibility { + PUBLIC + UNLISTED + PRIVATE +} + +type MailingList { + id: Int! + created: Time! + updated: Time! + name: String! + owner: Entity! @access(scope: PROFILE, kind: RO) + lastActivity: Time + + # Markdown + description: String + visibility: Visibility! + + """ + List of globs for permitted or rejected mimetypes on this list + e.g. text/* + """ + permitMime: [String!]! + rejectMime: [String!]! + + "List of threads on this list in order of most recently bumped" + threads(cursor: Cursor): ThreadCursor! @access(scope: EMAILS, kind: RO) + "List of emails received on this list in reverse chronological order" + emails(cursor: Cursor): EmailCursor! @access(scope: EMAILS, kind: RO) + "List of patches received on this list in order of most recently bumped" + patches(cursor: Cursor): PatchsetCursor! @access(scope: PATCHES, kind: RO) + + "Get a specific email archived in this list by its Message-ID" + message(messageID: String!): Email @access(scope: EMAILS, kind: RO) + + "True if an import operation is underway for this list" + importing: Boolean! + + "The access that applies to this user for this list" + access: ACL! @access(scope: ACLS, kind: RO) + + "The user's subscription for this list, if any" + subscription: MailingListSubscription @access(scope: SUBSCRIPTIONS, kind: RO) + + "URLs to application/mbox archives for this mailing list" + archive: URL! + last30days: URL! + + # + # The following resolvers are only available to the list owner: + + "Access control list entries for this mailing list" + acl(cursor: Cursor): MailingListACLCursor! @access(scope: ACLS, kind: RO) + + defaultACL: GeneralACL! + + """ + Returns a list of mailing list webhook subscriptions. For clients + authenticated with a personal access token, this returns all webhooks + configured by all GraphQL clients for your account. For clients + authenticated with an OAuth 2.0 access token, this returns only webhooks + registered for your client. + """ + webhooks(cursor: Cursor): WebhookSubscriptionCursor! + + "Returns details of a mailing list webhook subscription by its ID." + webhook(id: Int!): WebhookSubscription + + """ + Get the list of subscriptions to this mailing list. + + Internal use only. + """ + subscriptions: [MailingListSubscription!]! @internal + + """ + Get access permissions for a specific user by their email address. If there + is a related ACL entry for the given email address or an account linked to + that address, it will be returned. Otherwise, the default ACL of the mailing + list will be returned. + + Internal use only. + """ + userACL(email: String!): GeneralACL! @internal +} + +type OAuthClient { + uuid: String! +} + +enum WebhookEvent { + LIST_CREATED + LIST_UPDATED + LIST_DELETED + EMAIL_RECEIVED + PATCHSET_RECEIVED +} + +interface WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + + """ + If this webhook was registered by an authorized OAuth 2.0 client, this + field is non-null. + """ + client: OAuthClient @private + + "All deliveries which have been sent to this webhook." + deliveries(cursor: Cursor): WebhookDeliveryCursor! + + "Returns a sample payload for this subscription, for testing purposes" + sample(event: WebhookEvent!): String! +} + +type UserWebhookSubscription implements WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + client: OAuthClient @private + deliveries(cursor: Cursor): WebhookDeliveryCursor! + sample(event: WebhookEvent!): String! +} + +type MailingListWebhookSubscription implements WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + client: OAuthClient @private + deliveries(cursor: Cursor): WebhookDeliveryCursor! + sample(event: WebhookEvent!): String! + + list: MailingList! +} + +type WebhookDelivery { + uuid: String! + date: Time! + event: WebhookEvent! + subscription: WebhookSubscription! + requestBody: String! + + """ + These details are provided only after a response is received from the + remote server. If a response is sent whose Content-Type is not text/*, or + cannot be decoded as UTF-8, the response body will be null. It will be + truncated after 64 KiB. + """ + responseBody: String + responseHeaders: String + responseStatus: Int +} + +interface WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! +} + +type MailingListEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + list: MailingList! +} + +type EmailEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + email: Email! +} + +type PatchsetEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + patchset: Patchset! +} + +interface ACL { + "Permission to browse or subscribe to emails" + browse: Boolean! + "Permission to reply to existing threads" + reply: Boolean! + "Permission to start new threads" + post: Boolean! + "Permission to moderate the list" + moderate: Boolean! +} + +""" +These ACLs are configured for specific entities, and may be used to expand or +constrain the rights of a participant. +""" +type MailingListACL implements ACL { + id: Int! + created: Time! + list: MailingList! @access(scope: LISTS, kind: RO) + entity: Entity! @access(scope: PROFILE, kind: RO) + + browse: Boolean! + reply: Boolean! + post: Boolean! + moderate: Boolean! +} + +""" +An ACL entry that applies "generally", for example the rights which apply to +all subscribers to a list. +""" +type GeneralACL implements ACL { + browse: Boolean! + reply: Boolean! + post: Boolean! + moderate: Boolean! +} + +type Thread { + created: Time! + updated: Time! + subject: String! + replies: Int! + participants: Int! + sender: Entity! + + root: Email! + + list: MailingList! @access(scope: LISTS, kind: RO) + + "Replies to this thread, in chronological order" + descendants(cursor: Cursor): EmailCursor! + + "A mailto: URI for replying to the latest message in this thread" + mailto: String! + + "URL to an application/mbox archive of this thread" + mbox: URL! + + """ + Thread parsed as a tree. + + The returned list is never empty. The first item is guaranteed to be the root + message. The blocks are sorted in topological order. + """ + blocks: [ThreadBlock!]! +} + +""" +A block of text in an email thread. + +Blocks are parts of a message's body that aren't quotes of the parent message. +A block can be a reply to a parent block, in which case the parentStart and +parentEnd fields indicate which part of the parent message is replied to. A +block can have replies, each of which will be represented by a block in the +children field. +""" +type ThreadBlock { + "Unique identifier for this block." + key: String! + "The block's plain-text content." + body: String! + "Index of the parent block (if any) in Thread.blocks." + parent: Int + """ + Replies to this block. + + The list items are indexes into Thread.blocks. + """ + children: [Int!]! + + "The email this block comes from." + source: Email! + "The range of this block in the source email body." + sourceRange: ByteRange! + + """ + If this block is a reply to a particular chunk of the parent block, this + field indicates the range of that chunk in the parent's email body. + """ + parentRange: ByteRange +} + +""" +A byte range. +""" +type ByteRange { + "Inclusive start byte offset." + start: Int! + "Exclusive end byte offset." + end: Int! +} + +type Email { + id: Int! + + """ + The entity which sent this email. Will be a User if it can be associated + with an account, or a Mailbox otherwise. + """ + sender: Entity! + "Time we received this email (non-forgable)." + received: Time! + "Time given by Date header (forgable)." + date: Time + "The Subject header." + subject: String! + "The Message-ID header, without angle brackets." + messageID: String! + "The In-Reply-To header, if present, without angle brackets." + inReplyTo: String + + """ + Provides the value (or values) of a specific header from this email. Note + that the returned value is coerced to UTF-8 and may be lossy under certain + circumstances. + """ + header(want: String!): [String!]! + "Retrieves the value of an address list header, such as To or Cc." + addressList(want: String!): [Mailbox!]! + "The decoded text/plain message part of the email, i.e. email body." + body: String! + "A URL from which the full raw message may be downloaded." + rawMessage: URL! + + thread: Thread! + parent: Email + patch: Patch + + patchset: Patchset @access(scope: PATCHES, kind: RO) + list: MailingList! @access(scope: LISTS, kind: RO) +} + +type Trailer { + name: String! + value: String! +} + +""" +Information parsed from a patch emailed by git send-email. Given the following +subject line: + + [PATCH myproject v2 3/4] Add foo to bar + +The following fields are produced: + + index: 3 + count: 4 + version: 2 + prefix: "myproject" + subject: "Add foo to bar" +""" +type Patch { + index: Int + count: Int + version: Int + prefix: String + subject: String + + """ + git-style commit trailers parsed from the commit message of the patch. + """ + trailers: [Trailer!]! +} + +enum PatchsetStatus { + UNKNOWN + PROPOSED + NEEDS_REVISION + SUPERSEDED + APPROVED + REJECTED + APPLIED +} + +type Patchset { + id: Int! + created: Time! + updated: Time! + subject: String! + version: Int! + prefix: String + status: PatchsetStatus! + submitter: Entity! + + coverLetter: Email @access(scope: EMAILS, kind: RO) + thread: Thread! @access(scope: EMAILS, kind: RO) + supersededBy: Patchset + list: MailingList! @access(scope: LISTS, kind: RO) + patches(cursor: Cursor): EmailCursor! @access(scope: EMAILS, kind: RO) + tools: [PatchsetTool!]! + + "URL to an application/mbox archive of only the patches in this thread" + mbox: URL! +} + +enum ToolIcon { + PENDING + WAITING + SUCCESS + FAILED + CANCELLED +} + +""" +Used to add some kind of indicator for a third-party process associated with +a patchset, such as a CI service validating the change. +""" +type PatchsetTool { + id: Int! + created: Time! + updated: Time! + icon: ToolIcon! + details: String! + patchset: Patchset! +} + +interface ActivitySubscription { + id: Int! + created: Time! +} + +type MailingListSubscription implements ActivitySubscription { + id: Int! + created: Time! + list: MailingList! @access(scope: LISTS, kind: RO) + subscriber: Entity @internal +} + +""" +A cursor for enumerating ACL entries + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type MailingListACLCursor { + results: [MailingListACL!]! + cursor: Cursor +} + +""" +A cursor for enumerating mailing lists + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type MailingListCursor { + results: [MailingList!]! + cursor: Cursor +} + +""" +A cursor for enumerating threads + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type ThreadCursor { + results: [Thread!]! + cursor: Cursor +} + +""" +A cursor for enumerating emails + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type EmailCursor { + results: [Email!]! + cursor: Cursor +} + +""" +A cursor for enumerating patchsets + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type PatchsetCursor { + results: [Patchset!]! + cursor: Cursor +} + +""" +A cursor for enumerating subscriptions + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type ActivitySubscriptionCursor { + results: [ActivitySubscription!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of webhook deliveries + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type WebhookDeliveryCursor { + results: [WebhookDelivery!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of webhook subscriptions + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type WebhookSubscriptionCursor { + results: [WebhookSubscription!]! + cursor: Cursor +} + +type Preferences { + copySelf: Boolean! +} + +type Query { + "Returns API version information" + version: Version! + + "Returns the authenticated user" + me: User! @access(scope: PROFILE, kind: RO) + + "Looks up a specific user" + user(username: String!): User @access(scope: PROFILE, kind: RO) + + "Looks up a specific email by its ID" + email(id: Int!): Email @access(scope: EMAILS, kind: RO) + + "Looks up a patchset by ID" + patchset(id: Int!): Patchset @access(scope: EMAILS, kind: RO) + + "List of subscriptions of the authenticated user" + subscriptions(cursor: Cursor): ActivitySubscriptionCursor @access(scope: SUBSCRIPTIONS, kind: RO) + + """ + Returns a list of user webhook subscriptions. For clients + authenticated with a personal access token, this returns all webhooks + configured by all GraphQL clients for your account. For clients + authenticated with an OAuth 2.0 access token, this returns only webhooks + registered for your client. + """ + userWebhooks(cursor: Cursor): WebhookSubscriptionCursor! + + "Returns details of a user webhook subscription by its ID." + userWebhook(id: Int!): WebhookSubscription + + """ + Returns information about the webhook currently being processed. This is + not valid during normal queries over HTTP, and will return an error if used + outside of a webhook context. + """ + webhook: WebhookPayload! + + """ + Returns the preferences associated with this user account. + """ + preferences: Preferences! @access(scope: PROFILE, kind: RO) + + """ + If email corresponds to a registered user, return whether they wish to + receive their own emails; otherwise return false. + + Internal use only. + """ + copySelf(email: String!): Boolean! @anoninternal +} + +# You may omit any fields to leave them unchanged. +# TODO: Allow users to change the name of a mailing list +input MailingListInput { + description: String + visibility: Visibility + + """ + List of globs for permitted or rejected mimetypes on this list + e.g. text/* + """ + permitMime: [String!] + rejectMime: [String!] +} + +# All fields are required +input ACLInput { + browse: Boolean! + reply: Boolean! + post: Boolean! + moderate: Boolean! +} + +input UserWebhookInput { + url: String! + events: [WebhookEvent!]! + query: String! +} + +input MailingListWebhookInput { + url: String! + events: [WebhookEvent!]! + query: String! +} + +input PreferencesInput { + copySelf: Boolean! +} + +type Mutation { + "Creates a new mailing list" + createMailingList( + name: String!, + description: String, + visibility: Visibility!): MailingList! @access(scope: LISTS, kind: RW) + + "Updates a mailing list." + updateMailingList( + id: Int!, + input: MailingListInput!): MailingList @access(scope: LISTS, kind: RW) + + "Deletes a mailing list" + deleteMailingList(id: Int!): MailingList @access(scope: LISTS, kind: RW) + + "Adds or updates the ACL for a user on a mailing list" + updateUserACL( + listID: Int!, + userID: Int!, + input: ACLInput!): MailingListACL @access(scope: ACLS, kind: RW) + + "Adds or updates the ACL for an email address on a mailing list" + updateSenderACL( + listID: Int!, + address: String!, + input: ACLInput!): MailingListACL @access(scope: ACLS, kind: RW) + + """ + Updates the default ACL for a mailing list, which applies to users and + senders for whom a more specific ACL does not exist. + """ + updateMailingListACL( + listID: Int!, + input: ACLInput!): MailingList @access(scope: ACLS, kind: RW) + + """ + Removes a mailing list ACL. Following this, the default mailing list ACL will + apply to this user. + """ + deleteACL(id: Int!): MailingListACL @access(scope: ACLS, kind: RW) + + "Updates the status of a patchset" + updatePatchset(id: Int!, status: PatchsetStatus!): Patchset @access(scope: PATCHES, kind: RW) + + "Create a new patchset tool" + createTool(patchsetID: Int!, details: String!, icon: ToolIcon!): PatchsetTool @access(scope: PATCHES, kind: RW) + + "Updates the status of a patchset tool by its ID" + updateTool(id: Int!, details: String, icon: ToolIcon): PatchsetTool @access(scope: PATCHES, kind: RW) + + "Creates a mailing list subscription" + mailingListSubscribe(listID: Int!): MailingListSubscription @access(scope: SUBSCRIPTIONS, kind: RW) + + "Deletes a mailing list subscription" + mailingListUnsubscribe(listID: Int!): MailingListSubscription @access(scope: SUBSCRIPTIONS, kind: RW) + + "Imports a mail spool (must be in the Mbox format)" + importMailingListSpool(listID: Int!, spool: Upload!): Boolean! @access(scope: LISTS, kind: RW) + + """ + Creates a new user webhook subscription. When an event from the + provided list of events occurs, the 'query' parameter (a GraphQL query) + will be evaluated and the results will be sent to the provided URL as the + body of an HTTP POST request. The list of events must include at least one + event, and no duplicates. + + This query is evaluated in the webhook context, such that query { webhook } + may be used to access details of the event which trigged the webhook. The + query may not make any mutations. + """ + createUserWebhook(config: UserWebhookInput!): WebhookSubscription! + + """ + Deletes a user webhook. Any events already queued may still be + delivered after this request completes. Clients authenticated with a + personal access token may delete any webhook registered for their account, + but authorized OAuth 2.0 clients may only delete their own webhooks. + Manually deleting a webhook configured by a third-party client may cause + unexpected behavior with the third-party integration. + """ + deleteUserWebhook(id: Int!): WebhookSubscription! + + "Creates a new mailing list webhook." + createMailingListWebhook(listId: Int!, config: MailingListWebhookInput!): WebhookSubscription! + + "Deletes a mailing list webhook." + deleteMailingListWebhook(id: Int!): WebhookSubscription! + + """ + Triggers user webhooks for an email. + + The result can be null if the user does not have browse access to the + archived email. In this case, no webhook will be triggered. + """ + triggerUserEmailWebhooks(emailId: Int!): Email @internal + triggerListEmailWebhooks(listId: Int!, emailId: Int!): Email! @internal + + """ + Deletes the authenticated user's account. Internal use only. + """ + deleteUser: Int! @internal + + """ + Archives an email (RFC 2045 MIME message) into a mailing list. + Internal use only. + """ + archiveMessage(listID: Int!, message: Upload!): Boolean! @internal + + """ + Request a subscription to a mailing list for a given email address. If the + email is linked to a registered account, the subscription will be associated + to the account. Otherwise, it will be associated to the mailbox. If a pending + subscription request already exists for that list and email pair, the + existing confirmation token will be returned. + + Internal use only. + """ + requestSubscription(listID: Int!, email: String!): ConfirmationToken! @internal + + """ + Confirm a subscription request with a token previously obtained with + requestSubscription(). + + Internal use only. + """ + confirmSubscription(token: ConfirmationToken!, email: String!): MailingListSubscription! @internal + + """ + Request unsubscription from a mailing list for a given email address. + + Internal use only. + """ + requestUnsubscription(listID: Int!, email: String!): ConfirmationToken! @internal + + """ + Confirm an unsubscription request with a token previously obtained with + requestUnsubscription(). + + Internal use only. + """ + confirmUnsubscription(token: ConfirmationToken!, email: String!): MailingListSubscription! @internal + + """ + Updates the user's preferences. + """ + updatePreferences(preferences: PreferencesInput!): Preferences! @access(scope: PROFILE, kind: RW) +} diff --git a/server/forge/sourcehut/meta/gen.go b/server/forge/sourcehut/meta/gen.go new file mode 100644 index 00000000000..a11f56d143f --- /dev/null +++ b/server/forge/sourcehut/meta/gen.go @@ -0,0 +1,3 @@ +package meta + +//go:generate go run git.sr.ht/~emersion/gqlclient/cmd/gqlclientgen -s schema.graphqls -q queries.graphql -o gql.go -n meta diff --git a/server/forge/sourcehut/meta/queries.graphql b/server/forge/sourcehut/meta/queries.graphql new file mode 100644 index 00000000000..83c66617b86 --- /dev/null +++ b/server/forge/sourcehut/meta/queries.graphql @@ -0,0 +1,15 @@ +query FetchLoginUser { + me { + id + username + email + } +} + +query FetchUser($username: String!) { + userByName(username: $username) { + id + username + email + } +} diff --git a/server/forge/sourcehut/meta/schema.graphqls b/server/forge/sourcehut/meta/schema.graphqls new file mode 100644 index 00000000000..c8700446a88 --- /dev/null +++ b/server/forge/sourcehut/meta/schema.graphqls @@ -0,0 +1,1033 @@ +# This schema definition is available in the public domain, or under the terms +# of CC-0, at your choice. + +scalar Cursor +scalar Time + +"ISO-3166 country code" +scalar Country + +""" +This is used to decorate fields which are only accessible with a personal +access token, and are not available to clients using OAuth 2.0 access tokens. +""" +directive @private on FIELD_DEFINITION + +""" +Used to decorate fields which are for internal use, and are not available to +normal API users. +""" +directive @internal on FIELD_DEFINITION + +""" +Used to decorate fields which are for internal use, and are not available to +normal API users. +""" +directive @anoninternal on FIELD_DEFINITION + +""" +Used to decorate fields which are only available to admin accounts. +""" +directive @admin on FIELD_DEFINITION + +""" +Used to provide a human-friendly description of an access scope. +""" +directive @scopehelp(details: String!) on ENUM_VALUE + +enum AccessScope { + AUDIT_LOG @scopehelp(details: "audit log") + BILLING @scopehelp(details: "billing history") + PGP_KEYS @scopehelp(details: "PGP keys") + SSH_KEYS @scopehelp(details: "SSH keys") + PROFILE @scopehelp(details: "profile information") +} + +enum AccessKind { + RO @scopehelp(details: "read") + RW @scopehelp(details: "read and write") +} + +""" +Decorates fields for which access requires a particular OAuth 2.0 scope with +read or write access. For the meta.sr.ht API, you have access to all public +information without any special permissions - user profile information, +public keys, and so on. +""" +directive @access(scope: AccessScope!, kind: AccessKind!) on FIELD_DEFINITION + +# https://semver.org +type Version { + major: Int! + minor: Int! + patch: Int! + """ + If this API version is scheduled for deprecation, this is the date on which + it will stop working; or null if this API version is not scheduled for + deprecation. + """ + deprecationDate: Time +} + +interface Entity { + id: Int! + created: Time! + updated: Time! + """ + The canonical name of this entity. For users, this is their username + prefixed with '~'. Additional entity types will be supported in the future. + """ + canonicalName: String! +} + +enum UserType { + PENDING + USER + ADMIN + SUSPENDED +} + +enum PaymentStatus { + "User does not pay for their account" + UNPAID + "User is paid and their payment is current" + CURRENT + "User's payment has lapsed" + DELINQUENT + "User's paid services are subsidized" + SUBSIDIZED + "User receives paid services for free" + FREE +} + +type User implements Entity { + id: Int! + created: Time! + updated: Time! + canonicalName: String! + username: String! + email: String! + url: String + location: String + bio: String + userType: UserType! + + sshKeys(cursor: Cursor): SSHKeyCursor! @access(scope: SSH_KEYS, kind: RO) + pgpKeys(cursor: Cursor): PGPKeyCursor! @access(scope: PGP_KEYS, kind: RO) + + """ + User's current payment status. Only available if authenticated as this user. + """ + paymentStatus: PaymentStatus! @access(scope: BILLING, kind: RO) + + """ + Date at which next payment is due. Only available if authenticated as this + user. + """ + paymentDue: Time @access(scope: BILLING, kind: RO) + + """ + Details about the user's current paid subscription. Only available if + authenticated as this user. + """ + subscription: BillingSubscription @access(scope: BILLING, kind: RO) + + """ + Returns invoices for this user. Only available if authenticated as this user. + """ + invoices(cursor: Cursor): InvoiceCursor! @access(scope: BILLING, kind: RO) + + """ + User's billing address, if applicable, for invoicing. + """ + billingAddress: BillingAddress @access(scope: BILLING, kind: RO) + + """ + User's payment methods, if any. + """ + paymentMethods: [PaymentMethod!]! @access(scope: BILLING, kind: RO) + + """ + The default payment method in use for automatic subscription renewals. + """ + defaultPaymentMethod: PaymentMethod + + ### ### + ### Internal user attributes ### + ### ### + + "Returns true if this user should have access to paid services." + receivesPaidServices: Boolean! @private + + "Notice to provide to a suspended account" + suspensionNotice: String @anoninternal +} + +type AuditLogEntry { + id: Int! + created: Time! + ipAddress: String! + eventType: String! + details: String +} + +type LoginSecurity { + "True if TOTP (time-based one time passwords) is enabled for 2FA" + totp: Boolean! +} + +type SSHKey { + id: Int! + created: Time! + lastUsed: Time + user: User! @access(scope: PROFILE, kind: RO) + key: String! + fingerprint: String! + comment: String + + # TODO: replace with user.username + username: String! @anoninternal +} + +type PGPKey { + id: Int! + created: Time! + user: User! @access(scope: PROFILE, kind: RO) + key: String! + fingerprint: String! +} + +enum Currency { + EUR + USD +} + +enum PaymentInterval { + MONTHLY + ANNUALLY +} + +""" +A paid product available for purchase. +""" +type Product { + id: Int! + name: String! + prices: [ProductPrice!]! + + """ + This product has been retired and is no longer available for purchase. + """ + retired: Boolean! + + """ + This price is subsidized and available to users who cannot pay the standard + prices. + """ + subsidized: Boolean! +} + +""" +Price point for a product in a given currency. +""" +type ProductPrice { + "Applicable currency for this price" + currency: Currency! + + """ + Price in the smallest denomination of the currency, e.g. cents USD. Does not + include any applicable taxes. + """ + amount: Int! +} + +""" +Billing address for invoicing. +""" +type BillingAddress { + fullName: String + businessName: String + address1: String + address2: String + city: String + region: String + postcode: String + "ISO 3166 two-letter country code" + country: Country + "Value-added tax number (EU)" + vat: String +} + +type PaymentMethod { + id: Int! + created: Time! + + "Currency used with this payment method" + currency: Currency! + + "User-friendly name of this payment method (e.g. 'Visa ending in 1234')" + name: String! + + "Expiration date of this payment method, if applicable to the type" + expires: Time +} + +enum SubscriptionStatus { + """ + This subscription is pending and will become active once the first payment is + successfully recieved. + """ + PENDING + """ + This subscription is paid but the payment has not settled. Paid services are + available while awaiting settlement. + """ + SETTLEMENT + """ + This is the user's active subscription. + """ + ACTIVE + """ + This subscription has been cancelled and the service term is complete. + """ + INACTIVE +} + +enum PaymentIntentStatus { + PENDING + CANCELLED + PROCESSING + FAILED + SUCCEEDED +} + +type PaymentOutcome { + status: PaymentIntentStatus! + error: String +} + +type BillingSubscription { + id: Int! + user: User! @access(scope: PROFILE, kind: RO) + created: Time! + updated: Time! + status: SubscriptionStatus! + + "If true, payment is automatically renewed when term ellapses." + autorenew: Boolean! + + currency: Currency! + interval: PaymentInterval! + + """ + Selected product associated with this subscription. + """ + product: Product! + + """ + Shortcut to get the applicable price point for this subscription's product ID + and applicable currency + """ + price: ProductPrice! + + """ + Total price, not including applicable taxes, in the smallest denomination of + the currency, e.g. cents USD. + """ + subtotal: Int! + + """ + Total price, including applicable taxes, in the smallest denomination of the + currency, e.g. cents USD. + """ + total: Int! + + """ + Status of the last attempted payment for this subscription. + """ + payment: PaymentOutcome! +} + +type Invoice { + id: Int! + invoiceNo: String! + issued: Time! + entity: Entity! @access(scope: PROFILE, kind: RO) + + product: Product! + """Start of service period for which this invoice applies""" + serviceStart: Time! + """End of service period for which this invoice applies""" + serviceEnd: Time! + + currency: Currency! + """ + Amount charged denoted in the smallest denomination of the applicable + currency, e.g. cents USD. + """ + total: Int! +} + +type OAuthGrant { + id: Int! + client: OAuthClient! + issued: Time! + expires: Time! + tokenHash: String! @internal + grants: String +} + +type OAuthGrantRegistration { + grant: OAuthGrant! + grants: String! + secret: String! + refreshToken: String! +} + +type OAuthClient { + id: Int! + uuid: String! + redirectUrl: String! + + name: String! + description: String + url: String + + owner: Entity! @access(scope: PROFILE, kind: RO) +} + +type OAuthClientRegistration { + client: OAuthClient! + secret: String! +} + +type OAuthPersonalToken { + id: Int! + issued: Time! + expires: Time! + comment: String + grants: String +} + +type OAuthPersonalTokenRegistration { + token: OAuthPersonalToken! + secret: String! +} + +enum WebhookEvent { + "Used for user profile webhooks" + PROFILE_UPDATE + PGP_KEY_ADDED + PGP_KEY_REMOVED + SSH_KEY_ADDED + SSH_KEY_REMOVED +} + +interface WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + + """ + If this webhook was registered by an authorized OAuth 2.0 client, this + field is non-null. + """ + client: OAuthClient @private + + "All deliveries which have been sent to this webhook." + deliveries(cursor: Cursor): WebhookDeliveryCursor! + + "Returns a sample payload for this subscription, for testing purposes" + sample(event: WebhookEvent!): String! +} + +type ProfileWebhookSubscription implements WebhookSubscription { + id: Int! + events: [WebhookEvent!]! + query: String! + url: String! + client: OAuthClient @private + deliveries(cursor: Cursor): WebhookDeliveryCursor! + sample(event: WebhookEvent!): String! +} + +type WebhookDelivery { + uuid: String! + date: Time! + event: WebhookEvent! + subscription: WebhookSubscription! + requestBody: String! + + """ + These details are provided only after a response is received from the + remote server. If a response is sent whose Content-Type is not text/*, or + cannot be decoded as UTF-8, the response body will be null. It will be + truncated after 64 KiB. + """ + responseBody: String + responseHeaders: String + responseStatus: Int +} + +interface WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! +} + +type ProfileUpdateEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + profile: User! +} + +type PGPKeyEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + key: PGPKey! +} + +type SSHKeyEvent implements WebhookPayload { + uuid: String! + event: WebhookEvent! + date: Time! + + key: SSHKey! +} + +""" +A cursor for enumerating a list of audit log entries + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type AuditLogCursor { + results: [AuditLogEntry!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of invoices + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type InvoiceCursor { + results: [Invoice!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of SSH keys + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type SSHKeyCursor { + results: [SSHKey!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of PGP keys + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type PGPKeyCursor { + results: [PGPKey!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of webhook deliveries + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type WebhookDeliveryCursor { + results: [WebhookDelivery!]! + cursor: Cursor +} + +""" +A cursor for enumerating a list of webhook subscriptions + +If there are additional results available, the cursor object may be passed +back into the same endpoint to retrieve another page. If the cursor is null, +there are no remaining results to return. +""" +type WebhookSubscriptionCursor { + results: [WebhookSubscription!]! + cursor: Cursor +} + +type Query { + "Returns API version information." + version: Version! + + "Returns the authenticated user." + me: User! @access(scope: PROFILE, kind: RO) + + "Returns a specific user" + userByName(username: String!): User @access(scope: PROFILE, kind: RO) + userByEmail(email: String!): User @access(scope: PROFILE, kind: RO) + + "Returns a specific SSH key by its fingerprint, in hexadecimal" + sshKeyByFingerprint(fingerprint: String!): SSHKey @access(scope: SSH_KEYS, kind: RO) + + "Returns a specific PGP key by its fingerprint, in hexadecimal." + pgpKeyByFingerprint(fingerprint: String!): PGPKey @access(scope: PGP_KEYS, kind: RO) + + "Returns the audit log for the authenticated user." + auditLog(cursor: Cursor): AuditLogCursor! @access(scope: AUDIT_LOG, kind: RO) + + "Returns the authenticated user's login security settings." + loginSecurity: LoginSecurity! @access(scope: PROFILE, kind: RO) + + """ + Returns a list of user profile webhook subscriptions. For clients + authenticated with a personal access token, this returns all webhooks + configured by all GraphQL clients for your account. For clients + authenticated with an OAuth 2.0 access token, this returns only webhooks + registered for your client. + """ + profileWebhooks(cursor: Cursor): WebhookSubscriptionCursor! + + "Returns details of a user profile webhook subscription by its ID." + profileWebhook(id: Int!): WebhookSubscription + + """ + Returns information about the webhook currently being processed. This is + not valid during normal queries over HTTP, and will return an error if used + outside of a webhook context. + """ + webhook: WebhookPayload! + + "Returns the current OAuth grant in use, if any" + myOauthGrant: OAuthGrant + + "Returns OAuth grants issued for the authenticated user" + oauthGrants: [OAuthGrant!]! @private + + "List of OAuth clients this user administrates" + oauthClients: [OAuthClient!]! @private + + "Returns a list of personal OAuth tokens issued" + personalAccessTokens: [OAuthPersonalToken!]! @private + + "Returns information about a subscription product available for purchase." + product(id: Int!): Product + + "Returns a list of paid subscriptions available for purchase." + products: [Product!]! + + ### ### + ### The following resolvers are for internal use. ### + ### ### + + "Returns a specific user by ID" + userByID(id: Int!): User @anoninternal + + "Returns a specific user by username" + user(username: String!): User @anoninternal + + "Returns a specific OAuth client (by database ID)" + oauthClientByID(id: Int!): OAuthClient @internal + + "Returns a specific OAuth client (by UUID)" + oauthClientByUUID(uuid: String!): OAuthClient @internal + + """ + Returns the revocation status of a given OAuth 2.0 token hash (SHA-512). If + the token or client ID has been revoked, this returns true, and the key + should not be trusted. Client ID is optional for personal access tokens. + """ + tokenRevocationStatus(hash: String!, clientId: String): Boolean! @internal +} + +""" +Omit these fields to leave them unchanged, or set them to null to clear +their value. +""" +input UserInput { + url: String + location: String + bio: String + + """ + Note: changing the user's email address will not take effect immediately; + the user is sent an email to confirm the change first. + """ + email: String +} + +input ProfileWebhookInput { + url: String! + events: [WebhookEvent!]! + query: String! +} + +""" +All fields are optional. If a field is omitted, it is unchanged. If a field is +null, it is set to null. +""" +input BillingAddressInput { + fullName: String + businessName: String + address1: String + address2: String + city: String + region: String + postcode: String + "ISO 3166 two-letter country code" + country: Country + "Value-added tax number (EU)" + vat: String +} + +""" +For changing the active paid subscription's parameters. Changes apply from the +start of the next payment term (i.e. from User.paymentDue). +""" +input UpdateBillingSubscriptionInput { + productID: Int! + interval: PaymentInterval! +} + +input ProductPriceInput { + currency: Currency! + amount: Int! +} + +type TOTPConfig { + """ + List of generated recovery codes for use with TOTP. + """ + recoveryCodes: [String!]! +} + +type Mutation { + updateUser(input: UserInput): User! @access(scope: PROFILE, kind: RW) + + createPGPKey(key: String!): PGPKey! @access(scope: PGP_KEYS, kind: RW) + deletePGPKey(id: Int!): PGPKey @access(scope: PGP_KEYS, kind: RW) + + createSSHKey(key: String!): SSHKey! @access(scope: SSH_KEYS, kind: RW) + deleteSSHKey(id: Int!): SSHKey @access(scope: SSH_KEYS, kind: RW) + + """ + Causes the "last used" time of this SSH key to be set to the current time. + """ + updateSSHKeyLastUsed(fingerprint: String!): SSHKey! @access(scope: SSH_KEYS, kind: RO) @anoninternal + + """ + Update the authenticated user's billing address. + """ + updateBillingAddress(input: BillingAddressInput!): BillingAddress! @access(scope: BILLING, kind: RW) + + """ + Update an active billing subscription. + """ + updateBillingSubscription( + subscriptionID: Int!, + input: UpdateBillingSubscriptionInput!, + ): BillingSubscription @access(scope: BILLING, kind: RW) + + """ + Cancels an active billing subscription. The changes will not take effect + until the end of the paid term (unless the account is past due, in which case + it applies immediately). + """ + cancelBillingSubscription( + subscriptionID: Int!, + ): BillingSubscription @private + + # TODO: Allow removal of the last payment method if the user has disabled + # automatic renewal (blocker: allow users to disable autorenew) + """ + Removes a payment method from your account. Cannot be used to remove the last + payment method. + """ + deletePaymentMethod(id: Int!): PaymentMethod @access(scope: BILLING, kind: RW) + + """ + Sets the user's default payment method. + """ + setDefaultPaymentMethod(id: Int!): PaymentMethod @access(scope: BILLING, kind: RW) + + """ + Creates a new user profile webhook subscription. When an event from the + provided list of events occurs, the 'query' parameter (a GraphQL query) + will be evaluated and the results will be sent to the provided URL as the + body of an HTTP POST request. The list of events must include at least one + event, and no duplicates. + + This query is evaluated in the webhook context, such that query { webhook } + may be used to access details of the event which trigged the webhook. The + query may not make any mutations. + """ + createWebhook(config: ProfileWebhookInput!): WebhookSubscription! + + """ + Deletes a user profile webhook. Any events already queued may still be + delivered after this request completes. Clients authenticated with a + personal access token may delete any webhook registered for their account, + but authorized OAuth 2.0 clients may only delete their own webhooks. + Manually deleting a webhook configured by a third-party client may cause + unexpected behavior with the third-party integration. + """ + deleteWebhook(id: Int!): WebhookSubscription! + + ### ### + ### The following resolvers are for admin use. ### + ### ### + + """ + Transfers billing details from one user account to another, returning the + target account. + """ + billingTransfer(from: String!, to: String!): User! @admin + + """ + Grants free service to the given user ID until the provided expiration date. + """ + subsidizeUser(userID: Int!, until: Time!): User! @admin + + """ + Create a new paid product. + """ + createProduct(name: String!, prices: [ProductPriceInput!]!): Product! @admin + + """ + Retires a paid product. + """ + retireProduct(id: Int!): Product @admin + + ### ### + ### The following resolvers are for internal use. ### + ### ### + + "Registers a new account." + registerAccount(email: String!, + username: String!, + password: String!, + pgpKey: String): User @anoninternal + + "Requests a password reset. The return value is meaningless." + resetPassword(email: String!): Int @anoninternal + + "Confirm an account registration email." + confirmRegistration(token: String!): User @anoninternal + + "Confirm a user's change of email address" + confirmEmailChange(token: String!): User @anoninternal + + "Confirm a user's password change" + confirmPasswordChange( + token: String!, + newPassword: String!, + ): User @anoninternal + + """ + Enables TOTP for the user account. The secret is 20 base32-encoded bytes. + """ + enableTOTP(secret: String!): TOTPConfig! @internal + + # TODO: + # - Enable TOTP via GraphQL as well + # - Manage logins and authentication through GraphQL more generally + + """ + Registers an OAuth client. Only OAuth 2.0 confidental clients are + supported. + """ + registerOAuthClient( + redirectUri: String!, + clientName: String!, + clientDescription: String, + clientUrl: String): OAuthClientRegistration! @internal + + """ + Revokes this OAuth client, revoking all tokens for it and preventing future + use. + """ + revokeOAuthClient(uuid: String!): OAuthClient @internal + + "Revokes a specific OAuth grant." + revokeOAuthGrant(hash: String!): OAuthGrant @internal + + "Issues an OAuth personal access token." + issuePersonalAccessToken(grants: String, comment: String): + OAuthPersonalTokenRegistration! @internal + + "Revokes a personal access token." + revokePersonalAccessToken(id: Int!): OAuthPersonalToken @internal + + """ + Issues an OAuth 2.0 authorization code. Used after the user has consented + to the access grant request. + """ + issueAuthorizationCode(clientUUID: String!, grants: String!): String! @internal + + """ + Completes the OAuth 2.0 grant process and issues an OAuth token for a + specific OAuth client. + """ + issueOAuthGrant(authorization: String!, clientUUID: String, + clientSecret: String!, redirectUri: String): OAuthGrantRegistration @internal + + """ + Refreshes an existing OAuth 2.0 grant. This invalidates the previous grant + and returns a new one. + """ + refreshOAuthGrant(refreshToken: String!, clientUUID: String!, + clientSecret: String!, grants: String): OAuthGrantRegistration @internal + + """ + Send a notification email. + + The 'address' parameter must be a single RFC 5322 address (e.g. "Barry Gibbs + ", or "bg@example.com"). The 'message' parameter must be a + RFC 5322 compliant Internet message with the special requirement that it must + not contain any recipients (i.e. no 'To', 'Cc', or 'Bcc' header). + + The message will be signed with the site key. If the address is that of a + registered user it will be encrypted according to the user's privacy + settings. + """ + sendEmail(address: String!, message: String!): Boolean! @anoninternal + + """ + Deletes the authenticated user's account. + """ + deleteUser(reserve: Boolean!): Int! @internal + + """ + Orders a new paid product subscription. The new subscription is in a PENDING + state. The payment intent should be completed to proceed. + """ + orderProductSubscription( + productID: Int!, + currency: Currency!, + interval: PaymentInterval!, + autorenew: Boolean!, + """ + Optional: update the user's billing address at the same time + """ + address: BillingAddressInput, + """ + Optional: include this if retrying the same request + """ + idempotencyKey: String, + ): PaymentIntent @internal + + """ + Creates a new payment intent to charge a subscription's renewal fee + interactively. The subscription must be past due. + """ + beginSubscriptionRenewal( + subscriptionID: Int!, + """ + Optional: include this if retrying the same request + """ + idempotencyKey: String, + ): PaymentIntent! @internal + + """ + Finalizes a product order from the PaymentIntent ID after the user has + finalized their payment, returning the completed PaymentIntent. + """ + finalizePaymentIntent(intentID: String!): PaymentIntent! @internal + + """ + Creates a "setup" intent for adding a new payment method to the authenticated + user's account. + """ + createSetupIntent: SetupIntent! @internal + + """ + Finalizes a setup intent. + """ + finalizeSetupIntent(intentID: String!): SetupIntent @internal + + """ + Processes payment when the user's account is past due. Returns the payment + intent if a payment was collected, or null if a payment was not collected but + the operation was successful (the latter occurs when a subscription was + cancelled or a subsidized user's subsidy expired, in these cases the account + is settled and converted to non-paying). + """ + processPaymentDue: PaymentIntent @internal + + """ + Causes an invoice to be regenerated in order to issue a correction. The admin + is assumed to have manually made the required changes in the database. + """ + reissueInvoice(id: Int!): Invoice @admin +} + +# The following types are for internal use + +interface PaymentIntent { + id: String! + subscription: BillingSubscription! + billingAddress: BillingAddress! + idempotencyKey: String! + + taxRate: Float + taxDue: Int! + totalDue: Int! + + method: PaymentMethod + outcome: PaymentOutcome +} + +type StripePaymentIntent implements PaymentIntent { + id: String! + subscription: BillingSubscription! + billingAddress: BillingAddress! + idempotencyKey: String! + + totalDue: Int! + taxRate: Float + taxDue: Int! + + method: PaymentMethod + outcome: PaymentOutcome + + publicKey: String + clientSecret: String +} + +enum SetupIntentStatus { + PENDING + PROCESSING + SUCCEEDED + CANCELLED +} + +interface SetupIntent { + id: String! + status: SetupIntentStatus! + method: PaymentMethod +} + +type StripeSetupIntent implements SetupIntent { + id: String! + status: SetupIntentStatus! + method: PaymentMethod + + publicKey: String + clientSecret: String +} diff --git a/server/forge/sourcehut/sourcehut.go b/server/forge/sourcehut/sourcehut.go new file mode 100644 index 00000000000..62587798127 --- /dev/null +++ b/server/forge/sourcehut/sourcehut.go @@ -0,0 +1,511 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sourcehut + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "git.sr.ht/~emersion/gqlclient" + "github.com/rs/zerolog/log" + "golang.org/x/oauth2" + + "go.woodpecker-ci.org/woodpecker/v3/server" + "go.woodpecker-ci.org/woodpecker/v3/server/forge" + "go.woodpecker-ci.org/woodpecker/v3/server/forge/sourcehut/git" + "go.woodpecker-ci.org/woodpecker/v3/server/forge/sourcehut/meta" + forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" + "go.woodpecker-ci.org/woodpecker/v3/server/model" +) + +const ( + authorizeTokenURL = "%s/oauth2/authorize" + accessTokenURL = "%s/oauth2/access-token" + graphqlEndpointURL = "%s/query" +) + +const ( + gitWebhookPayload = ` + query { + version { + settings { + sshUser + } + } + + webhook { + uuid + event + + ... on GitEvent { + __typename + + repository { + id + name + visibility + owner { + canonicalName + } + access + + HEAD { + name + } + } + + pusher { + canonicalName + } + + updates { + ref { + name + } + + new { + id + type + + ... on Commit { + __typename + author { + name + email + } + message + } + } + + diff + } + } + } + } + ` +) + +type SourceHut struct { + id int64 + url string + metaURL string + gitURL string + listsURL string + oauth2URL string + oAuthClientID string + oAuthClientSecret string + skipVerify bool +} + +// Opts defines configuration options. +type Opts struct { + URL string // SourceHut info URL (e.g. project hub) + MetaURL string // SourceHut meta URL + GitURL string // SourceHut git URL + ListsURL string // SourceHut lists URL + OAuth2URL string // User-facing SourceHut server url for OAuth2. + OAuthClientID string // OAuth2 Client ID + OAuthClientSecret string // OAuth2 Client Secret + SkipVerify bool // Skip ssl verification. +} + +// New returns a Forge implementation that integrates with SourceHut. +// See https://sourcehut.org +func New(id int64, opts Opts) (forge.Forge, error) { + if opts.OAuth2URL == "" { + opts.OAuth2URL = opts.URL + } + + return &SourceHut{ + id: id, + url: opts.URL, + metaURL: opts.MetaURL, + gitURL: opts.GitURL, + listsURL: opts.ListsURL, + oauth2URL: opts.OAuth2URL, + oAuthClientID: opts.OAuthClientID, + oAuthClientSecret: opts.OAuthClientSecret, + skipVerify: opts.SkipVerify, + }, nil +} + +// Name returns the string name of this driver. +func (c *SourceHut) Name() string { + return "sourcehut" +} + +// URL returns the root url of a configured forge. +func (c *SourceHut) URL() string { + return c.metaURL +} + +func (c *SourceHut) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) { + return &oauth2.Config{ + ClientID: c.oAuthClientID, + ClientSecret: c.oAuthClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf(authorizeTokenURL, c.oauth2URL), + TokenURL: fmt.Sprintf(accessTokenURL, c.oauth2URL), + }, + RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), + Scopes: []string{ + "meta.sr.ht/PROFILE:RO", + "git.sr.ht/ACLS:RO", + "git.sr.ht/PROFILE:RO", + "git.sr.ht/REPOSITORIES:RO", + "git.sr.ht/OBJECTS:RO", + "lists.sr.ht/PROFILE:RO", + "lists.sr.ht/EMAILS:RO", + "lists.sr.ht/LISTS:RO", + "lists.sr.ht/PATCHES:RO", + }, + }, + + context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: c.skipVerify}, + Proxy: http.ProxyFromEnvironment, + }}) +} + +// Login authenticates an account with SourceHut. The SourceHut account details +// are returned when the user is successfully authenticated. +func (c *SourceHut) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { + config, oauth2Ctx := c.oauth2Config(ctx) + redirectURL := config.AuthCodeURL(req.State) + + // check the OAuth code + if len(req.Code) == 0 { + return nil, redirectURL, nil + } + + token, err := config.Exchange(oauth2Ctx, req.Code) + if err != nil { + return nil, redirectURL, err + } + + client := c.newClientToken(ctx, c.metaURL, token.AccessToken) + account, err := meta.FetchLoginUser(client, ctx) + if err != nil { + return nil, redirectURL, err + } + + return &model.User{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry.UTC().Unix(), + Login: account.Username, + Email: account.Email, + ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(account.Id)), + }, redirectURL, nil +} + +func (c *SourceHut) newClientToken(ctx context.Context, baseURL, accessToken string) *gqlclient.Client { + cfg, httpCtx := c.oauth2Config(ctx) + httpClient := cfg.Client(httpCtx, &oauth2.Token{ + AccessToken: accessToken, + }) + return gqlclient.New(fmt.Sprintf(graphqlEndpointURL, baseURL), httpClient) +} + +func (c *SourceHut) Auth(ctx context.Context, token, secret string) (string, error) { + client := c.newClientToken(ctx, c.metaURL, token) + account, err := meta.FetchLoginUser(client, ctx) + if err != nil { + return "", err + } + return account.Username, nil +} + +func (c *SourceHut) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { + return []*model.Team{}, nil +} + +func (c *SourceHut) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { + client := c.newClientToken(ctx, c.gitURL, u.AccessToken) + owner, name, _ = strings.Cut(string(remoteID), "/") + user, ver, err := git.GetRepo(client, ctx, owner[1:], name) + if err != nil { + return nil, err + } + if user.Repository == nil { + return nil, nil + } + return c.toRepo(user.Repository, ver), nil +} + +func (c *SourceHut) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { + // TODO: Pagination on SourceHut does not work well with Woodpecker's + // higher-level internals (and doesn't work well at all, to be frank) + if p.Page != 1 { + return nil, nil + } + + client := c.newClientToken(ctx, c.gitURL, u.AccessToken) + me, ver, err := git.GetRepos(client, ctx, nil) + if err != nil { + return nil, err + } + + var repos []*model.Repo + for _, repo := range me.Repositories.Results { + repos = append(repos, c.toRepo(&repo, ver)) + } + + return repos, nil +} + +func (c *SourceHut) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error) { + cfg, httpCtx := c.oauth2Config(ctx) + httpClient := cfg.Client(httpCtx, &oauth2.Token{ + AccessToken: u.AccessToken, + }) + client := gqlclient.New(fmt.Sprintf(graphqlEndpointURL, c.gitURL), httpClient) + + owner, name, _ := strings.Cut(string(r.ForgeRemoteID), "/") + user, err := git.GetFile(client, ctx, owner[1:], name, fileName) + if err != nil { + return nil, err + } + if user.Repository.Path == nil { + return nil, fmt.Errorf("path %s not found", fileName) + } + + var content string + switch obj := user.Repository.Path.Object.Value.(type) { + case *git.BinaryBlob: + content = string(obj.Content) + case *git.TextBlob: + content = string(obj.Content) + default: + return nil, fmt.Errorf("path %s is not a file", fileName) + } + + resp, err := httpClient.Get(content) + if err != nil { + return nil, err + } + data, err := io.ReadAll(resp.Body) + resp.Body.Close() + return data, err +} + +func (c *SourceHut) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*forge_types.FileMeta, error) { + cfg, httpCtx := c.oauth2Config(ctx) + httpClient := cfg.Client(httpCtx, &oauth2.Token{ + AccessToken: u.AccessToken, + }) + client := gqlclient.New(fmt.Sprintf(graphqlEndpointURL, c.gitURL), httpClient) + + owner, name, _ := strings.Cut(string(r.ForgeRemoteID), "/") + user, err := git.GetDir(client, ctx, owner[1:], name, dirName) + if err != nil { + return nil, err + } + + if user.Repository.Path == nil { + return nil, fmt.Errorf("path %s not found", dirName) + } + + var tree *git.Tree + switch obj := user.Repository.Path.Object.Value.(type) { + case *git.Tree: + tree = obj + default: + return nil, fmt.Errorf("path %s is not a directory", dirName) + } + + // TODO: Paginate this query + var entries []*forge_types.FileMeta + for _, ent := range tree.Entries.Results { + var content string + switch obj := ent.Object.Value.(type) { + case *git.TextBlob: + content = string(obj.Content) + case *git.BinaryBlob: + content = string(obj.Content) + default: + continue + } + + resp, err := httpClient.Get(content) + if err != nil { + return nil, err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + resp.Body.Close() + return nil, err + } + resp.Body.Close() + + entries = append(entries, &forge_types.FileMeta{ + Name: ent.Name, + Data: data, + }) + } + + return entries, nil +} + +func (c *SourceHut) Status(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error { + return nil // TODO +} + +func (c *SourceHut) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { + // XXX: SourceHut does not support cloning private repos over HTTP + return &model.Netrc{ + Login: "", + Password: "", + Machine: "", + Type: model.ForgeTypeSourceHut, + }, nil +} + +func (c *SourceHut) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { + client := c.newClientToken(ctx, c.gitURL, u.AccessToken) + owner, name, _ := strings.Cut(string(r.ForgeRemoteID), "/") + user, _, err := git.GetRepo(client, ctx, owner[1:], name) + repo := user.Repository + + _, err = git.RegisterPushWebhook(client, ctx, repo.Id, gitWebhookPayload, link) + if err != nil { + return err + } + + // XXX: Ideally we would store the webhook ID here + + return nil +} + +func (c *SourceHut) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { + client := c.newClientToken(ctx, c.gitURL, u.AccessToken) + owner, name, _ := strings.Cut(string(r.ForgeRemoteID), "/") + user, _, err := git.GetRepo(client, ctx, owner[1:], name) + if err != nil { + return err + } + + webhooks, err := git.GetPushWebhooks(client, ctx, user.Repository.Id) + if err != nil { + return err + } + + // Note: we would only ever have registered one webhook + webhook := webhooks.Results[0] + _, err = git.UnregisterPushWebhook(client, ctx, webhook.Id) + return err +} + +func (c *SourceHut) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { + // TODO: Pagination + if p.Page != 1 { + return nil, nil + } + + client := c.newClientToken(ctx, c.gitURL, u.AccessToken) + owner, name, _ := strings.Cut(string(r.ForgeRemoteID), "/") + + var branches []string + user, err := git.GetReferences(client, ctx, owner[1:], name, nil) + if err != nil { + return nil, nil + } + + for _, ref := range user.Repository.References.Results { + if strings.HasPrefix(ref.Name, "refs/heads/") { + branches = append(branches, ref.Name[len("refs/heads/"):]) + } + } + + return branches, nil +} + +func (c *SourceHut) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { + client := c.newClientToken(ctx, c.gitURL, u.AccessToken) + owner, name, _ := strings.Cut(string(r.ForgeRemoteID), "/") + user, err := git.GetHead(client, ctx, owner[1:], name, "refs/heads/"+branch) + if err != nil { + return nil, err + } + + target := user.Repository.Reference.Follow + return &model.Commit{ + SHA: target.Id, + ForgeURL: fmt.Sprintf("%s/%s/%s/commit/%s", c.gitURL, owner, name, target.Id), + }, nil +} + +func (c *SourceHut) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { + return nil, nil // TODO +} + +func (c *SourceHut) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { + type hookData struct { + Data struct { + Webhook *git.WebhookPayload `json:"webhook"` + Version *git.Version `json:"version"` + } `json:"data"` + } + + bytes, err := io.ReadAll(r.Body) + if err != nil { + return nil, nil, err + } + + var hook hookData + err = json.Unmarshal(bytes, &hook) + if err != nil { + return nil, nil, err + } + + var gitEvent *git.GitEvent + switch payload := hook.Data.Webhook.Value.(type) { + case *git.GitEvent: + gitEvent = payload + default: + log.Warn().Msg("Ignoring unknown webhook event") + return nil, nil, nil + } + + repo := c.toRepo(gitEvent.Repository, hook.Data.Version) + pipeline := c.toPushPipeline(gitEvent) + return repo, pipeline, nil +} + +func (c *SourceHut) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) { + return &model.OrgPerm{ + Member: true, + Admin: true, + }, nil +} + +func (c *SourceHut) Org(ctx context.Context, u *model.User, org string) (*model.Org, error) { + client := c.newClientToken(ctx, c.metaURL, u.AccessToken) + account, err := meta.FetchUser(client, ctx, org[1:]) + if err != nil { + return nil, err + } + return &model.Org{ + Name: account.Username, + ForgeID: int64(account.Id), + IsUser: true, + Private: false, + }, nil +} diff --git a/server/model/forge.go b/server/model/forge.go index 3cc91e33707..693ebf5f64d 100644 --- a/server/model/forge.go +++ b/server/model/forge.go @@ -23,6 +23,7 @@ const ( ForgeTypeForgejo ForgeType = "forgejo" ForgeTypeBitbucket ForgeType = "bitbucket" ForgeTypeBitbucketDatacenter ForgeType = "bitbucket-dc" + ForgeTypeSourceHut ForgeType = "sourcehut" ForgeTypeAddon ForgeType = "addon" ) diff --git a/server/services/setup.go b/server/services/setup.go index 1079326f69d..d388384cda3 100644 --- a/server/services/setup.go +++ b/server/services/setup.go @@ -156,6 +156,23 @@ func setupForgeService(c *cli.Command, _store store.Store) error { _forge.AdditionalOptions["git-username"] = c.String("bitbucket-dc-git-username") _forge.AdditionalOptions["git-password"] = c.String("bitbucket-dc-git-password") _forge.AdditionalOptions["oauth-enable-project-admin-scope"] = c.Bool("bitbucket-dc-oauth-enable-oauth2-scope-project-admin") + case c.Bool("sourcehut"): + _forge.Type = model.ForgeTypeSourceHut + if _forge.URL == "" { + _forge.URL = "https://sourcehut.org" + } + _forge.AdditionalOptions["meta-url"] = c.String("sourcehut-meta-url") + if _forge.AdditionalOptions["meta-url"].(string) == "" { + _forge.AdditionalOptions["meta-url"] = "https://meta.sr.ht" + } + _forge.AdditionalOptions["git-url"] = c.String("sourcehut-git-url") + if _forge.AdditionalOptions["git-url"].(string) == "" { + _forge.AdditionalOptions["git-url"] = "https://git.sr.ht" + } + _forge.AdditionalOptions["lists-url"] = c.String("sourcehut-lists-url") + if _forge.AdditionalOptions["lists-url"].(string) == "" { + _forge.AdditionalOptions["lists-url"] = "https://lists.sr.ht" + } default: return errors.New("forge not configured") } diff --git a/web/src/components/atomic/Icon.vue b/web/src/components/atomic/Icon.vue index 593045284c2..406ae665e81 100644 --- a/web/src/components/atomic/Icon.vue +++ b/web/src/components/atomic/Icon.vue @@ -79,6 +79,7 @@ +