From ce1cdf12da52854da696a07f72418ea3d9d4e59a Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Mon, 25 May 2026 15:46:22 +0300 Subject: [PATCH] feat(github): link pull requests to tracker issues Detect Huly Tracker identifiers such as ABC-123 in GitHub pull request titles and bodies, then link matching ordinary Tracker issues to the pull request instead of creating duplicate PR mirror tasks. The sync path now creates an idempotent GitHub backlink comment and a Huly activity item for resolved Tracker issues. PRs with no matching issue, or identifiers that already resolve to GitHub pull request mirrors, continue through the existing mirror sync flow. Ordinary Huly comment writes remain suppressed when GitHub writes are disabled, while the explicit PR-to-Tracker backlink remains part of the linking flow. Signed-off-by: Ignat Remizov --- .../src/sync/__tests__/pullrequests.test.ts | 57 +++- .../github/pod-github/src/sync/comments.ts | 37 +-- .../pod-github/src/sync/pullrequests.ts | 249 +++++++++++++++++- 3 files changed, 324 insertions(+), 19 deletions(-) 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 })