diff --git a/services/github/pod-github/src/sync/__tests__/pullrequests.test.ts b/services/github/pod-github/src/sync/__tests__/pullrequests.test.ts index 25f5ccf3c03..e17f05d65a2 100644 --- a/services/github/pod-github/src/sync/__tests__/pullrequests.test.ts +++ b/services/github/pod-github/src/sync/__tests__/pullrequests.test.ts @@ -19,10 +19,65 @@ jest.mock('../../config', () => ({ } })) -import { PullRequestSyncManager } from '../pullrequests' +import { + buildPullRequestTicketBacklinkBody, + extractTrackerIdentifiersFromPullRequest, + getPullRequestTicketLinkMarker, + isPullRequestTicketTargetIssue, + PullRequestSyncManager +} from '../pullrequests' /* eslint-enable import/first */ describe('PullRequestSyncManager', () => { + describe('extractTrackerIdentifiersFromPullRequest', () => { + it('extracts unique tracker identifiers from title and body in encounter order', () => { + const result = extractTrackerIdentifiersFromPullRequest( + 'fix: ABC-123 settle refunds', + 'Refs abc-123, DEF-4, and XYZ-389.' + ) + + expect(result).toEqual(['ABC-123', 'DEF-4', 'XYZ-389']) + }) + + it('ignores text without tracker-like identifiers', () => { + expect(extractTrackerIdentifiersFromPullRequest('release v0.7.423', 'No linked ticket')).toEqual([]) + }) + }) + + describe('buildPullRequestTicketBacklinkBody', () => { + it('includes a stable marker and the Huly issue link', () => { + const repository = { + _id: 'repo-id', + nodeId: 'repo-node', + name: 'svc' + } as any + const marker = getPullRequestTicketLinkMarker(repository, 42, 'ABC-123') + const body = buildPullRequestTicketBacklinkBody( + 'ABC-123', + 'https://example.com/workbench/workspace/tracker/ABC-123', + marker + ) + + expect(body).toContain('') + expect(body).toContain('https://example.com/workbench/workspace/tracker/ABC-123') + expect(body).toContain('Huly®: ABC-123') + }) + }) + + describe('isPullRequestTicketTargetIssue', () => { + it('does not treat a missing issue as a ticket target', () => { + expect(isPullRequestTicketTargetIssue()).toBe(false) + }) + + it('treats ordinary tracker issues as ticket targets', () => { + expect(isPullRequestTicketTargetIssue({ _class: 'tracker:class:Issue' } as any)).toBe(true) + }) + + it('does not treat GitHub pull request mirrors as ticket targets', () => { + expect(isPullRequestTicketTargetIssue({ _class: 'github:class:GithubPullRequest' } as any)).toBe(false) + }) + }) + describe('getReviewers', () => { let manager: PullRequestSyncManager let mockProvider: any diff --git a/services/github/pod-github/src/sync/comments.ts b/services/github/pod-github/src/sync/comments.ts index 10e8f506dd9..992c5c5bfeb 100644 --- a/services/github/pod-github/src/sync/comments.ts +++ b/services/github/pod-github/src/sync/comments.ts @@ -361,22 +361,24 @@ export class CommentSyncManager implements DocSyncManager { if (Object.keys(platformUpdate).length > 0) { // Check and update body with external - const okit = ensureRESTOctokit( - (await this.provider.getOctokit(ctx, existing.modifiedBy)) ?? container.container.octokit, - container - ) - const mdown = await this.provider.getMarkdown(existingComment.message) - if (mdown.trim().length > 0) { - await okit.rest.issues.updateComment({ - owner: repository.owner?.login as string, - repo: repository.name, - issue_number: parent.githubNumber, - comment_id: comment.id, - body: mdown, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' - } - }) + if (isGHWriteAllowed()) { + const okit = ensureRESTOctokit( + (await this.provider.getOctokit(ctx, existing.modifiedBy)) ?? container.container.octokit, + container + ) + const mdown = await this.provider.getMarkdown(existingComment.message) + if (mdown.trim().length > 0) { + await okit.rest.issues.updateComment({ + owner: repository.owner?.login as string, + repo: repository.name, + issue_number: parent.githubNumber, + comment_id: comment.id, + body: mdown, + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + }) + } } } if (Object.keys(update).length > 0) { @@ -436,6 +438,9 @@ export class CommentSyncManager implements DocSyncManager { if (parent === undefined) { return {} } + if (!isGHWriteAllowed()) { + return { needSync: githubSyncVersion } + } const chatMessage = existing as ChatMessage const okit = ensureRESTOctokit( (await this.provider.getOctokit(ctx, chatMessage.modifiedBy)) ?? container.container.octokit, diff --git a/services/github/pod-github/src/sync/pullrequests.ts b/services/github/pod-github/src/sync/pullrequests.ts index 0d574222057..00a8201804c 100644 --- a/services/github/pod-github/src/sync/pullrequests.ts +++ b/services/github/pod-github/src/sync/pullrequests.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import activity, { type ActivityInfoMessage } from '@hcengineering/activity' import { Analytics } from '@hcengineering/analytics' import contact, { Employee, Person } from '@hcengineering/contact' import core, { @@ -32,6 +33,7 @@ import github, { GithubTodo, LastReviewState } from '@hcengineering/github' +import { getPublicLink } from '@hcengineering/server-guest-resources' import task, { TaskType, calcRank, makeRank } from '@hcengineering/task' import time, { ToDo, ToDoPriority } from '@hcengineering/time' import tracker, { Issue, IssuePriority, IssueStatus, Project } from '@hcengineering/tracker' @@ -77,6 +79,48 @@ Omit type GithubPullRequestUpdate = DocumentUpdate> +const HULY_PR_TICKET_LINK_MARKER_PREFIX = '` +} + +export function buildPullRequestTicketBacklinkBody (identifier: string, issueUrl: string, marker: string): string { + return `${marker}\n

Connected to Huly®: ${identifier}

` +} + +export function getPullRequestTicketActivityId ( + repository: GithubIntegrationRepository, + prNumber: number, + issueId: Ref +): Ref { + return `github:pr-ticket-link:${repository._id}:${prNumber}:${issueId}` as Ref +} + +export function isPullRequestTicketTargetIssue (issue?: Pick): boolean { + return issue !== undefined && issue._class !== github.class.GithubPullRequest +} + export class PullRequestSyncManager extends IssueSyncManagerBase implements DocSyncManager { externalDerivedSync = true @@ -188,6 +232,24 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS return } + const linkedToExistingIssue = await this.ensurePullRequestTicketLinks( + ctx, + event.pull_request.number, + externalData, + repo, + integration + ) + if (linkedToExistingIssue) { + ctx.info('Skip pull request mirror for linked Huly issue', { + action: event.action, + prNumber: event.pull_request.number, + repo: repo.name, + url: externalData.url, + workspace: this.provider.getWorkspaceId() + }) + return + } + switch (event.action) { case 'opened': { await this.createSyncData(externalData, derivedClient, repo, account) @@ -374,6 +436,185 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS this.provider.sync() } + private async ensurePullRequestTicketLinks ( + ctx: MeasureContext, + prNumber: number, + pullRequestExternal: PullRequestExternalData, + repo: GithubIntegrationRepository, + integration: IntegrationContainer + ): Promise { + const identifiers = extractTrackerIdentifiersFromPullRequest(pullRequestExternal.title, pullRequestExternal.body) + if (identifiers.length === 0) { + return false + } + + let linkedExistingIssue = false + + for (const identifier of identifiers) { + try { + const issue = await this.client.findOne(tracker.class.Issue, { identifier }, { showArchived: true }) + if (issue === undefined) { + ctx.info('No Huly issue found for pull request ticket identifier', { + identifier, + url: pullRequestExternal.url, + workspace: this.provider.getWorkspaceId() + }) + continue + } + + if (!isPullRequestTicketTargetIssue(issue)) { + ctx.info('Pull request ticket identifier resolved to GitHub pull request mirror', { + identifier, + prNumber, + url: pullRequestExternal.url, + workspace: this.provider.getWorkspaceId() + }) + continue + } + + linkedExistingIssue = true + + const issueUrl = await getPublicLink( + issue, + this.client, + { + uuid: this.provider.getWorkspaceId(), + url: this.provider.getWorkspaceUrl() + }, + false, + this.provider.getBranding() + ) + + await this.ensureGithubTicketBacklink(ctx, integration, repo, prNumber, identifier, issueUrl) + await this.ensureHulyPullRequestLink(issue, pullRequestExternal, repo) + } catch (err: any) { + ctx.error('Failed to link pull request to Huly ticket identifier', { + identifier, + url: pullRequestExternal.url, + err: errorToObj(err) + }) + } + } + + return linkedExistingIssue + } + + private async shouldSyncPullRequestAsGithubTask ( + ctx: MeasureContext, + integration: IntegrationContainer, + repo: GithubIntegrationRepository, + pullRequestExternal: PullRequestExternalData + ): Promise { + const linkedToExistingIssue = await this.ensurePullRequestTicketLinks( + ctx, + pullRequestExternal.number, + pullRequestExternal, + repo, + integration + ) + + if (!linkedToExistingIssue) { + return true + } + + ctx.info('Skip pull request mirror for linked Huly issue', { + prNumber: pullRequestExternal.number, + repo: repo.name, + url: pullRequestExternal.url, + workspace: this.provider.getWorkspaceId() + }) + return false + } + + private async filterPullRequestsForGithubTaskSync ( + ctx: MeasureContext, + integration: IntegrationContainer, + repo: GithubIntegrationRepository, + pullRequests: PullRequestExternalData[] + ): Promise { + const filtered: PullRequestExternalData[] = [] + for (const pullRequest of pullRequests) { + if (await this.shouldSyncPullRequestAsGithubTask(ctx, integration, repo, pullRequest)) { + filtered.push(pullRequest) + } + } + return filtered + } + + private async ensureGithubTicketBacklink ( + ctx: MeasureContext, + integration: IntegrationContainer, + repository: GithubIntegrationRepository, + prNumber: number, + identifier: string, + issueUrl: string + ): Promise { + const owner = repository.owner?.login + if (owner == null) { + ctx.info('Cannot add pull request Huly backlink without repository owner', { + repository: repository.name, + identifier, + prNumber + }) + return + } + + const marker = getPullRequestTicketLinkMarker(repository, prNumber, identifier) + const headers = { 'X-GitHub-Api-Version': '2022-11-28' } + const commentPages = integration.octokit.paginate.iterator(integration.octokit.rest.issues.listComments, { + owner, + repo: repository.name, + issue_number: prNumber, + per_page: 100, + headers + }) + + for await (const comments of commentPages) { + if (comments.data.some((comment) => comment.body?.includes(marker) === true)) { + return + } + } + + await integration.octokit.rest.issues.createComment({ + owner, + repo: repository.name, + issue_number: prNumber, + body: buildPullRequestTicketBacklinkBody(identifier, issueUrl, marker), + headers + }) + } + + private async ensureHulyPullRequestLink ( + issue: Issue, + pullRequestExternal: PullRequestExternalData, + repository: GithubIntegrationRepository + ): Promise { + const activityId = getPullRequestTicketActivityId(repository, pullRequestExternal.number, issue._id) + const existing = await this.client.findOne(activity.class.ActivityInfoMessage, { _id: activityId }) + if (existing !== undefined) { + return + } + + await this.client.addCollection( + activity.class.ActivityInfoMessage, + issue.space, + issue._id, + issue._class, + 'activity', + { + message: github.string.PullRequestConnectedActivityInfo, + icon: github.icon.Github, + props: { + url: pullRequestExternal.url, + repository: repository.url?.replace('api.github.com/repos', 'github.com'), + repoName: repository.name, + number: pullRequestExternal.number + } + }, + activityId + ) + } + async syncToTarget ( ctx: MeasureContext, container: ContainerFocus, @@ -1356,7 +1597,8 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS data: cutObjectArray(response) }) } - await this.syncIssues(ctx, github.class.GithubPullRequest, repo, issues, derivedClient, docsPart) + const issuesToSync = await this.filterPullRequestsForGithubTaskSync(ctx, integration, repo, issues) + await this.syncIssues(ctx, github.class.GithubPullRequest, repo, issuesToSync, derivedClient, docsPart) } catch (err: any) { if (partsize > 1) { partsize = 1 @@ -1539,7 +1781,10 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS }) } - await this.syncIssues(ctx, github.class.GithubPullRequest, repo, issues, derivedClient) + const issuesToSync = await this.filterPullRequestsForGithubTaskSync(ctx, integration, repo, issues) + if (issuesToSync.length > 0) { + await this.syncIssues(ctx, github.class.GithubPullRequest, repo, issuesToSync, derivedClient) + } } } catch (err: any) { ctx.error('Error', { err })