From b393f30a403ba9ed6897d476e668c70233694902 Mon Sep 17 00:00:00 2001 From: pautran Date: Wed, 7 Jan 2026 17:57:40 +0100 Subject: [PATCH 1/6] patch typescript --- sample_settings.ts | 2 + src/githubHelper.ts | 215 +++++++++++++++++++++++++++++++++++++++++--- src/gitlabHelper.ts | 19 ++++ src/index.ts | 14 ++- src/settings.ts | 2 + 5 files changed, 236 insertions(+), 16 deletions(-) diff --git a/sample_settings.ts b/sample_settings.ts index 67bd8a9..df6ad70 100644 --- a/sample_settings.ts +++ b/sample_settings.ts @@ -62,5 +62,7 @@ export default { mergeRequests: { logFile: './merge-requests.json', log: false, + resetTargetBranchPerMr: false, + replayMergedRequests: false, }, } as Settings; diff --git a/src/githubHelper.ts b/src/githubHelper.ts index 5150598..a4c7c50 100644 --- a/src/githubHelper.ts +++ b/src/githubHelper.ts @@ -938,25 +938,64 @@ export class GithubHelper { async createPullRequestAndComments( mergeRequest: GitLabMergeRequest ): Promise { - let pullRequestData = await this.createPullRequest(mergeRequest); + const replayMerged = + settings.mergeRequests.replayMergedRequests && + mergeRequest.state === 'merged'; + let restoreTargetBranch: null | (() => Promise) = null; + let tempBranchName: string | null = null; + let tempBranchCreated = false; + const originalSourceBranch = mergeRequest.source_branch; + + if (replayMerged) { + await this.resetTargetBranchForMergeRequest(mergeRequest, false); + const tempBranch = await this.ensureTempBranchForMergeRequest( + mergeRequest + ); + if (tempBranch) { + tempBranchName = tempBranch.name; + tempBranchCreated = tempBranch.created; + mergeRequest.source_branch = tempBranchName; + } + } else if (settings.mergeRequests.resetTargetBranchPerMr) { + restoreTargetBranch = + await this.resetTargetBranchForMergeRequest(mergeRequest, true); + } - // createPullRequest() returns an issue number if a PR could not be created and - // an issue was created instead, and settings.useIssueImportAPI is true. In that - // case comments were already added and the state is already properly set - if (typeof pullRequestData === 'number' || !pullRequestData) return; + try { + let pullRequestData = await this.createPullRequest(mergeRequest); + + // createPullRequest() returns an issue number if a PR could not be created and + // an issue was created instead, and settings.useIssueImportAPI is true. In that + // case comments were already added and the state is already properly set + if (typeof pullRequestData === 'number' || !pullRequestData) return; - let pullRequest = pullRequestData.data; + let pullRequest = pullRequestData.data; - // data is set to null if one of the branches does not exist and the pull request cannot be created - if (pullRequest) { - // Add milestones, labels, and other attributes from the Issues API - await this.updatePullRequestData(pullRequest, mergeRequest); + // data is set to null if one of the branches does not exist and the pull request cannot be created + if (pullRequest) { + // Add milestones, labels, and other attributes from the Issues API + await this.updatePullRequestData(pullRequest, mergeRequest); - // add any comments/nodes associated with this pull request - await this.createPullRequestComments(pullRequest, mergeRequest); + // add any comments/nodes associated with this pull request + await this.createPullRequestComments(pullRequest, mergeRequest); - // Make sure to close the GitHub pull request if it is closed or merged in GitLab - await this.updatePullRequestState(pullRequest, mergeRequest); + if (replayMerged) { + await this.mergePullRequest(pullRequest, mergeRequest); + } else { + // Make sure to close the GitHub pull request if it is closed or merged in GitLab + await this.updatePullRequestState(pullRequest, mergeRequest); + } + } + } finally { + if (restoreTargetBranch) { + await restoreTargetBranch(); + } + if (tempBranchName) { + mergeRequest.source_branch = originalSourceBranch; + if (tempBranchCreated) { + await this.deleteTempBranch(tempBranchName, mergeRequest); + } + } } } @@ -1123,6 +1162,154 @@ export class GithubHelper { } } + private async getMergeRequestBaseSha( + mergeRequest: GitLabMergeRequest + ): Promise { + const baseSha = (mergeRequest as any).diff_refs?.base_sha; + if (baseSha) return baseSha; + if (!mergeRequest.iid) return null; + + const detailed = await this.gitlabHelper.getMergeRequest(mergeRequest.iid); + return (detailed as any)?.diff_refs?.base_sha ?? null; + } + + private async getMergeRequestHeadSha( + mergeRequest: GitLabMergeRequest + ): Promise { + const headSha = (mergeRequest as any).diff_refs?.head_sha ?? mergeRequest.sha; + if (headSha) return headSha; + if (!mergeRequest.iid) return null; + + const detailed = await this.gitlabHelper.getMergeRequest(mergeRequest.iid); + return (detailed as any)?.diff_refs?.head_sha ?? null; + } + + private async ensureTempBranchForMergeRequest( + mergeRequest: GitLabMergeRequest + ): Promise { + if (!mergeRequest.iid) return null; + const headSha = await this.getMergeRequestHeadSha(mergeRequest); + if (!headSha) return null; + + const name = `gl-mr-${mergeRequest.iid}`; + + try { + await this.githubApi.git.createRef({ + owner: this.githubOwner, + repo: this.githubRepo, + ref: `refs/heads/${name}`, + sha: headSha, + }); + return { name, created: true }; + } catch (err) { + if ((err as any).status !== 422) throw err; + } + + await this.githubApi.git.updateRef({ + owner: this.githubOwner, + repo: this.githubRepo, + ref: `heads/${name}`, + sha: headSha, + force: true, + }); + return { name, created: true }; + } + + private async deleteTempBranch( + branchName: string, + mergeRequest: GitLabMergeRequest + ): Promise { + try { + await this.githubApi.git.deleteRef({ + owner: this.githubOwner, + repo: this.githubRepo, + ref: `heads/${branchName}`, + }); + } catch (err) { + console.error( + `\tFailed to delete temp branch '${branchName}' for MR !${mergeRequest.iid}` + ); + console.error(err); + } + } + + private async mergePullRequest( + pullRequest: Pick, + mergeRequest: GitLabMergeRequest + ): Promise { + if (settings.dryRun) return; + if (pullRequest.state === 'closed') return; + + await utils.sleep(this.delayInMs); + + try { + await this.githubApi.pulls.merge({ + owner: this.githubOwner, + repo: this.githubRepo, + pull_number: pullRequest.number, + merge_method: 'merge', + }); + } catch (err) { + console.error( + `\tFailed to merge PR for MR !${mergeRequest.iid} (PR #${pullRequest.number}).` + ); + throw err; + } + } + + private async resetTargetBranchForMergeRequest( + mergeRequest: GitLabMergeRequest, + restore: boolean + ): Promise Promise)> { + if (settings.dryRun) return null; + const targetBranch = mergeRequest.target_branch; + const baseSha = await this.getMergeRequestBaseSha(mergeRequest); + + if (!targetBranch || !baseSha) { + console.log( + `\tSkipping target branch reset for MR !${mergeRequest.iid} (missing target branch or base sha).` + ); + return null; + } + + const currentBranch = await this.githubApi.repos.getBranch({ + owner: this.githubOwner, + repo: this.githubRepo, + branch: targetBranch, + }); + const currentSha = currentBranch.data.commit.sha; + + if (currentSha === baseSha) return null; + + console.log( + `\tResetting '${targetBranch}' to ${baseSha} for MR !${mergeRequest.iid}` + ); + await utils.sleep(this.delayInMs); + await this.githubApi.git.updateRef({ + owner: this.githubOwner, + repo: this.githubRepo, + ref: `heads/${targetBranch}`, + sha: baseSha, + force: true, + }); + + if (!restore) return null; + + return async () => { + console.log( + `\tRestoring '${targetBranch}' to ${currentSha} after MR !${mergeRequest.iid}` + ); + await utils.sleep(this.delayInMs); + await this.githubApi.git.updateRef({ + owner: this.githubOwner, + repo: this.githubRepo, + ref: `heads/${targetBranch}`, + sha: currentSha, + force: true, + }); + }; + } + // ---------------------------------------------------------------------------- /** diff --git a/src/gitlabHelper.ts b/src/gitlabHelper.ts index 0d37814..3a301ac 100644 --- a/src/gitlabHelper.ts +++ b/src/gitlabHelper.ts @@ -115,6 +115,25 @@ export class GitlabHelper { } } + /** + * Gets a merge request by IID to access detailed fields (e.g. diff_refs). + */ + async getMergeRequest( + mergeRequestIid: number + ): Promise { + try { + return await this.gitlabApi.MergeRequests.show( + this.gitlabProjectId, + mergeRequestIid + ); + } catch (err) { + console.error( + `Could not fetch GitLab merge request !${mergeRequestIid}.` + ); + return null; + } + } + /** * Gets attachment using http get */ diff --git a/src/index.ts b/src/index.ts index 5c0c2d3..922a3a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -538,8 +538,18 @@ async function transferMergeRequests() { labels: settings.filterByLabel, }); - // Sort merge requests in ascending order of their number (by iid) - mergeRequests = mergeRequests.sort((a, b) => a.iid - b.iid); + // Sort merge requests in ascending order of their number (by iid), unless + // we are replaying merged requests in chronological merge order. + if (settings.mergeRequests.replayMergedRequests) { + mergeRequests = mergeRequests.sort((a, b) => { + const aMerged = a.merged_at ? new Date(a.merged_at).getTime() : 0; + const bMerged = b.merged_at ? new Date(b.merged_at).getTime() : 0; + if (aMerged !== bMerged) return aMerged - bMerged; + return a.iid - b.iid; + }); + } else { + mergeRequests = mergeRequests.sort((a, b) => a.iid - b.iid); + } // Get a list of the current pull requests in the new GitHub repo (likely to // be empty) diff --git a/src/settings.ts b/src/settings.ts index cc611dd..019d719 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -33,6 +33,8 @@ export default interface Settings { mergeRequests: { logFile: string; log: boolean; + resetTargetBranchPerMr?: boolean; + replayMergedRequests?: boolean; }; commitMap?: { [key: string]: string; From 96ea8b3050e5fa561c13ea9e2649461126fe71ba Mon Sep 17 00:00:00 2001 From: pautran Date: Thu, 8 Jan 2026 10:01:57 +0100 Subject: [PATCH 2/6] fix Position is Invalid --- src/githubHelper.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/githubHelper.ts b/src/githubHelper.ts index a4c7c50..9577bec 100644 --- a/src/githubHelper.ts +++ b/src/githubHelper.ts @@ -1316,7 +1316,10 @@ export class GithubHelper { * Creates the information required for a new review comment. * See: https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#create-a-review-comment-for-a-pull-request */ - static createReviewCommentInformation(position, repoLink: string): object { + static createReviewCommentInformation( + position, + repoLink: string + ): object | null { if ( !repoLink || !repoLink.startsWith(gitHubLocation) || @@ -1324,7 +1327,7 @@ export class GithubHelper { !position.head_sha || !position.line_range ) { - throw new Error(`Position is invalid: ${JSON.stringify(position)}`); + return null; } let head_sha = position.head_sha; @@ -1430,10 +1433,20 @@ export class GithubHelper { } if (note.type === 'DiffNote') { - reviewComments.push({ - body: await this.convertIssuesAndComments(note.body, note, true, false), - ...GithubHelper.createReviewCommentInformation(note.position, repoLink), - }); + const reviewInfo = GithubHelper.createReviewCommentInformation( + note.position, + repoLink + ); + if (reviewInfo) { + reviewComments.push({ + body: await this.convertIssuesAndComments(note.body, note, true, false), + ...reviewInfo, + }); + } else { + console.log( + '\tSkipping invalid diff position for review comment, falling back to regular comment.' + ); + } } // create regular comment either way, in case the review comment cannot be created From 3c37ba01f26668e64b2c5c5fe40a19fa252a1041 Mon Sep 17 00:00:00 2001 From: pautran Date: Thu, 8 Jan 2026 14:02:27 +0100 Subject: [PATCH 3/6] remove resetTargetBranchPerMr --- sample_settings.ts | 1 - src/githubHelper.ts | 7 ------- src/settings.ts | 1 - 3 files changed, 9 deletions(-) diff --git a/sample_settings.ts b/sample_settings.ts index df6ad70..a48f529 100644 --- a/sample_settings.ts +++ b/sample_settings.ts @@ -62,7 +62,6 @@ export default { mergeRequests: { logFile: './merge-requests.json', log: false, - resetTargetBranchPerMr: false, replayMergedRequests: false, }, } as Settings; diff --git a/src/githubHelper.ts b/src/githubHelper.ts index 9577bec..7777a77 100644 --- a/src/githubHelper.ts +++ b/src/githubHelper.ts @@ -941,7 +941,6 @@ export class GithubHelper { const replayMerged = settings.mergeRequests.replayMergedRequests && mergeRequest.state === 'merged'; - let restoreTargetBranch: null | (() => Promise) = null; let tempBranchName: string | null = null; let tempBranchCreated = false; const originalSourceBranch = mergeRequest.source_branch; @@ -956,9 +955,6 @@ export class GithubHelper { tempBranchCreated = tempBranch.created; mergeRequest.source_branch = tempBranchName; } - } else if (settings.mergeRequests.resetTargetBranchPerMr) { - restoreTargetBranch = - await this.resetTargetBranchForMergeRequest(mergeRequest, true); } try { @@ -987,9 +983,6 @@ export class GithubHelper { } } } finally { - if (restoreTargetBranch) { - await restoreTargetBranch(); - } if (tempBranchName) { mergeRequest.source_branch = originalSourceBranch; if (tempBranchCreated) { diff --git a/src/settings.ts b/src/settings.ts index 019d719..22186ff 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -33,7 +33,6 @@ export default interface Settings { mergeRequests: { logFile: string; log: boolean; - resetTargetBranchPerMr?: boolean; replayMergedRequests?: boolean; }; commitMap?: { From e959f33c4543fd1a3287f98c35bdc46c386f645a Mon Sep 17 00:00:00 2001 From: pautran Date: Thu, 8 Jan 2026 16:57:30 +0100 Subject: [PATCH 4/6] add reviewer and fix last merged MR not being merged by the right user --- src/githubHelper.ts | 145 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 12 deletions(-) diff --git a/src/githubHelper.ts b/src/githubHelper.ts index 7777a77..391ecc6 100644 --- a/src/githubHelper.ts +++ b/src/githubHelper.ts @@ -972,11 +972,17 @@ export class GithubHelper { // Add milestones, labels, and other attributes from the Issues API await this.updatePullRequestData(pullRequest, mergeRequest); + // Request reviewers and add approvals info (best-effort). + await this.requestReviewersAndApprovers(pullRequest, mergeRequest); + // add any comments/nodes associated with this pull request await this.createPullRequestComments(pullRequest, mergeRequest); if (replayMerged) { - await this.mergePullRequest(pullRequest, mergeRequest); + await this.closePullRequestWithGitlabMergeInfo( + pullRequest, + mergeRequest + ); } else { // Make sure to close the GitHub pull request if it is closed or merged in GitLab await this.updatePullRequestState(pullRequest, mergeRequest); @@ -992,6 +998,78 @@ export class GithubHelper { } } + private convertReviewers(mergeRequest: GitLabMergeRequest): string[] { + if (!mergeRequest.reviewers) return []; + let reviewers: string[] = []; + for (let reviewer of mergeRequest.reviewers) { + let username: string = reviewer.username as string; + this.users.add(username); + if (username === settings.github.username) { + reviewers.push(settings.github.username); + } else if (settings.usermap && settings.usermap[username]) { + let gitHubUsername = settings.usermap[username]; + if (!this.githubOwnerIsOrg || this.members.has(gitHubUsername)) { + reviewers.push(gitHubUsername); + } else { + console.log( + `Cannot request reviewer: User ${gitHubUsername} is not a member of ${this.githubOwner}` + ); + } + } + } + + return reviewers; + } + + private async requestReviewersAndApprovers( + pullRequest: Pick, + mergeRequest: GitLabMergeRequest + ): Promise { + if (settings.dryRun) return; + + const reviewers = this.convertReviewers(mergeRequest); + const approvals = await this.gitlabHelper.getMergeRequestApprovals( + mergeRequest.iid + ); + const mappedApprovals = approvals + .map(u => (settings.usermap && settings.usermap[u] ? settings.usermap[u] : u)) + .filter(u => u); + + if (reviewers.length > 0) { + await utils.sleep(this.delayInMs); + await this.githubApi.pulls + .requestReviewers({ + owner: this.githubOwner, + repo: this.githubRepo, + pull_number: pullRequest.number, + reviewers: Array.from(new Set(reviewers)), + }) + .catch(err => { + console.error('could not request GitHub reviewers!'); + console.error(err); + }); + } + + if (mappedApprovals.length > 0) { + await utils.sleep(this.delayInMs); + const body = utils.organizationUsersString( + Array.from(new Set(mappedApprovals)), + 'Approved by' + ); + await this.githubApi.issues + .createComment({ + owner: this.githubOwner, + repo: this.githubRepo, + issue_number: pullRequest.number, + body: body.trim(), + }) + .catch(err => { + console.error('could not create GitHub approvals comment!'); + console.error(err); + }); + } + } + // ---------------------------------------------------------------------------- /** @@ -1177,6 +1255,17 @@ export class GithubHelper { return (detailed as any)?.diff_refs?.head_sha ?? null; } + private async getMergeRequestMergeCommitSha( + mergeRequest: GitLabMergeRequest + ): Promise { + const mergeSha = (mergeRequest as any).merge_commit_sha; + if (mergeSha) return mergeSha; + if (!mergeRequest.iid) return null; + + const detailed = await this.gitlabHelper.getMergeRequest(mergeRequest.iid); + return (detailed as any)?.merge_commit_sha ?? null; + } + private async ensureTempBranchForMergeRequest( mergeRequest: GitLabMergeRequest ): Promise { @@ -1226,28 +1315,60 @@ export class GithubHelper { } } - private async mergePullRequest( + private async closePullRequestWithGitlabMergeInfo( pullRequest: Pick, mergeRequest: GitLabMergeRequest ): Promise { if (settings.dryRun) return; if (pullRequest.state === 'closed') return; - await utils.sleep(this.delayInMs); + const mergeSha = await this.getMergeRequestMergeCommitSha(mergeRequest); + const targetBranch = mergeRequest.target_branch; - try { - await this.githubApi.pulls.merge({ + if (mergeSha && targetBranch) { + await utils.sleep(this.delayInMs); + await this.githubApi.git.updateRef({ owner: this.githubOwner, repo: this.githubRepo, - pull_number: pullRequest.number, - merge_method: 'merge', + ref: `heads/${targetBranch}`, + sha: mergeSha, + force: true, }); - } catch (err) { - console.error( - `\tFailed to merge PR for MR !${mergeRequest.iid} (PR #${pullRequest.number}).` - ); - throw err; } + + await utils.sleep(this.delayInMs); + await this.githubApi.issues.update({ + owner: this.githubOwner, + repo: this.githubRepo, + issue_number: pullRequest.number, + state: 'closed', + }); + + const mergedBy = (mergeRequest as any).merged_by?.username; + const mergedAt = (mergeRequest as any).merged_at; + const mappedMerger = + mergedBy && settings.usermap && settings.usermap[mergedBy] + ? settings.usermap[mergedBy] + : mergedBy; + + let info = 'Merged in GitLab.'; + if (mappedMerger) { + info += ` Merged by @${mappedMerger}`; + } + if (mergedAt) { + info += ` on ${mergedAt}.`; + } + if (mergeSha) { + info += ` Merge commit: ${mergeSha}.`; + } + + await utils.sleep(this.delayInMs); + await this.githubApi.issues.createComment({ + owner: this.githubOwner, + repo: this.githubRepo, + issue_number: pullRequest.number, + body: info, + }); } private async resetTargetBranchForMergeRequest( From 2e83d580fa5be8eb0d59bf10636e6c7125ceb333 Mon Sep 17 00:00:00 2001 From: pautran Date: Thu, 8 Jan 2026 17:24:53 +0100 Subject: [PATCH 5/6] remove one . --- src/githubHelper.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/githubHelper.ts b/src/githubHelper.ts index 391ecc6..e7651e3 100644 --- a/src/githubHelper.ts +++ b/src/githubHelper.ts @@ -1790,6 +1790,11 @@ export class GithubHelper { settings.projectmap !== null && Object.keys(settings.projectmap).length > 0; + str = str.replace( + /\[Compare with previous version\]\([^)]+\)/g, + 'Compare with previous version (link unavailable after force-push)' + ); + if (add_line) str = GithubHelper.addMigrationLine(str, item, repoLink, add_line_ref); let reString = ''; @@ -2048,7 +2053,7 @@ export class GithubHelper { ref = head_sha; } - let lineRef = `Commented on [${ref}](${repoLink}/compare/${base_sha}..${head_sha}${slug})\n\n`; + let lineRef = `Commented on [${ref}](${repoLink}/compare/${base_sha}...${head_sha}${slug})\n\n`; if (position.line_range) { if (position.line_range.start.type === 'new') { From b4550f0ae4202e52e0b440d569e94e99383798fa Mon Sep 17 00:00:00 2001 From: pautran Date: Sat, 14 Feb 2026 17:11:59 +0100 Subject: [PATCH 6/6] some fixes --- src/githubHelper.ts | 227 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 179 insertions(+), 48 deletions(-) diff --git a/src/githubHelper.ts b/src/githubHelper.ts index e7651e3..54a3357 100644 --- a/src/githubHelper.ts +++ b/src/githubHelper.ts @@ -955,6 +955,29 @@ export class GithubHelper { tempBranchCreated = tempBranch.created; mergeRequest.source_branch = tempBranchName; } + } else if (mergeRequest.source_branch) { + // If the source branch no longer exists on GitHub, try a temp branch from GitLab SHA. + try { + await this.githubApi.repos.getBranch({ + owner: this.githubOwner, + repo: this.githubRepo, + branch: mergeRequest.source_branch, + }); + } catch (err) { + const status = (err as any).status; + if (status === 404) { + const tempBranch = await this.ensureTempBranchForMergeRequest( + mergeRequest + ); + if (tempBranch) { + tempBranchName = tempBranch.name; + tempBranchCreated = tempBranch.created; + mergeRequest.source_branch = tempBranchName; + } + } else { + throw err; + } + } } try { @@ -1266,6 +1289,66 @@ export class GithubHelper { return (detailed as any)?.merge_commit_sha ?? null; } + private async githubHasCommit(sha: string): Promise { + try { + await this.githubApi.repos.getCommit({ + owner: this.githubOwner, + repo: this.githubRepo, + ref: sha, + }); + return true; + } catch (err) { + const status = (err as any).status; + if (status === 404 || status === 422) return false; + throw err; + } + } + + private refsWriteDisabled = false; + private refsWriteDisabledNotified = false; + + private noteRefsWriteDisabled(): void { + if (this.refsWriteDisabledNotified) return; + this.refsWriteDisabledNotified = true; + console.log( + `\tDisabling Git ref updates for this run (received 404/403 from GitHub refs API).` + ); + } + + private async safeUpdateRef( + ref: string, + sha: string, + context: string + ): Promise { + if (this.refsWriteDisabled) { + console.log( + `\tSkipping ${context}: ref updates are disabled for this run.` + ); + return false; + } + try { + await this.githubApi.git.updateRef({ + owner: this.githubOwner, + repo: this.githubRepo, + ref, + sha, + force: true, + }); + return true; + } catch (err) { + const status = (err as any).status; + if (status === 404 || status === 403) { + this.refsWriteDisabled = true; + this.noteRefsWriteDisabled(); + console.log( + `\tSkipping ${context}: cannot update '${ref}' (${status}).` + ); + return false; + } + throw err; + } + } + private async ensureTempBranchForMergeRequest( mergeRequest: GitLabMergeRequest ): Promise { @@ -1274,26 +1357,52 @@ export class GithubHelper { if (!headSha) return null; const name = `gl-mr-${mergeRequest.iid}`; + let branchSha = headSha; + const hasHeadSha = await this.githubHasCommit(headSha); + if (!hasHeadSha) { + const mergeSha = await this.getMergeRequestMergeCommitSha(mergeRequest); + if (mergeSha && (await this.githubHasCommit(mergeSha))) { + branchSha = mergeSha; + console.log( + `\tHead commit ${headSha} for MR !${mergeRequest.iid} is missing on GitHub; using merge commit ${mergeSha} for temp branch '${name}'.` + ); + } else { + console.log( + `\tSkipping temp branch '${name}' for MR !${mergeRequest.iid}: neither head commit ${headSha} nor merge commit ${mergeSha ?? ''} exists on GitHub.` + ); + return null; + } + } + if (this.refsWriteDisabled) { + console.log( + `\tSkipping temp branch '${name}' for MR !${mergeRequest.iid}: ref updates are disabled for this run.` + ); + return null; + } try { await this.githubApi.git.createRef({ owner: this.githubOwner, repo: this.githubRepo, ref: `refs/heads/${name}`, - sha: headSha, + sha: branchSha, }); return { name, created: true }; } catch (err) { - if ((err as any).status !== 422) throw err; + const status = (err as any).status; + const message = (err as any)?.response?.data?.message; + if (status === 404 || status === 403) { + this.refsWriteDisabled = true; + this.noteRefsWriteDisabled(); + console.log( + `\tSkipping temp branch '${name}' for MR !${mergeRequest.iid}: cannot create ref (status ${status}).` + ); + return null; + } + if (status !== 422 || message !== 'Reference already exists') throw err; } - await this.githubApi.git.updateRef({ - owner: this.githubOwner, - repo: this.githubRepo, - ref: `heads/${name}`, - sha: headSha, - force: true, - }); + await this.safeUpdateRef(`heads/${name}`, branchSha, `temp branch '${name}'`); return { name, created: true }; } @@ -1326,14 +1435,18 @@ export class GithubHelper { const targetBranch = mergeRequest.target_branch; if (mergeSha && targetBranch) { - await utils.sleep(this.delayInMs); - await this.githubApi.git.updateRef({ - owner: this.githubOwner, - repo: this.githubRepo, - ref: `heads/${targetBranch}`, - sha: mergeSha, - force: true, - }); + if (await this.githubHasCommit(mergeSha)) { + await utils.sleep(this.delayInMs); + await this.safeUpdateRef( + `heads/${targetBranch}`, + mergeSha, + `merge commit update for MR !${mergeRequest.iid}` + ); + } else { + console.log( + `\tSkipping '${targetBranch}' update for MR !${mergeRequest.iid}: merge commit ${mergeSha} does not exist on GitHub.` + ); + } } await utils.sleep(this.delayInMs); @@ -1386,41 +1499,56 @@ export class GithubHelper { return null; } - const currentBranch = await this.githubApi.repos.getBranch({ - owner: this.githubOwner, - repo: this.githubRepo, - branch: targetBranch, - }); + let currentBranch; + try { + currentBranch = await this.githubApi.repos.getBranch({ + owner: this.githubOwner, + repo: this.githubRepo, + branch: targetBranch, + }); + } catch (err) { + const status = (err as any).status; + if (status === 404) { + console.log( + `\tSkipping reset of '${targetBranch}' for MR !${mergeRequest.iid}: target branch does not exist on GitHub.` + ); + return null; + } + throw err; + } const currentSha = currentBranch.data.commit.sha; if (currentSha === baseSha) return null; + if (!(await this.githubHasCommit(baseSha))) { + console.log( + `\tSkipping reset of '${targetBranch}' for MR !${mergeRequest.iid}: base commit ${baseSha} does not exist on GitHub.` + ); + return null; + } + console.log( `\tResetting '${targetBranch}' to ${baseSha} for MR !${mergeRequest.iid}` ); await utils.sleep(this.delayInMs); - await this.githubApi.git.updateRef({ - owner: this.githubOwner, - repo: this.githubRepo, - ref: `heads/${targetBranch}`, - sha: baseSha, - force: true, - }); + const resetOk = await this.safeUpdateRef( + `heads/${targetBranch}`, + baseSha, + `reset of '${targetBranch}' for MR !${mergeRequest.iid}` + ); - if (!restore) return null; + if (!restore || !resetOk) return null; return async () => { console.log( `\tRestoring '${targetBranch}' to ${currentSha} after MR !${mergeRequest.iid}` ); await utils.sleep(this.delayInMs); - await this.githubApi.git.updateRef({ - owner: this.githubOwner, - repo: this.githubRepo, - ref: `heads/${targetBranch}`, - sha: currentSha, - force: true, - }); + await this.safeUpdateRef( + `heads/${targetBranch}`, + currentSha, + `restore of '${targetBranch}' for MR !${mergeRequest.iid}` + ); }; } @@ -1586,16 +1714,17 @@ export class GithubHelper { ...first_comment, }).catch(x => { let use_fallback = false; - if (x.status === 422) { - if (x.response.data.message === 'Validation Failed') { - let validation_error = x.response.data.errors[0]; - if (validation_error.message.endsWith(' is not part of the pull request')) { - // fall back to creating a regular comment for the discussion - create_regular_comment = true; - use_fallback = true; - console.log('fallback to regular comment'); - } - } + if (x.status === 422 || x.status === 404) { + // fall back to creating a regular comment for the discussion + create_regular_comment = true; + use_fallback = true; + const message = x.response?.data?.message; + const validation_error = x.response?.data?.errors?.[0]?.message; + console.log( + `fallback to regular comment (review comment failed: ${message ?? 'unknown'}${ + validation_error ? ` - ${validation_error}` : '' + })` + ); } if (!use_fallback) { @@ -1770,7 +1899,7 @@ export class GithubHelper { * @param add_issue_information Set to true to add assignees, reviewers, and approvers */ async convertIssuesAndComments( - str: string, + str: string | null | undefined, item: GitLabIssue | GitLabMergeRequest | GitLabNote | MilestoneImport | GitLabDiscussionNote, add_line: boolean = true, add_line_ref: boolean = true, @@ -1784,6 +1913,8 @@ export class GithubHelper { // before the #, and we do the same for MRs, labels and milestones. const repoLink = `${this.githubUrl}/${this.githubOwner}/${this.githubRepo}`; + // Normalize empty/nullable bodies to avoid null.replace crashes. + str = str ?? ''; const hasUsermap = settings.usermap !== null && Object.keys(settings.usermap).length > 0; const hasProjectmap =