mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-20 11:22:25 +02:00
* feat(tracker): add Gantt scheduling schema (startDate + IssueRelation)
Schema-only foundation for the upcoming Gantt-chart view in tracker.
No UI in this PR.
Changes:
- Issue.startDate: Timestamp | null (interface + IssueDraft + @Prop with @Index)
- Milestone.startDate: Timestamp | null (interface + @Prop, reusing the
existing tracker.string.StartDate IntlString)
- New DependencyKind type ('finish-to-start' | 'start-to-start' |
'finish-to-finish' | 'start-to-finish')
- New IssueRelation AttachedDoc class with kind: DependencyKind, signed
lag: number — registered in models/tracker via TIssueRelation
- 7 new IntlString keys: IssueStartDate, GanttDependency,
GanttDependency{FinishToStart,StartToStart,FinishToFinish,StartToFinish},
GanttLag — all 13 locales updated
- Cross-plugin literal updates in importer + github sync to satisfy the new
required Issue.startDate / Milestone.startDate fields:
- packages/importer/src/importer/importer.ts: AttachedData<Issue> literal
- services/github/pod-github/src/sync/issueBase.ts: 'startDate' added to
GithubIssueData Omit list (github sync does not own scheduling)
- services/github/pod-github/src/sync/issues.ts + pullrequests.ts:
AttachedData<Issue|GithubPullRequest> literals
Out of scope (deferred to follow-up PRs):
- UI for Gantt view, drag/resize, dependency editor, critical path
- blockedBy → IssueRelation migration (ships atomically with the writer
redirect in the dependency-UI PR)
- LinkIssues permission (tracker uses forbid-style permissions; needs
maintainer discussion)
- Activity-feed wiring for IssueRelation (needs a producer to test against)
- IssueTemplate.startDate (template propagation semantics undecided)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* test(model-tracker): add migrateAddStartDate jest tests
3 tests covering migrateAddStartDate:
- writes startDate=null to Issues in DOMAIN_TASK with the right filter
- writes startDate=null to Milestones in DOMAIN_TRACKER with the right filter
- issues exactly two update calls (one per class)
Follows the MigrationClient mock pattern from
models/chat/src/__tests__/migration.test.ts.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* feat(model-tracker): add migrateAddStartDate + wire into trackerOperation
Backfills startDate=null on existing Issues (DOMAIN_TASK) and Milestones
(DOMAIN_TRACKER) so the new schema field has a defined value on every
pre-existing document. Idempotent via the standard tryMigrate state-key
mechanism (state: 'gantt-add-startdate').
Verified domain choices against existing migration helpers:
- migrateIdentifiers / passIdentifierToParentInfo use DOMAIN_TASK for
Issues (lines 145, 161 in this file).
- TMilestone @Model decorator confirms DOMAIN_TRACKER for Milestones
(models/tracker/src/types.ts:372).
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* feat(tracker): expose Issue.startDate / Milestone.startDate in UI; tighten typing
UI changes (so the new schema fields are actually editable, in chronological
order Start → Due/Target):
- New StartDateEditor.svelte (mirrors DueDateEditor.svelte for startDate)
- ControlPanel: render Start Date row above Due Date row in the issue
side panel; both always-visible (no `!== null` guard) so users can set
them on issues that don't have a date yet
- NewMilestone form: Start Date input above Target Date input
- Milestone list view: Start Date column before Target Date column
- TIssueRelation: tighten interface to `extends AttachedDoc<Issue, 'relations'>`
so attachedTo + collection are statically typed. The model class
re-declares `collection: 'relations'` to match the narrower base.
- Drop 4 unused Dependency-kind IntlString keys (FinishToFinish,
FinishToStart, StartToFinish, StartToStart) — they had no consumer
in PR 1; will be re-introduced in PR 4 (dependency editor).
- Simplify migration.ts comments — drop ageing line-references.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* fix(tracker): set explicit @Prop ranks for Milestone date fields
The DocAttributeBar side panel sorts attributes by attr.rank ?? toRank(_id)
(see plugins/view-resources/src/components/ClassAttributeBar.svelte:42-47),
so without explicit ranks the visible order on a Milestone was hash-based
(startDate before Status, breaking the chronological flow the user expects).
Set ranks so the side panel renders Status → Start date → Target date.
Comments and attachments stay where they are (they're collections, filtered
out of the attribute panel by categorizeFields).
Issues are unaffected — the Issue side panel is the custom ControlPanel.svelte
which renders Start date / Due date in explicit slots (see PR 1's UI commit).
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* fix(tracker-resources): EditMilestone renders Status/Start/Target in body in chronological order
The right-side DocAttributeBar sorts attributes by attr.rank ?? toRank(_id),
giving startDate before status (toRank('startDate') < toRank('status')
lexicographically). Setting an explicit rank via @Prop's third arg did not
propagate through the workspace upgrade for existing Attribute documents
in the model TX log — the rank made it into the bundled txes but the
existing Attribute creation TXes are not replaced on upgrade-workspace.
Pivot: render Status, Start date, Target date in the EditMilestone body
in explicit chronological order, and add 'status', 'startDate', 'targetDate'
to ignoreKeys so they don't appear duplicated in the side panel. This
mirrors how Issue's ControlPanel.svelte handles its date fields.
Reverts the no-op @Prop rank attempt.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* fix(fulltext): bump model version to 0.7.423 to match deployed workspaces
The fulltext-pod's compiled model version (baked into bundle/model.json via
common/scripts/version.txt at build time) lags whenever the workspaces have
been migrated to a newer patch but the pod was not rebuilt. In that state the
indexer rejects every incoming Tx with a `wrong version` warning, new issues
silently fail to land in Elasticsearch, and search returns empty results for
any document created after the migration.
Bumping `version.txt` aligns the compiled model with the workspaces. All
future builds (front, transactor, workspace, tool, fulltext) will emit
0.7.423, the indexer accepts the Tx stream again, and the deferred backlog
gets consumed automatically — no manual reindex needed.
This commit is the build-side companion to the schema migration in this
same PR. Without it the fulltext-pod cannot consume the migrated workspace's
Tx events.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* chore: apply rush format after develop merge
Resolves the failing formatting check requested by @ArtyomSavchenko in
review of #10851 after the develop branch merge.
Affects three files in our PR scope:
- models/tracker/src/migration.ts: collapse short multi-line client.update call
- plugins/tracker/src/index.ts: inline DependencyKind union + IssueRelation comment
- plugins/tracker-resources/src/components/milestones/EditMilestone.svelte:
reformat inline arrow handlers, move QueryIssuesList block ahead of <style>
No logic changes; deterministic prettier output.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* test(tracker): fix milestone page-object selectors after startDate field addition
The Gantt schema PR added Milestone.startDate, which:
1. Adds a second datetime-button to the NewMilestone form pool. The
existing 'div.antiCard-pool button.datetime-button' locator matched
both buttons and tripped Playwright's strict-mode check. Scope the
target-date locator to .last() and add a sibling .first() helper for
the start-date button.
2. Moves Status / Start date / Target date editors from the
auto-generated side panel into EditMilestone's body
(div.dates-row > div.date-cell > span.cell-label + <button>) in
chronological order. The label span no longer has a sibling <div>
wrapping the button — the button is a direct sibling. Switch the
buttonStatus/buttonTargetDate XPath to following-sibling::button[1]
and match the new class="cell-label" span. Add a buttonStartDate
helper for the new editor row.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* test(tracker): shift buttonEstimation index after startDate row addition
ControlPanel.svelte (issue side panel) now renders the Start date and
Due date rows unconditionally — pre-PR the Due date row was conditional
on issue.dueDate !== null and the Start date row didn't exist at all.
Both new rows emit a <div><button> pair via DueDatePresenter, which the
existing (//span[text()='Estimation']/../div/button)[3] XPath counts as
extra matches and pushes the Estimation button from the 3rd to the 5th
direct div/button under the popupPanel-body__aside-grid.
Direct div/button order under the grid (document order):
1. CreatedBy (EmployeeBox > UserBox div > Button)
2. Assignee (AssigneeEditor div > Button)
3. Start date (NEW — StartDateEditor > DueDatePresenter div > button.datetime-button)
4. Due date (NEW — DueDateEditor > DueDatePresenter div > button.datetime-button)
5. Estimation (AttributeBarEditor div > Button)
buttonAssignee at [2] is unchanged. textEstimation uses 'following-sibling::div[1]'
(first sibling), which is unaffected by additions earlier in the grid.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* chore: apply rush format (prettier compliance for CI)
CI's rush fast-format --branch develop step flagged
tests/sanity/tests/model/tracker/milestones-details-page.ts for a
missing blank line between the buttonTargetDate locator (introduced in
86b1c19ee8) and the next field. Apply the local 'rush format' result.
The two other files CI flagged
(plugins/process-resources/src/components/settings/BindingsEditor.svelte
and ImportSlotsPopup.svelte) were actually upstream changes from PR
#10921 (Fix add tag) that landed after our last develop merge — the
preceding merge of upstream/develop into this branch resolves those
diffs.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
* fix(tests/tracker): use contains() for cell-label class to survive Svelte CSS scoping
The Svelte 4 compiler appends a scoped `svelte-<hash>` class to every
element matched by a component-local CSS selector. EditMilestone.svelte
styles `.cell-label` locally, so each label span ends up as
`<span class="cell-label svelte-XXXXX">` at runtime, not the bare
`<span class="cell-label">` shipped in source. The previous XPath
locator used strict `@class="cell-label"` and never matched.
Switch buttonStatus / buttonStartDate / buttonTargetDate to the standard
`contains(concat(' ', normalize-space(@class), ' '), ' cell-label ')`
class-match idiom so the locators tolerate the added scoped class.
Verified against the playwright accessibility snapshot from the failed
run (artifact playwright-results, hash 07a8f36b...md): the Status row
renders as a generic with text 'Status' immediately followed by a
button 'In progress' as the next direct sibling, matching the rest of
the XPath.
Fixes 5 milestone.spec.ts failures observed in run 27816114236:
- Create a Milestone (locator timeout on checkIssue → buttonStatus)
- Edit a Milestone (locator timeout on editIssue → buttonStatus.click)
- Delete a Milestone (locator timeout on checkIssue → buttonStatus)
plus their two retries each.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
---------
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Co-authored-by: Michael Uray <michaeluray@users.noreply.github.com>
Co-authored-by: Artyom Savchenko <armisav@gmail.com>
1587 lines
50 KiB
TypeScript
1587 lines
50 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
import { Analytics } from '@hcengineering/analytics'
|
|
import contact, { Employee, Person } from '@hcengineering/contact'
|
|
import core, {
|
|
AttachedData,
|
|
Doc,
|
|
DocumentUpdate,
|
|
PersonId,
|
|
Ref,
|
|
SortingOrder,
|
|
Status,
|
|
TxCUD,
|
|
TxMixin,
|
|
TxOperations,
|
|
TxProcessor,
|
|
WithLookup,
|
|
cutObjectArray,
|
|
generateId,
|
|
makeCollabId,
|
|
makeDocCollabId,
|
|
withContext,
|
|
type MeasureContext
|
|
} from '@hcengineering/core'
|
|
import github, {
|
|
DocSyncInfo,
|
|
GithubIntegrationRepository,
|
|
GithubIssue,
|
|
GithubIssueStateReason,
|
|
GithubProject,
|
|
GithubPullRequest,
|
|
GithubPullRequestState,
|
|
GithubTodo,
|
|
LastReviewState
|
|
} from '@hcengineering/github'
|
|
import task, { TaskType, calcRank, makeRank } from '@hcengineering/task'
|
|
import time, { ToDo, ToDoPriority } from '@hcengineering/time'
|
|
import tracker, { Issue, IssuePriority, IssueStatus, Project } from '@hcengineering/tracker'
|
|
import { ProjectsV2ItemEvent, PullRequestEvent } from '@octokit/webhooks-types'
|
|
import { Octokit } from 'octokit'
|
|
import config from '../config'
|
|
import {
|
|
ContainerFocus,
|
|
DocSyncManager,
|
|
ExternalSyncField,
|
|
IntegrationContainer,
|
|
githubDerivedSyncVersion,
|
|
githubExternalSyncVersion,
|
|
githubSyncVersion,
|
|
type UserInfo
|
|
} from '../types'
|
|
import {
|
|
IssueExternalData,
|
|
PullRequestExternalData,
|
|
PullRequestReviewState,
|
|
Review,
|
|
getUpdatedAtReviewThread,
|
|
pullRequestDetails,
|
|
toPRState,
|
|
toReviewDecision,
|
|
toReviewState
|
|
} from './githubTypes'
|
|
import { GithubIssueData, IssueSyncManagerBase, WithMarkup } from './issueBase'
|
|
import {
|
|
ensureGraphQLOctokit,
|
|
errorToObj,
|
|
getSinceRaw,
|
|
gqlp,
|
|
guessStatus,
|
|
isGHWriteAllowed,
|
|
syncChilds,
|
|
syncDerivedDocuments,
|
|
syncRunner
|
|
} from './utils'
|
|
|
|
type GithubPullRequestData = GithubIssueData &
|
|
Omit<GithubPullRequest, keyof Issue | 'commits' | 'reviews' | 'reviewComments'>
|
|
|
|
type GithubPullRequestUpdate = DocumentUpdate<WithMarkup<GithubPullRequest>>
|
|
|
|
export class PullRequestSyncManager extends IssueSyncManagerBase implements DocSyncManager {
|
|
externalDerivedSync = true
|
|
|
|
@withContext('pullrequests-handleEvent')
|
|
async handleEvent<T>(
|
|
ctx: MeasureContext,
|
|
integration: IntegrationContainer,
|
|
derivedClient: TxOperations,
|
|
evt: T
|
|
): Promise<void> {
|
|
const _event = evt as PullRequestEvent | ProjectsV2ItemEvent
|
|
|
|
if (_event.sender.type === 'Bot') {
|
|
// Ignore events from Bot if it is our bot
|
|
// No need to handle event from ourself
|
|
if (_event.sender.login.includes(config.BotName)) {
|
|
return
|
|
}
|
|
}
|
|
ctx.info('pull request:handleEvent', {
|
|
nodeId:
|
|
(_event as PullRequestEvent).pull_request?.html_url ??
|
|
(_event as ProjectsV2ItemEvent).projects_v2_item?.node_id,
|
|
action: _event.action,
|
|
login: _event.sender.login,
|
|
type: _event.sender.type,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
|
|
const projectV2Event = (_event as any as ProjectsV2ItemEvent).projects_v2_item?.id !== undefined
|
|
if (projectV2Event) {
|
|
// Ignore
|
|
} else {
|
|
const event = _event as PullRequestEvent
|
|
const { project, repository } = await this.provider.getProjectAndRepository(event.repository.node_id)
|
|
|
|
if (project === undefined || repository === undefined) {
|
|
ctx.info('No project for repository', {
|
|
name: event.repository.name,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
return
|
|
}
|
|
const url = event.pull_request.issue_url
|
|
|
|
await syncRunner.exec(url, async () => {
|
|
try {
|
|
await this.processEvent(ctx, event, derivedClient, repository, integration, project)
|
|
} catch (err: any) {
|
|
ctx.error('Error processing event', { error: err })
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
@withContext('pullrequests-processEvent')
|
|
private async processEvent (
|
|
ctx: MeasureContext,
|
|
event: PullRequestEvent,
|
|
derivedClient: TxOperations,
|
|
repo: GithubIntegrationRepository,
|
|
integration: IntegrationContainer,
|
|
prj: GithubProject
|
|
): Promise<void> {
|
|
const account = (await this.provider.getAccountU(event.sender)) ?? core.account.System
|
|
|
|
let externalData: PullRequestExternalData
|
|
try {
|
|
const response: any = await ctx.with('graphql', {}, (ctx) =>
|
|
integration.octokit.graphql(
|
|
`query listIssue($name: String!, $owner: String!, $issue: Int!) {
|
|
repository(name: $name, owner: $owner) {
|
|
pullRequest(number: $issue) {
|
|
${pullRequestDetails}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
name: repo.name,
|
|
owner: repo.owner?.login,
|
|
issue: event.pull_request.number
|
|
}
|
|
)
|
|
)
|
|
externalData = response.repository.pullRequest
|
|
} catch (err: any) {
|
|
ctx.error('Error', { err })
|
|
Analytics.handleError(err)
|
|
await this.createErrorSyncDataByUrl(
|
|
event.pull_request.html_url,
|
|
event.pull_request.number,
|
|
new Date(event.pull_request.updated_at),
|
|
derivedClient,
|
|
repo,
|
|
err
|
|
)
|
|
return
|
|
}
|
|
if (externalData === undefined) {
|
|
await this.createErrorSyncDataByUrl(
|
|
event.pull_request.html_url,
|
|
event.pull_request.number,
|
|
new Date(event.pull_request.updated_at),
|
|
derivedClient,
|
|
repo,
|
|
'no external data found'
|
|
)
|
|
return
|
|
}
|
|
|
|
switch (event.action) {
|
|
case 'opened': {
|
|
await this.createSyncData(externalData, derivedClient, repo, account)
|
|
break
|
|
}
|
|
case 'edited': {
|
|
const update: GithubPullRequestUpdate = {}
|
|
const du: DocumentUpdate<DocSyncInfo> = {}
|
|
if (event.changes.title !== undefined) {
|
|
update.title = event.pull_request.title
|
|
}
|
|
if (event.changes.body !== undefined) {
|
|
update.description = await this.provider.getMarkupSafe(
|
|
integration,
|
|
event.pull_request.body,
|
|
this.stripGuestLink
|
|
)
|
|
du.markdown = await this.provider.getMarkdown(update.description)
|
|
}
|
|
if (event.changes.base !== undefined) {
|
|
update.base = externalData.baseRef
|
|
}
|
|
await this.handleUpdate(ctx, externalData, derivedClient, update, account, prj, false, undefined, undefined, du)
|
|
break
|
|
}
|
|
case 'review_requested': {
|
|
const update: GithubPullRequestUpdate = {}
|
|
await this.handleUpdate(ctx, externalData, derivedClient, update, account, prj, true)
|
|
break
|
|
}
|
|
case 'review_request_removed': {
|
|
const update: GithubPullRequestUpdate = {}
|
|
await this.handleUpdate(ctx, externalData, derivedClient, update, account, prj, true)
|
|
break
|
|
}
|
|
case 'converted_to_draft':
|
|
case 'ready_for_review': {
|
|
await this.handleUpdate(ctx, externalData, derivedClient, {}, account, prj, true)
|
|
break
|
|
}
|
|
case 'assigned':
|
|
case 'unassigned': {
|
|
const assignees = await this.getAssignees(externalData)
|
|
const update: GithubPullRequestUpdate = {
|
|
assignee: assignees?.[0] ?? null
|
|
}
|
|
await this.handleUpdate(ctx, externalData, derivedClient, update, account, prj, true)
|
|
break
|
|
}
|
|
case 'closed':
|
|
case 'reopened': {
|
|
const type = await this.provider.getTaskTypeOf(prj.type, github.class.GithubPullRequest)
|
|
const statuses = await this.provider.getStatuses(type?._id)
|
|
|
|
const isMerged = event.pull_request?.merged_at !== null
|
|
|
|
const update: GithubPullRequestUpdate = {
|
|
draft: externalData.isDraft,
|
|
head: externalData.headRef,
|
|
base: externalData.baseRef,
|
|
mergeable: externalData.mergeable,
|
|
commits: externalData.commits?.nodes?.length,
|
|
remainingTime: 0,
|
|
state: toPRState(externalData.state ?? 'OPEN'),
|
|
reviewDecision: toReviewDecision(externalData.reviewDecision ?? 'REVIEW_REQUIRED'),
|
|
...(event.action === 'closed'
|
|
? {
|
|
status: (
|
|
await guessStatus(
|
|
{
|
|
state: 'CLOSED',
|
|
stateReason: isMerged ? GithubIssueStateReason.Completed : GithubIssueStateReason.NotPlanned
|
|
},
|
|
statuses
|
|
)
|
|
)._id,
|
|
mergedAt:
|
|
event.pull_request?.merged_at !== null ? new Date(event.pull_request?.merged_at).getTime() : null,
|
|
closedAt:
|
|
event.pull_request?.closed_at !== null ? new Date(event.pull_request?.closed_at).getTime() : null
|
|
}
|
|
: {
|
|
status: (await guessStatus({ state: 'OPEN', stateReason: GithubIssueStateReason.Reopened }, statuses))
|
|
._id,
|
|
mergedAt: null,
|
|
closedAt: null
|
|
})
|
|
}
|
|
await this.handleUpdate(
|
|
ctx,
|
|
externalData,
|
|
derivedClient,
|
|
update,
|
|
account,
|
|
prj,
|
|
true,
|
|
undefined,
|
|
async (state, existing, external, update) => {
|
|
// We need to be sure we not change status if category is same, since github doesn't know about it.
|
|
const existingStatus = statuses.find((it) => it._id === existing.status)
|
|
const updateState = statuses.find((it) => it._id === update.status)
|
|
if (existingStatus?.category === updateState?.category) {
|
|
delete update.status
|
|
}
|
|
return true
|
|
}
|
|
)
|
|
break
|
|
}
|
|
case 'synchronize': {
|
|
const syncData = await this.client.findOne(github.class.DocSyncInfo, {
|
|
space: repo.githubProject as Ref<GithubProject>,
|
|
url: (externalData.url ?? '').toLowerCase()
|
|
})
|
|
if (syncData !== undefined) {
|
|
await derivedClient.update(syncData, {
|
|
needSync: '',
|
|
external: externalData,
|
|
derivedVersion: '', // Check derived changes
|
|
updatePatch: true
|
|
})
|
|
this.provider.sync()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
async getReviewers (issue: PullRequestExternalData): Promise<PersonId[]> {
|
|
// Find Assignees and reviewers
|
|
const ids: UserInfo[] = (issue.reviewRequests?.nodes ?? [])
|
|
.filter((it: any) => it != null)
|
|
.map((it: any) => it.requestedReviewer)
|
|
.filter((id: any) => id != null)
|
|
|
|
const values: PersonId[] = []
|
|
|
|
for (const o of ids) {
|
|
const acc = await this.provider.getAccount(o)
|
|
if (acc !== undefined) {
|
|
values.push(acc)
|
|
}
|
|
}
|
|
|
|
for (const n of issue.latestReviews?.nodes ?? []) {
|
|
if (n?.author == null) {
|
|
continue
|
|
}
|
|
const acc = await this.provider.getAccount(n.author)
|
|
if (acc !== undefined) {
|
|
values.push(acc)
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
private async createSyncData (
|
|
pullRequestExternal: PullRequestExternalData,
|
|
derivedClient: TxOperations,
|
|
repo: GithubIntegrationRepository,
|
|
account: PersonId
|
|
): Promise<void> {
|
|
const lastModified = new Date(pullRequestExternal.updatedAt).getTime()
|
|
await derivedClient.createDoc(github.class.DocSyncInfo, repo.githubProject as Ref<GithubProject>, {
|
|
url: pullRequestExternal.url.toLowerCase(),
|
|
needSync: '', // we need to sync to retrieve patch in background
|
|
githubNumber: pullRequestExternal.number,
|
|
repository: repo._id,
|
|
objectClass: github.class.GithubPullRequest,
|
|
external: pullRequestExternal,
|
|
externalVersion: githubExternalSyncVersion,
|
|
derivedVersion: '',
|
|
addHulyLink: true,
|
|
lastModified,
|
|
lastGithubUser: account
|
|
})
|
|
// We need trigger comments, if their sync data created before
|
|
const childInfos = await this.client.findAll(github.class.DocSyncInfo, {
|
|
parent: (pullRequestExternal.url ?? '').toLowerCase()
|
|
})
|
|
for (const child of childInfos) {
|
|
await derivedClient?.update(child, { needSync: '' })
|
|
}
|
|
this.provider.sync()
|
|
}
|
|
|
|
async syncToTarget (
|
|
ctx: MeasureContext,
|
|
container: ContainerFocus,
|
|
existing: Doc | undefined,
|
|
pullRequestExternal: PullRequestExternalData,
|
|
derivedClient: TxOperations,
|
|
info: DocSyncInfo
|
|
): Promise<DocumentUpdate<DocSyncInfo>> {
|
|
const account =
|
|
existing?.modifiedBy ?? (await this.provider.getAccount(pullRequestExternal.author)) ?? core.account.System
|
|
const accountGH =
|
|
info.lastGithubUser ?? (await this.provider.getAccount(pullRequestExternal.author)) ?? core.account.System
|
|
|
|
const type = await this.provider.getTaskTypeOf(container.project.type, github.class.GithubPullRequest)
|
|
const statuses = await this.provider.getStatuses(type?._id)
|
|
|
|
const assignees = await this.getAssignees(pullRequestExternal)
|
|
const reviewers = await this.getPersonsFromId(await this.getReviewers(pullRequestExternal))
|
|
|
|
const latestReviews: LastReviewState[] = []
|
|
|
|
for (const d of pullRequestExternal.latestReviews?.nodes ?? []) {
|
|
const author = await this.provider.getAccount(d.author)
|
|
if (author !== undefined) {
|
|
latestReviews.push({
|
|
state: toReviewState(d.state),
|
|
user: author
|
|
})
|
|
}
|
|
}
|
|
const pullRequestData: GithubPullRequestData = {
|
|
title: pullRequestExternal.title,
|
|
description: await this.provider.getMarkupSafe(
|
|
container.container,
|
|
pullRequestExternal.body,
|
|
this.stripGuestLink
|
|
),
|
|
assignee: assignees[0] ?? null,
|
|
reviewers,
|
|
draft: pullRequestExternal.isDraft,
|
|
head: pullRequestExternal.headRef,
|
|
base: pullRequestExternal.baseRef,
|
|
mergedAt: pullRequestExternal.mergedAt != null ? new Date(pullRequestExternal.mergedAt).getTime() : null,
|
|
closedAt: pullRequestExternal.closedAt != null ? new Date(pullRequestExternal.closedAt).getTime() : null,
|
|
mergeable: pullRequestExternal.mergeable,
|
|
commits: pullRequestExternal.commits?.nodes?.length,
|
|
remainingTime: 0,
|
|
state: toPRState(pullRequestExternal.state ?? 'OPEN'),
|
|
latestReviews,
|
|
reviewDecision: toReviewDecision(pullRequestExternal.reviewDecision ?? 'REVIEW_REQUIRED'),
|
|
files: pullRequestExternal.files.totalCount
|
|
}
|
|
|
|
const taskTypes = (await this.client.findAll(task.class.TaskType, { parent: container.project.type })).filter(
|
|
(it) => this.client.getHierarchy().isDerived(it.targetClass, github.class.GithubPullRequest)
|
|
)
|
|
|
|
if (taskTypes.length === 0) {
|
|
// Missing required task type
|
|
ctx.error('Missing required task type', { url: pullRequestExternal.url })
|
|
return { needSync: githubSyncVersion }
|
|
}
|
|
|
|
const lastModified = new Date(pullRequestExternal.updatedAt).getTime()
|
|
|
|
if (existing === undefined) {
|
|
try {
|
|
await ctx.with(
|
|
'retrieve pull request patch',
|
|
{},
|
|
(ctx) =>
|
|
this.handlePatch(
|
|
ctx,
|
|
info,
|
|
container,
|
|
pullRequestExternal,
|
|
{
|
|
_id: info._id as unknown as Ref<GithubPullRequest>,
|
|
space: info.space as Ref<GithubProject>,
|
|
_class: github.class.GithubPullRequest
|
|
},
|
|
lastModified,
|
|
accountGH
|
|
),
|
|
{ url: pullRequestExternal.url },
|
|
{ log: true }
|
|
)
|
|
const { markdownCompatible, markdown } = await this.provider.checkMarkdownConversion(
|
|
container.container,
|
|
pullRequestExternal.body
|
|
)
|
|
|
|
let op = this.client.apply()
|
|
let createdPullRequest: GithubPullRequest | undefined
|
|
|
|
await ctx.with(
|
|
'create pull request in platform',
|
|
{},
|
|
async (ctx) => {
|
|
createdPullRequest = await this.createPullRequest(
|
|
op,
|
|
info,
|
|
accountGH,
|
|
{
|
|
...pullRequestData,
|
|
status: (await guessStatus(pullRequestExternal, statuses))._id as Ref<Status>
|
|
},
|
|
pullRequestExternal,
|
|
info.repository as Ref<GithubIntegrationRepository>,
|
|
container.project,
|
|
taskTypes[0]._id,
|
|
(await this.provider.getRepositoryById(info.repository)) as GithubIntegrationRepository,
|
|
!markdownCompatible
|
|
)
|
|
},
|
|
{ url: pullRequestExternal.url },
|
|
{ log: true }
|
|
)
|
|
|
|
await op.commit()
|
|
const pullRequestObj =
|
|
createdPullRequest ??
|
|
(await this.client.findOne(github.class.GithubPullRequest, {
|
|
_id: info._id as unknown as Ref<GithubPullRequest>
|
|
}))
|
|
if (pullRequestObj !== undefined) {
|
|
op = this.client.apply()
|
|
try {
|
|
await this.todoSync(ctx, op, pullRequestObj, pullRequestExternal, info, account)
|
|
} catch (err: any) {
|
|
ctx.error('failed to sync todos', { err, url: pullRequestExternal.url, id: pullRequestObj._id })
|
|
}
|
|
await op.commit()
|
|
}
|
|
|
|
// To sync reviews/review threads in case they are created before us.
|
|
await syncChilds(ctx, info, this.client, derivedClient)
|
|
|
|
return {
|
|
needSync: '',
|
|
external: pullRequestExternal,
|
|
externalVersion: githubExternalSyncVersion,
|
|
lastModified: new Date(pullRequestExternal.updatedAt).getTime(),
|
|
isDescriptionLocked: !markdownCompatible,
|
|
markdown
|
|
}
|
|
} catch (err: any) {
|
|
ctx.error('Error', { err })
|
|
Analytics.handleError(err)
|
|
return { needSync: githubSyncVersion, error: errorToObj(err) }
|
|
}
|
|
} else {
|
|
try {
|
|
if (info.updatePatch === true) {
|
|
await ctx.with(
|
|
'update pull request patch',
|
|
{},
|
|
(ctx) =>
|
|
this.handlePatch(
|
|
ctx,
|
|
info,
|
|
container,
|
|
pullRequestExternal,
|
|
{
|
|
_id: info._id as unknown as Ref<GithubPullRequest>,
|
|
space: info.space as Ref<GithubProject>,
|
|
_class: github.class.GithubPullRequest
|
|
},
|
|
lastModified,
|
|
accountGH
|
|
),
|
|
{ url: pullRequestExternal.url },
|
|
{ log: true }
|
|
)
|
|
}
|
|
|
|
const description = await ctx.with(
|
|
'query collaborative pull request description',
|
|
{},
|
|
async (ctx) => {
|
|
const collabId = makeDocCollabId(existing, 'description')
|
|
return await this.collaborator.getMarkup(collabId, (existing as GithubPullRequest).description)
|
|
},
|
|
{ url: pullRequestExternal.url },
|
|
{ log: true }
|
|
)
|
|
|
|
const update = await ctx.with(
|
|
'perform pull request diff update',
|
|
{},
|
|
(ctx) =>
|
|
this.handleDiffUpdate(
|
|
ctx,
|
|
container,
|
|
{ ...(existing as any), description },
|
|
info,
|
|
pullRequestData,
|
|
pullRequestExternal,
|
|
account,
|
|
accountGH
|
|
),
|
|
{ url: pullRequestExternal.url },
|
|
{ log: true }
|
|
)
|
|
return {
|
|
...update,
|
|
updatePatch: false,
|
|
lastModified: new Date(pullRequestExternal.updatedAt).getTime(),
|
|
lastGithubAccount: null
|
|
}
|
|
} catch (err: any) {
|
|
ctx.error('Error update pr', { err })
|
|
Analytics.handleError(err)
|
|
return { needSync: githubSyncVersion, error: errorToObj(err), external: pullRequestExternal }
|
|
}
|
|
}
|
|
}
|
|
|
|
async afterSync (
|
|
ctx: MeasureContext,
|
|
existing: Issue,
|
|
account: PersonId,
|
|
issueExternal: any,
|
|
info: DocSyncInfo
|
|
): Promise<void> {
|
|
const pullRequest = existing as GithubPullRequest
|
|
try {
|
|
await this.todoSync(ctx, this.client, pullRequest, issueExternal as PullRequestExternalData, info, account)
|
|
} catch (err: any) {
|
|
ctx.error('failed to sync todos', { err, url: issueExternal.url, id: pullRequest._id })
|
|
}
|
|
}
|
|
|
|
async todoSync (
|
|
ctx: MeasureContext,
|
|
client: TxOperations,
|
|
pullRequest: Pick<
|
|
GithubPullRequest,
|
|
'_id' | 'identifier' | 'reviewers' | 'title' | 'state' | 'space' | '_class' | 'modifiedBy'
|
|
>,
|
|
external: PullRequestExternalData,
|
|
info: DocSyncInfo,
|
|
account: PersonId
|
|
): Promise<void> {
|
|
// Find all todo's related to PR.
|
|
const allTodos = await client.findAll<GithubTodo>(github.mixin.GithubTodo, { attachedTo: pullRequest._id })
|
|
// We also need to track deleted Todos,
|
|
const removedTodos: GithubTodo[] = []
|
|
|
|
const removedTodoOps = await client.findAll<TxCUD<ToDo>>(
|
|
core.class.TxCUD,
|
|
{
|
|
attachedTo: pullRequest._id,
|
|
objectClass: time.class.ProjectToDo,
|
|
objectId: { $nin: allTodos.map((it) => it._id) }
|
|
},
|
|
{ sort: { modifiedOn: SortingOrder.Ascending } }
|
|
)
|
|
|
|
const todoIds = removedTodoOps.filter((it) => it._class === core.class.TxCreateDoc).map((it) => it.objectId)
|
|
|
|
const mixinOps = await client.findAll<TxMixin<ToDo, GithubTodo>>(
|
|
core.class.TxMixin,
|
|
{
|
|
objectId: { $in: todoIds }
|
|
},
|
|
{ sort: { modifiedOn: SortingOrder.Ascending } }
|
|
)
|
|
|
|
const groupedByTodo = new Map<Ref<ToDo>, TxCUD<ToDo>[]>()
|
|
|
|
const h = this.client.getHierarchy()
|
|
|
|
// We need to rebuild removed todos's if pressent.
|
|
for (const tx of removedTodoOps) {
|
|
const ops = groupedByTodo.get(tx.objectId)
|
|
groupedByTodo.set(tx.objectId, [...(ops ?? []), tx])
|
|
}
|
|
|
|
for (const tx of mixinOps) {
|
|
const ops = groupedByTodo.get(tx.objectId)
|
|
groupedByTodo.set(tx.objectId, [...(ops ?? []), tx])
|
|
}
|
|
|
|
for (const [, txes] of groupedByTodo) {
|
|
const todo = TxProcessor.buildDoc2Doc<ToDo>(txes)
|
|
if (todo != null && h.hasMixin(todo, github.mixin.GithubTodo)) {
|
|
removedTodos.push(h.as(todo, github.mixin.GithubTodo))
|
|
}
|
|
}
|
|
|
|
const pendingOrDismissedIds = new Map<PersonId, PullRequestReviewState>()
|
|
|
|
const approvedOrChangesRequested = new Map<PersonId, PullRequestReviewState>()
|
|
const reviewStates = new Map<PersonId, PullRequestReviewState[]>()
|
|
|
|
const sortedReviews: (Review & { date: number })[] = (external.reviews.nodes ?? [])
|
|
.filter((it) => it != null)
|
|
.map((it) => ({
|
|
...it,
|
|
date: new Date(it.updatedAt ?? it.submittedAt ?? it.createdAt).getTime()
|
|
}))
|
|
|
|
for (const it of external.latestReviews.nodes ?? []) {
|
|
if (sortedReviews.some((qt) => it.id === qt.id)) {
|
|
continue
|
|
}
|
|
|
|
sortedReviews.push({ ...it, date: new Date(it.updatedAt ?? it.submittedAt ?? it.createdAt).getTime() })
|
|
}
|
|
|
|
sortedReviews.sort((a, b) => b.date - a.date)
|
|
|
|
for (const r of sortedReviews) {
|
|
const rp = await this.provider.getAccount(r.author)
|
|
if (rp === undefined) {
|
|
continue
|
|
}
|
|
if (r.state === 'PENDING' || r.state === 'DISMISSED') {
|
|
pendingOrDismissedIds.set(rp, r.state)
|
|
}
|
|
if (r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED') {
|
|
approvedOrChangesRequested.set(rp, r.state)
|
|
}
|
|
reviewStates.set(rp, [...(reviewStates.get(rp) ?? []), r.state])
|
|
}
|
|
|
|
const pendingOrDismissed = new Set(
|
|
await this.getPersonsFromId(Array.from(pendingOrDismissedIds.entries()).map((it) => it[0]))
|
|
)
|
|
|
|
for (const r of pullRequest.reviewers ?? []) {
|
|
// Find all related todos's
|
|
const todos = [...allTodos, ...removedTodos].filter((it) => it.user === r && it.purpose === 'review')
|
|
// We also need to check if user deleted, todo, in this case we need to remove review request.
|
|
|
|
const hasPending = todos.some((it) => it.doneOn !== null)
|
|
|
|
// Create review Todo, if missing.
|
|
if (pullRequest.state === GithubPullRequestState.open || (!hasPending && pendingOrDismissed.has(r))) {
|
|
if (todos.length === 0) {
|
|
await this.requestReview(client, pullRequest, external, r, account)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle change requests.
|
|
// If we have change requests pending, we need to create Todo to resolve them to author or assigned person, to resolve them.
|
|
|
|
const changeRequestPersonsIds = new Set<PersonId>()
|
|
const author = await this.provider.getAccount(external.author)
|
|
if (author !== undefined) {
|
|
changeRequestPersonsIds.add(author)
|
|
}
|
|
for (const au of external.assignees.nodes ?? []) {
|
|
const u = await this.provider.getAccount(au)
|
|
if (u !== undefined) {
|
|
changeRequestPersonsIds.add(u)
|
|
}
|
|
}
|
|
|
|
// Check review threads and create todo to resolve them.
|
|
const requestedIds: Ref<Person>[] = []
|
|
|
|
const changeRequestPersons = await this.getPersonsFromId(Array.from(changeRequestPersonsIds))
|
|
|
|
let allResolved = true
|
|
for (const r of external.reviewThreads.nodes ?? []) {
|
|
if (!r.isResolved) {
|
|
allResolved = false
|
|
for (const c of changeRequestPersons) {
|
|
// We need to add Todo to resolve PR.
|
|
const todos = [...allTodos, ...removedTodos].filter((it) => it.user === c && it.purpose === 'fix')
|
|
if (todos.length === 0) {
|
|
requestedIds.push(c)
|
|
// We do not have todos's create one to solve issue.
|
|
await this.requestFix(client, pullRequest, external, c, account)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Handle change request
|
|
if (external.reviewThreads.nodes.length === 0) {
|
|
// If we have changes requested.
|
|
for (const [, sst] of approvedOrChangesRequested.entries()) {
|
|
if (sst === 'CHANGES_REQUESTED') {
|
|
// We have changes requested and not resolved yet.
|
|
for (const c of changeRequestPersons) {
|
|
const todos = [...allTodos, ...removedTodos].filter((it) => it.user === c && it.purpose === 'fix')
|
|
if (todos.length === 0 && !requestedIds.includes(c)) {
|
|
requestedIds.push(c)
|
|
// We do not have todos's create one to solve issue.
|
|
await this.requestFix(client, pullRequest, external, c, account)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allResolved) {
|
|
// We need to complete or remove todo, in case all are resolved.
|
|
if (!Array.from(approvedOrChangesRequested.values()).includes('CHANGES_REQUESTED')) {
|
|
const todos = allTodos.filter((it) => it.purpose === 'fix' && it.doneOn == null)
|
|
for (const t of todos) {
|
|
await this.markDoneOrDeleteTodo(t)
|
|
}
|
|
}
|
|
}
|
|
|
|
// In case of merged, reviewed, we need to close todos's
|
|
if (pullRequest.state !== GithubPullRequestState.open) {
|
|
for (const td of allTodos.filter((it) => it.doneOn == null)) {
|
|
await this.markDoneOrDeleteTodo(td)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async requestReview (
|
|
client: TxOperations,
|
|
pullRequest: Pick<GithubPullRequest, '_id' | 'identifier' | 'space' | '_class' | 'reviewers' | 'title' | 'state'>,
|
|
external: PullRequestExternalData,
|
|
todoUser: Ref<Person>,
|
|
account: PersonId
|
|
): Promise<void> {
|
|
const employee = (await client.findAll(contact.mixin.Employee, { _id: todoUser as Ref<Employee> }, { limit: 1 }))[0]
|
|
if (employee === undefined) return
|
|
const latestTodo = await client.findOne(
|
|
time.class.ToDo,
|
|
{
|
|
user: employee._id,
|
|
doneOn: null
|
|
},
|
|
{
|
|
sort: { rank: SortingOrder.Ascending }
|
|
}
|
|
)
|
|
const todoId = await client.addCollection(
|
|
time.class.ProjectToDo,
|
|
time.space.ToDos,
|
|
pullRequest._id,
|
|
pullRequest._class,
|
|
'todos',
|
|
{
|
|
title: 'Review ' + external.title,
|
|
description: external.url,
|
|
attachedSpace: pullRequest.space,
|
|
user: employee._id,
|
|
workslots: 0,
|
|
doneOn: null,
|
|
priority: ToDoPriority.High,
|
|
visibility: 'public',
|
|
rank: makeRank(undefined, latestTodo?.rank)
|
|
},
|
|
undefined,
|
|
undefined,
|
|
account
|
|
)
|
|
await client.createMixin(
|
|
todoId,
|
|
time.class.ToDo,
|
|
time.space.ToDos,
|
|
github.mixin.GithubTodo,
|
|
{
|
|
purpose: 'review'
|
|
},
|
|
undefined,
|
|
account
|
|
)
|
|
}
|
|
|
|
private async requestFix (
|
|
client: TxOperations,
|
|
pullRequest: Pick<
|
|
GithubPullRequest,
|
|
'_id' | 'identifier' | 'reviewers' | 'title' | 'space' | 'state' | 'space' | '_class'
|
|
>,
|
|
external: PullRequestExternalData,
|
|
todoUser: Ref<Person>,
|
|
account: PersonId
|
|
): Promise<void> {
|
|
const employee = (await client.findAll(contact.mixin.Employee, { _id: todoUser as Ref<Employee> }, { limit: 1 }))[0]
|
|
if (employee === undefined) return
|
|
const latestTodo = await client.findOne(
|
|
time.class.ToDo,
|
|
{
|
|
user: employee._id,
|
|
doneOn: null
|
|
},
|
|
{
|
|
sort: { rank: SortingOrder.Ascending }
|
|
}
|
|
)
|
|
|
|
const todoId = await client.addCollection(
|
|
time.class.ProjectToDo,
|
|
time.space.ToDos,
|
|
pullRequest._id,
|
|
pullRequest._class,
|
|
'todos',
|
|
{
|
|
attachedSpace: pullRequest.space,
|
|
title: 'Resolve ' + pullRequest.title,
|
|
description: external.url,
|
|
user: employee._id,
|
|
doneOn: null,
|
|
workslots: 0,
|
|
priority: ToDoPriority.High,
|
|
visibility: 'public',
|
|
rank: makeRank(undefined, latestTodo?.rank)
|
|
},
|
|
undefined,
|
|
undefined,
|
|
account
|
|
)
|
|
await client.createMixin(
|
|
todoId,
|
|
time.class.ToDo,
|
|
time.space.ToDos,
|
|
github.mixin.GithubTodo,
|
|
{
|
|
purpose: 'fix'
|
|
},
|
|
undefined,
|
|
account
|
|
)
|
|
}
|
|
|
|
private async markDoneOrDeleteTodo (td: WithLookup<GithubTodo>): Promise<void> {
|
|
// Let's mark as done in any case
|
|
await this.client.diffUpdate(td, {
|
|
doneOn: Date.now()
|
|
})
|
|
}
|
|
|
|
async fillBackChanges (update: DocumentUpdate<Issue>, existing: GithubIssue, external: any): Promise<void> {
|
|
const statuses = await this.provider.getStatuses(existing.kind)
|
|
const status = (existing as unknown as GithubPullRequest).status
|
|
const pullRequestExternal = external as PullRequestExternalData
|
|
|
|
// We need to update status in case category are different
|
|
const stInstance =
|
|
statuses.find((it) => it._id === status) ??
|
|
((await this.client.findOne(core.class.Status, { _id: status })) as Status)
|
|
|
|
let gs: IssueStatus | undefined
|
|
if (pullRequestExternal.merged) {
|
|
gs = await guessStatus({ state: 'MERGED' }, statuses)
|
|
}
|
|
|
|
// If PR is merged or closed, we need to update platform issue status
|
|
if (gs !== undefined && stInstance.category !== gs.category) {
|
|
update.status = gs._id
|
|
}
|
|
}
|
|
|
|
@withContext('pullrequests-sync')
|
|
async sync (
|
|
ctx: MeasureContext,
|
|
existing: Doc | undefined,
|
|
info: DocSyncInfo,
|
|
parent: DocSyncInfo | undefined,
|
|
derivedClient: TxOperations
|
|
): Promise<DocumentUpdate<DocSyncInfo> | undefined> {
|
|
const container = await this.provider.getContainer(info.space)
|
|
if (container?.container === undefined) {
|
|
return { needSync: githubSyncVersion }
|
|
}
|
|
const needCreateConnectedAtHuly = info.addHulyLink === true
|
|
|
|
if (info.repository == null) {
|
|
return { needSync: githubSyncVersion }
|
|
}
|
|
|
|
const pullRequestExternal = info.external as unknown as PullRequestExternalData
|
|
|
|
if (info.externalVersion !== githubExternalSyncVersion) {
|
|
return { needSync: '' }
|
|
}
|
|
|
|
const syncResult = await this.syncToTarget(ctx, container, existing, pullRequestExternal, derivedClient, info)
|
|
|
|
if (existing !== undefined && pullRequestExternal !== undefined && needCreateConnectedAtHuly) {
|
|
await this.addHulyLink(info, syncResult, existing, pullRequestExternal, container)
|
|
}
|
|
return {
|
|
...syncResult
|
|
}
|
|
}
|
|
|
|
async performIssueFieldsUpdate (
|
|
ctx: MeasureContext,
|
|
info: DocSyncInfo,
|
|
existing: WithMarkup<Issue>,
|
|
platformUpdate: DocumentUpdate<Issue>,
|
|
issueData: Pick<WithMarkup<Issue>, 'title' | 'description' | 'assignee' | 'status' | 'remainingTime' | 'component'>,
|
|
container: ContainerFocus,
|
|
issueExternal: IssueExternalData,
|
|
okit: Octokit,
|
|
account: PersonId
|
|
): Promise<boolean> {
|
|
const graphqlOkit = ensureGraphQLOctokit(okit, container)
|
|
|
|
let { state, stateReason, body, ...issueUpdate } = await this.collectIssueUpdate(
|
|
info,
|
|
existing,
|
|
platformUpdate,
|
|
issueData,
|
|
container,
|
|
issueExternal,
|
|
github.class.GithubPullRequest
|
|
)
|
|
|
|
if ((issueExternal as PullRequestExternalData).merged) {
|
|
// We could not change state for merged pull requests.
|
|
state = undefined
|
|
}
|
|
|
|
const hasFieldsUpdate = Object.keys(issueUpdate).length > 0 || state !== undefined
|
|
const isLocked = info.isDescriptionLocked === true && !(await this.provider.isPlatformUser(account))
|
|
|
|
if (hasFieldsUpdate || body !== undefined) {
|
|
if (body !== undefined && !isLocked) {
|
|
await ctx.with(
|
|
'==> updatePullRequest',
|
|
{},
|
|
async (ctx) => {
|
|
ctx.info('update-pr-fields', {
|
|
url: issueExternal.url,
|
|
...issueUpdate,
|
|
body,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
if (isGHWriteAllowed()) {
|
|
await graphqlOkit.graphql(
|
|
`
|
|
mutation updatePullRequest($issue: ID!, $body: String!) {
|
|
updatePullRequest(input: {
|
|
pullRequestId: $issue,
|
|
${state !== undefined ? `state: ${state as string}` : ''}
|
|
${gqlp(issueUpdate)},
|
|
body: $body
|
|
}) {
|
|
pullRequest {
|
|
id
|
|
updatedAt
|
|
}
|
|
}
|
|
}`,
|
|
{ issue: issueExternal.id, body }
|
|
)
|
|
}
|
|
},
|
|
{ url: issueExternal.url },
|
|
{ log: true }
|
|
)
|
|
issueData.description = await this.provider.getMarkupSafe(container.container, body, this.stripGuestLink)
|
|
} else if (hasFieldsUpdate) {
|
|
await ctx.with(
|
|
'==> updatePullRequest:',
|
|
{},
|
|
async (ctx) => {
|
|
ctx.info('update-fields', {
|
|
url: issueExternal.url,
|
|
...issueUpdate,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
if (isGHWriteAllowed()) {
|
|
await graphqlOkit.graphql(
|
|
`
|
|
mutation updatePullRequest($issue: ID!) {
|
|
updatePullRequest(input: {
|
|
pullRequestId: $issue,
|
|
${state !== undefined ? `state: ${state as string}` : ''}
|
|
${gqlp(issueUpdate)}
|
|
}) {
|
|
pullRequest {
|
|
id
|
|
updatedAt
|
|
}
|
|
}
|
|
}`,
|
|
{ issue: issueExternal.id }
|
|
)
|
|
}
|
|
},
|
|
{ issue: issueExternal.id },
|
|
{ log: true }
|
|
)
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private async handlePatch (
|
|
ctx: MeasureContext,
|
|
info: DocSyncInfo,
|
|
container: ContainerFocus,
|
|
pullRequestExternal: PullRequestExternalData,
|
|
existingPR: Pick<GithubPullRequest, '_id' | 'space' | '_class'>,
|
|
lastModified: number,
|
|
account: PersonId
|
|
): Promise<void> {
|
|
const repo = await this.provider.getRepositoryById(info.repository)
|
|
if (repo?.nodeId === undefined) {
|
|
return
|
|
}
|
|
if (info.external?.patch !== true) {
|
|
const { patch, contentType } = await this.fetchPatch(ctx, pullRequestExternal, container.container.octokit, repo)
|
|
|
|
// Update attached patch data.
|
|
const patchAttachment = await this.client.findOne(github.class.GithubPatch, { attachedTo: existingPR._id })
|
|
const blob = await this.provider.uploadFile(patch, patchAttachment?.file, contentType)
|
|
if (blob !== undefined) {
|
|
if (patchAttachment === undefined) {
|
|
await this.client.addCollection(
|
|
github.class.GithubPatch,
|
|
existingPR.space,
|
|
existingPR._id,
|
|
existingPR._class,
|
|
'attachments',
|
|
{
|
|
name: 'Patch.diff',
|
|
file: blob._id,
|
|
type: blob.contentType,
|
|
size: blob.size,
|
|
lastModified,
|
|
readonly: true
|
|
},
|
|
generateId(),
|
|
lastModified,
|
|
account
|
|
)
|
|
} else {
|
|
await this.client.diffUpdate(
|
|
patchAttachment,
|
|
{
|
|
size: blob.size,
|
|
type: blob.contentType,
|
|
lastModified
|
|
},
|
|
new Date().getTime(),
|
|
account
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async createPullRequest (
|
|
client: TxOperations,
|
|
info: DocSyncInfo,
|
|
account: PersonId,
|
|
pullRequestData: GithubPullRequestData & { status: Issue['status'] },
|
|
pullRequestExternal: PullRequestExternalData,
|
|
repo: Ref<GithubIntegrationRepository>,
|
|
prj: GithubProject,
|
|
taskType: Ref<TaskType>,
|
|
repository: GithubIntegrationRepository,
|
|
isDescriptionLocked: boolean
|
|
): Promise<GithubPullRequest> {
|
|
const lastOne = await client.findOne<Issue>(
|
|
tracker.class.Issue,
|
|
{ space: prj._id },
|
|
{ sort: { rank: SortingOrder.Descending }, limit: 1 }
|
|
)
|
|
const incResult = await this.client.updateDoc(
|
|
tracker.class.Project,
|
|
core.space.Space,
|
|
info.space as Ref<Project>,
|
|
{ $inc: { sequence: 1 } },
|
|
true,
|
|
new Date().getTime(),
|
|
account
|
|
)
|
|
|
|
const prId = info._id as unknown as Ref<GithubPullRequest>
|
|
|
|
const { description, ...data } = pullRequestData
|
|
const project = (incResult as any).object as Project
|
|
const number = project.sequence
|
|
const value: AttachedData<GithubPullRequest> = {
|
|
...data,
|
|
description: null,
|
|
kind: taskType,
|
|
component: null,
|
|
milestone: null,
|
|
number,
|
|
identifier: `${project.identifier}-${number}`,
|
|
priority: IssuePriority.Medium,
|
|
rank: calcRank(lastOne, undefined),
|
|
comments: 0,
|
|
subIssues: 0,
|
|
startDate: null,
|
|
dueDate: null,
|
|
parents: [],
|
|
reportedTime: 0,
|
|
estimation: 0,
|
|
remainingTime: 0,
|
|
reports: 0,
|
|
relations: [],
|
|
childInfo: [],
|
|
commits: 0,
|
|
reviewComments: 0,
|
|
reviews: 0
|
|
}
|
|
|
|
const collabId = makeCollabId(github.class.GithubPullRequest, prId, 'description')
|
|
await this.collaborator.updateMarkup(collabId, description)
|
|
|
|
await client.addCollection(
|
|
github.class.GithubPullRequest,
|
|
info.space,
|
|
tracker.ids.NoParent,
|
|
tracker.class.Issue,
|
|
'subIssues',
|
|
value,
|
|
prId,
|
|
new Date(pullRequestExternal.createdAt).getTime(),
|
|
account
|
|
)
|
|
await client.createMixin<Issue, GithubIssue>(
|
|
prId,
|
|
github.class.GithubPullRequest,
|
|
info.space,
|
|
github.mixin.GithubIssue,
|
|
{
|
|
githubNumber: pullRequestExternal.number,
|
|
url: pullRequestExternal.url,
|
|
repository: repo,
|
|
descriptionLocked: isDescriptionLocked
|
|
}
|
|
)
|
|
|
|
await this.addConnectToMessage(
|
|
github.string.PullRequestConnectedActivityInfo,
|
|
prj._id,
|
|
prId,
|
|
tracker.class.Issue,
|
|
pullRequestExternal,
|
|
repository
|
|
)
|
|
|
|
return {
|
|
...value,
|
|
_id: prId,
|
|
_class: github.class.GithubPullRequest,
|
|
space: info.space as any,
|
|
attachedTo: tracker.ids.NoParent,
|
|
attachedToClass: tracker.class.Issue,
|
|
collection: 'subIssues',
|
|
modifiedOn: new Date(pullRequestExternal.createdAt).getTime(),
|
|
modifiedBy: account,
|
|
createdOn: new Date(pullRequestExternal.createdAt).getTime(),
|
|
createdBy: account
|
|
}
|
|
}
|
|
|
|
@withContext('pullrequests-externalSync')
|
|
async externalSync (
|
|
ctx: MeasureContext,
|
|
integration: IntegrationContainer,
|
|
derivedClient: TxOperations,
|
|
kind: ExternalSyncField,
|
|
syncDocs: DocSyncInfo[],
|
|
repo: GithubIntegrationRepository,
|
|
prj: GithubProject
|
|
): Promise<void> {
|
|
if (kind === 'externalVersion') {
|
|
// Bulk update of selected PR's
|
|
// Wait global project sync
|
|
await this.performExternalSync(ctx, integration, prj, syncDocs, repo, derivedClient)
|
|
}
|
|
|
|
if (kind === 'derivedVersion') {
|
|
// Perform external synchronization's
|
|
// TODO: Add re-request for missing reviews/review threads.
|
|
await this.performDerivedSync(syncDocs, derivedClient, prj, repo)
|
|
}
|
|
}
|
|
|
|
private async performDerivedSync (
|
|
syncDocs: DocSyncInfo[],
|
|
derivedClient: TxOperations,
|
|
prj: GithubProject,
|
|
repo: GithubIntegrationRepository
|
|
): Promise<void> {
|
|
for (const d of syncDocs) {
|
|
const ext = d.external as PullRequestExternalData
|
|
if (ext == null) {
|
|
continue
|
|
}
|
|
if ((ext.reviews.nodes ?? []).length < ext.reviews.totalCount) {
|
|
// TODO: We need to fetch missing items.
|
|
}
|
|
|
|
if ((ext.reviewThreads.nodes ?? []).length < ext.reviewThreads.totalCount) {
|
|
// TODO: We need to fetch missing items.
|
|
}
|
|
|
|
await syncDerivedDocuments(
|
|
derivedClient,
|
|
d,
|
|
ext,
|
|
prj,
|
|
repo,
|
|
github.class.GithubReview,
|
|
{},
|
|
(ext) => ext.reviews.nodes ?? []
|
|
)
|
|
await syncDerivedDocuments(derivedClient, d, ext, prj, repo, github.class.GithubReviewThread, {}, (ext) =>
|
|
(ext.reviewThreads.nodes ?? []).map((it) => ({
|
|
...it,
|
|
url: it.id,
|
|
createdAt: new Date(it.comments.nodes[0].createdAt ?? Date.now()).toISOString(),
|
|
updatedAt: new Date(getUpdatedAtReviewThread(it)).toISOString()
|
|
}))
|
|
)
|
|
}
|
|
|
|
const tx = derivedClient.apply()
|
|
for (const d of syncDocs) {
|
|
await tx.update(d, { derivedVersion: githubDerivedSyncVersion })
|
|
}
|
|
await tx.commit()
|
|
this.provider.sync()
|
|
}
|
|
|
|
private async performExternalSync (
|
|
ctx: MeasureContext,
|
|
integration: IntegrationContainer,
|
|
prj: GithubProject,
|
|
syncDocs: DocSyncInfo[],
|
|
repo: GithubIntegrationRepository,
|
|
derivedClient: TxOperations
|
|
): Promise<void> {
|
|
await integration.syncLock.get(prj._id)
|
|
|
|
const allSyncDocs = [...syncDocs]
|
|
|
|
let partsize = 50
|
|
try {
|
|
while (true) {
|
|
const docsPart = allSyncDocs.splice(0, partsize)
|
|
const idsPart = docsPart
|
|
.map((it) => (it.external as IssueExternalData | undefined)?.id)
|
|
.filter((id): id is string => id !== undefined)
|
|
if (idsPart.length === 0) {
|
|
break
|
|
}
|
|
const idsp = idsPart.map((it) => `"${it}"`).join(', ')
|
|
try {
|
|
const response: any = await ctx.with(
|
|
'fetch pull request updates',
|
|
{},
|
|
async (ctx) =>
|
|
await integration.octokit.graphql(
|
|
`query listIssues {
|
|
nodes(ids: [${idsp}] ) {
|
|
... on PullRequest {
|
|
${pullRequestDetails}
|
|
}
|
|
}
|
|
}`
|
|
),
|
|
{
|
|
prj: prj.name,
|
|
repo: repo.name,
|
|
ids: idsp
|
|
},
|
|
{ log: true }
|
|
)
|
|
const issues: PullRequestExternalData[] = response.nodes
|
|
|
|
if (issues.some((issue) => issue.url === undefined && Object.keys(issue).length === 0)) {
|
|
ctx.error('empty document content updates', {
|
|
repo: repo.name,
|
|
workspace: this.provider.getWorkspaceId(),
|
|
data: cutObjectArray(response)
|
|
})
|
|
}
|
|
await this.syncIssues(ctx, github.class.GithubPullRequest, repo, issues, derivedClient, docsPart)
|
|
} catch (err: any) {
|
|
if (partsize > 1) {
|
|
partsize = 1
|
|
allSyncDocs.push(...docsPart)
|
|
ctx.warn('pull request external retrieval switch to one by one mode', {
|
|
errors: err.errors,
|
|
msg: err.message,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
} else if (partsize === 1) {
|
|
// We need to update issue, since it is missing on external side.
|
|
const syncDoc = syncDocs.find((it) => it.external?.id === idsPart[0])
|
|
if (syncDoc !== undefined) {
|
|
ctx.warn('mark missing external PR', {
|
|
errors: err.errors,
|
|
msg: err.message,
|
|
url: syncDoc.url,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
await derivedClient.diffUpdate(
|
|
syncDoc,
|
|
{
|
|
needSync: githubSyncVersion,
|
|
externalVersion: githubExternalSyncVersion,
|
|
derivedVersion: githubDerivedSyncVersion
|
|
},
|
|
Date.now()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const d of syncDocs) {
|
|
if ((d.external as IssueExternalData | undefined)?.id == null) {
|
|
ctx.error('failed to do external sync for', { objectClass: d.objectClass, _id: d._id })
|
|
// no external data for doc
|
|
await derivedClient.update<DocSyncInfo>(d, {
|
|
externalVersion: githubExternalSyncVersion
|
|
})
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
ctx.error('Error', { err })
|
|
Analytics.handleError(err)
|
|
}
|
|
this.provider.sync()
|
|
}
|
|
|
|
repositoryDisabled (ctx: MeasureContext, integration: IntegrationContainer, repo: GithubIntegrationRepository): void {
|
|
integration.synchronized.delete(`${repo._id}:pullRequests`)
|
|
}
|
|
|
|
@withContext('pullrequests-externalFullSync')
|
|
async externalFullSync (
|
|
ctx: MeasureContext,
|
|
integration: IntegrationContainer,
|
|
derivedClient: TxOperations,
|
|
projects: GithubProject[],
|
|
repositories: GithubIntegrationRepository[]
|
|
): Promise<void> {
|
|
for (const repo of repositories) {
|
|
if (this.provider.isClosing()) {
|
|
break
|
|
}
|
|
const prj = projects.find((it) => repo.githubProject === it._id)
|
|
if (prj === undefined) {
|
|
continue
|
|
}
|
|
// Wait global project sync
|
|
await integration.syncLock.get(prj._id)
|
|
|
|
const syncKey = `${repo._id}:pullRequests`
|
|
if (
|
|
repo.githubProject === undefined ||
|
|
!repo.enabled ||
|
|
integration.synchronized.has(syncKey) ||
|
|
integration.octokit === undefined
|
|
) {
|
|
if (!repo.enabled) {
|
|
integration.synchronized.delete(syncKey)
|
|
}
|
|
continue
|
|
}
|
|
const since = await getSinceRaw(this.client, github.class.GithubPullRequest, repo)
|
|
|
|
// We need always sync open PRs, since review changes are not included into PR updated state.
|
|
ctx.info('sync external pull requests', {
|
|
repo: repo.name,
|
|
since,
|
|
workspace: this.provider.getWorkspaceId(),
|
|
state: 'OPEN'
|
|
})
|
|
await this.performPRSync(ctx, integration, repo, 'OPEN', undefined, derivedClient, prj)
|
|
|
|
ctx.info('sync external pull requests', {
|
|
repo: repo.name,
|
|
since,
|
|
workspace: this.provider.getWorkspaceId(),
|
|
state: 'CLOSED, MERGED'
|
|
})
|
|
await this.performPRSync(ctx, integration, repo, 'CLOSED, MERGED', since, derivedClient, prj)
|
|
|
|
ctx.info('sync external pull requests - done', {
|
|
repo: repo.name,
|
|
since,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
|
|
this.provider.sync()
|
|
integration.synchronized.add(syncKey)
|
|
}
|
|
}
|
|
|
|
private async performPRSync (
|
|
ctx: MeasureContext,
|
|
integration: IntegrationContainer,
|
|
repo: GithubIntegrationRepository,
|
|
states: string,
|
|
since: number | undefined,
|
|
derivedClient: TxOperations,
|
|
prj: GithubProject
|
|
): Promise<void> {
|
|
try {
|
|
const pullRequestIterator = integration.octokit.graphql.paginate.iterator(
|
|
`query listPullRequests($name: String!, $owner: String!, $cursor: String) {
|
|
repository(name: $name, owner: $owner) {
|
|
pullRequests(
|
|
first: 25,
|
|
orderBy: {field: UPDATED_AT, direction: DESC},
|
|
states: [${states}],
|
|
after: $cursor) {
|
|
nodes {
|
|
${pullRequestDetails}
|
|
}
|
|
pageInfo {
|
|
startCursor
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
name: repo.name,
|
|
owner: repo.owner?.login ?? ''
|
|
}
|
|
)
|
|
for await (const data of pullRequestIterator) {
|
|
if (this.provider.isClosing()) {
|
|
break
|
|
}
|
|
const issues: PullRequestExternalData[] = data.repository.pullRequests.nodes
|
|
ctx.info('retrieve pull requests for', {
|
|
repo: repo.name,
|
|
since,
|
|
len: issues.length,
|
|
workspace: this.provider.getWorkspaceId()
|
|
})
|
|
|
|
if (since !== undefined) {
|
|
// Check if since > all updated data, then break and store since.
|
|
const hasUpdated = issues.some((it) => new Date(it.updatedAt).getTime() > since)
|
|
if (!hasUpdated) {
|
|
// We updated all since documents already
|
|
break
|
|
}
|
|
}
|
|
|
|
let emptyIndex = -1
|
|
emptyIndex = issues.findIndex((issue) => issue.url === undefined && Object.keys(issue).length === 0)
|
|
if (emptyIndex !== -1) {
|
|
ctx.error('empty document content', {
|
|
repo: repo.name,
|
|
workspace: this.provider.getWorkspaceId(),
|
|
data: cutObjectArray(data),
|
|
emptyIndex,
|
|
el: JSON.stringify(issues[emptyIndex])
|
|
})
|
|
}
|
|
|
|
await this.syncIssues(ctx, github.class.GithubPullRequest, repo, issues, derivedClient)
|
|
}
|
|
} catch (err: any) {
|
|
ctx.error('Error', { err })
|
|
Analytics.handleError(err)
|
|
}
|
|
}
|
|
|
|
async fetchPatch (
|
|
ctx: MeasureContext,
|
|
pullRequest: PullRequestExternalData,
|
|
octokit: Octokit,
|
|
repository: GithubIntegrationRepository
|
|
): Promise<{ patch: string, contentType: string }> {
|
|
let patch = ''
|
|
let contentType = 'application/vnd.github.VERSION.diff'
|
|
try {
|
|
const patchContent = await octokit.rest.pulls.get({
|
|
owner: repository.owner?.login as string,
|
|
repo: repository.name,
|
|
pull_number: pullRequest.number,
|
|
headers: {
|
|
Accept: 'application/vnd.github.VERSION.diff',
|
|
'X-GitHub-Api-Version': '2022-11-28'
|
|
}
|
|
})
|
|
patch = (patchContent.data as unknown as string) ?? ''
|
|
contentType = patchContent.headers['content-type'] ?? 'application/vnd.github.VERSION.diff'
|
|
} catch (err: any) {
|
|
ctx.error('Error', { err })
|
|
Analytics.handleError(err)
|
|
}
|
|
return { patch, contentType }
|
|
}
|
|
|
|
async deleteGithubDocument (
|
|
ctx: MeasureContext,
|
|
container: ContainerFocus,
|
|
account: PersonId,
|
|
id: string
|
|
): Promise<void> {
|
|
// No delete is allowed for pull requests
|
|
}
|
|
}
|