diff --git a/README.md b/README.md index 09141652..196887e6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/peter-evans/create-pull-request/workflows/CI/badge.svg)](https://github.com/peter-evans/create-pull-request/actions?query=workflow%3ACI) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Create%20Pull%20Request-blue.svg?colorA=24292e&colorB=0366d6&style=flat&longCache=true&logo=github)](https://github.com/marketplace/actions/create-pull-request) -A GitHub action to create a pull request for changes to your repository in the actions workspace. +A GitHub action to create a pull request for changes to your repository in the actions workspace. This action also supports GHES and Gitea instances. Changes to a repository in the Actions workspace persist between steps in a workflow. This action is designed to be used in conjunction with other steps that modify or add files to your repository. @@ -35,6 +35,25 @@ Create Pull Request action will: uses: peter-evans/create-pull-request@v7 ``` +### Usage with Gitea or GHES + +If you're using this action with Gitea, you need to specify the Gitea hostname using the `github-server-url` parameter: + +```yml + - name: Create Pull Request + continue-on-error: true + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + github-server-url: gitea.mediatek.inc + base: master + branch: chore/pre-commit-hooks + title: "chore: Update pre-commit hooks" + body: "Update versions of pre-commit hooks to latest version." + commit-message: "chore: update pre-commit hooks" + delete-branch: true +``` + You can also pin to a [specific release](https://github.com/peter-evans/create-pull-request/releases) version in the format `@v7.x.x` ### Workflow permissions @@ -74,6 +93,7 @@ All inputs are **optional**. If not set, sensible defaults will be used. | `milestone` | The number of the milestone to associate this pull request with. | | | `draft` | Create a [draft pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests#draft-pull-requests). Valid values are `true` (only on create), `always-true` (on create and update), and `false`. | `false` | | `maintainer-can-modify` | Indicates whether [maintainers can modify](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) the pull request. | `true` | +| `github-server-url` | A comma-separated list of GHES/Gitea hostnames (e.g., 'gitea.example.com,gitea.company.org'). Required when using this action with Gitea instances. | | #### token diff --git a/action.yml b/action.yml index 9d28570c..78cd4842 100644 --- a/action.yml +++ b/action.yml @@ -82,6 +82,10 @@ inputs: maintainer-can-modify: description: 'Indicates whether maintainers can modify the pull request.' default: true + github-server-url: + description: > + A comma-separated list of Gitea hostnames (e.g., 'gitea.example.com,gitea.company.org'). + Required when using this action with Gitea instances. outputs: pull-request-number: description: 'The pull request number' diff --git a/dist/index.js b/dist/index.js index 55f49bcf..6cf74b0a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1327,11 +1327,12 @@ class GitHubHelper { if (token) { options.auth = `${token}`; } - if (githubServerHostname !== 'github.com') { - options.baseUrl = `https://${githubServerHostname}/api/v3`; - } - else { - options.baseUrl = 'https://api.github.com'; + // Check if this is a Gitea instance + this.isGiteaInstance = (0, octokit_client_1.isGitea)(githubServerHostname); + // Set the appropriate API base URL for GitHub or Gitea + options.baseUrl = (0, octokit_client_1.getApiBaseUrl)(githubServerHostname); + if (this.isGiteaInstance) { + core.info(`Detected Gitea instance at ${githubServerHostname}. Using API endpoint ${options.baseUrl}`); } options.throttle = octokit_client_1.throttleOptions; this.octokit = new octokit_client_1.Octokit(options); @@ -1347,10 +1348,21 @@ class GitHubHelper { return __awaiter(this, void 0, void 0, function* () { const [headOwner] = headRepository.split('/'); const headBranch = `${headOwner}:${inputs.branch}`; + // For Gitea, the head branch format is different - it's just the branch name + const giteaHeadBranch = this.isGiteaInstance ? inputs.branch : headBranch; // Try to create the pull request try { core.info(`Attempting creation of pull request`); - const { data: pull } = yield this.octokit.rest.pulls.create(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { title: inputs.title, head: headBranch, head_repo: headRepository, base: inputs.base, body: inputs.body, draft: inputs.draft.value, maintainer_can_modify: inputs.maintainerCanModify })); + const createParams = Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { title: inputs.title, head: this.isGiteaInstance ? giteaHeadBranch : headBranch, base: inputs.base, body: inputs.body, maintainer_can_modify: inputs.maintainerCanModify }); + // Add draft parameter only for GitHub (Gitea doesn't support draft PRs via the API) + if (!this.isGiteaInstance) { + Object.assign(createParams, { draft: inputs.draft.value }); + } + // For Gitea, if using fork, we need to specify the head_repo + if (this.isGiteaInstance && inputs.pushToFork) { + Object.assign(createParams, { head_repo: headRepository }); + } + const { data: pull } = yield this.octokit.rest.pulls.create(createParams); core.info(`Created pull request #${pull.number} (${headBranch} => ${inputs.base})`); return { number: pull.number, @@ -1376,7 +1388,7 @@ class GitHubHelper { } // Update the pull request that exists for this branch and base core.info(`Fetching existing pull request`); - const { data: pulls } = yield this.octokit.rest.pulls.list(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { state: 'open', head: headBranch, base: inputs.base })); + const { data: pulls } = yield this.octokit.rest.pulls.list(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { state: 'open', head: this.isGiteaInstance ? giteaHeadBranch : headBranch, base: inputs.base })); core.info(`Attempting update of pull request`); const { data: pull } = yield this.octokit.rest.pulls.update(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { pull_number: pulls[0].number, title: inputs.title, body: inputs.body })); core.info(`Updated pull request #${pull.number} (${headBranch} => ${inputs.base})`); @@ -1391,11 +1403,22 @@ class GitHubHelper { } getRepositoryParent(headRepository) { return __awaiter(this, void 0, void 0, function* () { - const { data: headRepo } = yield this.octokit.rest.repos.get(Object.assign({}, this.parseRepository(headRepository))); - if (!headRepo.parent) { - return null; + try { + const { data: headRepo } = yield this.octokit.rest.repos.get(Object.assign({}, this.parseRepository(headRepository))); + if (!headRepo.parent) { + return null; + } + return headRepo.parent.full_name; + } + catch (error) { + // Gitea may not have the same parent repository structure + // Fall back to null if this fails + if (this.isGiteaInstance) { + core.warning(`Unable to determine parent repository for ${headRepository}. This is expected for Gitea.`); + return null; + } + throw error; } - return headRepo.parent.full_name; }); } createOrUpdatePullRequest(inputs, baseRepository, headRepository) { @@ -1415,35 +1438,73 @@ class GitHubHelper { // Apply assignees if (inputs.assignees.length > 0) { core.info(`Applying assignees '${inputs.assignees}'`); - yield this.octokit.rest.issues.addAssignees(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, assignees: inputs.assignees })); - } - // Request reviewers and team reviewers - const requestReviewersParams = {}; - if (inputs.reviewers.length > 0) { - requestReviewersParams['reviewers'] = inputs.reviewers; - core.info(`Requesting reviewers '${inputs.reviewers}'`); - } - if (inputs.teamReviewers.length > 0) { - const teams = utils.stripOrgPrefixFromTeams(inputs.teamReviewers); - requestReviewersParams['team_reviewers'] = teams; - core.info(`Requesting team reviewers '${teams}'`); - } - if (Object.keys(requestReviewersParams).length > 0) { - try { - yield this.octokit.rest.pulls.requestReviewers(Object.assign(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { pull_number: pull.number }), requestReviewersParams)); - } - catch (e) { - if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) { - core.error(`Unable to request reviewers. If requesting team reviewers a 'repo' scoped PAT is required.`); + // Gitea has different assignee handling + if (this.isGiteaInstance) { + try { + for (const assignee of inputs.assignees) { + yield this.octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, assignees: [assignee] })); + } + } + catch (error) { + core.warning(`Error assigning users in Gitea: ${utils.getErrorMessage(error)}`); } - throw e; } + else { + // GitHub standard API + yield this.octokit.rest.issues.addAssignees(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { issue_number: pull.number, assignees: inputs.assignees })); + } + } + // Skip reviewers functionality for Gitea as it might not be compatible + if (!this.isGiteaInstance && + (inputs.reviewers.length > 0 || inputs.teamReviewers.length > 0)) { + const requestReviewersParams = {}; + if (inputs.reviewers.length > 0) { + requestReviewersParams['reviewers'] = inputs.reviewers; + core.info(`Requesting reviewers '${inputs.reviewers}'`); + } + if (inputs.teamReviewers.length > 0) { + const teams = utils.stripOrgPrefixFromTeams(inputs.teamReviewers); + requestReviewersParams['team_reviewers'] = teams; + core.info(`Requesting team reviewers '${teams}'`); + } + if (Object.keys(requestReviewersParams).length > 0) { + try { + yield this.octokit.rest.pulls.requestReviewers(Object.assign(Object.assign(Object.assign({}, this.parseRepository(baseRepository)), { pull_number: pull.number }), requestReviewersParams)); + } + catch (e) { + if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) { + core.error(`Unable to request reviewers. If requesting team reviewers a 'repo' scoped PAT is required.`); + } + throw e; + } + } + } + else if (this.isGiteaInstance && + (inputs.reviewers.length > 0 || inputs.teamReviewers.length > 0)) { + core.warning('Reviewer assignment is not supported for Gitea instances'); } return pull; }); } pushSignedCommits(git, branchCommits, baseCommit, repoPath, branchRepository, branch) { return __awaiter(this, void 0, void 0, function* () { + var _a, _b; + // For Gitea, fall back to standard Git push if signed commits are not supported + if (this.isGiteaInstance) { + core.warning('Signed commits via API may not be fully supported in Gitea. Falling back to standard Git push.'); + yield git.push([ + '--force-with-lease', + 'origin', + `${branch}:refs/heads/${branch}` + ]); + // Return a simplified commit response + return { + sha: ((_a = branchCommits[branchCommits.length - 1]) === null || _a === void 0 ? void 0 : _a.sha) || baseCommit.sha, + tree: ((_b = branchCommits[branchCommits.length - 1]) === null || _b === void 0 ? void 0 : _b.tree) || baseCommit.tree, + verified: false + }; + } + // Original GitHub implementation let headCommit = { sha: baseCommit.sha, tree: baseCommit.tree, @@ -1511,43 +1572,89 @@ class GitHubHelper { } const { data: remoteCommit } = yield this.octokit.rest.git.createCommit(Object.assign(Object.assign({}, repository), { parents: [parentCommit.sha], tree: treeSha, message: `${commit.subject}\n\n${commit.body}` })); core.info(`Created commit ${remoteCommit.sha} for local commit ${commit.sha}`); - core.info(`Commit verified: ${remoteCommit.verification.verified}; reason: ${remoteCommit.verification.reason}`); + // Gitea might not have the same verification structure + let verified = false; + if (remoteCommit.verification && + typeof remoteCommit.verification.verified !== 'undefined') { + verified = remoteCommit.verification.verified; + core.info(`Commit verified: ${verified}; reason: ${remoteCommit.verification.reason || 'unknown'}`); + } + else { + core.info('Commit verification information not available'); + } return { sha: remoteCommit.sha, tree: remoteCommit.tree.sha, - verified: remoteCommit.verification.verified + verified: verified }; }); } getCommit(sha, branchRepository) { return __awaiter(this, void 0, void 0, function* () { const repository = this.parseRepository(branchRepository); - const { data: remoteCommit } = yield this.octokit.rest.git.getCommit(Object.assign(Object.assign({}, repository), { commit_sha: sha })); - return { - sha: remoteCommit.sha, - tree: remoteCommit.tree.sha, - verified: remoteCommit.verification.verified - }; + try { + const { data: remoteCommit } = yield this.octokit.rest.git.getCommit(Object.assign(Object.assign({}, repository), { commit_sha: sha })); + // Handle different verification structure between GitHub and Gitea + let verified = false; + if (remoteCommit.verification && + typeof remoteCommit.verification.verified !== 'undefined') { + verified = remoteCommit.verification.verified; + } + return { + sha: remoteCommit.sha, + tree: remoteCommit.tree.sha, + verified: verified + }; + } + catch (error) { + if (this.isGiteaInstance) { + core.warning(`Unable to get commit details from Gitea. This might be expected: ${utils.getErrorMessage(error)}`); + // Return a placeholder response + return { + sha: sha, + tree: '', // We don't know the tree SHA + verified: false + }; + } + throw error; + } }); } createOrUpdateRef(branchRepository, branch, newHead) { return __awaiter(this, void 0, void 0, function* () { const repository = this.parseRepository(branchRepository); - const branchExists = yield this.octokit.rest.repos - .getBranch(Object.assign(Object.assign({}, repository), { branch: branch })) - .then(() => true, () => false); + // Check if branch exists + let branchExists = false; + try { + yield this.octokit.rest.repos.getBranch(Object.assign(Object.assign({}, repository), { branch: branch })); + branchExists = true; + } + catch (_a) { + branchExists = false; + } if (branchExists) { core.info(`Branch ${branch} exists; Updating ref`); yield this.octokit.rest.git.updateRef(Object.assign(Object.assign({}, repository), { sha: newHead, ref: `heads/${branch}`, force: true })); } else { core.info(`Branch ${branch} does not exist; Creating ref`); - yield this.octokit.rest.git.createRef(Object.assign(Object.assign({}, repository), { sha: newHead, ref: `refs/heads/${branch}` })); + try { + yield this.octokit.rest.git.createRef(Object.assign(Object.assign({}, repository), { sha: newHead, ref: `refs/heads/${branch}` })); + } + catch (error) { + core.error(`Failed to create branch: ${utils.getErrorMessage(error)}`); + throw error; + } } }); } convertToDraft(id) { return __awaiter(this, void 0, void 0, function* () { + // Skip for Gitea since GraphQL API likely isn't compatible + if (this.isGiteaInstance) { + core.warning('Draft pull requests are not supported in Gitea via the GraphQL API'); + return; + } core.info(`Converting pull request to draft`); yield this.octokit.graphql({ query: `mutation($pullRequestId: ID!) { @@ -1627,9 +1734,26 @@ function getDraftInput() { return { value: core.getBooleanInput('draft'), always: false }; } } +// Set Gitea instances from environment variable or input +function configureGiteaInstances() { + // First check if there's already an environment variable + if (!process.env.GITEA_INSTANCES) { + // If not, check if it was provided as input + const giteaInstancesInput = core.getInput('github-server-url'); + if (giteaInstancesInput) { + core.info(`Setting GITEA_INSTANCES environment variable to: ${giteaInstancesInput}`); + process.env.GITEA_INSTANCES = giteaInstancesInput; + } + } + if (process.env.GITEA_INSTANCES) { + core.info(`Configured Gitea instances: ${process.env.GITEA_INSTANCES}`); + } +} function run() { return __awaiter(this, void 0, void 0, function* () { try { + // Configure Gitea instances before anything else + configureGiteaInstances(); const inputs = { token: core.getInput('token'), branchToken: core.getInput('branch-token'), @@ -1727,6 +1851,8 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.throttleOptions = exports.Octokit = void 0; +exports.isGitea = isGitea; +exports.getApiBaseUrl = getApiBaseUrl; const core = __importStar(__nccwpck_require__(7484)); const core_1 = __nccwpck_require__(767); const plugin_paginate_rest_1 = __nccwpck_require__(3779); @@ -1754,6 +1880,25 @@ function autoProxyAgent(octokit) { options.request.fetch = proxy_1.fetch; }); } +// Determine if a hostname is a Gitea instance +function isGitea(hostname) { + return process.env.GITEA_INSTANCES + ? process.env.GITEA_INSTANCES.split(',').includes(hostname) + : false; +} +// Get the API base URL for a given hostname +function getApiBaseUrl(hostname) { + // For GitHub, we'll use their standard API endpoint + if (hostname === 'github.com') { + return 'https://api.github.com'; + } + // For Gitea, we need to modify the API path + if (isGitea(hostname)) { + return `https://${hostname}/api/v1`; + } + // For GitHub Enterprise or other GitHub-compatible APIs + return `https://${hostname}/api/v3`; +} /***/ }), diff --git a/src/github-helper.ts b/src/github-helper.ts index 6d41e06d..ee3a9688 100644 --- a/src/github-helper.ts +++ b/src/github-helper.ts @@ -1,7 +1,13 @@ import * as core from '@actions/core' import {Inputs} from './create-pull-request' import {Commit, GitCommandManager} from './git-command-manager' -import {Octokit, OctokitOptions, throttleOptions} from './octokit-client' +import { + Octokit, + OctokitOptions, + throttleOptions, + isGitea, + getApiBaseUrl +} from './octokit-client' import pLimit from 'p-limit' import * as utils from './utils' @@ -9,7 +15,6 @@ const ERROR_PR_ALREADY_EXISTS = 'A pull request already exists for' const ERROR_PR_REVIEW_TOKEN_SCOPE = 'Validation Failed: "Could not resolve to a node with the global id of' const ERROR_PR_FORK_COLLAB = `Fork collab can't be granted by someone without permission` - const blobCreationLimit = pLimit(8) interface Repository { @@ -40,17 +45,26 @@ type TreeObject = { export class GitHubHelper { private octokit: InstanceType + private isGiteaInstance: boolean constructor(githubServerHostname: string, token: string) { const options: OctokitOptions = {} if (token) { options.auth = `${token}` } - if (githubServerHostname !== 'github.com') { - options.baseUrl = `https://${githubServerHostname}/api/v3` - } else { - options.baseUrl = 'https://api.github.com' + + // Check if this is a Gitea instance + this.isGiteaInstance = isGitea(githubServerHostname) + + // Set the appropriate API base URL for GitHub or Gitea + options.baseUrl = getApiBaseUrl(githubServerHostname) + + if (this.isGiteaInstance) { + core.info( + `Detected Gitea instance at ${githubServerHostname}. Using API endpoint ${options.baseUrl}` + ) } + options.throttle = throttleOptions this.octokit = new Octokit(options) } @@ -71,19 +85,33 @@ export class GitHubHelper { const [headOwner] = headRepository.split('/') const headBranch = `${headOwner}:${inputs.branch}` + // For Gitea, the head branch format is different - it's just the branch name + const giteaHeadBranch = this.isGiteaInstance ? inputs.branch : headBranch + // Try to create the pull request try { core.info(`Attempting creation of pull request`) - const {data: pull} = await this.octokit.rest.pulls.create({ + const createParams = { ...this.parseRepository(baseRepository), title: inputs.title, - head: headBranch, - head_repo: headRepository, + head: this.isGiteaInstance ? giteaHeadBranch : headBranch, base: inputs.base, body: inputs.body, - draft: inputs.draft.value, maintainer_can_modify: inputs.maintainerCanModify - }) + } + + // Add draft parameter only for GitHub (Gitea doesn't support draft PRs via the API) + if (!this.isGiteaInstance) { + Object.assign(createParams, {draft: inputs.draft.value}) + } + + // For Gitea, if using fork, we need to specify the head_repo + if (this.isGiteaInstance && inputs.pushToFork) { + Object.assign(createParams, {head_repo: headRepository}) + } + + const {data: pull} = await this.octokit.rest.pulls.create(createParams) + core.info( `Created pull request #${pull.number} (${headBranch} => ${inputs.base})` ) @@ -116,9 +144,10 @@ export class GitHubHelper { const {data: pulls} = await this.octokit.rest.pulls.list({ ...this.parseRepository(baseRepository), state: 'open', - head: headBranch, + head: this.isGiteaInstance ? giteaHeadBranch : headBranch, base: inputs.base }) + core.info(`Attempting update of pull request`) const {data: pull} = await this.octokit.rest.pulls.update({ ...this.parseRepository(baseRepository), @@ -126,9 +155,11 @@ export class GitHubHelper { title: inputs.title, body: inputs.body }) + core.info( `Updated pull request #${pull.number} (${headBranch} => ${inputs.base})` ) + return { number: pull.number, html_url: pull.html_url, @@ -139,13 +170,27 @@ export class GitHubHelper { } async getRepositoryParent(headRepository: string): Promise { - const {data: headRepo} = await this.octokit.rest.repos.get({ - ...this.parseRepository(headRepository) - }) - if (!headRepo.parent) { - return null + try { + const {data: headRepo} = await this.octokit.rest.repos.get({ + ...this.parseRepository(headRepository) + }) + + if (!headRepo.parent) { + return null + } + + return headRepo.parent.full_name + } catch (error) { + // Gitea may not have the same parent repository structure + // Fall back to null if this fails + if (this.isGiteaInstance) { + core.warning( + `Unable to determine parent repository for ${headRepository}. This is expected for Gitea.` + ) + return null + } + throw error } - return headRepo.parent.full_name } async createOrUpdatePullRequest( @@ -169,6 +214,7 @@ export class GitHubHelper { milestone: inputs.milestone }) } + // Apply labels if (inputs.labels.length > 0) { core.info(`Applying labels '${inputs.labels}'`) @@ -178,44 +224,76 @@ export class GitHubHelper { labels: inputs.labels }) } + // Apply assignees if (inputs.assignees.length > 0) { core.info(`Applying assignees '${inputs.assignees}'`) - await this.octokit.rest.issues.addAssignees({ - ...this.parseRepository(baseRepository), - issue_number: pull.number, - assignees: inputs.assignees - }) - } - - // Request reviewers and team reviewers - const requestReviewersParams = {} - if (inputs.reviewers.length > 0) { - requestReviewersParams['reviewers'] = inputs.reviewers - core.info(`Requesting reviewers '${inputs.reviewers}'`) - } - if (inputs.teamReviewers.length > 0) { - const teams = utils.stripOrgPrefixFromTeams(inputs.teamReviewers) - requestReviewersParams['team_reviewers'] = teams - core.info(`Requesting team reviewers '${teams}'`) - } - if (Object.keys(requestReviewersParams).length > 0) { - try { - await this.octokit.rest.pulls.requestReviewers({ - ...this.parseRepository(baseRepository), - pull_number: pull.number, - ...requestReviewersParams - }) - } catch (e) { - if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) { - core.error( - `Unable to request reviewers. If requesting team reviewers a 'repo' scoped PAT is required.` + // Gitea has different assignee handling + if (this.isGiteaInstance) { + try { + for (const assignee of inputs.assignees) { + await this.octokit.request( + 'POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', + { + ...this.parseRepository(baseRepository), + issue_number: pull.number, + assignees: [assignee] + } + ) + } + } catch (error) { + core.warning( + `Error assigning users in Gitea: ${utils.getErrorMessage(error)}` ) } - throw e + } else { + // GitHub standard API + await this.octokit.rest.issues.addAssignees({ + ...this.parseRepository(baseRepository), + issue_number: pull.number, + assignees: inputs.assignees + }) } } + // Skip reviewers functionality for Gitea as it might not be compatible + if ( + !this.isGiteaInstance && + (inputs.reviewers.length > 0 || inputs.teamReviewers.length > 0) + ) { + const requestReviewersParams = {} + if (inputs.reviewers.length > 0) { + requestReviewersParams['reviewers'] = inputs.reviewers + core.info(`Requesting reviewers '${inputs.reviewers}'`) + } + if (inputs.teamReviewers.length > 0) { + const teams = utils.stripOrgPrefixFromTeams(inputs.teamReviewers) + requestReviewersParams['team_reviewers'] = teams + core.info(`Requesting team reviewers '${teams}'`) + } + if (Object.keys(requestReviewersParams).length > 0) { + try { + await this.octokit.rest.pulls.requestReviewers({ + ...this.parseRepository(baseRepository), + pull_number: pull.number, + ...requestReviewersParams + }) + } catch (e) { + if (utils.getErrorMessage(e).includes(ERROR_PR_REVIEW_TOKEN_SCOPE)) { + core.error( + `Unable to request reviewers. If requesting team reviewers a 'repo' scoped PAT is required.` + ) + } + throw e + } + } + } else if ( + this.isGiteaInstance && + (inputs.reviewers.length > 0 || inputs.teamReviewers.length > 0) + ) { + core.warning('Reviewer assignment is not supported for Gitea instances') + } + return pull } @@ -227,11 +305,32 @@ export class GitHubHelper { branchRepository: string, branch: string ): Promise { + // For Gitea, fall back to standard Git push if signed commits are not supported + if (this.isGiteaInstance) { + core.warning( + 'Signed commits via API may not be fully supported in Gitea. Falling back to standard Git push.' + ) + await git.push([ + '--force-with-lease', + 'origin', + `${branch}:refs/heads/${branch}` + ]) + + // Return a simplified commit response + return { + sha: branchCommits[branchCommits.length - 1]?.sha || baseCommit.sha, + tree: branchCommits[branchCommits.length - 1]?.tree || baseCommit.tree, + verified: false + } + } + + // Original GitHub implementation let headCommit: CommitResponse = { sha: baseCommit.sha, tree: baseCommit.tree, verified: false } + for (const commit of branchCommits) { headCommit = await this.createCommit( git, @@ -241,6 +340,7 @@ export class GitHubHelper { branchRepository ) } + await this.createOrUpdateRef(branchRepository, branch, headCommit.sha) return headCommit } @@ -255,6 +355,7 @@ export class GitHubHelper { const repository = this.parseRepository(branchRepository) // In the case of an empty commit, the tree references the parent's tree let treeSha = parentCommit.tree + if (commit.changes.length > 0) { core.info(`Creating tree objects for local commit ${commit.sha}`) const treeObjects = await Promise.all( @@ -329,16 +430,29 @@ export class GitHubHelper { tree: treeSha, message: `${commit.subject}\n\n${commit.body}` }) + core.info( `Created commit ${remoteCommit.sha} for local commit ${commit.sha}` ) - core.info( - `Commit verified: ${remoteCommit.verification.verified}; reason: ${remoteCommit.verification.reason}` - ) + + // Gitea might not have the same verification structure + let verified = false + if ( + remoteCommit.verification && + typeof remoteCommit.verification.verified !== 'undefined' + ) { + verified = remoteCommit.verification.verified + core.info( + `Commit verified: ${verified}; reason: ${remoteCommit.verification.reason || 'unknown'}` + ) + } else { + core.info('Commit verification information not available') + } + return { sha: remoteCommit.sha, tree: remoteCommit.tree.sha, - verified: remoteCommit.verification.verified + verified: verified } } @@ -347,14 +461,40 @@ export class GitHubHelper { branchRepository: string ): Promise { const repository = this.parseRepository(branchRepository) - const {data: remoteCommit} = await this.octokit.rest.git.getCommit({ - ...repository, - commit_sha: sha - }) - return { - sha: remoteCommit.sha, - tree: remoteCommit.tree.sha, - verified: remoteCommit.verification.verified + + try { + const {data: remoteCommit} = await this.octokit.rest.git.getCommit({ + ...repository, + commit_sha: sha + }) + + // Handle different verification structure between GitHub and Gitea + let verified = false + if ( + remoteCommit.verification && + typeof remoteCommit.verification.verified !== 'undefined' + ) { + verified = remoteCommit.verification.verified + } + + return { + sha: remoteCommit.sha, + tree: remoteCommit.tree.sha, + verified: verified + } + } catch (error) { + if (this.isGiteaInstance) { + core.warning( + `Unable to get commit details from Gitea. This might be expected: ${utils.getErrorMessage(error)}` + ) + // Return a placeholder response + return { + sha: sha, + tree: '', // We don't know the tree SHA + verified: false + } + } + throw error } } @@ -364,15 +504,18 @@ export class GitHubHelper { newHead: string ) { const repository = this.parseRepository(branchRepository) - const branchExists = await this.octokit.rest.repos - .getBranch({ + + // Check if branch exists + let branchExists = false + try { + await this.octokit.rest.repos.getBranch({ ...repository, branch: branch }) - .then( - () => true, - () => false - ) + branchExists = true + } catch { + branchExists = false + } if (branchExists) { core.info(`Branch ${branch} exists; Updating ref`) @@ -384,15 +527,28 @@ export class GitHubHelper { }) } else { core.info(`Branch ${branch} does not exist; Creating ref`) - await this.octokit.rest.git.createRef({ - ...repository, - sha: newHead, - ref: `refs/heads/${branch}` - }) + try { + await this.octokit.rest.git.createRef({ + ...repository, + sha: newHead, + ref: `refs/heads/${branch}` + }) + } catch (error) { + core.error(`Failed to create branch: ${utils.getErrorMessage(error)}`) + throw error + } } } async convertToDraft(id: string): Promise { + // Skip for Gitea since GraphQL API likely isn't compatible + if (this.isGiteaInstance) { + core.warning( + 'Draft pull requests are not supported in Gitea via the GraphQL API' + ) + return + } + core.info(`Converting pull request to draft`) await this.octokit.graphql({ query: `mutation($pullRequestId: ID!) { diff --git a/src/main.ts b/src/main.ts index dad26794..df8f5f11 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,8 +11,30 @@ function getDraftInput(): {value: boolean; always: boolean} { } } +// Set Gitea instances from environment variable or input +function configureGiteaInstances() { + // First check if there's already an environment variable + if (!process.env.GITEA_INSTANCES) { + // If not, check if it was provided as input + const giteaInstancesInput = core.getInput('github-server-url') + if (giteaInstancesInput) { + core.info( + `Setting GITEA_INSTANCES environment variable to: ${giteaInstancesInput}` + ) + process.env.GITEA_INSTANCES = giteaInstancesInput + } + } + + if (process.env.GITEA_INSTANCES) { + core.info(`Configured Gitea instances: ${process.env.GITEA_INSTANCES}`) + } +} + async function run(): Promise { try { + // Configure Gitea instances before anything else + configureGiteaInstances() + const inputs: Inputs = { token: core.getInput('token'), branchToken: core.getInput('branch-token'), @@ -44,9 +66,11 @@ async function run(): Promise { if (!inputs.token) { throw new Error(`Input 'token' not supplied. Unable to continue.`) } + if (!inputs.branchToken) { inputs.branchToken = inputs.token } + if (inputs.bodyPath) { if (!utils.fileExistsSync(inputs.bodyPath)) { throw new Error(`File '${inputs.bodyPath}' does not exist.`) @@ -54,6 +78,7 @@ async function run(): Promise { // Update the body input with the contents of the file inputs.body = utils.readFile(inputs.bodyPath) } + // 65536 characters is the maximum allowed for the pull request body. if (inputs.body.length > 65536) { core.warning( diff --git a/src/octokit-client.ts b/src/octokit-client.ts index 8c2c02ad..f3c0803e 100644 --- a/src/octokit-client.ts +++ b/src/octokit-client.ts @@ -38,3 +38,26 @@ function autoProxyAgent(octokit: OctokitCore) { options.request.fetch = fetch }) } + +// Determine if a hostname is a Gitea instance +export function isGitea(hostname: string): boolean { + return process.env.GITEA_INSTANCES + ? process.env.GITEA_INSTANCES.split(',').includes(hostname) + : false +} + +// Get the API base URL for a given hostname +export function getApiBaseUrl(hostname: string): string { + // For GitHub, we'll use their standard API endpoint + if (hostname === 'github.com') { + return 'https://api.github.com' + } + + // For Gitea, we need to modify the API path + if (isGitea(hostname)) { + return `https://${hostname}/api/v1` + } + + // For GitHub Enterprise or other GitHub-compatible APIs + return `https://${hostname}/api/v3` +}