diff --git a/pagure/hooks/files/default_hook.py b/pagure/hooks/files/default_hook.py
index 4694560..00e13fd 100644
--- a/pagure/hooks/files/default_hook.py
+++ b/pagure/hooks/files/default_hook.py
@@ -149,6 +149,10 @@ def inform_pull_request_urls(
# Link to existing PRs if there are any
seen = len(prs) != 0
for pr in prs:
+ # Link tickets with pull-requests if the commit mentions it
+ pagure.lib.tasks.link_pr_to_ticket.delay(pr.uid)
+
+ # Inform the user about the PR
print('View pull-request for %s' % refname)
print(' %s/%s/pull-request/%s' % (
_config['APP_URL'].rstrip('/'),
diff --git a/pagure/lib/__init__.py b/pagure/lib/__init__.py
index 0d9f1e1..f020ccb 100644
--- a/pagure/lib/__init__.py
+++ b/pagure/lib/__init__.py
@@ -1773,6 +1773,8 @@ def new_pull_request(session, branch_from,
pagure.lib.git.update_git(
request, repo=request.project, repofolder=requestfolder)
+ pagure.lib.tasks.link_pr_to_ticket.delay(request.uid)
+
log_action(session, 'created', request, user_obj)
if notify:
@@ -5055,3 +5057,28 @@ def get_project_family(session, project):
)
return [parent] + query.all()
+
+
+def link_pr_issue(session, issue, request):
+ ''' Associate the specified issue with the specified pull-requets.
+
+ :arg session: The SQLAlchemy session to use
+ :type session: sqlalchemy.orm.session.Session
+ :arg issue: The issue mentionned in the commits of the pull-requests to
+ be associated with
+ :type issue: pagure.lib.model.Issue
+ :arg request: A pull-request to associate the specified issue with
+ :type request: pagure.lib.model.PullRequest
+
+ '''
+
+
+ associated_issue = [iss.uid for iss in request.related_issues]
+ if issue.uid not in associated_issue:
+ obj = model.PrToIssue(
+ pull_request_uid=request.uid,
+ issue_uid=issue.uid
+ )
+ session.add(obj)
+ session.flush()
+ session.commit()
diff --git a/pagure/lib/model.py b/pagure/lib/model.py
index 533e450..b9c1e35 100644
--- a/pagure/lib/model.py
+++ b/pagure/lib/model.py
@@ -1312,6 +1312,28 @@ class IssueToIssue(BASE):
primary_key=True)
+class PrToIssue(BASE):
+ """ Stores the associations between issues and pull-requests.
+
+ Table -- pr_to_issue
+ """
+
+ __tablename__ = 'pr_to_issue'
+
+ pull_request_uid = sa.Column(
+ sa.String(32),
+ sa.ForeignKey(
+ 'pull_requests.uid', ondelete='CASCADE', onupdate='CASCADE',
+ ),
+ primary_key=True)
+ issue_uid = sa.Column(
+ sa.String(32),
+ sa.ForeignKey(
+ 'issues.uid', ondelete='CASCADE', onupdate='CASCADE',
+ ),
+ primary_key=True)
+
+
class IssueComment(BASE):
""" Stores the comments made on a commit/file.
@@ -1765,6 +1787,14 @@ class PullRequest(BASE):
viewonly=True
)
+ related_issues = relation(
+ "Issue",
+ secondary="pr_to_issue",
+ primaryjoin="pull_requests.c.uid==pr_to_issue.c.pull_request_uid",
+ secondaryjoin="pr_to_issue.c.issue_uid==issues.c.uid",
+ backref=backref("related_prs", order_by="pull_requests.c.id.desc()")
+ )
+
def __repr__(self):
return 'PullRequest(%s, project:%s, user:%s, title:%s)' % (
self.id, self.project.name, self.user.user, self.title
diff --git a/pagure/lib/tasks.py b/pagure/lib/tasks.py
index 8a329d1..35e132e 100644
--- a/pagure/lib/tasks.py
+++ b/pagure/lib/tasks.py
@@ -32,9 +32,11 @@ from sqlalchemy.exc import SQLAlchemyError
import pagure.lib
import pagure.lib.git
import pagure.lib.git_auth
+import pagure.lib.link
import pagure.lib.repo
import pagure.utils
from pagure.config import config as pagure_config
+from pagure.utils import get_parent_repo_path
# logging.config.dictConfig(pagure_config.get('LOGGING') or {'version': 1})
_log = logging.getLogger(__name__)
@@ -850,3 +852,65 @@ def commits_history_stats(self, session, repopath):
dates[arrow.get(commit.commit_time).date().isoformat()] += 1
return [(key, dates[key]) for key in sorted(dates)]
+
+
+@conn.task(bind=True)
+@pagure_task
+def link_pr_to_ticket(self, session, pr_uid):
+ """ Link the specified pull-request against the ticket(s) mentioned in
+ the commits of the pull-request
+
+ """
+ _log.info(
+ 'LINK_PR_TO_TICKET: Linking ticket(s) to PR for: %s' % pr_uid)
+
+ request = pagure.lib.get_request_by_uid(session, pr_uid)
+ if not request:
+ _log.info('LINK_PR_TO_TICKET: Not PR found for: %s' % pr_uid)
+ return
+
+ if request.remote:
+ repopath = pagure.utils.get_remote_repo_path(
+ request.remote_git, request.branch_from)
+ parentpath = pagure.utils.get_repo_path(request.project)
+ else:
+ repo_from = request.project_from
+ repopath = pagure.utils.get_repo_path(repo_from)
+ parentpath = get_parent_repo_path(repo_from)
+
+ repo_obj = pygit2.Repository(repopath)
+ orig_repo = pygit2.Repository(parentpath)
+
+ diff_commits = pagure.lib.git.diff_pull_request(
+ session, request, repo_obj, orig_repo,
+ requestfolder=pagure_config['REQUESTS_FOLDER'], with_diff=False)
+
+ _log.info(
+ 'LINK_PR_TO_TICKET: Found %s commits in that PR' % len(diff_commits))
+
+ name = request.project.name
+ namespace = request.project.namespace
+ user = request.project.user.user \
+ if request.project.is_fork else None
+ branch = request.branch_from
+
+ for line in pagure.lib.git.read_git_lines(
+ ['log', '--no-walk']
+ + [c.oid.hex for c in diff_commits]
+ + ['--'], repopath):
+
+ line = line.strip()
+ for issue in pagure.lib.link.get_relation(
+ session, name, user, namespace, line, 'fixes',
+ include_prs=False):
+ _log.info(
+ 'LINK_PR_TO_TICKET: Link ticket %s to PRs %s' % (
+ issue, request))
+ pagure.lib.link_pr_issue(session, issue, request)
+
+ for issue in pagure.lib.link.get_relation(
+ session, name, user, namespace, line, 'relates'):
+ _log.info(
+ 'LINK_PR_TO_TICKET: Link ticket %s to PRs %s' % (
+ issue, request))
+ pagure.lib.link_pr_issue(session, issue, request)
diff --git a/pagure/templates/issue.html b/pagure/templates/issue.html
index aee9f69..e2ab290 100644
--- a/pagure/templates/issue.html
+++ b/pagure/templates/issue.html
@@ -365,6 +365,32 @@
{% endif %}
+ {% if issue.related_prs %}
+
+
+
+
+
+
+ {% for pr in issue.related_prs %}
+ -
+ #{{pr.id}}
+ {{ pr.status if pr.status != 'Open' else 'Last updated'
+ }} {{ pr.last_updated | humanize }}
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
{% if repo.issue_keys %}
diff --git a/tests/__init__.py b/tests/__init__.py
index 520e6d3..4eec5b0 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -476,31 +476,37 @@ class FakeUser(object): # pylint: disable=too-few-public-methods
return self.dic[key]
-def create_projects(session):
+def create_projects(session, is_fork=False, user_id=1, hook_token_suffix=''):
""" Create some projects in the database. """
item = pagure.lib.model.Project(
- user_id=1, # pingou
+ user_id=user_id, # pingou
name='test',
+ is_fork=is_fork,
+ parent_id=1 if is_fork else None,
description='test project #1',
- hook_token='aaabbbccc',
+ hook_token='aaabbbccc' + hook_token_suffix,
)
item.close_status = ['Invalid', 'Insufficient data', 'Fixed', 'Duplicate']
session.add(item)
item = pagure.lib.model.Project(
- user_id=1, # pingou
+ user_id=user_id, # pingou
name='test2',
+ is_fork=is_fork,
+ parent_id=2 if is_fork else None,
description='test project #2',
- hook_token='aaabbbddd',
+ hook_token='aaabbbddd' + hook_token_suffix,
)
item.close_status = ['Invalid', 'Insufficient data', 'Fixed', 'Duplicate']
session.add(item)
item = pagure.lib.model.Project(
- user_id=1, # pingou
+ user_id=user_id, # pingou
name='test3',
+ is_fork=is_fork,
+ parent_id=3 if is_fork else None,
description='namespaced test project',
- hook_token='aaabbbeee',
+ hook_token='aaabbbeee' + hook_token_suffix,
namespace='somenamespace',
)
item.close_status = ['Invalid', 'Insufficient data', 'Fixed', 'Duplicate']
diff --git a/tests/test_pagure_flask_ui_issue_pr_link.py b/tests/test_pagure_flask_ui_issue_pr_link.py
new file mode 100644
index 0000000..a82e9a8
--- /dev/null
+++ b/tests/test_pagure_flask_ui_issue_pr_link.py
@@ -0,0 +1,160 @@
+# -*- coding: utf-8 -*-
+
+"""
+ (c) 2018 - Copyright Red Hat Inc
+
+ Authors:
+ Pierre-Yves Chibon
+
+"""
+
+__requires__ = ['SQLAlchemy >= 0.8']
+import pkg_resources
+
+import datetime
+import json
+import unittest
+import re
+import shutil
+import sys
+import tempfile
+import time
+import os
+
+import pygit2
+from mock import ANY, patch, MagicMock
+
+sys.path.insert(0, os.path.join(os.path.dirname(
+ os.path.abspath(__file__)), '..'))
+
+import pagure
+import pagure.lib
+import tests
+from pagure.lib.repo import PagureRepo
+
+
+class PagureFlaskPrIssueLinkTest(tests.Modeltests):
+ """ Tests pagure when linking PRs to tickets """
+
+ maxDiff = None
+
+ def setUp(self):
+ """ Set up the environnment, ran before every tests. """
+ super(PagureFlaskPrIssueLinkTest, self).setUp()
+
+ tests.create_projects(self.session)
+ tests.create_projects(
+ self.session, is_fork=True, user_id=2, hook_token_suffix='bar')
+ tests.create_projects_git(os.path.join(self.path, 'repos'), bare=True)
+ tests.create_projects_git(os.path.join(
+ self.path, 'repos', 'forks', 'foo'), bare=True)
+
+ repo = pagure.lib.get_authorized_project(self.session, 'test')
+
+ # Create issues to play with
+ msg = pagure.lib.new_issue(
+ session=self.session,
+ repo=repo,
+ title=u'tést íssüé',
+ content='We should work on this',
+ user='pingou',
+ ticketfolder=None
+ )
+ self.session.commit()
+ self.assertEqual(msg.title, u'tést íssüé')
+
+ msg = pagure.lib.new_issue(
+ session=self.session,
+ repo=repo,
+ title=u'tést íssüé #2',
+ content='We should still work on this',
+ user='foo',
+ ticketfolder=None
+ )
+ self.session.commit()
+ self.assertEqual(msg.title, u'tést íssüé #2')
+
+ # Add a commit to the fork
+
+ newpath = tempfile.mkdtemp(prefix='pagure-fork-test')
+ repopath = os.path.join(newpath, 'test')
+ clone_repo = pygit2.clone_repository(os.path.join(
+ self.path, 'repos', 'forks', 'foo', 'test.git'), repopath)
+
+ # Create a file in that git repo
+ with open(os.path.join(repopath, 'sources'), 'w') as stream:
+ stream.write('foo\n bar')
+ clone_repo.index.add('sources')
+ clone_repo.index.write()
+
+ try:
+ com = repo.revparse_single('HEAD')
+ prev_commit = [com.oid.hex]
+ except:
+ prev_commit = []
+
+ # Commits the files added
+ tree = clone_repo.index.write_tree()
+ author = pygit2.Signature(
+ 'Alice Author', 'alice@authors.tld')
+ committer = pygit2.Signature(
+ 'Cecil Committer', 'cecil@committers.tld')
+ clone_repo.create_commit(
+ 'refs/heads/master', # the name of the reference to update
+ author,
+ committer,
+ 'Add sources file for testing\n\n Relates to #2',
+ # binary string representing the tree object ID
+ tree,
+ # list of binary strings representing parents of the new commit
+ prev_commit
+ )
+ refname = 'refs/heads/master:refs/heads/master'
+ ori_remote = clone_repo.remotes[0]
+ PagureRepo.push(ori_remote, refname)
+
+ # Create the corresponding PR
+
+ repo = pagure.lib.get_authorized_project(self.session, 'test')
+ fork_repo = pagure.lib.get_authorized_project(
+ self.session, 'test', user='foo')
+
+ request = pagure.lib.new_pull_request(
+ self.session,
+ branch_from='master',
+ repo_to=repo,
+ branch_to='master',
+ title='test PR',
+ user='foo',
+ requestfolder=None,
+ initial_comment=None,
+ repo_from=fork_repo,
+ )
+ self.session.commit()
+
+ pagure.lib.tasks.link_pr_to_ticket(request.uid)
+ self.assertEqual(request.id, 3)
+
+ def test_ticket_no_link(self):
+ """ Test that no Related PR(s) block is showing in the issue page.
+ """
+ output = self.app.get('/test/issue/1')
+ self.assertEqual(output.status_code, 200)
+ self.assertNotIn(
+ u'Related PR(s)',
+ output.data.decode('utf-8'))
+
+ def test_ticket_link(self):
+ """ Test that no Related PR(s) block is showing in the issue page.
+ """
+ time.sleep(1)
+ output = self.app.get('/test/issue/2')
+ print output.data.decode('utf-8')
+ self.assertEqual(output.status_code, 200)
+ self.assertIn(
+ u'Related PR(s)',
+ output.data.decode('utf-8'))
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=2)