1<?php
2
3# Copyright (c) 2012 John Reese
4# Licensed under the MIT license
5
6require_once( 'MantisSourcePlugin.class.php' );
7
8/**
9 * General source control integration API.
10 * @author John Reese
11 */
12
13if (!function_exists('plugin_lang_get_defaulted')) {
14	/**
15	 * Get a language string for the plugin.
16	 * - If found, return the appropriate string.
17	 * - If not found, no default supplied, return the supplied string as is.
18	 * - If not found, default supplied, return default.
19	 * Automatically prepends plugin_<basename> to the string requested.
20	 * @param string $p_name     Language string name.
21	 * @param string $p_default  The default value to return.
22	 * @param string $p_basename Plugin basename.
23	 * @return string Language string
24	 */
25	function plugin_lang_get_defaulted( $p_name, $p_default = null, $p_basename = null ) {
26		if( !is_null( $p_basename ) ) {
27			plugin_push_current( $p_basename );
28		}
29		$t_basename = plugin_get_current();
30		$t_name = 'plugin_' . $t_basename . '_' . $p_name;
31		$t_string = lang_get_defaulted( $t_name, $p_default );
32
33		if( !is_null( $p_basename ) ) {
34			plugin_pop_current();
35		}
36		return $t_string;
37	}
38}
39
40# branch mapping strategies
41define( 'SOURCE_EXPLICIT',		1 );
42define( 'SOURCE_NEAR',			2 );
43define( 'SOURCE_FAR',			3 );
44define( 'SOURCE_FIRST',			4 );
45define( 'SOURCE_LAST',			5 );
46
47function SourceType( $p_type ) {
48	$t_types = SourceTypes();
49
50	if ( isset( $t_types[$p_type] ) ) {
51		return $t_types[$p_type];
52	}
53
54	return $p_type;
55}
56
57function SourceTypes() {
58	static $s_types = null;
59
60	if ( is_null( $s_types ) ) {
61		$s_types = array();
62
63		foreach( SourceVCS::all() as $t_type => $t_vcs ) {
64			$s_types[ $t_type ] = $t_vcs->show_type();
65		}
66
67		asort( $s_types );
68	}
69
70	return $s_types;
71}
72
73/**
74 * Determine if the Product Matrix integration is enabled, and trigger
75 * an error if integration is enabled but the plugin is not running.
76 * @param boolean $p_trigger_error Trigger error
77 * @return boolean Integration enabled
78 */
79function Source_PVM( $p_trigger_error=true ) {
80	if ( config_get( 'plugin_Source_enable_product_matrix' ) ) {
81		if ( plugin_is_loaded( 'ProductMatrix' ) || !$p_trigger_error ) {
82			return true;
83		} else {
84			plugin_error( SourcePlugin::ERROR_PRODUCTMATRIX_NOT_LOADED );
85		}
86	}
87	return false;
88}
89
90/**
91 * Parse basic bug links from a changeset commit message
92 * and return a list of referenced bug IDs.
93 * @param string $p_string Changeset commit message
94 * @return array Bug IDs
95 */
96function Source_Parse_Buglinks( $p_string ) {
97	static $s_regex1, $s_regex2;
98
99	$t_bugs = array();
100
101	if ( is_null( $s_regex1 ) ) {
102		$s_regex1 = config_get( 'plugin_Source_buglink_regex_1' );
103		$s_regex2 = config_get( 'plugin_Source_buglink_regex_2' );
104	}
105
106	preg_match_all( $s_regex1, $p_string, $t_matches_all );
107
108	foreach( $t_matches_all[0] as $t_substring ) {
109		preg_match_all( $s_regex2, $t_substring, $t_matches );
110		foreach ( $t_matches[1] as $t_match ) {
111			if ( 0 < (int)$t_match ) {
112				$t_bugs[$t_match] = true;
113			}
114		}
115	}
116
117	return array_keys( $t_bugs );
118}
119
120/**
121 * Parse resolved bug fix links from a changeset commit message
122 * and return a list of referenced bug IDs.
123 * @param string $p_string Changeset commit message
124 * @return array Bug IDs
125 */
126function Source_Parse_Bugfixes( $p_string ) {
127	static $s_regex1, $s_regex2;
128
129	$t_bugs = array();
130
131	if ( is_null( $s_regex1 ) ) {
132		$s_regex1 = config_get( 'plugin_Source_bugfix_regex_1' );
133		$s_regex2 = config_get( 'plugin_Source_bugfix_regex_2' );
134	}
135
136	preg_match_all( $s_regex1, $p_string, $t_matches_all );
137
138	foreach( $t_matches_all[0] as $t_substring ) {
139		preg_match_all( $s_regex2, $t_substring, $t_matches );
140		foreach ( $t_matches[1] as $t_match ) {
141			if ( 0 < (int)$t_match ) {
142				$t_bugs[$t_match] = true;
143			}
144		}
145	}
146
147	return array_keys( $t_bugs );
148}
149
150/**
151 * Sets the changeset's user id by looking up email address or name
152 * Generic code for both Author and Committer, based on the given properties
153 * @param object $p_changeset
154 * @param string $p_user_type 'author' or 'committer'
155 */
156function Source_set_changeset_user( &$p_changeset, $p_user_type ) {
157	static $s_vcs_names;
158	static $s_names = array();
159	static $s_emails = array();
160
161	# Set the fields
162	switch( $p_user_type ) {
163		case 'committer':
164			list( $t_id_prop, $t_name_prop, $t_email_prop ) = explode( ' ', 'committer_id committer committer_email' );
165			break;
166
167		case 'author':
168		default:
169			list( $t_id_prop, $t_name_prop, $t_email_prop ) = explode( ' ', 'user_id author author_email' );
170			break;
171	}
172
173	# The user's id is already set, nothing to do
174	if( $p_changeset->$t_id_prop ) {
175		return;
176	}
177
178	# cache the vcs username mappings
179	if( is_null( $s_vcs_names ) ) {
180		$s_vcs_names = SourceUser::load_mappings();
181	}
182
183	# Check username associations
184	if( isset( $s_vcs_names[ $p_changeset->$t_name_prop ] ) ) {
185		$p_changeset->$t_id_prop = $s_vcs_names[ $p_changeset->$t_name_prop ];
186		return;
187	}
188
189	# Look up the email address if given
190	if( $t_email = $p_changeset->$t_email_prop ) {
191		if( isset( $s_emails[ $t_email ] ) ) {
192			$p_changeset->$t_id_prop = $s_emails[ $t_email ];
193			return;
194
195		} else if( false !== ( $t_email_id = user_get_id_by_email( $t_email ) ) ) {
196			$s_emails[ $t_email ] = $p_changeset->$t_id_prop = $t_email_id;
197			return;
198		}
199	}
200
201	# Look up the name if the email failed
202	if( $t_name = $p_changeset->$t_name_prop ) {
203		if( isset( $s_names[ $t_name ] ) ) {
204			$p_changeset->$t_id_prop = $s_names[ $t_name ];
205			return;
206
207		} else if( false !== ( $t_user_id = user_get_id_by_realname( $t_name ) ) ) {
208			$s_names[ $t_name ] = $p_changeset->$t_id_prop = $t_user_id;
209			return;
210
211		} else if( false !== ( $t_user_id = user_get_id_by_name( $p_changeset->$t_name_prop ) ) ) {
212			$s_names[ $t_name ] = $p_changeset->$t_id_prop = $t_user_id;
213			return;
214		}
215	}
216}
217
218/**
219 * Determine the user ID for both the author and committer.
220 * First checks the email address for a matching user, then
221 * checks the name for a matching username or realname.
222 * @param object $p_changeset Changeset object
223 * @return object updated Changeset object
224 */
225function Source_Parse_Users( $p_changeset ) {
226
227	# Handle the changeset author
228	Source_set_changeset_user( $p_changeset, 'author' );
229
230	# Handle the changeset committer
231	Source_set_changeset_user( $p_changeset, 'committer' );
232
233	return $p_changeset;
234}
235
236/**
237 * Given a set of changeset objects, parse the bug links
238 * and save the changes.
239 * @param array $p_changesets Changeset objects
240 * @param object $p_repo      Repository object
241 */
242function Source_Process_Changesets( $p_changesets, $p_repo=null ) {
243	global $g_cache_current_user_id;
244
245	if ( !is_array( $p_changesets ) ) {
246		return;
247	}
248
249	if ( is_null( $p_repo ) ) {
250		$t_repos = SourceRepo::load_by_changesets( $p_changesets );
251	} else {
252		$t_repos = array( $p_repo->id => $p_repo );
253	}
254
255	$t_resolved_threshold = config_get('bug_resolved_status_threshold');
256	$t_fixed_threshold = config_get('bug_resolution_fixed_threshold');
257	$t_notfixed_threshold = config_get('bug_resolution_not_fixed_threshold');
258	$t_handle_bug_threshold = config_get( 'handle_bug_threshold' );
259
260	# Link author and committer name/email to user accounts
261	foreach( $p_changesets as $t_key => $t_changeset ) {
262		$p_changesets[ $t_key ] = Source_Parse_Users( $t_changeset );
263	}
264
265	# Parse normal bug links, excluding non-existing bugs
266	foreach( $p_changesets as $t_changeset ) {
267		$t_bugs = Source_Parse_Buglinks( $t_changeset->message );
268		foreach( $t_bugs as $t_bug_id ) {
269			if( bug_exists( $t_bug_id ) ) {
270				$t_changeset->bugs[] = $t_bug_id;
271			}
272		}
273	}
274
275	# Parse fixed bug links
276	$t_fixed_bugs = array();
277
278	# Find and associate resolve links with the changeset
279	foreach( $p_changesets as $t_changeset ) {
280		$t_bugs = Source_Parse_Bugfixes( $t_changeset->message );
281
282		foreach( $t_bugs as $t_key => $t_bug_id ) {
283			# Only process existing bugs
284			if( bug_exists( $t_bug_id ) ) {
285				$t_fixed_bugs[$t_bug_id] = $t_changeset;
286			} else {
287				unset( $t_bugs[$t_key] );
288			}
289		}
290
291		# Add the link to the normal set of buglinks
292		$t_changeset->bugs = array_unique( array_merge( $t_changeset->bugs, $t_bugs ) );
293	}
294
295	# Save changeset data before processing their consequences
296	foreach( $p_changesets as $t_changeset ) {
297		$t_changeset->repo = $p_repo;
298		$t_changeset->save();
299	}
300
301	# Precache information for resolved bugs
302	bug_cache_array_rows( array_keys( $t_fixed_bugs ) );
303
304	$t_current_user_id = $g_cache_current_user_id;
305	$t_enable_resolving = config_get( 'plugin_Source_enable_resolving' );
306	$t_enable_message = config_get( 'plugin_Source_enable_message' );
307	$t_enable_mapping = config_get( 'plugin_Source_enable_mapping' );
308
309	$t_bugfix_status = config_get( 'plugin_Source_bugfix_status' );
310	$t_bugfix_status_pvm = config_get( 'plugin_Source_bugfix_status_pvm' );
311	$t_resolution = config_get( 'plugin_Source_bugfix_resolution' );
312	$t_handler = config_get( 'plugin_Source_bugfix_handler' );
313	$t_message_template = str_replace(
314		array( '$1', '$2', '$3', '$4', '$5', '$6' ),
315		array( '%1$s', '%2$s', '%3$s', '%4$s', '%5$s', '%6$s' ),
316		config_get( 'plugin_Source_bugfix_message' ) );
317
318	$t_mappings = array();
319
320	# Start fixing and/or resolving issues
321	foreach( $t_fixed_bugs as $t_bug_id => $t_changeset ) {
322
323		# Determine the Mantis user to associate with the issue referenced in
324		# the changeset:
325		# - use Author if they can handle the issue
326		# - use Committer if not
327		# - if Committer can't handle issue either, it will not be resolved.
328		# This is used to generate the history entries and set the bug handler
329		# if the changeset fixes the issue.
330		$t_user_id = null;
331		if ( $t_changeset->user_id > 0 ) {
332			$t_can_handle_bug = access_has_bug_level( $t_handle_bug_threshold, $t_bug_id, $t_changeset->user_id );
333			if( $t_can_handle_bug ) {
334				$t_user_id = $t_changeset->user_id;
335			}
336		}
337		$t_handler_id = $t_user_id;
338		if( $t_handler_id === null && $t_changeset->committer_id > 0 ) {
339			$t_user_id = $t_changeset->committer_id;
340			$t_can_handle_bug = access_has_bug_level( $t_handle_bug_threshold, $t_bug_id, $t_user_id );
341			if( $t_can_handle_bug ) {
342				$t_handler_id = $t_user_id;
343			}
344		}
345
346		if ( !is_null( $t_user_id ) ) {
347			$g_cache_current_user_id = $t_user_id;
348		} else if ( !is_null( $t_current_user_id ) ) {
349			$g_cache_current_user_id = $t_current_user_id;
350		} else {
351			$g_cache_current_user_id = 0;
352		}
353
354		# generate the branch mappings
355		$t_version = '';
356		$t_pvm_version_id = 0;
357		if ( $t_enable_mapping ) {
358			$t_repo_id = $t_changeset->repo_id;
359
360			if ( !isset( $t_mappings[ $t_repo_id ] ) ) {
361				$t_mappings[ $t_repo_id ] = SourceMapping::load_by_repo( $t_repo_id );
362			}
363
364			if ( isset( $t_mappings[ $t_repo_id ][ $t_changeset->branch ] ) ) {
365				$t_mapping = $t_mappings[ $t_repo_id ][ $t_changeset->branch ];
366				if ( Source_PVM() ) {
367					$t_pvm_version_id = $t_mapping->apply_pvm( $t_bug_id );
368				} else {
369					$t_version = $t_mapping->apply( $t_bug_id );
370				}
371			}
372		}
373
374		# generate a note message
375		if ( $t_enable_message ) {
376			$t_message = sprintf( $t_message_template,
377				$t_changeset->branch,
378				$t_changeset->revision,
379				$t_changeset->timestamp,
380				$t_changeset->message,
381				$t_repos[ $t_changeset->repo_id ]->name,
382				$t_changeset->id
383			);
384		} else {
385			$t_message = '';
386		}
387
388		$t_bug = bug_get( $t_bug_id );
389
390		# Update the resolution, fixed-in version, and/or add a bugnote
391		$t_update = false;
392
393		if ( Source_PVM() ) {
394			if ( $t_bugfix_status_pvm > 0 && $t_pvm_version_id > 0 ) {
395				$t_matrix = new ProductMatrix( $t_bug_id );
396				if ( isset( $t_matrix->status[ $t_pvm_version_id ] ) ) {
397					$t_matrix->status[ $t_pvm_version_id ] = $t_bugfix_status_pvm;
398					$t_matrix->save();
399				}
400			}
401
402		} elseif( $t_handler && $t_handler_id !== null ) {
403			# We only resolve the issue if an authorized handler has been
404			# identified; otherwise, it will remain open.
405
406			if ( $t_bugfix_status > 0 && $t_bug->status != $t_bugfix_status ) {
407				$t_bug->status = $t_bugfix_status;
408				$t_update = true;
409			} else if ( $t_enable_resolving && $t_bugfix_status == -1 && $t_bug->status < $t_resolved_threshold ) {
410				$t_bug->status = $t_resolved_threshold;
411				$t_update = true;
412			}
413
414			if( $t_bug->resolution < $t_fixed_threshold || $t_bug->resolution >= $t_notfixed_threshold
415				# With default MantisBT settings, 'reopened' is above 'fixed'
416				# but below 'not fixed' thresholds, so we need a special case
417				# to make sure the resolution is set to 'fixed'.
418				|| $t_bug->resolution == REOPENED
419			) {
420				$t_bug->resolution = $t_resolution;
421				$t_update = true;
422			}
423			if ( is_blank( $t_bug->fixed_in_version ) ) {
424				$t_bug->fixed_in_version = $t_version;
425				$t_update = true;
426			}
427
428			if( $t_bug->handler_id != $t_handler_id ) {
429				$t_bug->handler_id = $t_handler_id;
430				$t_update = true;
431			}
432		}
433
434		$t_private = plugin_config_get( 'bugfix_message_view_status' ) == VS_PRIVATE;
435
436		if ( $t_update ) {
437			if ( $t_message ) {
438				# Add a note without sending mail, since the notification will
439				# be sent by the subsequent bug update.
440				bugnote_add( $t_bug_id, $t_message, '0:00', $t_private, 0, '', null, false );
441			}
442			$t_bug->update();
443
444		} else if ( $t_message ) {
445			bugnote_add( $t_bug_id, $t_message, '0:00', $t_private );
446		}
447	}
448
449	# reset the user ID
450	$g_cache_current_user_id = $t_current_user_id;
451
452	# Allow other plugins to post-process commit data
453	event_signal( 'EVENT_SOURCE_COMMITS', array( $p_changesets ) );
454	event_signal( 'EVENT_SOURCE_FIXED', array( $t_fixed_bugs ) );
455}
456
457/**
458 * Object for handling registration and retrieval of VCS type extension plugins.
459 */
460class SourceVCS {
461	static private $cache = array();
462
463	/**
464	 * Initialize the extension cache.
465	 */
466	static public function init() {
467		if ( is_array( self::$cache ) && !empty( self::$cache ) ) {
468			return;
469		}
470
471		$t_raw_data = event_signal( 'EVENT_SOURCE_INTEGRATION' );
472		foreach ( $t_raw_data as $t_plugin => $t_callbacks ) {
473			foreach ( $t_callbacks as $t_callback => $t_object ) {
474				if ( is_subclass_of( $t_object, 'MantisSourcePlugin' ) &&
475					is_string( $t_object->type ) && !is_blank( $t_object->type ) ) {
476						$t_type = strtolower($t_object->type);
477						self::$cache[ $t_type ] = new SourceVCSWrapper( $t_object );
478				}
479			}
480		}
481
482		ksort( self::$cache );
483	}
484
485	/**
486	 * Retrieve an extension plugin that can handle the requested repo's VCS type.
487	 * If the requested type is not available, the "generic" type will be returned.
488	 * @param object $p_repo Repository object
489	 * @return object VCS plugin
490	 */
491	static public function repo( $p_repo ) {
492		return self::type( $p_repo->type );
493	}
494
495	/**
496	 * Retrieve an extension plugin that can handle the requested VCS type.
497	 * If the requested type is not available, the "generic" type will be returned.
498	 * @param string $p_type VCS type
499	 * @return object VCS plugin
500	 */
501	static public function type( $p_type ) {
502		$p_type = strtolower( $p_type );
503
504		if ( isset( self::$cache[ $p_type ] ) ) {
505			return self::$cache[ $p_type ];
506		} else {
507			return self::$cache['generic'];
508		}
509	}
510
511	/**
512	 * Retrieve a list of all registered VCS types.
513	 * @return array VCS plugins
514	 */
515	static public function all() {
516		return self::$cache;
517	}
518}
519
520/**
521 * Class for wrapping VCS objects with plugin API calls
522 */
523class SourceVCSWrapper {
524	private $object;
525	private $basename;
526
527	/**
528	 * Build a wrapper around a VCS plugin object.
529	 * @param $p_object
530	 */
531	function __construct( $p_object ) {
532		$this->object = $p_object;
533		$this->basename = $p_object->basename;
534	}
535
536	/**
537	 * Wrap method calls to the target object in plugin_push/pop calls.
538	 * @param $p_method
539	 * @param $p_args
540	 * @return mixed
541	 */
542	function __call( $p_method, $p_args ) {
543		plugin_push_current( $this->basename );
544		$value = call_user_func_array( array( $this->object, $p_method ), $p_args );
545		plugin_pop_current();
546
547		return $value;
548	}
549
550	/**
551	 * Wrap property reference to target object.
552	 * @param $p_name
553	 * @return mixed
554	 */
555	function __get( $p_name ) {
556		return $this->object->$p_name;
557	}
558
559	/**
560	 * Wrap property mutation to target object.
561	 * @param $p_name
562	 * @param $p_value
563	 * @return mixed
564	 */
565	function __set( $p_name, $p_value ) {
566		return $this->object->$p_name = $p_value;
567	}
568}
569
570/**
571 * Abstract source control repository data.
572 */
573class SourceRepo {
574	var $id;
575	var $type;
576	var $name;
577	var $url;
578	var $info;
579	var $branches;
580	var $mappings;
581
582	/**
583	 * Build a new Repo object given certain properties.
584	 * @param string $p_type Repo type
585	 * @param string $p_name Name
586	 * @param string $p_url  URL
587	 * @param string $p_info Info
588	 */
589	function __construct( $p_type, $p_name, $p_url='', $p_info='' ) {
590		$this->id	= 0;
591		$this->type	= $p_type;
592		$this->name	= $p_name;
593		$this->url	= $p_url;
594		if ( is_blank( $p_info ) ) {
595			$this->info = array();
596		} else {
597			$this->info = unserialize( $p_info );
598		}
599		$this->branches = array();
600		$this->mappings = array();
601	}
602
603	/**
604	 * Create or update repository data.
605	 * Creates database row if $this->id is zero, updates an existing row otherwise.
606	 */
607	function save() {
608		if ( is_blank( $this->type ) || is_blank( $this->name ) ) {
609			if( is_blank( $this->type ) ) {
610				error_parameters( plugin_lang_get( 'type' ) );
611			} else {
612				error_parameters( plugin_lang_get( 'name' ) );
613			}
614			trigger_error( ERROR_EMPTY_FIELD, ERROR );
615		}
616
617		$t_repo_table = plugin_table( 'repository', 'Source' );
618
619		if ( 0 == $this->id ) { # create
620			$t_query = "INSERT INTO $t_repo_table ( type, name, url, info ) VALUES ( " .
621				db_param() . ', ' . db_param() . ', ' . db_param() . ', ' . db_param() . ' )';
622			db_query( $t_query, array( $this->type, $this->name, $this->url, serialize($this->info) ) );
623
624			$this->id = db_insert_id( $t_repo_table );
625		} else { # update
626			$t_query = "UPDATE $t_repo_table SET type=" . db_param() . ', name=' . db_param() .
627				', url=' . db_param() . ', info=' . db_param() . ' WHERE id=' . db_param();
628			db_query( $t_query, array( $this->type, $this->name, $this->url, serialize($this->info), $this->id ) );
629		}
630
631		foreach( $this->mappings as $t_mapping ) {
632			$t_mapping->save();
633		}
634	}
635
636	/**
637	 * Load and cache the list of unique branches for the repo's changesets.
638	 */
639	function load_branches() {
640		if ( count( $this->branches ) < 1 ) {
641			$t_changeset_table = plugin_table( 'changeset', 'Source' );
642
643			$t_query = "SELECT DISTINCT branch FROM $t_changeset_table WHERE repo_id=" .
644				db_param() . ' ORDER BY branch ASC';
645			$t_result = db_query( $t_query, array( $this->id ) );
646
647			while( $t_row = db_fetch_array( $t_result ) ) {
648				$this->branches[] = $t_row['branch'];
649			}
650		}
651
652		return $this->branches;
653	}
654
655	/**
656	 * Load and cache the set of branch mappings for the repository.
657	 */
658	function load_mappings() {
659		if ( count( $this->mappings ) < 1 ) {
660			$this->mappings = SourceMapping::load_by_repo( $this->id );
661		}
662
663		return $this->mappings;
664	}
665
666	/**
667	 * Get a list of repository statistics.
668	 * @param bool $p_all
669	 * @return array Stats
670	 */
671	function stats( $p_all=true ) {
672		$t_stats = array();
673
674		$t_changeset_table = plugin_table( 'changeset', 'Source' );
675		$t_file_table = plugin_table( 'file', 'Source' );
676		$t_bug_table = plugin_table( 'bug', 'Source' );
677
678		$t_query = "SELECT COUNT(*) FROM $t_changeset_table WHERE repo_id=" . db_param();
679		$t_stats['changesets'] = db_result( db_query( $t_query, array( $this->id ) ) );
680
681		if ( $p_all ) {
682			$t_query = "SELECT COUNT(DISTINCT filename) FROM $t_file_table AS f
683						JOIN $t_changeset_table AS c
684						ON c.id=f.change_id
685						WHERE c.repo_id=" . db_param();
686			$t_stats['files'] = db_result( db_query( $t_query, array( $this->id ) ) );
687
688			$t_query = "SELECT COUNT(DISTINCT bug_id) FROM $t_bug_table AS b
689						JOIN $t_changeset_table AS c
690						ON c.id=b.change_id
691						WHERE c.repo_id=" . db_param();
692			$t_stats['bugs'] = db_result( db_query( $t_query, array( $this->id ) ) );
693		}
694
695		return $t_stats;
696	}
697
698	/**
699	 * Fetch a new Repo object given an ID.
700	 * @param int $p_id Repository ID
701	 * @return object Repo object
702	 */
703	static function load( $p_id ) {
704		$t_repo_table = plugin_table( 'repository', 'Source' );
705
706		$t_query = "SELECT * FROM $t_repo_table WHERE id=" . db_param();
707		$t_result = db_query( $t_query, array( (int) $p_id ) );
708
709		if ( db_num_rows( $t_result ) < 1 ) {
710			error_parameters( $p_id );
711			plugin_error( SourcePlugin::ERROR_REPO_MISSING );
712		}
713
714		$t_row = db_fetch_array( $t_result );
715
716		$t_repo = new SourceRepo( $t_row['type'], $t_row['name'], $t_row['url'], $t_row['info'] );
717		$t_repo->id = $t_row['id'];
718
719		return $t_repo;
720	}
721
722	/**
723	 * Fetch a new Repo object given a name.
724	 * @param string $p_name Repository name
725	 * @return SourceRepo Repo object
726	 */
727	static function load_from_name( $p_name ) {
728		$p_name = trim($p_name);
729		$t_repo_table = plugin_table( 'repository', 'Source' );
730
731		$t_query = "SELECT * FROM $t_repo_table WHERE name LIKE " . db_param();
732		$t_result = db_query( $t_query, array( $p_name ) );
733
734		if ( db_num_rows( $t_result ) < 1 ) {
735			error_parameters( $p_name );
736			plugin_error( SourcePlugin::ERROR_REPO_MISSING );
737		}
738
739		$t_row = db_fetch_array( $t_result );
740
741		$t_repo = new SourceRepo( $t_row['type'], $t_row['name'], $t_row['url'], $t_row['info'] );
742		$t_repo->id = $t_row['id'];
743
744		return $t_repo;
745	}
746
747	/**
748	 * Fetch an array of all Repo objects.
749	 * @return array All repo objects.
750	 */
751	static function load_all() {
752		$t_repo_table = plugin_table( 'repository', 'Source' );
753
754		$t_query = "SELECT * FROM $t_repo_table ORDER BY name ASC";
755		$t_result = db_query( $t_query );
756
757		$t_repos = array();
758
759		while ( $t_row = db_fetch_array( $t_result ) ) {
760			$t_repo = new SourceRepo( $t_row['type'], $t_row['name'], $t_row['url'], $t_row['info'] );
761			$t_repo->id = $t_row['id'];
762
763			$t_repos[] = $t_repo;
764		}
765
766		return $t_repos;
767	}
768
769	/**
770	 * Fetch a repository object with the given name.
771	 * @param string $p_repo_name
772	 * @return null|SourceRepo Repo object, or null if not found
773	 */
774	static function load_by_name( $p_repo_name ) {
775		$t_repo_table = plugin_table( 'repository', 'Source' );
776
777		# Look for a repository with the exact name given
778		$t_query = "SELECT * FROM $t_repo_table WHERE name LIKE " . db_param();
779		$t_result = db_query( $t_query, array( $p_repo_name ) );
780
781		# If not found, look for a repo containing the name given
782		if ( db_num_rows( $t_result ) < 1 ) {
783			$t_query = "SELECT * FROM $t_repo_table WHERE name LIKE " . db_param();
784			$t_result = db_query( $t_query, array( '%' . $p_repo_name . '%' ) );
785
786			if ( db_num_rows( $t_result ) < 1 ) {
787				return null;
788			}
789		}
790
791		$t_row = db_fetch_array( $t_result );
792
793		$t_repo = new SourceRepo( $t_row['type'], $t_row['name'], $t_row['url'], $t_row['info'] );
794		$t_repo->id = $t_row['id'];
795
796		return $t_repo;
797	}
798
799	/**
800	 * Fetch an array of repository objects that includes all given changesets.
801	 * @param array|SourceChangeset $p_changesets Changeset objects
802	 * @return array Repository objects
803	 */
804	static function load_by_changesets( $p_changesets ) {
805		if ( !is_array( $p_changesets ) ) {
806			$p_changesets = array( $p_changesets );
807		}
808		elseif ( count( $p_changesets ) < 1 ) {
809			return array();
810		}
811
812		$t_repo_table = plugin_table( 'repository', 'Source' );
813
814		$t_repos = array();
815
816		foreach ( $p_changesets as $t_changeset ) {
817			if ( !isset( $t_repos[$t_changeset->repo_id] ) ) {
818				$t_repos[$t_changeset->repo_id] = true;
819			}
820		}
821
822		$t_list = array();
823		$t_param = array();
824		foreach ( $t_repos as $t_repo_id => $t_repo ) {
825			$t_list[] = db_param();
826			$t_param[] = (int)$t_repo_id;
827		}
828		$t_query = "SELECT * FROM $t_repo_table WHERE id IN ("
829			. join( ', ', $t_list )
830			. ') ORDER BY name ASC';
831		$t_result = db_query( $t_query, $t_param );
832
833		while ( $t_row = db_fetch_array( $t_result ) ) {
834			$t_repo = new SourceRepo( $t_row['type'], $t_row['name'], $t_row['url'], $t_row['info'] );
835			$t_repo->id = $t_row['id'];
836
837			$t_repos[$t_repo->id] = $t_repo;
838		}
839
840		return $t_repos;
841	}
842
843	/**
844	 * Delete a repository with the given ID.
845	 * @param int $p_id Repository ID
846	 */
847	static function delete( $p_id ) {
848		SourceChangeset::delete_by_repo( $p_id );
849
850		$t_repo_table = plugin_table( 'repository', 'Source' );
851
852		$t_query = "DELETE FROM $t_repo_table WHERE id=" . db_param();
853		db_query( $t_query, array( (int) $p_id ) );
854	}
855
856	/**
857	 * Check to see if a repository exists with the given ID.
858	 * @param int $p_id Repository ID
859	 * @return boolean True if repository exists
860	 */
861	static function exists( $p_id ) {
862		$t_repo_table = plugin_table( 'repository', 'Source' );
863
864		$t_query = "SELECT COUNT(*) FROM $t_repo_table WHERE id=" . db_param();
865		$t_result = db_query( $t_query, array( (int) $p_id ) );
866
867		return db_result( $t_result ) > 0;
868	}
869
870	static function ensure_exists( $p_id ) {
871		if ( !SourceRepo::exists( $p_id ) ) {
872			error_parameters( $p_id );
873			plugin_error( SourcePlugin::ERROR_REPO_MISSING );
874		}
875	}
876}
877
878/**
879 * Abstract source control changeset data.
880 */
881class SourceChangeset {
882	var $id;
883	var $repo_id;
884	var $user_id;
885	var $revision;
886	var $parent;
887	var $branch;
888	var $ported;
889	var $timestamp;
890	var $author;
891	var $author_email;
892	var $committer;
893	var $committer_email;
894	var $committer_id;
895	var $message;
896	var $info;
897
898	var $files; # array of SourceFile's
899	var $bugs;
900	var $__bugs;
901	var $repo;
902
903	/**
904	 * Build a new changeset object given certain properties.
905	 * @param int    $p_repo_id    Repository ID
906	 * @param string $p_revision   Changeset revision
907	 * @param string $p_branch
908	 * @param string $p_timestamp  Timestamp
909	 * @param string $p_author     Author
910	 * @param string $p_message    Commit message
911	 * @param int    $p_user_id
912	 * @param string $p_parent
913	 * @param string $p_ported
914	 * @param string $p_author_email
915	 */
916	function __construct( $p_repo_id, $p_revision, $p_branch='', $p_timestamp='',
917		$p_author='', $p_message='', $p_user_id=0, $p_parent='', $p_ported='', $p_author_email='' ) {
918
919		$this->id				= 0;
920		$this->user_id			= $p_user_id;
921		$this->repo_id			= $p_repo_id;
922		$this->revision			= $p_revision;
923		$this->parent			= $p_parent;
924		$this->branch			= $p_branch;
925		$this->ported			= $p_ported;
926		$this->timestamp		= $p_timestamp;
927		$this->author			= $p_author;
928		$this->author_email		= $p_author_email;
929		$this->message			= $p_message;
930		$this->info				= '';
931		$this->committer		= '';
932		$this->committer_email	= '';
933		$this->committer_id		= 0;
934
935		$this->files			= array();
936		$this->bugs				= array();
937		$this->__bugs			= array();
938	}
939
940	/**
941	 * Create or update changeset data.
942	 * Creates database row if $this->id is zero, updates an existing row otherwise.
943	 */
944	function save() {
945		if ( 0 == $this->repo_id ) {
946			error_parameters( $this->id );
947			plugin_error( SourcePlugin::ERROR_CHANGESET_INVALID_REPO );
948		}
949
950		$t_changeset_table = plugin_table( 'changeset', 'Source' );
951
952		if ( 0 == $this->id ) { # create
953			$t_query = "INSERT INTO $t_changeset_table ( repo_id, revision, parent, branch, user_id,
954				timestamp, author, message, info, ported, author_email, committer, committer_email, committer_id
955				) VALUES ( " .
956				db_param() . ', ' . db_param() . ', ' . db_param() . ', ' . db_param() . ', ' .
957				db_param() . ', ' . db_param() . ', ' . db_param() . ', ' . db_param() . ', ' .
958				db_param() . ', ' . db_param() . ', ' . db_param() . ', ' . db_param() . ', ' .
959				db_param() . ', ' . db_param() . ' )';
960			db_query( $t_query, array(
961				$this->repo_id, $this->revision, $this->parent, $this->branch,
962				$this->user_id, $this->timestamp, $this->author, db_mysql_fix_utf8( $this->message ), $this->info,
963				$this->ported, $this->author_email, $this->committer, $this->committer_email,
964				$this->committer_id ) );
965
966			$this->id = db_insert_id( $t_changeset_table );
967
968			foreach( $this->files as $t_file ) {
969				$t_file->change_id = $this->id;
970			}
971
972		} else { # update
973			$t_query = "UPDATE $t_changeset_table SET repo_id=" . db_param() . ', revision=' . db_param() .
974				', parent=' . db_param() . ', branch=' . db_param() . ', user_id=' . db_param() .
975				', timestamp=' . db_param() . ', author=' . db_param() . ', message=' . db_param() .
976				', info=' . db_param() . ', ported=' . db_param() . ', author_email=' . db_param() .
977				', committer=' . db_param() . ', committer_email=' . db_param() . ', committer_id=' . db_param() .
978				' WHERE id=' . db_param();
979			db_query( $t_query, array(
980				$this->repo_id, $this->revision,
981				$this->parent, $this->branch, $this->user_id,
982				$this->timestamp, $this->author, $this->message,
983				$this->info, $this->ported, $this->author_email,
984				$this->committer, $this->committer_email,
985				$this->committer_id, $this->id ) );
986		}
987
988		foreach( $this->files as $t_file ) {
989			$t_file->save();
990		}
991
992		$this->save_bugs();
993	}
994
995	/**
996	 * Update changeset relations to affected bugs.
997	 * @param int $p_user_id
998	 */
999	function save_bugs( $p_user_id=null ) {
1000		$t_bug_table = plugin_table( 'bug', 'Source' );
1001
1002		$this->bugs = array_unique( $this->bugs );
1003		$this->__bugs = array_unique( $this->__bugs );
1004
1005		$t_bugs_added = array_unique( array_diff( $this->bugs, $this->__bugs ) );
1006		$t_bugs_deleted = array_unique( array_diff( $this->__bugs, $this->bugs ) );
1007
1008		$this->load_repo();
1009		$t_vcs = SourceVCS::repo( $this->repo );
1010
1011		$t_user_id = (int)$p_user_id;
1012		if ( $t_user_id < 1 ) {
1013			if ( $this->committer_id > 0 ) {
1014				$t_user_id = $this->committer_id;
1015			} else if ( $this->user_id > 0 ) {
1016				$t_user_id = $this->user_id;
1017			}
1018		}
1019
1020		if ( count( $t_bugs_deleted ) ) {
1021			$t_bugs_deleted_str = join( ',', $t_bugs_deleted );
1022
1023			$t_query = "DELETE FROM $t_bug_table WHERE change_id=" . $this->id .
1024				" AND bug_id IN ( $t_bugs_deleted_str )";
1025			db_query( $t_query );
1026
1027			foreach( $t_bugs_deleted as $t_bug_id ) {
1028				plugin_history_log( $t_bug_id, 'changeset_removed',
1029					$this->repo->name . ' ' . $t_vcs->show_changeset( $this->repo, $this ),
1030					'', $t_user_id, 'Source' );
1031				bug_update_date( $t_bug_id );
1032			}
1033		}
1034
1035		if ( count( $t_bugs_added ) > 0 ) {
1036			$t_query = "INSERT INTO $t_bug_table ( change_id, bug_id ) VALUES ";
1037
1038			$t_count = 0;
1039			$t_params = array();
1040
1041			foreach( $t_bugs_added as $t_bug_id ) {
1042				$t_query .= ( $t_count == 0 ? '' : ', ' ) .
1043					'(' . db_param() . ', ' . db_param() . ')';
1044				$t_params[] = $this->id;
1045				$t_params[] = $t_bug_id;
1046				$t_count++;
1047			}
1048
1049			db_query( $t_query, $t_params );
1050
1051			foreach( $t_bugs_added as $t_bug_id ) {
1052				plugin_history_log( $t_bug_id, 'changeset_attached', '',
1053					$this->repo->name . ' ' . $t_vcs->show_changeset( $this->repo, $this ),
1054					$t_user_id, 'Source' );
1055				bug_update_date( $t_bug_id );
1056			}
1057		}
1058	}
1059
1060	/**
1061	 * Load/cache repo object.
1062	 */
1063	function load_repo() {
1064		if ( is_null( $this->repo ) ) {
1065			$t_repos = SourceRepo::load_by_changesets( $this );
1066			$this->repo = array_shift( $t_repos );
1067		}
1068	}
1069
1070	/**
1071	 * Load all file objects associated with this changeset.
1072	 */
1073	function load_files() {
1074		if ( count( $this->files ) < 1 ) {
1075			$this->files = SourceFile::load_by_changeset( $this->id );
1076		}
1077
1078		return $this->files;
1079	}
1080
1081	/**
1082	 * Load all bug numbers associated with this changeset.
1083	 */
1084	function load_bugs() {
1085		if ( count( $this->bugs ) < 1 ) {
1086			$t_bug_table = plugin_table( 'bug', 'Source' );
1087
1088			$t_query = "SELECT bug_id FROM $t_bug_table WHERE change_id=" . db_param();
1089			$t_result = db_query( $t_query, array( $this->id ) );
1090
1091			$this->bugs = array();
1092			$this->__bugs = array();
1093			while( $t_row = db_fetch_array( $t_result ) ) {
1094				$this->bugs[] = $t_row['bug_id'];
1095				$this->__bugs[] = $t_row['bug_id'];
1096			}
1097		}
1098
1099		return $this->bugs;
1100	}
1101
1102	/**
1103	 * Check if a repository's changeset already exists in the database.
1104	 * @param int    $p_repo_id  Repo ID
1105	 * @param string $p_revision Revision
1106	 * @param string $p_branch   Branch
1107	 * @return boolean True if changeset exists
1108	 */
1109	static function exists( $p_repo_id, $p_revision, $p_branch=null ) {
1110		$t_changeset_table = plugin_table( 'changeset', 'Source' );
1111
1112		$t_query = "SELECT * FROM $t_changeset_table WHERE repo_id=" . db_param() . '
1113				AND revision=' . db_param();
1114		$t_params = array( $p_repo_id, $p_revision );
1115
1116		if ( !is_null( $p_branch ) ) {
1117			$t_query .= ' AND branch=' . db_param();
1118			$t_params[] = $p_branch;
1119		}
1120
1121		$t_result = db_query( $t_query, $t_params );
1122		return db_num_rows( $t_result ) > 0;
1123	}
1124
1125	/**
1126	 * Fetch a new changeset object given an ID.
1127	 * @param int $p_id Changeset ID
1128	 * @return mixed Changeset object
1129	 */
1130	static function load( $p_id ) {
1131		$t_changeset_table = plugin_table( 'changeset', 'Source' );
1132
1133		$t_query = "SELECT * FROM $t_changeset_table WHERE id=" . db_param() . '
1134				ORDER BY timestamp DESC';
1135		$t_result = db_query( $t_query, array( $p_id ) );
1136
1137		if ( db_num_rows( $t_result ) < 1 ) {
1138			error_parameters( $p_id );
1139			plugin_error( SourcePlugin::ERROR_CHANGESET_MISSING_ID );
1140		}
1141
1142		$t_array = self::from_result( $t_result );
1143		return array_shift( $t_array );
1144	}
1145
1146	/**
1147	 * Fetch a changeset object given a repository and revision.
1148	 * @param object $p_repo     Repo object
1149	 * @param string $p_revision Revision
1150	 * @return mixed Changeset object
1151	 */
1152	static function load_by_revision( $p_repo, $p_revision ) {
1153		$t_changeset_table = plugin_table( 'changeset', 'Source' );
1154
1155		$t_query = "SELECT * FROM $t_changeset_table WHERE repo_id=" . db_param() . '
1156				AND revision=' . db_param() . ' ORDER BY timestamp DESC';
1157		$t_result = db_query( $t_query, array( $p_repo->id, $p_revision ) );
1158
1159		if ( db_num_rows( $t_result ) < 1 ) {
1160			error_parameters( $p_revision, $p_repo->name  );
1161			plugin_error( SourcePlugin::ERROR_CHANGESET_MISSING_REPO );
1162		}
1163
1164		$t_array = self::from_result( $t_result );
1165		return array_shift( $t_array );
1166	}
1167
1168	/**
1169	 * Fetch an array of changeset objects for a given repository ID.
1170	 * @param int $p_repo_id Repository ID
1171	 * @param bool $p_load_files
1172	 * @param null $p_page
1173	 * @param int  $p_limit
1174	 * @return array Changeset objects
1175	 */
1176	static function load_by_repo( $p_repo_id, $p_load_files=false, $p_page=null, $p_limit=25  ) {
1177		$t_changeset_table = plugin_table( 'changeset', 'Source' );
1178
1179		$t_query = "SELECT * FROM $t_changeset_table WHERE repo_id=" . db_param() . '
1180				ORDER BY timestamp DESC';
1181		if ( is_null( $p_page ) ) {
1182			$t_result = db_query( $t_query, array( $p_repo_id ) );
1183		} else {
1184			$t_result = db_query( $t_query, array( $p_repo_id ), $p_limit, ($p_page - 1) * $p_limit );
1185		}
1186
1187		return self::from_result( $t_result, $p_load_files );
1188	}
1189
1190	/**
1191	 * Fetch an array of changeset objects for a given bug ID.
1192	 * @param int  $p_bug_id      Bug ID
1193	 * @param bool $p_load_files
1194	 * @return array Changeset objects
1195	 */
1196	static function load_by_bug( $p_bug_id, $p_load_files=false ) {
1197		$t_changeset_table = plugin_table( 'changeset', 'Source' );
1198		$t_bug_table = plugin_table( 'bug', 'Source' );
1199
1200		$t_order = strtoupper( config_get( 'history_order' ) ) == 'ASC' ? 'ASC' : 'DESC';
1201		$t_query = "SELECT c.* FROM $t_changeset_table AS c
1202		   		JOIN $t_bug_table AS b ON c.id=b.change_id
1203				WHERE b.bug_id=" . db_param() . "
1204				ORDER BY c.timestamp $t_order";
1205		$t_result = db_query( $t_query, array( $p_bug_id ) );
1206
1207		return self::from_result( $t_result, $p_load_files );
1208	}
1209
1210	/**
1211	 * Return a set of changeset objects from a database result.
1212	 * Assumes selecting * from changeset_table.
1213	 * @param IteratorAggregate $p_result Database result
1214	 * @param bool              $p_load_files
1215	 * @return array Changeset objects
1216	 */
1217	static function from_result( $p_result, $p_load_files=false ) {
1218		$t_changesets = array();
1219
1220		while ( $t_row = db_fetch_array( $p_result ) ) {
1221			$t_changeset = new SourceChangeset( $t_row['repo_id'], $t_row['revision'] );
1222
1223			$t_changeset->id = $t_row['id'];
1224			$t_changeset->parent = $t_row['parent'];
1225			$t_changeset->branch = $t_row['branch'];
1226			$t_changeset->timestamp = $t_row['timestamp'];
1227			$t_changeset->user_id = $t_row['user_id'];
1228			$t_changeset->author = $t_row['author'];
1229			$t_changeset->author_email = $t_row['author_email'];
1230			$t_changeset->message = $t_row['message'];
1231			$t_changeset->info = $t_row['info'];
1232			$t_changeset->ported = $t_row['ported'];
1233			$t_changeset->committer = $t_row['committer'];
1234			$t_changeset->committer_email = $t_row['committer_email'];
1235			$t_changeset->committer_id = $t_row['committer_id'];
1236
1237			if ( $p_load_files ) {
1238				$t_changeset->load_files();
1239			}
1240
1241			$t_changesets[ $t_changeset->id ] = $t_changeset;
1242		}
1243
1244		return $t_changesets;
1245	}
1246
1247	/**
1248	 * Delete all changesets for a given repository ID.
1249	 * @param int $p_repo_id Repository ID
1250	 */
1251	static function delete_by_repo( $p_repo_id ) {
1252		$t_changeset_table = plugin_table( 'changeset', 'Source' );
1253
1254		# first drop any files for the repository's changesets
1255		SourceFile::delete_by_repo( $p_repo_id );
1256
1257		$t_query = "DELETE FROM $t_changeset_table WHERE repo_id=" . db_param();
1258		db_query( $t_query, array( $p_repo_id ) );
1259	}
1260
1261}
1262
1263/**
1264 * Abstract source control file data.
1265 */
1266class SourceFile {
1267	var $id;
1268	var $change_id;
1269	var $revision;
1270	var $action;
1271	var $filename;
1272
1273	function __construct( $p_change_id, $p_revision, $p_filename, $p_action='' ) {
1274		$this->id			= 0;
1275		$this->change_id	= $p_change_id;
1276		$this->revision		= $p_revision;
1277		$this->action		= $p_action;
1278		$this->filename		= $p_filename;
1279	}
1280
1281	function save() {
1282		if ( 0 == $this->change_id ) {
1283			error_parameters( $this->id );
1284			plugin_error( SourcePlugin::ERROR_FILE_INVALID_CHANGESET );
1285		}
1286
1287		$t_file_table = plugin_table( 'file', 'Source' );
1288
1289		if ( 0 == $this->id ) { # create
1290			$t_query = "INSERT INTO $t_file_table ( change_id, revision, action, filename ) VALUES ( " .
1291				db_param() . ', ' . db_param() . ', ' . db_param() . ', ' . db_param() . ' )';
1292			db_query( $t_query, array( $this->change_id, $this->revision, $this->action, $this->filename ) );
1293
1294			$this->id = db_insert_id( $t_file_table );
1295		} else { # update
1296			$t_query = "UPDATE $t_file_table SET change_id=" . db_param() . ', revision=' . db_param() .
1297				', action=' . db_param() . ', filename=' . db_param() . ' WHERE id=' . db_param();
1298			db_query( $t_query, array( $this->change_id, $this->revision, $this->action, $this->filename, $this->id ) );
1299		}
1300	}
1301
1302	static function load( $p_id ) {
1303		$t_file_table = plugin_table( 'file', 'Source' );
1304
1305		$t_query = "SELECT * FROM $t_file_table WHERE id=" . db_param();
1306		$t_result = db_query( $t_query, array( $p_id ) );
1307
1308		if ( db_num_rows( $t_result ) < 1 ) {
1309			error_parameters( $p_id );
1310			plugin_error( SourcePlugin::ERROR_FILE_MISSING );
1311		}
1312
1313		$t_row = db_fetch_array( $t_result );
1314		$t_file = new SourceFile( $t_row['change_id'], $t_row['revision'], $t_row['filename'], $t_row['action'] );
1315		$t_file->id = $t_row['id'];
1316
1317		return $t_file;
1318	}
1319
1320	static function load_by_changeset( $p_change_id ) {
1321		$t_file_table = plugin_table( 'file', 'Source' );
1322
1323		$t_query = "SELECT * FROM $t_file_table WHERE change_id=" . db_param();
1324		$t_result = db_query( $t_query, array( $p_change_id ) );
1325
1326		$t_files = array();
1327
1328		while ( $t_row = db_fetch_array( $t_result ) ) {
1329			$t_file = new SourceFile( $t_row['change_id'], $t_row['revision'], $t_row['filename'], $t_row['action'] );
1330			$t_file->id = $t_row['id'];
1331			$t_files[] = $t_file;
1332		}
1333
1334		return $t_files;
1335	}
1336
1337	static function delete_by_changeset( $p_change_id ) {
1338		$t_file_table = plugin_table( 'file', 'Source' );
1339
1340		$t_query = "DELETE FROM $t_file_table WHERE change_id=" . db_param();
1341		db_query( $t_query, array( $p_change_id ) );
1342	}
1343
1344	/**
1345	 * Delete all file objects from the database for a given repository.
1346	 * @param int $p_repo_id Repository ID
1347	 */
1348	static function delete_by_repo( $p_repo_id ) {
1349		$t_file_table = plugin_table( 'file', 'Source' );
1350		$t_changeset_table = plugin_table( 'changeset', 'Source' );
1351
1352		$t_query = "DELETE FROM $t_file_table WHERE change_id IN ( SELECT id FROM $t_changeset_table WHERE repo_id=" . db_param() . ')';
1353		db_query( $t_query, array( $p_repo_id ) );
1354	}
1355}
1356
1357/**
1358 * Class for handling branch version mappings on a repository.
1359 */
1360class SourceMapping {
1361	var $_new = true;
1362	var $repo_id;
1363	var $branch;
1364	var $type;
1365
1366	var $version;
1367	var $regex;
1368	var $pvm_version_id;
1369
1370	/**
1371	 * Initialize a mapping object.
1372	 * @param int    $p_repo_id
1373	 * @param string $p_branch
1374	 * @param int    $p_type
1375	 * @param string $p_version
1376	 * @param string $p_regex
1377	 * @param int    $p_pvm_version_id
1378	 */
1379	function __construct( $p_repo_id, $p_branch, $p_type, $p_version='', $p_regex='', $p_pvm_version_id=0 ) {
1380		$this->repo_id = $p_repo_id;
1381		$this->branch = $p_branch;
1382		$this->type = $p_type;
1383		$this->version = $p_version;
1384		$this->regex = $p_regex;
1385		$this->pvm_version_id = $p_pvm_version_id;
1386	}
1387
1388	/**
1389	 * Save the given mapping object to the database.
1390	 */
1391	function save() {
1392		$t_branch_table = plugin_table( 'branch' );
1393
1394		if ( $this->_new ) {
1395			$t_query = "INSERT INTO $t_branch_table ( repo_id, branch, type, version, regex, pvm_version_id ) VALUES (" .
1396				db_param() . ', ' .db_param() . ', ' .db_param() . ', ' .db_param() . ', ' .    db_param() . ', ' .    db_param() . ')';
1397			db_query( $t_query, array( $this->repo_id, $this->branch, $this->type, $this->version, $this->regex, $this->pvm_version_id ) );
1398
1399		} else {
1400			$t_query = "UPDATE $t_branch_table SET branch=" . db_param() . ', type=' . db_param() . ', version=' . db_param() .
1401				', regex=' . db_param() . ', pvm_version_id=' . db_param() . ' WHERE repo_id=' . db_param() . ' AND branch=' . db_param();
1402			db_query( $t_query, array( $this->branch, $this->type, $this->version,
1403				$this->regex, $this->pvm_version_id, $this->repo_id, $this->branch ) );
1404		}
1405	}
1406
1407	/**
1408	 * Delete a branch mapping.
1409	 */
1410	function delete() {
1411		$t_branch_table = plugin_table( 'branch' );
1412
1413		if ( !$this->_new ) {
1414			$t_query = "DELETE FROM $t_branch_table WHERE repo_id=" . db_param() . ' AND branch=' . db_param();
1415			db_query( $t_query, array( $this->repo_id, $this->branch ) );
1416
1417			$this->_new = true;
1418		}
1419	}
1420
1421	/**
1422	 * Load a group of mapping objects for a given repository.
1423	 * @param int $p_repo_id Repository object
1424	 * @return array Mapping objects
1425	 */
1426	static function load_by_repo( $p_repo_id ) {
1427		$t_branch_table = plugin_table( 'branch' );
1428
1429		$t_query = "SELECT * FROM $t_branch_table WHERE repo_id=" . db_param() . ' ORDER BY branch';
1430		$t_result = db_query( $t_query, array( $p_repo_id ) );
1431
1432		$t_mappings = array();
1433
1434		while( $t_row = db_fetch_array( $t_result ) ) {
1435			$t_mapping = new SourceMapping( $t_row['repo_id'], $t_row['branch'], $t_row['type'], $t_row['version'], $t_row['regex'], $t_row['pvm_version_id'] );
1436			$t_mapping->_new = false;
1437
1438			$t_mappings[$t_mapping->branch] = $t_mapping;
1439		}
1440
1441		return $t_mappings;
1442	}
1443
1444	/**
1445	 * Given a bug ID, apply the appropriate branch mapping algorithm
1446	 * to find and return the appropriate version ID.
1447	 * @param int $p_bug_id Bug ID
1448	 * @return int Version ID
1449	 */
1450	function apply( $p_bug_id ) {
1451		static $s_versions = array();
1452		static $s_versions_sorted = array();
1453
1454		# if it's explicit, return the version_id before doing anything else
1455		if ( $this->type == SOURCE_EXPLICIT ) {
1456			return $this->version;
1457		}
1458
1459		# cache project/version sets, and the appropriate sorting
1460		$t_project_id = bug_get_field( $p_bug_id, 'project_id' );
1461		if ( !isset( $s_versions[ $t_project_id ] ) ) {
1462			$s_versions[ $t_project_id ] = version_get_all_rows( $t_project_id, false );
1463		}
1464
1465		# handle empty version sets
1466		if ( count( $s_versions[ $t_project_id ] ) < 1 ) {
1467			return '';
1468		}
1469
1470		# cache the version set based on the current algorithm
1471		if ( !isset( $s_versions_sorted[ $t_project_id ][ $this->type ] ) ) {
1472			$s_versions_sorted[ $t_project_id ][ $this->type ] = $s_versions[ $t_project_id ];
1473
1474			switch( $this->type ) {
1475				case SOURCE_NEAR:
1476					usort( $s_versions_sorted[ $t_project_id ][ $this->type ], array( 'SourceMapping', 'cmp_near' ) );
1477					break;
1478				case SOURCE_FAR:
1479					usort( $s_versions_sorted[ $t_project_id ][ $this->type ], array( 'SourceMapping', 'cmp_far' ) );
1480					break;
1481				case SOURCE_FIRST:
1482					usort( $s_versions_sorted[ $t_project_id ][ $this->type ], array( 'SourceMapping', 'cmp_first' ) );
1483					break;
1484				case SOURCE_LAST:
1485					usort( $s_versions_sorted[ $t_project_id ][ $this->type ], array( 'SourceMapping', 'cmp_last' ) );
1486					break;
1487			}
1488		}
1489
1490		# pull the appropriate versions set from the cache
1491		$t_versions = $s_versions_sorted[ $t_project_id ][ $this->type ];
1492
1493		# handle non-regex mappings
1494		if ( is_blank( $this->regex ) ) {
1495			return $t_versions[0]['version'];
1496		}
1497
1498		# handle regex mappings
1499		foreach( $t_versions as $t_version ) {
1500			if ( preg_match( $this->regex, $t_version['version'] ) ) {
1501				return $t_version['version'];
1502			}
1503		}
1504
1505		# no version matches the regex
1506		return '';
1507	}
1508
1509	/**
1510	 * Given a bug ID, apply the appropriate branch mapping algorithm
1511	 * to find and return the appropriate product matrix version ID.
1512	 * @param int $p_bug_id Bug ID
1513	 * @return int Product version ID
1514	 */
1515	function apply_pvm( $p_bug_id ) {
1516		# if it's explicit, return the version_id before doing anything else
1517		if ( $this->type == SOURCE_EXPLICIT ) {
1518			return $this->pvm_version_id;
1519		}
1520
1521		# no version matches the regex
1522		return 0;
1523	}
1524
1525	function cmp_near( $a, $b ) {
1526		return strcmp( $a['date_order'], $b['date_order'] );
1527	}
1528	function cmp_far( $a, $b ) {
1529		return strcmp( $b['date_order'], $a['date_order'] );
1530	}
1531	function cmp_first( $a, $b ) {
1532		return version_compare( $a['version'], $b['version'] );
1533	}
1534	function cmp_last( $a, $b ) {
1535		return version_compare( $b['version'], $a['version'] );
1536	}
1537}
1538
1539/**
1540 * Object for handling VCS username associations.
1541 */
1542class SourceUser {
1543	var $new = true;
1544
1545	var $user_id;
1546	var $username;
1547
1548	function __construct( $p_user_id, $p_username='' ) {
1549		$this->user_id = $p_user_id;
1550		$this->username = $p_username;
1551	}
1552
1553	/**
1554	 * Load a user object from the database for a given user ID, or generate
1555	 * a new object if the database entry does not exist.
1556	 * @param int $p_user_id User ID
1557	 * @return object User object
1558	 */
1559	static function load( $p_user_id ) {
1560		$t_user_table = plugin_table( 'user', 'Source' );
1561
1562		$t_query = "SELECT * FROM $t_user_table WHERE user_id=" . db_param();
1563		$t_result = db_query( $t_query, array( $p_user_id ) );
1564
1565		if ( db_num_rows( $t_result ) > 0 ) {
1566			$t_row = db_fetch_array( $t_result );
1567
1568			$t_user = new SourceUser( $t_row['user_id'], $t_row['username'] );
1569			$t_user->new = false;
1570
1571		} else {
1572			$t_user = new SourceUser( $p_user_id );
1573		}
1574
1575		return $t_user;
1576	}
1577
1578	/**
1579	 * Load all user objects from the database and create an array indexed by
1580	 * username, pointing to user IDs.
1581	 * @return array Username mappings
1582	 */
1583	static function load_mappings() {
1584		$t_user_table = plugin_table( 'user', 'Source' );
1585
1586		$t_query = "SELECT * FROM $t_user_table";
1587		$t_result = db_query( $t_query );
1588
1589		$t_usernames = array();
1590		while( $t_row = db_fetch_array( $t_result ) ) {
1591			$t_usernames[ $t_row['username'] ] = $t_row['user_id'];
1592		}
1593
1594		return $t_usernames;
1595	}
1596
1597	/**
1598	 * Persist a user object to the database.  If the user object contains a blank
1599	 * username, then delete any existing data from the database to minimize storage.
1600	 */
1601	function save() {
1602		$t_user_table = plugin_table( 'user', 'Source' );
1603
1604		# handle new objects
1605		if ( $this->new ) {
1606			if ( is_blank( $this->username ) ) { # do nothing
1607				return;
1608
1609			} else { # insert new entry
1610				$t_query = "INSERT INTO $t_user_table ( user_id, username ) VALUES (" .
1611					db_param() . ', ' . db_param() . ')';
1612				db_query( $t_query, array( $this->user_id, $this->username ) );
1613
1614				$this->new = false;
1615			}
1616
1617		# handle loaded objects
1618		} else {
1619			if ( is_blank( $this->username ) ) { # delete existing entry
1620				$t_query = "DELETE FROM $t_user_table WHERE user_id=" . db_param();
1621				db_query( $t_query, array( $this->user_id ) );
1622
1623			} else { # update existing entry
1624				$t_query = "UPDATE $t_user_table SET username=" . db_param() .
1625					' WHERE user_id=' . db_param();
1626				db_query( $t_query, array( $this->username, $this->user_id ) );
1627			}
1628		}
1629	}
1630}
1631