mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-10 19:57:43 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -103,8 +103,16 @@ export function initStatisticsContext (
|
||||
const handleError = (err: any): void => {
|
||||
errorToSend++
|
||||
if (errorToSend % 2 === 0) {
|
||||
if (err.code !== 'UND_ERR_SOCKET') {
|
||||
console.error(err)
|
||||
const code = err?.code ?? err?.cause?.code
|
||||
if (code !== 'UND_ERR_SOCKET') {
|
||||
metricsContext.warn('Failed to send statistics', {
|
||||
service: serviceName,
|
||||
statsUrl,
|
||||
code,
|
||||
message: err?.message,
|
||||
causeMessage: err?.cause?.message,
|
||||
err
|
||||
})
|
||||
}
|
||||
}
|
||||
prev = undefined
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl } from '../markdown/escape'
|
||||
import { escapeMarkdownLinkText, escapeMarkdownLinkUrl, escapeTableCell } from '../markdown/escape'
|
||||
|
||||
describe('markdown/escape', () => {
|
||||
describe('escapeMarkdownLinkText', () => {
|
||||
@@ -48,8 +48,38 @@ describe('markdown/escape', () => {
|
||||
expect(escapeMarkdownLinkUrl('https://example.com/path)')).toBe('https://example.com/path\\)')
|
||||
})
|
||||
|
||||
it('escapes pipe', () => {
|
||||
expect(escapeMarkdownLinkUrl('https://example.com/x|y')).toBe('https://example.com/x\\|y')
|
||||
})
|
||||
|
||||
it('returns plain URL unchanged when no special chars', () => {
|
||||
expect(escapeMarkdownLinkUrl('https://example.com')).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeTableCellPreservingLink', () => {
|
||||
it('escapes plain text pipe for table safety', () => {
|
||||
expect(escapeTableCell('a|b')).toBe('a\\|b')
|
||||
})
|
||||
|
||||
it('preserves markdown link and escapes pipes inside text and URL', () => {
|
||||
const input = '[a|b](http://example.com/x|y)'
|
||||
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y)')
|
||||
})
|
||||
|
||||
it('escapes pipes even when value contains escaped characters', () => {
|
||||
const input = '[a|b](http://example.com/x|y\\z)'
|
||||
expect(escapeTableCell(input)).toBe('[a\\|b](http://example.com/x\\|y\\\\z)')
|
||||
})
|
||||
|
||||
it('treats strings that do not end with `)` as plain text', () => {
|
||||
const input = '[a|b](http://example.com/x|y'
|
||||
expect(escapeTableCell(input)).toBe('\\[a\\|b\\](http://example.com/x\\|y')
|
||||
})
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeTableCell(null)).toBe('')
|
||||
expect(escapeTableCell(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,5 +29,31 @@ export function escapeMarkdownLinkText (text: string): string {
|
||||
* Escape markdown link URL (backslashes and closing parentheses)
|
||||
*/
|
||||
export function escapeMarkdownLinkUrl (url: string): string {
|
||||
return url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)')
|
||||
return (
|
||||
url
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\)/g, '\\)')
|
||||
// Pipes break markdown tables unless escaped, and are safe to escape in URLs.
|
||||
.replace(/\|/g, '\\|')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a markdown table cell while preserving `[text](url)` links.
|
||||
*/
|
||||
export function escapeTableCell (value: unknown): string {
|
||||
const s = value == null ? '' : String(value)
|
||||
|
||||
const sep = s.indexOf('](')
|
||||
const looksLikeMarkdownLink = s.startsWith('[') && sep !== -1 && s.endsWith(')')
|
||||
if (!looksLikeMarkdownLink) {
|
||||
return escapeMarkdownLinkText(s)
|
||||
}
|
||||
|
||||
const rawText = s.slice(1, sep)
|
||||
const rawUrl = s.slice(sep + 2, -1)
|
||||
|
||||
const escapedText = escapeMarkdownLinkText(rawText)
|
||||
const escapedUrl = escapeMarkdownLinkUrl(rawUrl)
|
||||
return `[${escapedText}](${escapedUrl})`
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } f
|
||||
import { formatValue } from '../formatter'
|
||||
import { generateHeaders, loadViewletConfig, buildTableModel } from '../model'
|
||||
import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data'
|
||||
import { escapeMarkdownLinkText } from './escape'
|
||||
import { escapeTableCell } from './escape'
|
||||
import { createMarkdownLink } from './link'
|
||||
|
||||
async function preloadRefLookups (
|
||||
@@ -253,10 +253,7 @@ export async function buildMarkdownTableFromDocs (
|
||||
const linkValue = await createMarkdownLink(hierarchy, card, value)
|
||||
row.push(linkValue)
|
||||
} else {
|
||||
// If formatter already returned a markdown link, do not escape it again.
|
||||
const looksLikeMarkdownLink =
|
||||
typeof value === 'string' && value.startsWith('[') && value.includes('](') && value.endsWith(')')
|
||||
row.push(looksLikeMarkdownLink ? value : escapeMarkdownLinkText(value))
|
||||
row.push(escapeTableCell(value))
|
||||
}
|
||||
}
|
||||
rows.push(row)
|
||||
@@ -378,7 +375,7 @@ export async function buildRelationshipTableMarkdown (
|
||||
if (isDocumentTitle) {
|
||||
value = await createMarkdownLink(hierarchy, docToUse, value)
|
||||
} else {
|
||||
value = escapeMarkdownLinkText(value)
|
||||
value = escapeTableCell(value)
|
||||
}
|
||||
|
||||
row[attrIndex] = value
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"build:watch": "compile ui",
|
||||
"_phase:build": "compile ui",
|
||||
"_phase:format": "format src",
|
||||
"_phase:validate": "compile validate"
|
||||
"_phase:validate": "compile validate",
|
||||
"test": "jest --passWithNoTests --silent",
|
||||
"_phase:test": "jest --passWithNoTests --silent"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hcengineering/platform-rig": "workspace:^0.7.19",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createRemoteProvider } from './utils'
|
||||
|
||||
const baseDestroy = jest.fn()
|
||||
let lastConfig: any
|
||||
|
||||
jest.mock('@hcengineering/core', () => ({
|
||||
generateId: () => 'guid-1'
|
||||
}))
|
||||
|
||||
jest.mock('@hcengineering/presentation', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
metadata: {
|
||||
Token: 'token',
|
||||
CollaboratorUrl: 'collaboratorUrl',
|
||||
WorkspaceUuid: 'workspaceUuid'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
jest.mock('@hcengineering/platform', () => {
|
||||
const getMetadata = jest.fn()
|
||||
const setPlatformStatus = jest.fn()
|
||||
|
||||
class Status {
|
||||
severity: any
|
||||
code: any
|
||||
params: any
|
||||
|
||||
constructor (severity: any, code: any, params: any) {
|
||||
this.severity = severity
|
||||
this.code = code
|
||||
this.params = params
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
OK: { code: 'OK' },
|
||||
Severity: { ERROR: 'ERROR' },
|
||||
Status,
|
||||
getMetadata,
|
||||
setPlatformStatus
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@hcengineering/collaborator-client', () => {
|
||||
const encodeDocumentId = jest.fn()
|
||||
return { encodeDocumentId }
|
||||
})
|
||||
|
||||
jest.mock('../plugin', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
string: {
|
||||
CannotConnectToCollaborationService: 'CannotConnectToCollaborationService'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
jest.mock('./hocuspocus', () => ({
|
||||
HocuspocusCollabProvider: class {
|
||||
destroy: () => void
|
||||
|
||||
constructor (config: any) {
|
||||
lastConfig = config
|
||||
this.destroy = baseDestroy
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('createRemoteProvider reconnect grace behavior', () => {
|
||||
const RECONNECT_GRACE_MS = 5000
|
||||
const platformMock = jest.requireMock('@hcengineering/platform')
|
||||
const collaboratorClientMock = jest.requireMock('@hcengineering/collaborator-client')
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.clearAllMocks()
|
||||
baseDestroy.mockReset()
|
||||
lastConfig = undefined
|
||||
|
||||
platformMock.getMetadata.mockImplementation((key: string) => {
|
||||
if (key === 'token') return 'token-1'
|
||||
if (key === 'collaboratorUrl') return 'wss://collab.example/ws'
|
||||
if (key === 'workspaceUuid') return 'ws-1'
|
||||
return undefined
|
||||
})
|
||||
collaboratorClientMock.encodeDocumentId.mockReturnValue('encoded-doc-id')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not report error before grace timeout', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
createRemoteProvider({} as any, 'doc-1' as any, null)
|
||||
|
||||
lastConfig.onClose({ event: { code: 1006 } })
|
||||
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1)
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('starts grace timer once and does not postpone on repeated 1006 closes', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
createRemoteProvider({} as any, 'doc-1' as any, null)
|
||||
|
||||
lastConfig.onClose({ event: { code: 1006 } })
|
||||
jest.advanceTimersByTime(2000)
|
||||
lastConfig.onClose({ event: { code: 1006 } })
|
||||
jest.advanceTimersByTime(2000)
|
||||
lastConfig.onClose({ event: { code: 1006 } })
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears pending error when reconnect succeeds in grace window', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
createRemoteProvider({} as any, 'doc-1' as any, null)
|
||||
|
||||
lastConfig.onClose({ event: { code: 1006 } })
|
||||
jest.advanceTimersByTime(RECONNECT_GRACE_MS - 1000)
|
||||
lastConfig.onConnect()
|
||||
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(platformMock.setPlatformStatus).toHaveBeenCalledTimes(1)
|
||||
expect(platformMock.setPlatformStatus).toHaveBeenCalledWith({ code: 'OK' })
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears pending error timer on provider destroy', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const provider = createRemoteProvider({} as any, 'doc-1' as any, null)
|
||||
|
||||
lastConfig.onClose({ event: { code: 1006 } })
|
||||
void provider.destroy()
|
||||
jest.advanceTimersByTime(RECONNECT_GRACE_MS + 1000)
|
||||
|
||||
expect(baseDestroy).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(platformMock.setPlatformStatus).not.toHaveBeenCalled()
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,9 @@ import plugin from '../plugin'
|
||||
import { HocuspocusCollabProvider } from './hocuspocus'
|
||||
import { type Provider } from './types'
|
||||
|
||||
/** After idle/tab sleep the WS often closes with 1006; Hocuspocus reconnects. Defer user-visible errors. */
|
||||
const COLLABORATOR_RECONNECT_GRACE_MS = 5000
|
||||
|
||||
function getDocumentId (doc: CollaborativeDoc): string {
|
||||
const workspace = getMetadata(presentation.metadata.WorkspaceUuid) ?? ''
|
||||
return encodeDocumentId(workspace, doc)
|
||||
@@ -35,6 +38,15 @@ export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content
|
||||
|
||||
const documentId = getDocumentId(doc)
|
||||
|
||||
let reconnectGraceTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const clearReconnectGrace = (): void => {
|
||||
if (reconnectGraceTimeout !== undefined) {
|
||||
clearTimeout(reconnectGraceTimeout)
|
||||
reconnectGraceTimeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const provider = new HocuspocusCollabProvider({
|
||||
url: collaboratorUrl,
|
||||
name: documentId,
|
||||
@@ -42,17 +54,29 @@ export function createRemoteProvider (ydoc: Ydoc, doc: CollaborativeDoc, content
|
||||
token,
|
||||
parameters: { content },
|
||||
onConnect: () => {
|
||||
clearReconnectGrace()
|
||||
void setPlatformStatus(OK)
|
||||
},
|
||||
onClose: (data) => {
|
||||
if (data.event.code === 1006) {
|
||||
console.error('Failed to connect to collaborator', data.event)
|
||||
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
|
||||
void setPlatformStatus(status)
|
||||
if (reconnectGraceTimeout === undefined) {
|
||||
reconnectGraceTimeout = setTimeout(() => {
|
||||
reconnectGraceTimeout = undefined
|
||||
console.error('Failed to connect to collaborator', data.event)
|
||||
const status = new Status(Severity.ERROR, plugin.string.CannotConnectToCollaborationService, {})
|
||||
void setPlatformStatus(status)
|
||||
}, COLLABORATOR_RECONNECT_GRACE_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const baseDestroy = provider.destroy.bind(provider)
|
||||
provider.destroy = (): void => {
|
||||
clearReconnectGrace()
|
||||
baseDestroy()
|
||||
}
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"build:watch": "compile ui",
|
||||
"_phase:build": "compile ui",
|
||||
"_phase:format": "format src",
|
||||
"_phase:validate": "compile validate"
|
||||
"_phase:validate": "compile validate",
|
||||
"svelte-check": "do-svelte-check",
|
||||
"_phase:svelte-check": "do-svelte-check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hcengineering/platform-rig": "workspace:^0.7.19",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import core, { ClassifierKind, Ref, WithLookup, generateId } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel, getMetadata, translate } from '@hcengineering/platform'
|
||||
import core, { Ref, WithLookup, generateId } from '@hcengineering/core'
|
||||
import { getMetadata, translate } from '@hcengineering/platform'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import task, { TaskType, updateProjectType, type TaskStatusFactory } from '@hcengineering/task'
|
||||
import tracker, { Project, createStatesData } from '@hcengineering/tracker'
|
||||
@@ -63,13 +63,6 @@
|
||||
|
||||
if (!client.getHierarchy().hasMixin(projectInst, github.mixin.GithubProject)) {
|
||||
// We need to add GithubProject mixin
|
||||
const mixinId = await getClient().createDoc(core.class.Mixin, core.space.Model, {
|
||||
extends: github.mixin.GithubIssue,
|
||||
kind: ClassifierKind.MIXIN,
|
||||
label: getEmbeddedLabel(projectInst.name),
|
||||
hidden: false,
|
||||
icon: github.icon.Github
|
||||
})
|
||||
await getClient().createMixin(
|
||||
projectInst._id,
|
||||
tracker.class.Project,
|
||||
@@ -77,9 +70,7 @@
|
||||
github.mixin.GithubProject,
|
||||
{
|
||||
integration: integration._id,
|
||||
repositories: [],
|
||||
mixinClass: mixinId,
|
||||
mappings: []
|
||||
repositories: []
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
//
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PersonAccount } from '@hcengineering/contact'
|
||||
import { getCurrentAccount } from '@hcengineering/core'
|
||||
import { createQuery, getClient, getFileUrl } from '@hcengineering/presentation'
|
||||
import { Button, Chevron, Component, ExpandCollapse, Label } from '@hcengineering/ui'
|
||||
import diffview from '@hcengineering/diffview'
|
||||
import { GithubPatch, GithubPullRequest, GithubPullRequestReview } from '@hcengineering/github'
|
||||
|
||||
import github from '../plugin'
|
||||
import { getCurrentEmployee } from '@hcengineering/contact'
|
||||
|
||||
export let pullRequest: GithubPullRequest
|
||||
|
||||
const me = getCurrentAccount() as PersonAccount
|
||||
const me = getCurrentEmployee()
|
||||
|
||||
let isCollapsed = true
|
||||
|
||||
@@ -37,6 +36,7 @@
|
||||
}
|
||||
|
||||
$: hasPatch = patch !== undefined && patchText !== ''
|
||||
$: changedFilesCount = patchText === '' ? 0 : (patchText.match(/^diff --git /gm)?.length ?? 0)
|
||||
|
||||
let review: GithubPullRequestReview | undefined
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
github.class.GithubPullRequestReview,
|
||||
{
|
||||
attachedTo: pullRequest._id,
|
||||
author: me.person
|
||||
author: me
|
||||
},
|
||||
(res) => {
|
||||
;[review] = res
|
||||
@@ -60,7 +60,7 @@
|
||||
async function handleFileViewed (fileName: string, sha: string, viewed: boolean): Promise<void> {
|
||||
const current = await client.findOne(github.class.GithubPullRequestReview, {
|
||||
attachedTo: pullRequest._id,
|
||||
author: me.person
|
||||
author: me
|
||||
})
|
||||
|
||||
const files = current?.files ?? []
|
||||
@@ -73,7 +73,7 @@
|
||||
files.push({ fileName, sha })
|
||||
}
|
||||
|
||||
if (current) {
|
||||
if (current != null) {
|
||||
await client.update(current, { files })
|
||||
} else {
|
||||
await client.addCollection(
|
||||
@@ -82,7 +82,7 @@
|
||||
pullRequest._id,
|
||||
github.class.GithubPullRequest,
|
||||
'reviewsVisual',
|
||||
{ author: me.person, files }
|
||||
{ author: me, files }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@
|
||||
fill={'var(--caption-color)'}
|
||||
marginRight={'.375rem'}
|
||||
/>
|
||||
<Label label={github.string.ChangedFiles} params={{ files: pullRequest.files }} />
|
||||
<Label label={github.string.ChangedFiles} params={{ files: changedFilesCount }} />
|
||||
</svelte:fragment>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@
|
||||
props={{ patch: patchText, viewed: viewedFiles }}
|
||||
on:change={(evt) => {
|
||||
const { fileName, sha, viewed } = evt.detail
|
||||
handleFileViewed(fileName, sha, viewed)
|
||||
void handleFileViewed(fileName, sha, viewed)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</script>
|
||||
|
||||
<HyperlinkEditor
|
||||
value={repository?.repository?.html_url ?? repository?.htmlURL}
|
||||
value={repository?.htmlURL ?? ''}
|
||||
placeholder={getEmbeddedLabel(repository?.name ?? '')}
|
||||
title={repository?.name ?? ''}
|
||||
readonly
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
export let label: IntlString = github.string.AssignRepository
|
||||
export let showIcon: boolean = false
|
||||
|
||||
$: repository = $integrationRepositories.get(value)
|
||||
$: repository = value != null ? $integrationRepositories.get(value) : undefined
|
||||
|
||||
let selectedRepository: GithubIntegrationRepository | undefined
|
||||
|
||||
|
||||
+5
-6
@@ -3,7 +3,7 @@
|
||||
//
|
||||
-->
|
||||
<script lang="ts">
|
||||
import core, { PersonId, Ref, WithLookup, getCurrentAccount } from '@hcengineering/core'
|
||||
import core, { Ref, WithLookup, getCurrentAccount } from '@hcengineering/core'
|
||||
import { GithubPullRequest, GithubReviewComment, GithubReviewThread } from '@hcengineering/github'
|
||||
|
||||
import { ActivityMessageHeader, ActivityMessageTemplate } from '@hcengineering/activity-resources'
|
||||
@@ -82,8 +82,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
const toRefPersonAccount = (account: PersonId): PersonId => account
|
||||
const toRefPerson = (account?: Ref<Person>): Ref<Person> => account as Ref<Person>
|
||||
function onDiffExpand (nextExpanded: boolean): void {
|
||||
expanded = nextExpanded
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -122,9 +123,7 @@
|
||||
fileName: value.path,
|
||||
expandable: value.isResolved,
|
||||
expanded,
|
||||
onExpand: (value) => {
|
||||
expanded = value
|
||||
}
|
||||
onExpand: onDiffExpand
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { MeasureContext, WorkspaceInfoWithStatus, WorkspaceUuid } from '@hcengineering/core'
|
||||
import { GithubWorkerWorkspaceState, getGithubWorkerState } from '../workspaceUtils'
|
||||
|
||||
const WS = '00000000-0000-0000-0000-000000000001' as WorkspaceUuid
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function createMockCtx (): MeasureContext {
|
||||
return {
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn()
|
||||
} as unknown as MeasureContext
|
||||
}
|
||||
|
||||
function baseInfo (overrides: Partial<WorkspaceInfoWithStatus> = {}): WorkspaceInfoWithStatus {
|
||||
return {
|
||||
uuid: WS,
|
||||
name: 'ws',
|
||||
url: 'ws-url',
|
||||
createdOn: 0,
|
||||
versionMajor: 0,
|
||||
versionMinor: 7,
|
||||
versionPatch: 0,
|
||||
mode: 'active',
|
||||
processingAttemps: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('getGithubWorkerState', () => {
|
||||
it('returns Skip and warns when uuid is absent', () => {
|
||||
const ctx = createMockCtx()
|
||||
const checked = new Set<string>()
|
||||
const info = { ...baseInfo(), uuid: undefined as unknown as WorkspaceUuid }
|
||||
const state = getGithubWorkerState(ctx, WS, info, 3, checked)
|
||||
expect(state).toBe(GithubWorkerWorkspaceState.Skip)
|
||||
expect(ctx.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns Skip when workspace is disabled', () => {
|
||||
const ctx = createMockCtx()
|
||||
const state = getGithubWorkerState(ctx, WS, baseInfo({ isDisabled: true, mode: 'active' }), 3, new Set())
|
||||
expect(state).toBe(GithubWorkerWorkspaceState.Skip)
|
||||
expect(ctx.info).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['pending-deletion', 'deleting', 'deleted'] as const)('returns Skip for mode %s', (mode) => {
|
||||
const ctx = createMockCtx()
|
||||
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Skip)
|
||||
})
|
||||
|
||||
it.each(['archived', 'archiving-pending-backup', 'archiving-clean'] as const)(
|
||||
'returns Skip for archiving %s',
|
||||
(mode) => {
|
||||
const ctx = createMockCtx()
|
||||
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Skip)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['upgrading', 'creating', 'pending-creation'] as const)('returns Wait for mode %s', (mode) => {
|
||||
const ctx = createMockCtx()
|
||||
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode }), 3, new Set())).toBe(GithubWorkerWorkspaceState.Wait)
|
||||
expect(ctx.warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns Wait when last visit exceeds inactivity interval', () => {
|
||||
const ctx = createMockCtx()
|
||||
const nowMs = 1_700_000_000_000
|
||||
const lastVisit = nowMs - 4 * DAY_MS
|
||||
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 3, new Set(), nowMs)).toBe(
|
||||
GithubWorkerWorkspaceState.Wait
|
||||
)
|
||||
})
|
||||
|
||||
it('returns Connect when within inactivity interval', () => {
|
||||
const ctx = createMockCtx()
|
||||
const nowMs = 1_700_000_000_000
|
||||
const lastVisit = nowMs - 2 * DAY_MS
|
||||
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 3, new Set(), nowMs)).toBe(
|
||||
GithubWorkerWorkspaceState.Connect
|
||||
)
|
||||
})
|
||||
|
||||
it('does not apply inactivity gate when interval is 0', () => {
|
||||
const ctx = createMockCtx()
|
||||
const nowMs = 1_700_000_000_000
|
||||
const lastVisit = nowMs - 365 * DAY_MS
|
||||
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active', lastVisit }), 0, new Set(), nowMs)).toBe(
|
||||
GithubWorkerWorkspaceState.Connect
|
||||
)
|
||||
})
|
||||
|
||||
it('missing lastVisit uses epoch → Wait inactive when interval > 0', () => {
|
||||
const ctx = createMockCtx()
|
||||
const nowMs = 1_700_000_000_000
|
||||
expect(getGithubWorkerState(ctx, WS, baseInfo({ mode: 'active' }), 3, new Set(), nowMs)).toBe(
|
||||
GithubWorkerWorkspaceState.Wait
|
||||
)
|
||||
})
|
||||
|
||||
it('logs inactive warning only once per workspace id', () => {
|
||||
const ctx = createMockCtx()
|
||||
const checked = new Set<string>()
|
||||
const nowMs = 1_700_000_000_000
|
||||
const info = baseInfo({ mode: 'active', lastVisit: nowMs - 10 * DAY_MS })
|
||||
getGithubWorkerState(ctx, WS, info, 3, checked, nowMs)
|
||||
getGithubWorkerState(ctx, WS, info, 3, checked, nowMs)
|
||||
expect(ctx.warn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,8 +11,6 @@ import core, {
|
||||
Client,
|
||||
ClientConnectEvent,
|
||||
DocumentUpdate,
|
||||
isActiveMode,
|
||||
isDeletingMode,
|
||||
MeasureContext,
|
||||
PersonId,
|
||||
RateLimiter,
|
||||
@@ -46,6 +44,7 @@ import { type StorageAdapter } from '@hcengineering/server-core'
|
||||
import { join } from 'path'
|
||||
import { createPlatformClient } from './client'
|
||||
import config from './config'
|
||||
import { GithubWorkerWorkspaceState, getGithubWorkerState } from './workspaceUtils'
|
||||
import { registerLoaders } from './loaders'
|
||||
import { createNotification } from './notifications'
|
||||
import { errorToObj } from './sync/utils'
|
||||
@@ -68,6 +67,8 @@ interface IntegrationDataValue {
|
||||
installationId: number | number[]
|
||||
}
|
||||
|
||||
export { GithubWorkerWorkspaceState, getGithubWorkerState } from './workspaceUtils'
|
||||
|
||||
export class PlatformWorker {
|
||||
private readonly clients = new Map<WorkspaceUuid, GithubWorker>()
|
||||
|
||||
@@ -948,32 +949,6 @@ export class PlatformWorker {
|
||||
|
||||
checkedWorkspaces = new Set<string>()
|
||||
|
||||
checkWorkspaceIsActive (workspace: WorkspaceUuid, workspaceInfo: WorkspaceInfoWithStatus): boolean {
|
||||
if (workspaceInfo?.uuid === undefined) {
|
||||
this.ctx.error('No workspace exists for workspaceId', { workspace })
|
||||
return false
|
||||
}
|
||||
if (workspaceInfo?.isDisabled === true || isDeletingMode(workspaceInfo?.mode)) {
|
||||
this.ctx.warn('Workspace is disabled', { workspace })
|
||||
return false
|
||||
}
|
||||
if (!isActiveMode(workspaceInfo?.mode)) {
|
||||
this.ctx.warn('Workspace is in maitenance, skipping for now.', { workspace, mode: workspaceInfo?.mode })
|
||||
return true
|
||||
}
|
||||
|
||||
const lastVisit = (Date.now() - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000) // In days
|
||||
|
||||
if (config.WorkspaceInactivityInterval > 0 && lastVisit > config.WorkspaceInactivityInterval) {
|
||||
if (!this.checkedWorkspaces.has(workspace)) {
|
||||
this.checkedWorkspaces.add(workspace)
|
||||
this.ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace })
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
checkReconnect (workspace: WorkspaceUuid, event: ClientConnectEvent, worker: GithubWorker): void {
|
||||
if (event === ClientConnectEvent.Refresh || event === ClientConnectEvent.Upgraded) {
|
||||
void this.clients
|
||||
@@ -989,9 +964,15 @@ export class PlatformWorker {
|
||||
getAccountClient(config.AccountsURL, token)
|
||||
.getWorkspaceInfo()
|
||||
.then((wsInfo) => {
|
||||
const res = this.checkWorkspaceIsActive(workspace, wsInfo)
|
||||
if (!res) {
|
||||
this.ctx.warn('Workspace is inactive, removing from clients list.', { workspace })
|
||||
const state = getGithubWorkerState(
|
||||
this.ctx,
|
||||
workspace,
|
||||
wsInfo,
|
||||
config.WorkspaceInactivityInterval,
|
||||
this.checkedWorkspaces
|
||||
)
|
||||
if (state === GithubWorkerWorkspaceState.Skip) {
|
||||
this.ctx.warn('Github worker state is skip, removing from clients list.', { workspace })
|
||||
this.clients.delete(workspace)
|
||||
void worker?.close().catch((err) => {
|
||||
this.ctx.error('Failed to close workspace', { workspace, error: err })
|
||||
@@ -1053,11 +1034,20 @@ export class PlatformWorker {
|
||||
rechecks.push(workspace)
|
||||
continue
|
||||
}
|
||||
const needRecheck = this.checkWorkspaceIsActive(workspace, returnedInfo)
|
||||
if (needRecheck) {
|
||||
const state = getGithubWorkerState(
|
||||
this.ctx,
|
||||
workspace,
|
||||
returnedInfo,
|
||||
config.WorkspaceInactivityInterval,
|
||||
this.checkedWorkspaces
|
||||
)
|
||||
if (state === GithubWorkerWorkspaceState.Wait) {
|
||||
rechecks.push(workspace)
|
||||
continue
|
||||
}
|
||||
if (state === GithubWorkerWorkspaceState.Skip) {
|
||||
continue
|
||||
}
|
||||
await rateLimiter.add(async () => {
|
||||
try {
|
||||
const branding = Object.values(this.brandingMap).find((b) => b.key === returnedInfo?.branding) ?? null
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import {
|
||||
isActiveMode,
|
||||
isArchivingMode,
|
||||
isDeletingMode,
|
||||
type MeasureContext,
|
||||
type WorkspaceInfoWithStatus,
|
||||
type WorkspaceUuid
|
||||
} from '@hcengineering/core'
|
||||
|
||||
/** Whether this pod should run a GithubWorker for the workspace. */
|
||||
export enum GithubWorkerWorkspaceState {
|
||||
/** Active workspace — connect and run worker. */
|
||||
Connect = 'connect',
|
||||
/** Not ready yet (upgrade, creation, …) or too inactive — recheck later; keep existing worker. */
|
||||
Wait = 'wait',
|
||||
/** Disabled, deleting, deleted, or archiving — do not run; drop worker if present. */
|
||||
Skip = 'skip'
|
||||
}
|
||||
|
||||
export function getGithubWorkerState (
|
||||
ctx: MeasureContext,
|
||||
workspace: WorkspaceUuid,
|
||||
workspaceInfo: WorkspaceInfoWithStatus,
|
||||
inactivityIntervalDays: number,
|
||||
checkedWorkspaces: Set<string>,
|
||||
nowMs: number = Date.now()
|
||||
): GithubWorkerWorkspaceState {
|
||||
if (workspaceInfo?.uuid === undefined) {
|
||||
ctx.warn('No workspace exists for workspaceId', { workspace })
|
||||
return GithubWorkerWorkspaceState.Skip
|
||||
}
|
||||
if (workspaceInfo.isDisabled === true || isDeletingMode(workspaceInfo.mode) || isArchivingMode(workspaceInfo.mode)) {
|
||||
ctx.info('Workspace is disabled, deleting, or archived — skipping github worker', {
|
||||
workspace,
|
||||
mode: workspaceInfo.mode,
|
||||
isDisabled: workspaceInfo.isDisabled
|
||||
})
|
||||
return GithubWorkerWorkspaceState.Skip
|
||||
}
|
||||
if (!isActiveMode(workspaceInfo.mode)) {
|
||||
ctx.warn('Workspace is in maintenance, skipping for now.', { workspace, mode: workspaceInfo.mode })
|
||||
return GithubWorkerWorkspaceState.Wait
|
||||
}
|
||||
|
||||
const lastVisitDays = (nowMs - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000)
|
||||
|
||||
if (inactivityIntervalDays > 0 && lastVisitDays > inactivityIntervalDays) {
|
||||
if (!checkedWorkspaces.has(workspace)) {
|
||||
checkedWorkspaces.add(workspace)
|
||||
ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace })
|
||||
}
|
||||
return GithubWorkerWorkspaceState.Wait
|
||||
}
|
||||
return GithubWorkerWorkspaceState.Connect
|
||||
}
|
||||
Reference in New Issue
Block a user