Skip to content

Commit

Permalink
YTM-12768 introduce VCS changes in the issue activity
Browse files Browse the repository at this point in the history
  • Loading branch information
anisimov74 committed Aug 31, 2021
1 parent 4457e6f commit 7c7cb0a
Show file tree
Hide file tree
Showing 25 changed files with 649 additions and 89 deletions.
5 changes: 4 additions & 1 deletion jest.config.js
Expand Up @@ -2,7 +2,10 @@ module.exports = {
'preset': 'react-native',
'transform': {
'^.+\\.(js|ts)$': 'babel-jest',
'^[./a-zA-Z0-9$_-]+\\.(bmp|gif|jpg|jpeg|png|psd|svg|webp)$': '<rootDir>/node_modules/react-native/jest/assetFileTransformer.js',
'^[./a-zA-Z0-9$_-]+\\.(gif|jpg|jpeg|png|svg)$': 'jest-transform-stub',
},
'moduleNameMapper': {
'^.+.(svg)$': 'jest-transform-stub',
},
'setupFilesAfterEnv': [
'<rootDir>/test/jest-setup.js',
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -163,6 +163,7 @@
"jest": "^26.6.3",
"jest-circus": "^26.6.3",
"jest-teamcity-reporter": "^0.9.0",
"jest-transform-stub": "^2.0.0",
"metro-react-native-babel-preset": "^0.64.0",
"react-native-clean-project": "^3.6.3",
"react-test-renderer": "17.0.2",
Expand Down
4 changes: 2 additions & 2 deletions src/components/activity-stream/activity__stream-helper.js
Expand Up @@ -4,8 +4,8 @@ import getEventTitle from '../activity/activity__history-title';

import type {Activity} from '../../flow/Activity';

const firstActivityChange = (activity: Activity): any => {
if (!activity.added) {
const firstActivityChange = (activity: ?Activity): Object | null => {
if (!activity || !activity.added) {
return null;
}
if (Array.isArray(activity.added)) {
Expand Down
4 changes: 3 additions & 1 deletion src/components/activity-stream/activity__stream-user-info.js
Expand Up @@ -21,7 +21,9 @@ const StreamUserInfo = (props: Props) => {
<Text style={styles.activityAuthorName}>
{getEntityPresentation(props.activityGroup.author)}
</Text>
{!props.noTimestamp && <Text><StreamTimestamp timestamp={props.activityGroup.timestamp}/></Text>}
{!props.noTimestamp && !!props.activityGroup.timestamp && (
<Text><StreamTimestamp timestamp={props.activityGroup.timestamp}/></Text>
)}
</View>
);
};
Expand Down
197 changes: 197 additions & 0 deletions src/components/activity-stream/activity__stream-vcs-helper.js
@@ -0,0 +1,197 @@
/* @flow */

import {ResourceTypes} from '../api/api__resource-types';
import type {VcsChange, VcsChangeState, VcsCommand, VcsProcessor} from '../../flow/Vcs';

const HUB_DEFAULT_ERROR: string = 'YouTrack was unable to match the VCS user name to a Hub account for an unknown reason. Check your server logs for details.';
export const userNotFoundMessageMap: { [string]: string } = {
TEAMCITY_NO_USER_INFO_PROVIDED: HUB_DEFAULT_ERROR,
TEAMCITY_NO_USER: HUB_DEFAULT_ERROR,
TEAMCITY_NO_EMAIL_FOR_THAT_USER: 'When integrating TeamCity and YouTrack, the users are matched by their email addresses. TeamCity has not provided any email address of the committer.',
TEAMCITY_NO_USER_IN_YOUTRACK_BY_EMAIL: 'The author of this commit has not been identified because they haven\'t specified their email address in their YouTrack profile.',
TEAMCITY_USER_IS_NOT_UNIQUE_BY_EMAIL: 'When integrating TeamCity and YouTrack, the users are matched by their email addresses. There are two or more different YouTrack users that have the same email address as the commit\'s author has in TeamCity.',
TEAMCITY_ERROR_RETRIEVING_USER: 'There was an error retrieving information about the change\'s author from TeamCity. The most probable reason is that TeamCity user that integration runs on behalf of does not have the required permissions.',

UPSOURCE_NO_USER: 'No user information provided by Upsource.',

HUB_ERROR_RING_INTEGRATION: 'YouTrack was unable to match the VCS user name to a Hub account for an unknown reason. Check your server logs for details.',
HUB_ERROR_FORCE_SYNC: 'YouTrack was unable to match the VCS user name to a Hub account for an unknown reason. Check your server logs for details.',
HUB_NULL_USER: 'The VCS user name does not match any user in YouTrack. To link future commits, add the VCS user name to the Hub account for this user.',
HUB_MULTIPLE_USERS: 'The VCS user name matches more than one user in YouTrack. To link future commits, remove the duplicate VCS user names from one or more Hub accounts or merge the duplicates into a single user account.',

VCS_NOT_IN_COMMITTERS_GROUP: 'The commit author does not belong to the committers group.',
INTEGRATION_NOT_AN_ASSIGNEE: 'The commit author is not an assignee in the project.',

BITBUCKET_NO_USER_INFO_PROVIDED: 'YouTrack did not receive user data for this commit author from Bitbucket.',
BITBUCKET_NO_USER_FOUND_IN_YOUTRACK: 'YouTrack did not find a user that matches the user account in Bitbucket.',
BITBUCKET_USER_NOT_UNIQUE: 'YouTrack found multiple users with email addresses that match the registered email in Bitbucket.',
BITBUCKET_NO_USER_BY_EMAIL: 'YouTrack did not receive an email address for the commit author from Bitbucket.',
BITBUCKET_NO_RAW_EMAIL: 'The commit author has not specified an email address.',
BITBUCKET_AUTHORS_DO_NOT_MATCH_DEPRECATED: 'The commit author has specified an email address that is associated with a different user account.',

GITLAB_NO_USER_INFO_PROVIDED: 'YouTrack did not receive user data for this commit author from GitLab.',
GITLAB_NO_USER_FOUND_IN_YOUTRACK: 'YouTrack did not find a user that matches the user account in GitLab.',
GITLAB_USER_NOT_UNIQUE: 'YouTrack found multiple users with email addresses that match the registered email in GitLab.',
GITLAB_NO_EMAIL: 'YouTrack did not receive an email address for the commit author from GitLab.',

GITHUB_NO_USER_INFO_PROVIDED: 'YouTrack did not receive user data for this commit author from GitHub.',
GITHUB_NO_USER_FOUND_IN_YOUTRACK: 'YouTrack did not find a user that matches the user account in GitHub.',
GITHUB_USER_NOT_UNIQUE: 'YouTrack found multiple users with email addresses that match the registered email in GitHub.',
GITHUB_NO_EMAIL: 'YouTrack did not receive an email address for the commit author from GitHub.',
};


function getProcessorName(type: string): string {
const processorName: { [string]: string } = {
TeamCity: 'TeamCity',
GitLab: 'GitLab',
Bitbucket: 'Bitbucket',
GitHub: 'GitHub',
Upsource: 'Upsource',
Jenkins: 'Jenkins',
Gogs: 'Gogs',
Gitea: 'Gitea',
Space: 'Space',
};
let name: $Keys<typeof processorName> = '';

switch (type) {
case ResourceTypes.TEAMCITY_CHANGES_PROCESSOR:
case 'TeamcityBuildConfMapping':
name = processorName.TeamCity;
break;
case ResourceTypes.GITLAB_MAPPING:
case 'GitLabChangesProcessor':
name = processorName.GitLab;
break;
case ResourceTypes.GITHUB_MAPPING:
case 'GithubRepo':
name = processorName.GitHub;
break;
case ResourceTypes.GOGS:
case 'GogsChangesProcessor':
name = processorName.Gogs;
break;
case ResourceTypes.GITEA:
case 'GiteaChangesProcessor':
name = processorName.Gitea;
break;
case ResourceTypes.UPSOURCE_PROCESSOR:
case 'UpsourceChangesProcessor':
name = processorName.Upsource;
break;
case ResourceTypes.JENKINS_SERVER:
case ResourceTypes.JENKINS_CHANGES_PROCESSOR:
name = processorName.Jenkins;
break;
case ResourceTypes.BITBUCKET:
case ResourceTypes.BITBUCKET_MAPPING:
name = processorName.Bitbucket;
break;
case ResourceTypes.SPACE_SERVER:
case ResourceTypes.SPACE_MAPPING:
name = processorName.Space;
break;
}
return name;
}

function getCommandsWithError(change: VcsChange) {
return (change?.commands || []).filter((command: VcsCommand) => {
return command.hasError === true;
});
}

function getUserNotFoundErrors(change: VcsChange): Array<string> {
if (!change.noHubUserReason || !change.noUserReason) {
return [];
}
const notFoundMessages = [].concat(change.noHubUserReason || []).concat(change.noUserReason || []);
const allMessages = notFoundMessages.map((message) => userNotFoundMessageMap[message.id]);
return [...new Set(allMessages)];
}

function vcsChangeStateMessage(vcsChangeState: VcsChangeState): (code: Object) => string {
return (code) => {
let message: string = '';
switch (code) {
case vcsChangeState.attached:
message = 'The change has been manually attached to this issue.';
break;
case vcsChangeState.detached:
message = 'The change has been detached from this issue. It is still displayed here because its comment mentions the issue.';
break;
case vcsChangeState.legacy:
message = 'The change was processed during the initial data fetching, thus no command has been applied.';
}
return message;
};
}

const getErrorMessages = (change: VcsChange): Array<string> => {
const errors: Array<string> = [].concat(getUserNotFoundErrors(change));
const commandsWithError: Array<string> = getCommandsWithError(change).map((command: VcsCommand) => command.errorText);
return errors.concat(commandsWithError);
};

const getInfoMessages = (change: VcsChange): Array<string> => {
if (typeof change.state !== 'number') {
return [];
}
const vcsChangeCommandMessage = {
COMMAND_APPLIED: 'Command was successfully applied.',
COMMAND_NOT_APPLIED: 'Could not apply specified command.',
};
const messages: Array<string> = [];
const stateMessage: ?(code: Object) => string = vcsChangeStateMessage(change.state);
if (stateMessage) {
messages.push(stateMessage());
}
const commands: Array<VcsCommand> = change?.commands || [];
if (commands[0]) {
const commandStateMessage = (
getCommandsWithError(change)[0]
? vcsChangeCommandMessage.COMMAND_NOT_APPLIED
: vcsChangeCommandMessage.COMMAND_APPLIED
);
messages.push(commandStateMessage);
}

return messages;
};

const getVcsPresentation = (change: VcsChange): string => {
if (change.idExternal) {
return `#${change.idExternal}`;
} else {
return (change.version || '').substring(0, 8);
}
};

const getProcessorsUrls = function (change: VcsChange): Array<VcsProcessor> {
const changeUrls: Array<string> = change && change.urls || [];
const urlsDistinct: { [string]: boolean } = {};

return change.processors
.map((processor: VcsProcessor, index: number) => ({
...processor,
label: getProcessorName(processor.$type),
url: changeUrls[index],
}))
.filter((processor: VcsProcessor) => {
if (!urlsDistinct.hasOwnProperty(processor.url)) {
urlsDistinct[processor.url] = true;
return true;
} else {
return false;
}
});
};


export {
getErrorMessages,
getInfoMessages,
getVcsPresentation,
getProcessorsUrls,
};
120 changes: 120 additions & 0 deletions src/components/activity-stream/activity__stream-vcs.js
@@ -0,0 +1,120 @@
/* @flow */

import React from 'react';
import {Linking, Text, TouchableOpacity, View} from 'react-native';

import Details from '../details/details';
import MarkdownView from '../wiki/markdown-view';
import StreamUserInfo from './activity__stream-user-info';
import {firstActivityChange} from './activity__stream-helper';
import {getErrorMessages, getInfoMessages, getVcsPresentation, getProcessorsUrls} from './activity__stream-vcs-helper';
import {HIT_SLOP} from '../common-styles/button';
import {relativeDate} from '../issue-formatter/issue-formatter';

import styles from './activity__stream.styles';

import type {Activity} from '../../flow/Activity';
import type {VcsChange, VcsProcessor} from '../../flow/Vcs';

type Props = {
activityGroup: Activity,
}

const StreamVCS = (props: Props) => {
const vcs: VcsChange | null = firstActivityChange(props.activityGroup.vcs);

if (!vcs) {
return null;
}

const infoMessages: Array<string> = getInfoMessages(vcs);
const errorMessages: Array<string> = getErrorMessages(vcs);
const date: number = vcs.fetched || vcs.date;
const renderProcessorURL: (
processor: VcsProcessor,
singleUrl?: boolean
) => React$Element<typeof View> = (processor: VcsProcessor, singleProcessor?: boolean) => {
return (
<View
key={processor.id}>
<TouchableOpacity
hitSlop={HIT_SLOP}
onPress={() => Linking.openURL(processor.url)}
>
<Text style={styles.link}>{singleProcessor ? getVcsPresentation(vcs) : processor.label}</Text>
</TouchableOpacity>
</View>
);
};
const processors: Array<VcsProcessor> = getProcessorsUrls(vcs);
const title: string = props.activityGroup.merged ? '' : 'Committed changes' + ' ';

return (
<View>
{!props.activityGroup.merged && props.activityGroup.author && (
<StreamUserInfo activityGroup={{...props.activityGroup, timestamp: 0}}/>
)}

<View style={styles.activityChange}>
<View style={styles.vcsInfo}>
{!!date && (
<Text style={[styles.vcsInfoDate, styles.secondaryTextColor]}>{title}{relativeDate(date)}</Text>
)}

{!!vcs.version && <View>
{processors.length === 1 && renderProcessorURL(processors[0], true)}
{processors.length > 1 && (
<Details
toggler={getVcsPresentation(vcs)}
renderer={() => <>{processors.map((processor: VcsProcessor) => renderProcessorURL(processor))}</>}
/>
)}
</View>}
</View>

{!!vcs.text && (
<View style={vcs.id && styles.activityWorkComment}>
<MarkdownView>
{vcs.text}
</MarkdownView>
</View>
)}

{(infoMessages.length || errorMessages.length) && (
<Details
style={styles.secondaryTextColor}
toggler="Show more"
renderer={() => (
<>
{infoMessages.length > 0 && infoMessages.map((msg: string, index: number) => (
<View key={`infoMessage_${index}`}>
<Text style={styles.vcsMessage}>
{msg}
</Text>
</View>
))}
{errorMessages.length > 0 && errorMessages.map((msg: string, index: number) => (
<View key={`errorMessage_${index}`}>
<Text style={styles.vcsError}>{msg}</Text>
</View>
))}
</>
)}
/>
)}

{!!vcs.files && vcs.files !== -1 && (
<View style={styles.vcsFilesAmount}>
<Text style={[styles.activityLabel]}>
{vcs.files} {vcs.files > 1 ? 'files' : 'file'}
</Text>
</View>
)}

</View>
</View>
);

};

export default (React.memo<Props>(StreamVCS): React$AbstractComponent<Props, mixed>);

0 comments on commit 7c7cb0a

Please sign in to comment.