Skip to content

Commit 410acc8

Browse files
Adds utils and models to save associated issues in git config
1 parent ef30150 commit 410acc8

File tree

5 files changed

+253
-6
lines changed

5 files changed

+253
-6
lines changed

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const enum CharCode {
5050
export type GitConfigKeys =
5151
| `branch.${string}.${'gk' | 'vscode'}-merge-base`
5252
| `branch.${string}.gk-target-base`
53+
| `branch.${string}.gk-associated-issues`
5354
| `branch.${string}.github-pr-owner-number`;
5455

5556
export const enum GlyphChars {

src/git/models/branch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
getBranchTrackingWithoutRemote,
1111
getRemoteNameFromBranchName,
1212
getRemoteNameSlashIndex,
13-
isDetachedHead } from './branch.utils';
13+
isDetachedHead,
14+
} from './branch.utils';
1415
import type { PullRequest, PullRequestState } from './pullRequest';
1516
import type { GitBranchReference } from './reference';
1617
import type { GitRemote } from './remote';

src/git/models/branch.utils.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import type { CancellationToken } from 'vscode';
22
import type { GitConfigKeys } from '../../constants';
33
import type { Container } from '../../container';
4+
import type { IssueResourceDescriptor, RepositoryDescriptor } from '../../plus/integrations/integration';
5+
import type { GitConfigEntityIdentifier } from '../../plus/integrations/providers/models';
6+
import {
7+
encodeIssueOrPullRequestForGitConfig,
8+
getIssueFromGitConfigEntityIdentifier,
9+
} from '../../plus/integrations/providers/utils';
10+
import { Logger } from '../../system/logger';
411
import { PageableResult } from '../../system/paging';
512
import type { MaybePausedResult } from '../../system/promise';
613
import { pauseOnCancelOrTimeout } from '../../system/promise';
714
import type { GitBranch } from './branch';
15+
import type { Issue } from './issue';
816
import type { PullRequest } from './pullRequest';
917
import type { GitBranchReference, GitReference } from './reference';
1018
import type { Repository } from './repository';
@@ -172,3 +180,110 @@ export function getNameWithoutRemote(ref: GitReference) {
172180
}
173181
return ref.name;
174182
}
183+
184+
export async function getAssociatedIssuesForBranch(
185+
container: Container,
186+
branch: GitBranch,
187+
options?: {
188+
cancellation?: CancellationToken;
189+
timeout?: number;
190+
},
191+
): Promise<MaybePausedResult<Issue[] | undefined>> {
192+
const associatedIssuesConfigKey: GitConfigKeys = `branch.${branch.name}.gk-associated-issues`;
193+
// Associated issues encoded as a string array of stringified JSON objects
194+
const associatedIssuesEncoded = await container.git.getConfig(branch.repoPath, associatedIssuesConfigKey);
195+
if (options?.cancellation?.isCancellationRequested) return { value: undefined, paused: false };
196+
197+
let associatedIssues: GitConfigEntityIdentifier[] | undefined;
198+
if (associatedIssuesEncoded != null) {
199+
try {
200+
associatedIssues = JSON.parse(associatedIssuesEncoded) as GitConfigEntityIdentifier[];
201+
} catch (ex) {
202+
Logger.error(ex, 'getAssociatedIssuesForBranch');
203+
return { value: undefined, paused: false };
204+
}
205+
206+
if (options?.cancellation?.isCancellationRequested) return { value: undefined, paused: false };
207+
if (associatedIssues != null) {
208+
return pauseOnCancelOrTimeout(
209+
(async () => {
210+
const output = [];
211+
for (const issueDecoded of associatedIssues) {
212+
try {
213+
const issue = await getIssueFromGitConfigEntityIdentifier(container, issueDecoded);
214+
if (issue != null) {
215+
output.push(issue);
216+
}
217+
} catch (ex) {
218+
Logger.error(ex, 'getAssociatedIssuesForBranch');
219+
}
220+
}
221+
return output;
222+
})(),
223+
options?.cancellation,
224+
options?.timeout,
225+
);
226+
}
227+
}
228+
229+
return { value: undefined, paused: false };
230+
}
231+
232+
export async function addAssociatedIssueToBranch(
233+
container: Container,
234+
branch: GitBranchReference,
235+
issue: Issue,
236+
resource: RepositoryDescriptor | IssueResourceDescriptor,
237+
options?: {
238+
cancellation?: CancellationToken;
239+
},
240+
) {
241+
const associatedIssuesConfigKey: GitConfigKeys = `branch.${branch.name}.gk-associated-issues`;
242+
const associatedIssuesEncoded = await container.git.getConfig(branch.repoPath, associatedIssuesConfigKey);
243+
if (options?.cancellation?.isCancellationRequested) return;
244+
try {
245+
const associatedIssues: GitConfigEntityIdentifier[] = associatedIssuesEncoded
246+
? (JSON.parse(associatedIssuesEncoded) as GitConfigEntityIdentifier[])
247+
: [];
248+
if (options?.cancellation?.isCancellationRequested || associatedIssues.some(i => i.entityId === issue.nodeId)) {
249+
return;
250+
}
251+
associatedIssues.push(encodeIssueOrPullRequestForGitConfig(issue, resource));
252+
await container.git.setConfig(branch.repoPath, associatedIssuesConfigKey, JSON.stringify(associatedIssues));
253+
} catch (ex) {
254+
Logger.error(ex, 'addAssociatedIssueToBranch');
255+
}
256+
}
257+
258+
export async function removeAssociatedIssueFromBranch(
259+
container: Container,
260+
branch: GitBranchReference,
261+
id: string,
262+
options?: {
263+
cancellation?: CancellationToken;
264+
},
265+
) {
266+
const associatedIssuesConfigKey: GitConfigKeys = `branch.${branch.name}.gk-associated-issues`;
267+
const associatedIssuesEncoded = await container.git.getConfig(branch.repoPath, associatedIssuesConfigKey);
268+
if (options?.cancellation?.isCancellationRequested) return;
269+
try {
270+
let associatedIssues: GitConfigEntityIdentifier[] = associatedIssuesEncoded
271+
? (JSON.parse(associatedIssuesEncoded) as GitConfigEntityIdentifier[])
272+
: [];
273+
if (options?.cancellation?.isCancellationRequested) return;
274+
associatedIssues = associatedIssues.filter(i => i.entityId !== id);
275+
if (associatedIssues.length === 0) {
276+
await container.git.setConfig(branch.repoPath, associatedIssuesConfigKey, undefined);
277+
} else {
278+
await container.git.setConfig(branch.repoPath, associatedIssuesConfigKey, JSON.stringify(associatedIssues));
279+
}
280+
} catch (ex) {
281+
Logger.error(ex, 'removeAssociatedIssueFromBranch');
282+
}
283+
}
284+
285+
export async function clearAssociatedIssuesForBranch(container: Container, branch: GitBranchReference) {
286+
const associatedIssuesConfigKey: GitConfigKeys = `branch.${branch.name}.gk-associated-issues`;
287+
if ((await container.git.getConfig(branch.repoPath, associatedIssuesConfigKey)) == null) return;
288+
await container.git.setConfig(branch.repoPath, associatedIssuesConfigKey, undefined);
289+
}

src/plus/integrations/providers/models.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
Account,
33
ActionablePullRequest,
4+
AnyEntityIdentifierInput,
45
AzureDevOps,
56
AzureOrganization,
67
AzureProject,
@@ -891,3 +892,9 @@ export type EnrichablePullRequest = ProviderPullRequest & {
891892
};
892893

893894
export const getActionablePullRequests = GitProviderUtils.getActionablePullRequests;
895+
896+
export type GitConfigEntityIdentifier = AnyEntityIdentifierInput & {
897+
searchId: string;
898+
resource: { key: string; name: string; id: string | undefined; owner: string | undefined };
899+
createdDate: string;
900+
};

src/plus/integrations/providers/utils.ts

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import type { AnyEntityIdentifierInput, EntityIdentifier } from '@gitkraken/provider-apis';
22
import { EntityIdentifierProviderType, EntityType, EntityVersion } from '@gitkraken/provider-apis';
33
import type { IntegrationId } from '../../../constants.integrations';
4-
import { HostingIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations';
5-
import type { IssueOrPullRequest } from '../../../git/models/issue';
4+
import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations';
5+
import type { Container } from '../../../container';
6+
import type { Issue, IssueOrPullRequest } from '../../../git/models/issue';
7+
import type { PullRequest } from '../../../git/models/pullRequest';
68
import { equalsIgnoreCase } from '../../../system/string';
79
import type { LaunchpadItem } from '../../launchpad/launchpadProvider';
10+
import type { IssueResourceDescriptor, RepositoryDescriptor } from '../integration';
11+
import { isIssueResourceDescriptor, isRepositoryDescriptor } from '../integration';
12+
import type { GitConfigEntityIdentifier } from './models';
813

914
function isGitHubDotCom(domain: string): boolean {
1015
return equalsIgnoreCase(domain, 'github.com');
@@ -18,6 +23,10 @@ function isLaunchpadItem(item: IssueOrPullRequest | LaunchpadItem): item is Laun
1823
return (item as LaunchpadItem).uuid !== undefined;
1924
}
2025

26+
function isIssue(item: IssueOrPullRequest | LaunchpadItem): item is Issue {
27+
return item.type === 'issue';
28+
}
29+
2130
export function getEntityIdentifierInput(entity: IssueOrPullRequest | LaunchpadItem): AnyEntityIdentifierInput {
2231
let entityType = EntityType.Issue;
2332
if (entity.type === 'pullrequest') {
@@ -34,13 +43,23 @@ export function getEntityIdentifierInput(entity: IssueOrPullRequest | LaunchpadI
3443
provider = EntityIdentifierProviderType.GitlabSelfHosted;
3544
domain = entity.provider.domain;
3645
}
46+
let projectId = null;
47+
let resourceId = null;
48+
if (provider === EntityIdentifierProviderType.Jira) {
49+
if (!isIssue(entity) || entity.project == null) {
50+
throw new Error('Jira issues must have a project');
51+
}
52+
53+
projectId = entity.project.id;
54+
resourceId = entity.project.resourceId;
55+
}
3756

3857
return {
3958
accountOrOrgId: null, // needed for Trello issues, once supported
4059
organizationName: null, // needed for Azure issues and PRs, once supported
41-
projectId: null, // needed for Jira issues, Trello issues, and Azure issues and PRs, once supported
60+
projectId: projectId, // needed for Jira issues, Trello issues, and Azure issues and PRs, once supported
4261
repoId: null, // needed for Azure and BitBucket PRs, once supported
43-
resourceId: null, // needed for Jira issues, once supported
62+
resourceId: resourceId, // needed for Jira issues
4463
provider: provider,
4564
entityType: entityType,
4665
version: EntityVersion.One,
@@ -49,12 +68,16 @@ export function getEntityIdentifierInput(entity: IssueOrPullRequest | LaunchpadI
4968
};
5069
}
5170

52-
export function getProviderIdFromEntityIdentifier(entityIdentifier: EntityIdentifier): IntegrationId | undefined {
71+
export function getProviderIdFromEntityIdentifier(
72+
entityIdentifier: EntityIdentifier | AnyEntityIdentifierInput,
73+
): IntegrationId | undefined {
5374
switch (entityIdentifier.provider) {
5475
case EntityIdentifierProviderType.Github:
5576
return HostingIntegrationId.GitHub;
5677
case EntityIdentifierProviderType.GithubEnterprise:
5778
return SelfHostedIntegrationId.GitHubEnterprise;
79+
case EntityIdentifierProviderType.Jira:
80+
return IssueIntegrationId.Jira;
5881
default:
5982
return undefined;
6083
}
@@ -66,7 +89,107 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier
6689
return EntityIdentifierProviderType.Github;
6790
case 'gitlab':
6891
return EntityIdentifierProviderType.Gitlab;
92+
case 'jira':
93+
return EntityIdentifierProviderType.Jira;
6994
default:
7095
throw new Error(`Unknown provider type '${str}'`);
7196
}
7297
}
98+
99+
export function encodeIssueOrPullRequestForGitConfig(
100+
entity: Issue | PullRequest,
101+
resource: RepositoryDescriptor | IssueResourceDescriptor,
102+
): GitConfigEntityIdentifier {
103+
const encodedResource: GitConfigEntityIdentifier['resource'] = {
104+
key: resource.key,
105+
name: resource.name,
106+
id: undefined,
107+
owner: undefined,
108+
};
109+
if (isRepositoryDescriptor(resource)) {
110+
encodedResource.owner = resource.owner;
111+
} else if (isIssueResourceDescriptor(resource)) {
112+
encodedResource.id = resource.id;
113+
} else {
114+
throw new Error('Invalid resource');
115+
}
116+
117+
return {
118+
...getEntityIdentifierInput(entity),
119+
searchId: entity.id,
120+
resource: encodedResource,
121+
createdDate: new Date().toISOString(),
122+
};
123+
}
124+
125+
export function isGitConfigEntityIdentifier(entity: unknown): entity is GitConfigEntityIdentifier {
126+
return (
127+
entity != null &&
128+
typeof entity === 'object' &&
129+
'provider' in entity &&
130+
entity.provider != null &&
131+
'entityType' in entity &&
132+
entity.entityType != null &&
133+
'version' in entity &&
134+
entity.version != null &&
135+
'entityId' in entity &&
136+
entity.entityId != null &&
137+
'searchId' in entity &&
138+
entity.searchId != null
139+
);
140+
}
141+
142+
export function decodeEntityFromGitConfig(str: string): GitConfigEntityIdentifier {
143+
const decoded = JSON.parse(str);
144+
145+
if (!isGitConfigEntityIdentifier(decoded)) {
146+
throw new Error('Invalid issue or pull request');
147+
}
148+
149+
if (
150+
decoded.provider === EntityIdentifierProviderType.Jira &&
151+
(decoded.resourceId == null || decoded.projectId == null)
152+
) {
153+
throw new Error('Invalid Jira issue');
154+
}
155+
156+
return decoded;
157+
}
158+
159+
export async function getIssueFromGitConfigEntityIdentifier(
160+
container: Container,
161+
identifier: GitConfigEntityIdentifier,
162+
): Promise<Issue | undefined> {
163+
if (identifier.entityType !== EntityType.Issue) {
164+
return undefined;
165+
}
166+
167+
// TODO: Centralize where we represent all supported providers for issues
168+
if (
169+
identifier.provider !== EntityIdentifierProviderType.Jira &&
170+
identifier.provider !== EntityIdentifierProviderType.Github &&
171+
identifier.provider !== EntityIdentifierProviderType.Gitlab
172+
) {
173+
return undefined;
174+
}
175+
176+
const integrationId = getProviderIdFromEntityIdentifier(identifier);
177+
if (integrationId == null) {
178+
return undefined;
179+
}
180+
181+
const integration = await container.integrations.get(integrationId);
182+
if (integration == null) {
183+
return undefined;
184+
}
185+
186+
return integration.getIssue(
187+
{
188+
id: identifier.resource.id,
189+
key: identifier.resource.key,
190+
owner: identifier.resource.owner,
191+
name: identifier.resource.name,
192+
},
193+
identifier.searchId,
194+
);
195+
}

0 commit comments

Comments
 (0)