From 30e438379294dda5acc52167eac7ac0ea3428c19 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Tue, 14 Oct 2025 01:38:10 +0200 Subject: [PATCH] Document Forge interface precisely (#5636) --- server/forge/forge.go | 132 ++++++++++++++++++++++++++++++---------- server/forge/refresh.go | 15 +++-- 2 files changed, 111 insertions(+), 36 deletions(-) diff --git a/server/forge/forge.go b/server/forge/forge.go index ad6b084e2b..8c8e093b3b 100644 --- a/server/forge/forge.go +++ b/server/forge/forge.go @@ -13,6 +13,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package forge defines the Forge interface for integrating with Git hosting +// platforms (GitHub, GitLab, Gitea, Forgejo, Bitbucket, etc.). +// +// The Forge interface provides a unified abstraction for OAuth authentication, +// repository management, webhook processing, and status reporting. package forge import ( @@ -23,71 +28,134 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/model" ) -// TODO: use pagination - +// Forge defines the interface for integrating with Git hosting platforms. +// +// Architecture: +// A Forge instance represents a single forge provider. Woodpecker supports +// multiple forge instances simultaneously through ForgeManager. +// Each User and Repo has a ForgeID field associating them with a specific forge. +// +// Thread Safety: +// Implementations must be safe for concurrent use. Methods receive context.Context +// for cancellation/timeout. Do not maintain user-specific state; user context is +// passed via *model.User parameter. +// +// Authentication: +// OAuth2-based authentication is assumed. Tokens are refreshed 30 minutes before +// expiry via the optional Refresher interface. +// +// Configuration Fetching: +// Pipeline configurations retrieved via File() or Dir() from Repo.Config path +// with fallback to defaults. +// +// Error Handling: +// - types.ErrIgnoreEvent: Skippable webhook events +// - types.RecordNotExist: Resource not found +// - nil Repo/Pipeline: "No action needed" (not an error). type Forge interface { - // Name returns the string name of this driver + // Name returns the unique identifier of this forge driver. + // Examples: "github", "gitlab", "gitea", "forgejo", "bitbucket" + // Must be unique and constant across all implementations. Name() string - // URL returns the root url of a configured forge + // URL returns the root URL of the forge instance. + // Examples: "https://github.com", "https://gitlab.example.com" URL() string - // Login authenticates the session and returns the - // forge user details and the URL to redirect to if not authorized yet. + // Login authenticates a user via OAuth2. + // + // OAuth Flow: + // 1. Initial call with empty OAuthRequest.Code returns (nil, redirectURL, nil) + // 2. User authorizes at redirectURL + // 3. Second call with OAuthRequest.Code returns (User, redirectURL, nil) + // + // Returned User must contain: Login, Email, Avatar, AccessToken, RefreshToken, Expiry, ForgeRemoteID Login(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error) - // Auth authenticates the session and returns the forge user - // login for the given token and secret + // Auth validates an access token and returns the associated username. Auth(ctx context.Context, token, secret string) (string, error) - // Teams fetches a list of team memberships from the forge. + // Teams fetches all team/organization memberships for a user. + // May return empty slice if forge doesn't support teams/organizations. + // Used to determine if an user is member of an team/organization. Teams(ctx context.Context, u *model.User) ([]*model.Team, error) - // Repo fetches the repository from the forge, preferred is using the ID, fallback is owner/name. + // Repo fetches a single repository. + // + // Lookup Strategy: + // - Prefer lookup by remoteID (forge's internal ID) if provided (more reliable as repos can be renamed) + // - Fallback to owner/name if remoteID empty + // + // Must verify user has at least read access. Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) - // Repos fetches a list of repos from the forge. + // Repos fetches all repositories accessible to the user. + // Should include user's permission level in Repo.Perm. Repos(ctx context.Context, u *model.User) ([]*model.Repo, error) - // File fetches a file from the forge repository and returns it in string - // format. - File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) + // File fetches a single file at a specific commit. + // Primary method for retrieving pipeline configuration files. + // Must fetch at specific commit (b.Commit), not branch head. + File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error) - // Dir fetches a folder from the forge repository - Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*types.FileMeta, error) + // Dir fetches all files in a directory at a specific commit. + // Supports pipeline configurations split across multiple files. + // Should return files only. + Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*types.FileMeta, error) - // Status sends the commit status to the forge. - // An example would be the GitHub pull request status. + // Status sends workflow status updates to the forge. + // Provides visual feedback in forge UI (commit checks, PR status). + // Failures should be logged but not block pipeline execution. Status(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error - // Netrc returns a .netrc file that can be used to clone - // private repositories from a forge. + // Netrc generates .netrc credentials for cloning private repositories. + // May receive nil user for public repos. Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) - // Activate activates a repository by creating the post-commit hook. + // Activate creates a webhook pointing to Woodpecker. + // Called when user activates a repository. + // Must verify user has admin access. Should set webhook secret from r.Hash. + // Configure webhook for all events Hook() can parse. Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error - // Deactivate deactivates a repository by removing all previously created - // post-commit hooks matching the given link. + // Deactivate removes the webhook. + // Should ignore if webhook doesn't exist anymore. Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error - // Branches returns the names of all branches for the named repository. + // Branches returns all branch names in the repository. + // Should support pagination via ListOptions. Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) - // BranchHead returns the sha of the head (latest commit) of the specified branch + // BranchHead returns the latest commit SHA for a branch. BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) - // PullRequests returns all pull requests for the named repository. + // PullRequests returns all open pull requests. + // Should support pagination via ListOptions. PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) - // Hook parses the post-commit hook from the Request body and returns the - // required data in a standard format. - Hook(ctx context.Context, r *http.Request) (repo *model.Repo, pipeline *model.Pipeline, err error) + // Hook parses incoming webhook and returns pipeline data. + // + // Webhook Processing Flow: + // 1. HTTP request arrives at /api/hook with forge-specific format + // 2. Webhook token verified against repo.Hash + // 3. Hook() parses webhook and returns (Repo, Pipeline, error) + // + // Return Semantics: + // - (repo, pipeline, nil): Execute pipeline for this event + // - (repo, nil, nil): Valid webhook, no pipeline should run + // - (nil, nil, types.ErrIgnoreEvent): Event ignored (logged) + // - (nil, nil, error): Invalid webhook or parsing error + // + // Must verify webhook signature to prevent spoofing. + // Should return types.ErrIgnoreEvent for non-pipeline events + // (e.g. repository settings changed). + Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) - // OrgMembership returns if user is member of organization and if user - // is admin/owner in that organization. + // OrgMembership checks if user is member of organization and their permission. + // Should return (Member: false, Admin: false) if not a member. OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) - // Org fetches the organization from the forge by name. If the name is a user an org with type user is returned. + // Org fetches organization details. + // If identifier is a user, return org with IsUser: true. Org(ctx context.Context, u *model.User, org string) (*model.Org, error) } diff --git a/server/forge/refresh.go b/server/forge/refresh.go index 0b0e5fac56..c75e42bfaa 100644 --- a/server/forge/refresh.go +++ b/server/forge/refresh.go @@ -24,11 +24,18 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/store" ) -// Refresher refreshes an oauth token and expiration for the given user. It -// returns true if the token was refreshed, false if the token was not refreshed, -// and error if it failed to refresh. +// Refresher is an optional interface for OAuth token refresh support. +// +// Tokens are checked before each operation. If expiring within 30 minutes, +// Refresh() is called automatically. +// +// Implementations: GitLab, Bitbucket (GitHub/Gitea tokens don't expire). type Refresher interface { - Refresh(context.Context, *model.User) (bool, error) + // Refresh attempts to refresh the user's OAuth access token. + // Should update u.AccessToken, u.RefreshToken, and u.Expiry. + // Returns true if any fields were updated. + // Caller must persist updated user to database. + Refresh(ctx context.Context, u *model.User) (bool, error) } func Refresh(c context.Context, forge Forge, _store store.Store, user *model.User) {