1 | # TicketModifiedFiles plugin |
---|
2 | # This software is licensed as described in the file COPYING.txt, which you |
---|
3 | # should have received as part of this distribution. |
---|
4 | |
---|
5 | import re |
---|
6 | |
---|
7 | from trac.core import * |
---|
8 | from trac.ticket.model import Ticket |
---|
9 | from trac.web import IRequestHandler |
---|
10 | from trac.web.api import IRequestFilter, ITemplateStreamFilter |
---|
11 | from trac.web.chrome import ITemplateProvider, add_stylesheet, add_script, add_ctxtnav |
---|
12 | from trac.util.datefmt import format_time |
---|
13 | |
---|
14 | #WARNING: genshi.filters.Transformer requires Genshi 0.5+ |
---|
15 | from genshi.filters import Transformer |
---|
16 | from genshi.builder import tag |
---|
17 | |
---|
18 | class TicketModifiedFilesPlugin(Component): |
---|
19 | implements(IRequestHandler, IRequestFilter, ITemplateProvider, ITemplateStreamFilter) |
---|
20 | |
---|
21 | # IRequestHandler methods |
---|
22 | def match_request(self, req): |
---|
23 | match = re.match(r'/modifiedfiles/([0-9]+)$', req.path_info) |
---|
24 | if match: |
---|
25 | req.args['id'] = match.group(1) |
---|
26 | return True |
---|
27 | |
---|
28 | def process_request(self, req): |
---|
29 | #Retrieve the information needed to display in the /modifiedfiles/ page |
---|
30 | (id, files, deletedfiles, ticketsperfile, filestatus, conflictingtickets, ticketisclosed, revisions, ticketsdescription) = self.__process_ticket_request(req) |
---|
31 | #Pack the information to send to the html file |
---|
32 | data = {'ticketid':id, 'files':files, 'deletedfiles':deletedfiles, 'ticketsperfile':ticketsperfile, 'filestatus':filestatus, 'conflictingtickets':conflictingtickets, 'ticketisclosed':ticketisclosed, 'revisions':revisions, 'ticketsdescription':ticketsdescription} |
---|
33 | |
---|
34 | add_ctxtnav(req, 'Back to Ticket #%s' % id, req.href.ticket(id)) |
---|
35 | |
---|
36 | #Add the custom stylesheet |
---|
37 | add_stylesheet(req, 'common/css/timeline.css') |
---|
38 | add_stylesheet(req, 'tmf/css/ticketmodifiedfiles.css') |
---|
39 | add_script(req, 'tmf/js/ticketmodifiedfiles.js') |
---|
40 | return 'ticketmodifiedfiles.html', data, None |
---|
41 | |
---|
42 | # IRequestFilter methods |
---|
43 | def pre_process_request(self, req, handler): |
---|
44 | return handler |
---|
45 | |
---|
46 | def post_process_request(self, req, template, data, content_type): |
---|
47 | match = re.match(r'/ticket/([0-9]+)$', req.path_info) |
---|
48 | if match: |
---|
49 | data['modifiedfiles'] = int(match.group(1)) |
---|
50 | return template, data, content_type |
---|
51 | |
---|
52 | # ITemplateProvider methods |
---|
53 | # Used to add the plugin's templates and htdocs |
---|
54 | def get_templates_dirs(self): |
---|
55 | from pkg_resources import resource_filename |
---|
56 | return [resource_filename(__name__, 'templates')] |
---|
57 | |
---|
58 | def get_htdocs_dirs(self): |
---|
59 | """Return a list of directories with static resources (such as style |
---|
60 | sheets, images, etc.) |
---|
61 | |
---|
62 | Each item in the list must be a `(prefix, abspath)` tuple. The |
---|
63 | `prefix` part defines the path in the URL that requests to these |
---|
64 | resources are prefixed with. |
---|
65 | |
---|
66 | The `abspath` is the absolute path to the directory containing the |
---|
67 | resources on the local file system. |
---|
68 | """ |
---|
69 | from pkg_resources import resource_filename |
---|
70 | return [('tmf', resource_filename(__name__, 'htdocs'))] |
---|
71 | |
---|
72 | # ITemplateStreamFilter methods |
---|
73 | def filter_stream(self, req, method, filename, stream, data): |
---|
74 | if 'modifiedfiles' in data: |
---|
75 | numconflictingtickets = self.__process_ticket_request(req, True) |
---|
76 | #Display a warning message if there are conflicting tickets |
---|
77 | if numconflictingtickets > 0: |
---|
78 | if numconflictingtickets == 1: |
---|
79 | text = " There is one ticket in conflict!" |
---|
80 | else: |
---|
81 | text = " There are %s tickets in conflict!" % str(numconflictingtickets) |
---|
82 | stream |= Transformer("//div[@id='changelog']").before(tag.p(tag.strong("Warning:"), text, style='background: #def; border: 2px solid #00d; padding: 3px;')) |
---|
83 | |
---|
84 | #Display the link to this ticket's modifiedfiles page |
---|
85 | stream |= Transformer("//div[@id='changelog']").before( |
---|
86 | tag.p( |
---|
87 | 'Have a look at the ', |
---|
88 | tag.a("list of modified files", href="../modifiedfiles/" + str(data["modifiedfiles"])), |
---|
89 | ' related to this ticket.' |
---|
90 | ) |
---|
91 | ) |
---|
92 | return stream |
---|
93 | |
---|
94 | # Internal methods |
---|
95 | def __process_ticket_request(self, req, justnumconflictingtickets = False): |
---|
96 | id = int(req.args.get('id')) |
---|
97 | req.perm('ticket', id, None).require('TICKET_VIEW') |
---|
98 | #Get the list of status that have to be ignored when looking for conflicts |
---|
99 | ignored_statuses = self.__striplist(self.env.config.get("modifiedfiles", "ignored_statuses", "closed").split(",")) |
---|
100 | |
---|
101 | #Check if the ticket exists (throws an exception if the ticket does not exist) |
---|
102 | thisticket = Ticket(self.env, id) |
---|
103 | |
---|
104 | #Tickets that are in the ignored states can not be in conflict |
---|
105 | if justnumconflictingtickets and thisticket['status'] in ignored_statuses: |
---|
106 | return 0 |
---|
107 | |
---|
108 | files = [] |
---|
109 | revisions = [] |
---|
110 | ticketsperfile = {} |
---|
111 | |
---|
112 | db = self.env.get_db_cnx() |
---|
113 | cursor = db.cursor() |
---|
114 | #Retrieve all the revisions which's messages contain "#<TICKETID>" |
---|
115 | cursor.execute("SELECT rev, time, author, message FROM revision WHERE message LIKE '%%#%s%%'" % id) |
---|
116 | repos = self.env.get_repository() |
---|
117 | for rev, time, author, message, in cursor: |
---|
118 | #Filter out non-related revisions. |
---|
119 | #for instance, you are lookink for #19, so you don't want #190, #191, #192, etc. to interfere |
---|
120 | #To filter, check what the eventual char after "#19" is. |
---|
121 | #If it's a number, we dont' want it (validrevision = False), but if it's text, keep this revision |
---|
122 | validrevision = True |
---|
123 | tempstr = message.split("#" + str(id), 1) |
---|
124 | if len(tempstr[1]) > 0: |
---|
125 | try: |
---|
126 | int(tempstr[1][0]) |
---|
127 | validrevision = False |
---|
128 | except: |
---|
129 | pass |
---|
130 | |
---|
131 | if validrevision: |
---|
132 | if not justnumconflictingtickets: |
---|
133 | date = "(" + format_time(time, str('%d/%m/%Y - %H:%M')) + ")" |
---|
134 | revisions.append((rev, author, date)) |
---|
135 | for node_change in repos.get_changeset(rev).get_changes(): |
---|
136 | files.append(node_change[0]) |
---|
137 | |
---|
138 | |
---|
139 | #Remove duplicated values |
---|
140 | files = self.__remove_duplicated_elements_and_sort(files) |
---|
141 | |
---|
142 | filestatus = {} |
---|
143 | |
---|
144 | for file in files: |
---|
145 | #Get the last status of each file |
---|
146 | if not justnumconflictingtickets: |
---|
147 | try: |
---|
148 | node = repos.get_node(file) |
---|
149 | filestatus[file] = node.get_history().next()[2] |
---|
150 | except: |
---|
151 | #If the node doesn't exist (in the last revision) it means that it has been deleted |
---|
152 | filestatus[file] = "delete" |
---|
153 | |
---|
154 | #Get the list of conflicting tickets per file |
---|
155 | tempticketslist = [] |
---|
156 | cursor.execute("SELECT message FROM revision WHERE rev IN (SELECT rev FROM node_change WHERE path='%s')" % file) |
---|
157 | for message, in cursor: |
---|
158 | #Extract the ticket number |
---|
159 | match = re.search(r'#([0-9]+)', message) |
---|
160 | if match: |
---|
161 | ticket = int(match.group(1)) |
---|
162 | #Don't add yourself |
---|
163 | if ticket != id: |
---|
164 | tempticketslist.append(ticket) |
---|
165 | tempticketslist = self.__remove_duplicated_elements_and_sort(tempticketslist) |
---|
166 | |
---|
167 | ticketsperfile[file] = [] |
---|
168 | #Keep only the active tickets |
---|
169 | for ticket in tempticketslist: |
---|
170 | try: |
---|
171 | if Ticket(self.env, ticket)['status'] not in ignored_statuses: |
---|
172 | ticketsperfile[file].append(ticket) |
---|
173 | except: |
---|
174 | pass |
---|
175 | |
---|
176 | #Get the global list of conflicting tickets |
---|
177 | #Only if the ticket is not already closed |
---|
178 | conflictingtickets=[] |
---|
179 | ticketsdescription={} |
---|
180 | ticketsdescription[id] = thisticket['summary'] |
---|
181 | ticketisclosed = True |
---|
182 | if thisticket['status'] not in ignored_statuses: |
---|
183 | ticketisclosed = False |
---|
184 | for fn, relticketids in ticketsperfile.items(): |
---|
185 | for relticketid in relticketids: |
---|
186 | tick = Ticket(self.env, relticketid) |
---|
187 | conflictingtickets.append((relticketid, tick['status'], tick['owner'])) |
---|
188 | ticketsdescription[relticketid] = tick['summary'] |
---|
189 | |
---|
190 | #Remove duplicated values |
---|
191 | conflictingtickets = self.__remove_duplicated_elements_and_sort(conflictingtickets) |
---|
192 | |
---|
193 | #Close the repository |
---|
194 | repos.close() |
---|
195 | |
---|
196 | #Return only the number of conflicting tickets (if asked for) |
---|
197 | if justnumconflictingtickets: |
---|
198 | return len(conflictingtickets) |
---|
199 | |
---|
200 | #Separate the deleted files from the others |
---|
201 | deletedfiles = [] |
---|
202 | for file in files: |
---|
203 | if filestatus[file] == "delete": |
---|
204 | deletedfiles.append(file) |
---|
205 | for deletedfile in deletedfiles: |
---|
206 | files.remove(deletedfile) |
---|
207 | |
---|
208 | #Return all the needed information |
---|
209 | return (id, files, deletedfiles, ticketsperfile, filestatus, conflictingtickets, ticketisclosed, revisions, ticketsdescription) |
---|
210 | |
---|
211 | def __remove_duplicated_elements_and_sort(self, list): |
---|
212 | d = {} |
---|
213 | for x in list: d[x]=1 |
---|
214 | return sorted(d.keys()) |
---|
215 | |
---|
216 | def __striplist(self, l): |
---|
217 | return([x.strip() for x in l]) |
---|