Merged BugsSite to Bugzilla-3.0.3
[WebKit-https.git] / BugsSite / whine.pl
1 #!/usr/bin/perl -wT
2 # -*- Mode: perl; indent-tabs-mode: nil -*-
3 #
4 # The contents of this file are subject to the Mozilla Public
5 # License Version 1.1 (the "License"); you may not use this file
6 # except in compliance with the License. You may obtain a copy of
7 # the License at http://www.mozilla.org/MPL/
8 #
9 # Software distributed under the License is distributed on an "AS
10 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
11 # implied. See the License for the specific language governing
12 # rights and limitations under the License.
13 #
14 # The Original Code is the Bugzilla Bug Tracking System.
15 #
16 # The Initial Developer of the Original Code is Netscape Communications
17 # Corporation. Portions created by Netscape are
18 # Copyright (C) 1998 Netscape Communications Corporation. All
19 # Rights Reserved.
20 #
21 # Contributor(s): Erik Stambaugh <erik@dasbistro.com>
22
23 ################################################################################
24 # Script Initialization
25 ################################################################################
26
27 use strict;
28
29 use lib ".";
30
31 use Bugzilla;
32 use Bugzilla::Constants;
33 use Bugzilla::Search;
34 use Bugzilla::User;
35 use Bugzilla::Mailer;
36 use Bugzilla::Util;
37
38 # create some handles that we'll need
39 my $template = Bugzilla->template;
40 my $dbh      = Bugzilla->dbh;
41 my $sth;
42
43 # @seen_schedules is a list of all of the schedules that have already been
44 # touched by reset_timer.  If reset_timer sees a schedule more than once, it
45 # sets it to NULL so it won't come up again until the next execution of
46 # whine.pl
47 my @seen_schedules = ();
48
49 # These statement handles should live outside of their functions in order to
50 # allow the database to keep their SQL compiled.
51 my $sth_run_queries =
52     $dbh->prepare("SELECT " .
53                   "query_name, title, onemailperbug " .
54                   "FROM whine_queries " .
55                   "WHERE eventid=? " .
56                   "ORDER BY sortkey");
57 my $sth_get_query =
58     $dbh->prepare("SELECT query FROM namedqueries " .
59                   "WHERE userid = ? AND name = ?");
60
61 # get the event that's scheduled with the lowest run_next value
62 my $sth_next_scheduled_event = $dbh->prepare(
63     "SELECT " .
64     " whine_schedules.eventid, " .
65     " whine_events.owner_userid, " .
66     " whine_events.subject, " .
67     " whine_events.body " .
68     "FROM whine_schedules " .
69     "LEFT JOIN whine_events " .
70     " ON whine_events.id = whine_schedules.eventid " .
71     "WHERE run_next <= NOW() " .
72     "ORDER BY run_next " .
73     $dbh->sql_limit(1)
74 );
75
76 # get all pending schedules matching an eventid
77 my $sth_schedules_by_event = $dbh->prepare(
78     "SELECT id, mailto_type, mailto " .
79     "FROM whine_schedules " .
80     "WHERE eventid=? AND run_next <= NOW()"
81 );
82
83
84 ################################################################################
85 # Main Body Execution
86 ################################################################################
87
88 # This script needs to check through the database for schedules that have
89 # run_next set to NULL, which means that schedule is new or has been altered.
90 # It then sets it to run immediately if the schedule entry has it running at
91 # an interval like every hour, otherwise to the appropriate day and time.
92
93 # After that, it looks over each user to see if they have schedules that need
94 # running, then runs those and generates the email messages.
95
96 # Send whines from the address in the 'mailfrom' Parameter so that all
97 # Bugzilla-originated mail appears to come from a single address.
98 my $fromaddress = Bugzilla->params->{'mailfrom'};
99
100 # get the current date and time
101 my ($now_sec, $now_minute, $now_hour, $now_day, $now_month, $now_year, 
102     $now_weekday) = localtime;
103 # Convert year to two digits
104 $now_year = sprintf("%02d", $now_year % 100);
105 # Convert the month to January being "1" instead of January being "0".
106 $now_month++;
107
108 my @daysinmonth = qw(0 31 28 31 30 31 30 31 31 30 31 30 31);
109 # Alter February in case of a leap year.  This simple way to do it only
110 # applies if you won't be looking at February of next year, which whining
111 # doesn't need to do.
112 if (($now_year % 4 == 0) &&
113     (($now_year % 100 != 0) || ($now_year % 400 == 0))) {
114     $daysinmonth[2] = 29;
115 }
116
117 # run_day can contain either a calendar day (1, 2, 3...), a day of the week
118 # (Mon, Tue, Wed...), a range of days (All, MF), or 'last' for the last day of
119 # the month.
120 #
121 # run_time can contain either an hour (0, 1, 2...) or an interval
122 # (60min, 30min, 15min).
123 #
124 # We go over each uninitialized schedule record and use its settings to
125 # determine what the next time it runs should be
126 my $sched_h = $dbh->prepare("SELECT id, run_day, run_time " .
127                             "FROM whine_schedules " .
128                             "WHERE run_next IS NULL" );
129 $sched_h->execute();
130 while (my ($schedule_id, $day, $time) = $sched_h->fetchrow_array) {
131     # fill in some defaults in case they're blank
132     $day  ||= '0';
133     $time ||= '0';
134
135     # If this schedule is supposed to run today, we see if it's supposed to be
136     # run at a particular hour.  If so, we set it for that hour, and if not,
137     # it runs at an interval over the course of a day, which means we should
138     # set it to run immediately.
139     if (&check_today($day)) {
140         # Values that are not entirely numeric are intervals, like "30min"
141         if ($time !~ /^\d+$/) {
142             # set it to now
143             $sth = $dbh->prepare( "UPDATE whine_schedules " .
144                                   "SET run_next=NOW() " .
145                                   "WHERE id=?");
146             $sth->execute($schedule_id);
147         }
148         # A time greater than now means it still has to run today
149         elsif ($time >= $now_hour) {
150             # set it to today + number of hours
151             $sth = $dbh->prepare("UPDATE whine_schedules " .
152                                  "SET run_next = CURRENT_DATE + " .
153                                  $dbh->sql_interval('?', 'HOUR') .
154                                  " WHERE id = ?");
155             $sth->execute($time, $schedule_id);
156         }
157         # the target time is less than the current time
158         else { # set it for the next applicable day
159             $day = &get_next_date($day);
160             $sth = $dbh->prepare("UPDATE whine_schedules " .
161                                  "SET run_next = (CURRENT_DATE + " .
162                                  $dbh->sql_interval('?', 'DAY') . ") + " .
163                                  $dbh->sql_interval('?', 'HOUR') .
164                                  " WHERE id = ?");
165             $sth->execute($day, $time, $schedule_id);
166         }
167
168     }
169     # If the schedule is not supposed to run today, we set it to run on the
170     # appropriate date and time
171     else {
172         my $target_date = &get_next_date($day);
173         # If configured for a particular time, set it to that, otherwise
174         # midnight
175         my $target_time = ($time =~ /^\d+$/) ? $time : 0;
176
177         $sth = $dbh->prepare("UPDATE whine_schedules " .
178                              "SET run_next = (CURRENT_DATE + " .
179                              $dbh->sql_interval('?', 'DAY') . ") + " .
180                              $dbh->sql_interval('?', 'HOUR') .
181                              " WHERE id = ?");
182         $sth->execute($target_date, $target_time, $schedule_id);
183     }
184 }
185 $sched_h->finish();
186
187 # get_next_event
188 #
189 # This function will:
190 #   1. Lock whine_schedules
191 #   2. Grab the most overdue pending schedules on the same event that must run
192 #   3. Update those schedules' run_next value
193 #   4. Unlock the table
194 #   5. Return an event hashref
195 #
196 # The event hashref consists of:
197 #   eventid - ID of the event 
198 #   author  - user object for the event's creator
199 #   users   - array of user objects for recipients
200 #   subject - Subject line for the email
201 #   body    - the text inserted above the bug lists
202
203 sub get_next_event {
204     my $event = {};
205
206     # Loop until there's something to return
207     until (scalar keys %{$event}) {
208
209         $dbh->bz_lock_tables('whine_schedules WRITE',
210                              'whine_events READ',
211                              'profiles READ',
212                              'groups READ',
213                              'group_group_map READ',
214                              'user_group_map READ');
215
216         # Get the event ID for the first pending schedule
217         $sth_next_scheduled_event->execute;
218         my $fetched = $sth_next_scheduled_event->fetch;
219         $sth_next_scheduled_event->finish;
220         return undef unless $fetched;
221         my ($eventid, $owner_id, $subject, $body) = @{$fetched};
222
223         my $owner = Bugzilla::User->new($owner_id);
224
225         my $whineatothers = $owner->in_group('bz_canusewhineatothers');
226
227         my %user_objects;   # Used for keeping track of who has been added
228
229         # Get all schedules that match that event ID and are pending
230         $sth_schedules_by_event->execute($eventid);
231
232         # Add the users from those schedules to the list
233         while (my $row = $sth_schedules_by_event->fetch) {
234             my ($sid, $mailto_type, $mailto) = @{$row};
235
236             # Only bother doing any work if this user has whine permission
237             if ($owner->in_group('bz_canusewhines')) {
238
239                 if ($mailto_type == MAILTO_USER) {
240                     if (not defined $user_objects{$mailto}) {
241                         if ($mailto == $owner_id) {
242                             $user_objects{$mailto} = $owner;
243                         }
244                         elsif ($whineatothers) {
245                             $user_objects{$mailto} = Bugzilla::User->new($mailto);
246                         }
247                     }
248                 }
249                 elsif ($mailto_type == MAILTO_GROUP) {
250                     my $sth = $dbh->prepare("SELECT name FROM groups " .
251                                             "WHERE id=?");
252                     $sth->execute($mailto);
253                     my $groupname = $sth->fetch->[0];
254                     my $group_id = Bugzilla::Group::ValidateGroupName(
255                         $groupname, $owner);
256                     if ($group_id) {
257                         my $glist = join(',',
258                             @{Bugzilla::User->flatten_group_membership(
259                             $group_id)});
260                         $sth = $dbh->prepare("SELECT user_id FROM " .
261                                              "user_group_map " .
262                                              "WHERE group_id IN ($glist)");
263                         $sth->execute();
264                         for my $row (@{$sth->fetchall_arrayref}) {
265                             if (not defined $user_objects{$row->[0]}) {
266                                 $user_objects{$row->[0]} =
267                                     Bugzilla::User->new($row->[0]);
268                             }
269                         }
270                     }
271                 }
272
273             }
274
275             reset_timer($sid);
276         }
277
278         $dbh->bz_unlock_tables();
279
280         # Only set $event if the user is allowed to do whining
281         if ($owner->in_group('bz_canusewhines')) {
282             my @users = values %user_objects;
283             $event = {
284                     'eventid' => $eventid,
285                     'author'  => $owner,
286                     'mailto'  => \@users,
287                     'subject' => $subject,
288                     'body'    => $body,
289             };
290         }
291     }
292     return $event;
293 }
294
295 # Run the queries for each event
296 #
297 # $event:
298 #   eventid (the database ID for this event)
299 #   author  (user object for who created the event)
300 #   mailto  (array of user objects for mail targets)
301 #   subject (subject line for message)
302 #   body    (text blurb at top of message)
303 while (my $event = get_next_event) {
304
305     my $eventid = $event->{'eventid'};
306
307     # We loop for each target user because some of the queries will be using
308     # subjective pronouns
309     $dbh = Bugzilla->switch_to_shadow_db();
310     for my $target (@{$event->{'mailto'}}) {
311         my $args = {
312             'subject'     => $event->{'subject'},
313             'body'        => $event->{'body'},
314             'eventid'     => $event->{'eventid'},
315             'author'      => $event->{'author'},
316             'recipient'   => $target,
317             'from'        => $fromaddress,
318         };
319
320         # run the queries for this schedule
321         my $queries = run_queries($args);
322
323         # check to make sure there is something to output
324         my $there_are_bugs = 0;
325         for my $query (@{$queries}) {
326             $there_are_bugs = 1 if scalar @{$query->{'bugs'}};
327         }
328         next unless $there_are_bugs;
329
330         $args->{'queries'} = $queries;
331
332         mail($args);
333     }
334     $dbh = Bugzilla->switch_to_main_db();
335 }
336
337 ################################################################################
338 # Functions
339 ################################################################################
340
341 # The mail and run_queries functions use an anonymous hash ($args) for their
342 # arguments, which are then passed to the templates.
343 #
344 # When run_queries is run, $args contains the following fields:
345 #  - body           Message body defined in event
346 #  - from           Bugzilla system email address
347 #  - queries        array of hashes containing:
348 #          - bugs:  array of hashes mapping fieldnames to values for this bug
349 #          - title: text title given to this query in the whine event
350 #  - schedule_id    integer id of the schedule being run
351 #  - subject        Subject line for the message
352 #  - recipient      user object for the recipient
353 #  - author         user object of the person who created the whine event
354 #
355 # In addition, mail adds two more fields to $args:
356 #  - alternatives   array of hashes defining mime multipart types and contents
357 #  - boundary       a MIME boundary generated using the process id and time
358 #
359 sub mail {
360     my $args = shift;
361
362     # Don't send mail to someone on the nomail list.
363     return if $args->{recipient}->email_disabled;
364
365     my $msg = ''; # it's a temporary variable to hold the template output
366     $args->{'alternatives'} ||= [];
367
368     # put together the different multipart mime segments
369
370     $template->process("whine/mail.txt.tmpl", $args, \$msg)
371         or die($template->error());
372     push @{$args->{'alternatives'}},
373         {
374             'content' => $msg,
375             'type'    => 'text/plain',
376         };
377     $msg = '';
378
379     $template->process("whine/mail.html.tmpl", $args, \$msg)
380         or die($template->error());
381     push @{$args->{'alternatives'}},
382         {
383             'content' => $msg,
384             'type'    => 'text/html',
385         };
386     $msg = '';
387
388     # now produce a ready-to-mail mime-encoded message
389
390     $args->{'boundary'} = "----------" . $$ . "--" . time() . "-----";
391
392     $template->process("whine/multipart-mime.txt.tmpl", $args, \$msg)
393         or die($template->error());
394
395     MessageToMTA($msg);
396
397     delete $args->{'boundary'};
398     delete $args->{'alternatives'};
399
400 }
401
402 # run_queries runs all of the queries associated with a schedule ID, adding
403 # the results to $args or mailing off the template if a query wants individual
404 # messages for each bug
405 sub run_queries {
406     my $args = shift;
407
408     my $return_queries = [];
409
410     $sth_run_queries->execute($args->{'eventid'});
411     my @queries = ();
412     for (@{$sth_run_queries->fetchall_arrayref}) {
413         push(@queries,
414             {
415               'name'          => $_->[0],
416               'title'         => $_->[1],
417               'onemailperbug' => $_->[2],
418               'bugs'          => [],
419             }
420         );
421     }
422
423     foreach my $thisquery (@queries) {
424         next unless $thisquery->{'name'};   # named query is blank
425
426         my $savedquery = get_query($thisquery->{'name'}, $args->{'author'});
427         next unless $savedquery;    # silently ignore missing queries
428
429         # Execute the saved query
430         my @searchfields = (
431             'bugs.bug_id',
432             'bugs.bug_severity',
433             'bugs.priority',
434             'bugs.rep_platform',
435             'bugs.assigned_to',
436             'bugs.bug_status',
437             'bugs.resolution',
438             'bugs.short_desc',
439             'map_assigned_to.login_name',
440         );
441         # A new Bugzilla::CGI object needs to be created to allow
442         # Bugzilla::Search to execute a saved query.  It's exceedingly weird,
443         # but that's how it works.
444         my $searchparams = new Bugzilla::CGI($savedquery);
445         my $search = new Bugzilla::Search(
446             'fields' => \@searchfields,
447             'params' => $searchparams,
448             'user'   => $args->{'recipient'}, # the search runs as the recipient
449         );
450         my $sqlquery = $search->getSQL();
451         $sth = $dbh->prepare($sqlquery);
452         $sth->execute;
453
454         while (my @row = $sth->fetchrow_array) {
455             my $bug = {};
456             for my $field (@searchfields) {
457                 my $fieldname = $field;
458                 $fieldname =~ s/^bugs\.//;  # No need for bugs.whatever
459                 $bug->{$fieldname} = shift @row;
460             }
461
462             if ($thisquery->{'onemailperbug'}) {
463                 $args->{'queries'} = [
464                     {
465                         'name' => $thisquery->{'name'},
466                         'title' => $thisquery->{'title'},
467                         'bugs' => [ $bug ],
468                     },
469                 ];
470                 mail($args);
471                 delete $args->{'queries'};
472             }
473             else {  # It belongs in one message with any other lists
474                 push @{$thisquery->{'bugs'}}, $bug;
475             }
476         }
477         unless ($thisquery->{'onemailperbug'}) {
478             push @{$return_queries}, $thisquery;
479         }
480     }
481
482     return $return_queries;
483 }
484
485 # get_query gets the namedquery.  It's similar to LookupNamedQuery (in
486 # buglist.cgi), but doesn't care if a query name really exists or not, since
487 # individual named queries might go away without the whine_queries that point
488 # to them being removed.
489 sub get_query {
490     my ($name, $user) = @_;
491     my $qname = $name;
492     $sth_get_query->execute($user->{'id'}, $qname);
493     my $fetched = $sth_get_query->fetch;
494     $sth_get_query->finish;
495     return $fetched ? $fetched->[0] : '';
496 }
497
498 # check_today gets a run day from the schedule and sees if it matches today
499 # a run day value can contain any of:
500 #   - a three-letter day of the week
501 #   - a number for a day of the month
502 #   - 'last' for the last day of the month
503 #   - 'All' for every day
504 #   - 'MF' for every weekday
505
506 sub check_today {
507     my $run_day  = shift;
508
509     if (($run_day eq 'MF')
510      && ($now_weekday > 0)
511      && ($now_weekday < 6)) {
512         return 1;
513     }
514     elsif (
515          length($run_day) == 3 &&
516          index("SunMonTueWedThuFriSat", $run_day)/3 == $now_weekday) {
517         return 1;
518     }
519     elsif  (($run_day eq 'All')
520          || (($run_day eq 'last')  &&
521              ($now_day == $daysinmonth[$now_month] ))
522          || ($run_day eq $now_day)) {
523         return 1;
524     }
525     return 0;
526 }
527
528 # reset_timer sets the next time a whine is supposed to run, assuming it just
529 # ran moments ago.  Its only parameter is a schedule ID.
530 #
531 # reset_timer does not lock the whine_schedules table.  Anything that calls it
532 # should do that itself.
533 sub reset_timer {
534     my $schedule_id = shift;
535
536     # Schedules may not be executed more than once for each invocation of
537     # whine.pl -- there are legitimate circumstances that can cause this, like
538     # a set of whines that take a very long time to execute, so it's done
539     # quietly.
540     if (grep($_ == $schedule_id, @seen_schedules)) {
541         null_schedule($schedule_id);
542         return;
543     }
544     push @seen_schedules, $schedule_id;
545
546     $sth = $dbh->prepare( "SELECT run_day, run_time FROM whine_schedules " .
547                           "WHERE id=?" );
548     $sth->execute($schedule_id);
549     my ($run_day, $run_time) = $sth->fetchrow_array;
550
551     # It may happen that the run_time field is NULL or blank due to
552     # a bug in editwhines.cgi when this field was initially 0.
553     $run_time ||= 0;
554
555     my $run_today = 0;
556     my $minute_offset = 0;
557
558     # If the schedule is to run today, and it runs many times per day,
559     # it shall be set to run immediately.
560     $run_today = &check_today($run_day);
561     if (($run_today) && ($run_time !~ /^\d+$/)) {
562         # The default of 60 catches any bad value
563         my $minute_interval = 60;
564         if ($run_time =~ /^(\d+)min$/i) {
565             $minute_interval = $1;
566         }
567
568         # set the minute offset to the next interval point
569         $minute_offset = $minute_interval - ($now_minute % $minute_interval);
570     }
571     elsif (($run_today) && ($run_time > $now_hour)) {
572         # timed event for later today
573         # (This should only happen if, for example, an 11pm scheduled event
574         #  didn't happen until after midnight)
575         $minute_offset = (60 * ($run_time - $now_hour)) - $now_minute;
576     }
577     else {
578         # it's not something that runs later today.
579         $minute_offset = 0;
580
581         # Set the target time if it's a specific hour
582         my $target_time = ($run_time =~ /^\d+$/) ? $run_time : 0;
583
584         my $nextdate = &get_next_date($run_day);
585
586         $sth = $dbh->prepare("UPDATE whine_schedules " .
587                              "SET run_next = (CURRENT_DATE + " .
588                              $dbh->sql_interval('?', 'DAY') . ") + " .
589                              $dbh->sql_interval('?', 'HOUR') .
590                              " WHERE id = ?");
591         $sth->execute($nextdate, $target_time, $schedule_id);
592         return;
593     }
594
595     if ($minute_offset > 0) {
596         # Scheduling is done in terms of whole minutes.
597         my $next_run = $dbh->selectrow_array('SELECT NOW() + ' .
598                                              $dbh->sql_interval('?', 'MINUTE'),
599                                              undef, $minute_offset);
600         $next_run = format_time($next_run, "%Y-%m-%d %R");
601
602         $sth = $dbh->prepare("UPDATE whine_schedules " .
603                              "SET run_next = ? WHERE id = ?");
604         $sth->execute($next_run, $schedule_id);
605     } else {
606         # The minute offset is zero or less, which is not supposed to happen.
607         # complain to STDERR
608         null_schedule($schedule_id);
609         print STDERR "Error: bad minute_offset for schedule ID $schedule_id\n";
610     }
611 }
612
613 # null_schedule is used to safeguard against infinite loops.  Schedules with
614 # run_next set to NULL will not be available to get_next_event until they are
615 # rescheduled, which only happens when whine.pl starts.
616 sub null_schedule {
617     my $schedule_id = shift;
618     $sth = $dbh->prepare("UPDATE whine_schedules " .
619                          "SET run_next = NULL " .
620                          "WHERE id=?");
621     $sth->execute($schedule_id);
622 }
623
624 # get_next_date determines the difference in days between now and the next
625 # time a schedule should run, excluding today
626 #
627 # It takes a run_day argument (see check_today, above, for an explanation),
628 # and returns an integer, representing a number of days.
629 sub get_next_date {
630     my $day = shift;
631
632     my $add_days = 0;
633
634     if ($day eq 'All') {
635         $add_days = 1;
636     }
637     elsif ($day eq 'last') {
638         # next_date should contain the last day of this month, or next month
639         # if it's today
640         if ($daysinmonth[$now_month] == $now_day) {
641             my $month = $now_month + 1;
642             $month = 1 if $month > 12;
643             $add_days = $daysinmonth[$month] + 1;
644         }
645         else {
646             $add_days = $daysinmonth[$now_month] - $now_day;
647         }
648     }
649     elsif ($day eq 'MF') { # any day Monday through Friday
650         if ($now_weekday < 5) { # Sun-Thurs
651             $add_days = 1;
652         }
653         elsif ($now_weekday == 5) { # Friday
654             $add_days = 3;
655         }
656         else { # it's 6, Saturday
657             $add_days = 2;
658         }
659     }
660     elsif ($day !~ /^\d+$/) { # A specific day of the week
661         # The default is used if there is a bad value in the database, in
662         # which case we mark it to a less-popular day (Sunday)
663         my $day_num = 0;
664
665         if (length($day) == 3) {
666             $day_num = (index("SunMonTueWedThuFriSat", $day)/3) or 0;
667         }
668
669         $add_days = $day_num - $now_weekday;
670         if ($add_days <= 0) { # it's next week
671             $add_days += 7;
672         }
673     }
674     else { # it's a number, so we set it for that calendar day
675         $add_days = $day - $now_day;
676         # If it's already beyond that day this month, set it to the next one
677         if ($add_days <= 0) {
678             $add_days += $daysinmonth[$now_month];
679         }
680     }
681     return $add_days;
682 }