diff --git a/bin/load-mocks b/bin/load-mocks index b735003607bfed..247bc877422826 100755 --- a/bin/load-mocks +++ b/bin/load-mocks @@ -444,6 +444,21 @@ def main(num_events=1, extra_events=False): order=commit_index, ) + # create an unreleased commit + Commit.objects.get_or_create( + organization_id=org.id, + repository_id=repo[0].id, + key=sha1(uuid4().hex).hexdigest(), + defaults={ + 'author': CommitAuthor.objects.get_or_create( + organization_id=org.id, + email=user.email, + defaults={'name': user.name} + )[0], + 'message': 'feat: Do something to {}\n{}'.format(random.choice(loremipsum.words) + '.js', make_sentence()), + }, + )[0] + Activity.objects.create( type=Activity.RELEASE, project=project, diff --git a/src/sentry/api/endpoints/organization_member_commits.py b/src/sentry/api/endpoints/organization_member_commits.py deleted file mode 100644 index 1f95cde4fa10e2..00000000000000 --- a/src/sentry/api/endpoints/organization_member_commits.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import absolute_import - -from sentry.api.bases import OrganizationMemberEndpoint -from sentry.api.paginator import OffsetPaginator -from sentry.api.serializers import serialize, CommitWithReleaseSerializer -from sentry.db.models.query import in_iexact -from sentry.models import Commit, CommitAuthor, UserEmail - - -class OrganizationMemberCommitsEndpoint(OrganizationMemberEndpoint): - def get(self, request, organization, member): - email_list = list(UserEmail.objects.filter( - user=member.user_id, - is_verified=True, - ).values_list('email', flat=True)) - if email_list: - queryset = Commit.objects.filter( - organization_id=organization.id, - author__in=CommitAuthor.objects.filter( - in_iexact('email', email_list), - organization_id=organization.id, - ) - ).order_by('-date_added') - else: - queryset = Commit.objects.none() - - return self.paginate( - request=request, - queryset=queryset, - order_by='-date_added', - paginator_cls=OffsetPaginator, - # TODO(dcramer): we dont want to return author here - on_results=lambda x: serialize(x, request.user, CommitWithReleaseSerializer()), - ) diff --git a/src/sentry/api/endpoints/organization_member_unreleased_commits.py b/src/sentry/api/endpoints/organization_member_unreleased_commits.py new file mode 100644 index 00000000000000..4e2c812db58a55 --- /dev/null +++ b/src/sentry/api/endpoints/organization_member_unreleased_commits.py @@ -0,0 +1,73 @@ +from __future__ import absolute_import + +import six + +from django.db import connections +from itertools import izip + +from sentry.api.bases import OrganizationMemberEndpoint +from sentry.api.serializers import serialize +from sentry.models import Commit, Repository, UserEmail + +query = """ +select c1.* +from sentry_commit c1 +join ( + select max(c2.date_added) as date_added, c2.repository_id + from sentry_commit as c2 + join ( + select distinct commit_id from sentry_releasecommit + where organization_id = %%s + ) as rc2 + on c2.id = rc2.commit_id + group by c2.repository_id +) as cmax +on c1.repository_id = cmax.repository_id +where c1.date_added > cmax.date_added +and c1.author_id IN ( + select id + from sentry_commitauthor + where organization_id = %%s + and upper(email) IN (%s) +) +order by c1.date_added desc +""" + +quote_name = connections['default'].ops.quote_name + + +class OrganizationMemberUnreleasedCommitsEndpoint(OrganizationMemberEndpoint): + def get(self, request, organization, member): + email_list = list(UserEmail.objects.filter( + user=member.user_id, + is_verified=True, + ).values_list('email', flat=True)) + if not email_list: + return self.respond([]) + + params = [organization.id, organization.id] + for e in email_list: + params.append(e.upper()) + + queryset = Commit.objects.raw(query % (', '.join('%s' for _ in email_list),), params) + + results = list(queryset) + + if results: + repos = list(Repository.objects.filter( + id__in=set([r.repository_id for r in results]), + )) + else: + repos = [] + + return self.respond({ + 'commits': [{ + 'id': c.key, + 'message': c.message, + 'dateCreated': c.date_added, + 'repositoryID': six.text_type(c.repository_id), + } for c in results], + 'repositories': { + six.text_type(r.id): d for r, d in izip(repos, serialize(repos, request.user)) + } + }) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index c78dd90e261ac3..52bb64c8b62ec2 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -48,12 +48,12 @@ from .endpoints.organization_shortid import ShortIdLookupEndpoint from .endpoints.organization_slugs import SlugsUpdateEndpoint from .endpoints.organization_issues_new import OrganizationIssuesNewEndpoint -from .endpoints.organization_member_commits import OrganizationMemberCommitsEndpoint from .endpoints.organization_member_details import OrganizationMemberDetailsEndpoint from .endpoints.organization_member_index import OrganizationMemberIndexEndpoint from .endpoints.organization_member_issues_assigned import OrganizationMemberIssuesAssignedEndpoint from .endpoints.organization_member_issues_bookmarked import OrganizationMemberIssuesBookmarkedEndpoint from .endpoints.organization_member_issues_viewed import OrganizationMemberIssuesViewedEndpoint +from .endpoints.organization_member_unreleased_commits import OrganizationMemberUnreleasedCommitsEndpoint from .endpoints.organization_member_team_details import OrganizationMemberTeamDetailsEndpoint from .endpoints.organization_onboarding_tasks import OrganizationOnboardingTaskEndpoint from .endpoints.organization_index import OrganizationIndexEndpoint @@ -401,9 +401,9 @@ name='sentry-api-0-organization-member-details' ), url( - r'^organizations/(?P[^\/]+)/members/(?P[^\/]+)/commits/$', - OrganizationMemberCommitsEndpoint.as_view(), - name='sentry-api-0-organization-member-commits' + r'^organizations/(?P[^\/]+)/members/(?P[^\/]+)/unreleased-commits/$', + OrganizationMemberUnreleasedCommitsEndpoint.as_view(), + name='sentry-api-0-organization-member-unreleased-commits' ), url( r'^organizations/(?P[^\/]+)/members/(?P[^\/]+)/issues/assigned/$', diff --git a/src/sentry/static/sentry/app/views/organizationCommits.jsx b/src/sentry/static/sentry/app/views/organizationCommits.jsx index e1899312e51af4..c4c8f3584a5dd2 100644 --- a/src/sentry/static/sentry/app/views/organizationCommits.jsx +++ b/src/sentry/static/sentry/app/views/organizationCommits.jsx @@ -3,14 +3,18 @@ import React from 'react'; import AsyncView from '../views/asyncView'; import OrganizationHomeContainer from '../components/organizations/homeContainer'; -import CommitRow from '../components/commitRow'; +import TimeSince from '../components/timeSince'; +import CommitLink from '../components/commitLink'; import Pagination from '../components/pagination'; import {t} from '../locale'; export default class OrganizationCommits extends AsyncView { getEndpoints() { return [ - ['commitList', `/organizations/${this.props.params.orgId}/members/me/commits/`], + [ + 'unreleasedCommits', + `/organizations/${this.props.params.orgId}/members/me/unreleased-commits/`, + ], ]; } @@ -18,6 +22,16 @@ export default class OrganizationCommits extends AsyncView { return 'Commits'; } + renderMessage = message => { + if (!message) { + return t('No message provided'); + } + + let firstLine = message.split(/\n/)[0]; + + return firstLine; + }; + emptyState() { return (
@@ -30,76 +44,37 @@ export default class OrganizationCommits extends AsyncView { ); } - getCommitsByRepository(commitList) { - let commitsByRepository = commitList.reduce(function(cbr, commit) { - let {repository} = commit; - if (!cbr.hasOwnProperty(repository.name)) { - cbr[repository.name] = []; - } - - cbr[repository.name].push(commit); - return cbr; - }, {}); - return commitsByRepository; - } - - renderCommitsForRepo(repo, commitList) { - let commitsByRepository = this.getCommitsByRepository(commitList); - let activeCommits = commitsByRepository[repo]; - return ( -
-
{repo}
-
    - {activeCommits.map(commit => { - return ; - })} -
-
- ); - } - renderBody() { - let {commitList, commitListPageLinks} = this.state; - if (!commitList.length) return this.emptyState(); - - let unreleasedCommits = [], - releasedCommits = []; - let marker = false; - commitList.forEach(commit => { - if (marker) { - releasedCommits.push(commit); - } else if (commit.releases.length) { - marker = true; - releasedCommits.push(commit); - } else { - unreleasedCommits.push(commit); - } - }); + let {unreleasedCommits, unreleasedCommitsPageLinks} = this.state; + let {commits, repositories} = unreleasedCommits; + if (!commits.length) return this.emptyState(); return (
- {unreleasedCommits.length && ( -
-
Unreleased
-
    - {unreleasedCommits.map(commit => { - return ; - })} -
-
- )} - {releasedCommits.length && ( -
-
Released
-
    - {releasedCommits.map(commit => { - return ; - })} -
-
- )} - {commitListPageLinks && ( - +
+
    + {commits.map(commit => { + let repo = repositories[commit.repositoryID]; + return ( +
  • +
    +
    +
    {this.renderMessage(commit.message)}
    +

    + {repo.name} — +

    +
    +
    + +
    +
    +
  • + ); + })} +
+
+ {!!unreleasedCommitsPageLinks && ( + )}
); @@ -108,7 +83,9 @@ export default class OrganizationCommits extends AsyncView { render() { return ( -

{t('My Commits')}

+

+ {t('Unreleased Changes')} Mine +

{this.renderComponent()}
); diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py index 824836005d2d95..ef0885aa3874e5 100644 --- a/src/sentry/testutils/fixtures.py +++ b/src/sentry/testutils/fixtures.py @@ -10,15 +10,18 @@ import copy import json +import os import petname +import random import six import warnings +from django.utils import timezone from django.utils.text import slugify from exam import fixture +from hashlib import sha1 +from loremipsum import Generator from uuid import uuid4 -import os -from django.utils import timezone from sentry.models import ( Activity, Environment, Event, EventError, EventMapping, Group, Organization, OrganizationMember, @@ -26,6 +29,21 @@ CommitAuthor, Repository, CommitFileChange ) +loremipsum = Generator() + + +def make_sentence(words=None): + if words is None: + words = int(random.weibullvariate(8, 3)) + return ' '.join(random.choice(loremipsum.words) for _ in range(words)) + + +def make_word(words=None): + if words is None: + words = int(random.weibullvariate(8, 3)) + return random.choice(loremipsum.words) + + DEFAULT_EVENT_DATA = { 'extra': { 'loadavg': [0.97607421875, 0.88330078125, 0.833984375], @@ -327,8 +345,15 @@ def create_release(self, project, user=None, version=None): # add commits if user: author = self.create_commit_author(project, user) - repo = self.create_repo(project) - commit = self.create_commit(project, repo, author, release) + repo = self.create_repo(project, name='organization-{}'.format(project.slug)) + commit = self.create_commit( + project=project, + repo=repo, + author=author, + release=release, + key='deadbeef', + message='placeholder commit message', + ) release.update( authors=[six.text_type(author.id)], @@ -338,49 +363,51 @@ def create_release(self, project, user=None, version=None): return release - def create_repo(self, project): + def create_repo(self, project, name=None): repo = Repository.objects.create( organization_id=project.organization_id, - name='organization-{}'.format(project.slug), + name=name or make_word(), ) return repo - def create_commit(self, project, repo, author, release): + def create_commit(self, project, repo, author=None, release=None, + message=None, key=None, date_added=None): commit = Commit.objects.get_or_create( organization_id=project.organization_id, repository_id=repo.id, - key='deadbeef', + key=key or sha1(uuid4().hex).hexdigest(), defaults={ - 'message': 'placeholder commit message', - 'author': author, - 'date_added': timezone.now(), + 'message': message or make_sentence(), + 'author': author or self.create_commit_author(project), + 'date_added': date_added or timezone.now(), } )[0] - # add it to release - ReleaseCommit.objects.create( - organization_id=project.organization_id, - project_id=project.id, - release=release, - commit=commit, - order=1, - ) + if release: + ReleaseCommit.objects.create( + organization_id=project.organization_id, + project_id=project.id, + release=release, + commit=commit, + order=1, + ) - self.create_commit_file_change(commit, release, project, '/models/foo.py') - self.create_commit_file_change(commit, release, project, '/worsematch/foo.py') - self.create_commit_file_change(commit, release, project, '/models/other.py') + self.create_commit_file_change(commit, project, '/models/foo.py') + self.create_commit_file_change(commit, project, '/worsematch/foo.py') + self.create_commit_file_change(commit, project, '/models/other.py') return commit - def create_commit_author(self, project, user): - commit_author = CommitAuthor.objects.get_or_create( - organization_id=project.organization_id, email=user, defaults={ - 'name': user, + def create_commit_author(self, project, user=None): + return CommitAuthor.objects.get_or_create( + organization_id=project.organization_id, + email=user.email if user else '{}@example.com'.format(make_word()), + defaults={ + 'name': user.name if user else make_word(), } )[0] - return commit_author - def create_commit_file_change(self, commit, release, project, filename): + def create_commit_file_change(self, commit, project, filename): commit_file_change = CommitFileChange.objects.get_or_create( organization_id=project.organization_id, commit=commit, @@ -403,7 +430,7 @@ def create_user(self, email=None, **kwargs): user.save() # UserEmail is created by a signal - UserEmail.objects.filter( + assert UserEmail.objects.filter( user=user, email=email, ).update(is_verified=True) diff --git a/tests/sentry/api/endpoints/test_organization_member_unreleased_commits.py b/tests/sentry/api/endpoints/test_organization_member_unreleased_commits.py new file mode 100644 index 00000000000000..e949bd99963a64 --- /dev/null +++ b/tests/sentry/api/endpoints/test_organization_member_unreleased_commits.py @@ -0,0 +1,64 @@ +from __future__ import absolute_import + +import six + +from datetime import datetime +from django.utils import timezone + +from sentry.testutils import APITestCase + + +class OrganizationMemberUnreleasedCommitsTest(APITestCase): + def test_simple(self): + user = self.create_user('foo@example.com') + org = self.create_organization(name='foo') + team = self.create_team(name='foo', organization=org) + self.create_member( + organization=org, + user=user, + role='admin', + teams=[team], + ) + project = self.create_project(name='foo', organization=org, teams=[team]) + + repo = self.create_repo(project) + repo2 = self.create_repo(project) + + # we first need to create a release attached to a repository, or that repository + # will never be included + release = self.create_release(project) + author = self.create_commit_author(project=project, user=user) + + # note: passing 'release' to create_commit causes it to bind ReleaseCommit + self.create_commit( + project=project, repo=repo, release=release, author=author, + date_added=datetime(2015, 1, 1, tzinfo=timezone.utc)) + + # create a commit associated with an unreleased repo -- which should not appear + self.create_commit( + project=project, repo=repo2, author=author, date_added=datetime( + 2015, 1, 2, tzinfo=timezone.utc)) + + # create several unreleased commits associated with repo + unreleased_commit = self.create_commit( + project=project, repo=repo, author=author, date_added=datetime( + 2015, 1, 2, tzinfo=timezone.utc)) + unreleased_commit2 = self.create_commit( + project=project, repo=repo, author=author, date_added=datetime( + 2015, 1, 3, tzinfo=timezone.utc)) + self.create_commit( + project=project, repo=repo, date_added=datetime( + 2015, 1, 3, tzinfo=timezone.utc)) + + path = '/api/0/organizations/{}/members/me/unreleased-commits/'.format(org.slug) + + self.login_as(user) + + resp = self.client.get(path) + + assert resp.status_code == 200 + assert len(resp.data['commits']) == 2 + assert resp.data['commits'][0]['id'] == unreleased_commit2.key + assert resp.data['commits'][1]['id'] == unreleased_commit.key + assert len(resp.data['repositories']) == 1 + assert six.text_type(repo.id) in resp.data['repositories'] diff --git a/tests/sentry/api/serializers/test_activity.py b/tests/sentry/api/serializers/test_activity.py index c2a326b9612d99..5674aaa5b8065d 100644 --- a/tests/sentry/api/serializers/test_activity.py +++ b/tests/sentry/api/serializers/test_activity.py @@ -19,7 +19,7 @@ def test_pr_activity(self): group = self.create_group( status=GroupStatus.UNRESOLVED, ) - repo = self.create_repo(self.project) + repo = self.create_repo(self.project, name='organization-bar') pr = PullRequest.objects.create( organization_id=self.org.id, repository_id=repo.id, @@ -51,7 +51,7 @@ def test_commit_activity(self): group = self.create_group( status=GroupStatus.UNRESOLVED, ) - repo = self.create_repo(self.project) + repo = self.create_repo(self.project, name='organization-bar') commit = Commit.objects.create( organization_id=self.org.id,