diff --git a/resources/sql/patches/releeph.sql b/resources/sql/patches/releeph.sql new file mode 100644 index 0000000000..a824357958 --- /dev/null +++ b/resources/sql/patches/releeph.sql @@ -0,0 +1,92 @@ +CREATE TABLE {$NAMESPACE}_releeph.`releeph_project` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `dateCreated` int(10) unsigned NOT NULL, + `dateModified` int(10) unsigned NOT NULL, + `phid` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `name` varchar(255) NOT NULL, + `trunkBranch` varchar(255) NOT NULL, + `repositoryID` int(10) unsigned NOT NULL, + `repositoryPHID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `arcanistProjectID` int(10) unsigned NOT NULL, + `createdByUserPHID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `isActive` tinyint(1) NOT NULL DEFAULT '1', + `projectID` int(10) unsigned DEFAULT NULL, + `details` longtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `projectName` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE {$NAMESPACE}_releeph.`releeph_branch` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `dateCreated` int(10) unsigned NOT NULL, + `dateModified` int(10) unsigned NOT NULL, + `basename` varchar(64) NOT NULL, + `releephProjectID` int(10) unsigned NOT NULL, + `createdByUserPHID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `cutPointCommitIdentifier` + varchar(40) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `cutPointCommitPHID` + varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `isActive` tinyint(1) NOT NULL DEFAULT '1', + `symbolicName` varchar(64) DEFAULT NULL, + `details` longtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `phid` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `name` varchar(128) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `releephProjectID_2` (`releephProjectID`,`basename`), + UNIQUE KEY `releephProjectID_name` (`releephProjectID`,`name`), + UNIQUE KEY `releephProjectID` (`releephProjectID`,`symbolicName`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE {$NAMESPACE}_releeph.`releeph_request` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `dateCreated` int(10) unsigned NOT NULL, + `dateModified` int(10) unsigned NOT NULL, + `phid` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `branchID` int(10) unsigned NOT NULL, + `summary` longtext CHARACTER SET utf8 COLLATE utf8_bin, + `requestUserPHID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `requestCommitIdentifier` + varchar(40) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `requestCommitPHID` + varchar(64) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL, + `requestCommitOrdinal` int(10) unsigned NOT NULL, + `commitIdentifier` + varchar(40) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL, + `committedByUserPHID` + varchar(64) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL, + `commitPHID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL, + `status` tinyint(4) DEFAULT NULL, + `pickStatus` tinyint(4) DEFAULT NULL, + `details` longtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `userIntents` longtext CHARACTER SET utf8 COLLATE utf8_bin, + `inBranch` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `phid` (`phid`), + UNIQUE KEY `requestIdentifierBranch` (`requestCommitIdentifier`,`branchID`), + KEY `branchID` (`branchID`,`requestCommitOrdinal`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE {$NAMESPACE}_releeph.`releeph_requestevent` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `dateCreated` int(10) unsigned NOT NULL, + `dateModified` int(10) unsigned NOT NULL, + `releephRequestID` int(10) unsigned NOT NULL, + `actorPHID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL, + `details` longtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + `type` varchar(32) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE {$NAMESPACE}_releeph.`releeph_event` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `dateCreated` int(10) unsigned NOT NULL, + `dateModified` int(10) unsigned NOT NULL, + `releephProjectID` int(10) unsigned NOT NULL, + `releephBranchID` int(10) unsigned DEFAULT NULL, + `type` varchar(32) NOT NULL, + `epoch` int(10) unsigned DEFAULT NULL, + `actorPHID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL, + `details` longtext CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/src/__celerity_resource_map__.php b/src/__celerity_resource_map__.php index 3f64a695a7..43b2d86a46 100644 --- a/src/__celerity_resource_map__.php +++ b/src/__celerity_resource_map__.php @@ -1977,7 +1977,7 @@ celerity_register_resource_map(array( ), 'javelin-behavior-pholio-mock-view' => array( - 'uri' => '/res/eefc43b3/rsrc/js/application/pholio/behavior-pholio-mock-view.js', + 'uri' => '/res/ecf5f969/rsrc/js/application/pholio/behavior-pholio-mock-view.js', 'type' => 'js', 'requires' => array( @@ -2062,6 +2062,54 @@ celerity_register_resource_map(array( ), 'disk' => '/rsrc/js/application/core/behavior-refresh-csrf.js', ), + 'javelin-behavior-releeph-preview-branch' => + array( + 'uri' => '/res/a77ebc86/rsrc/js/application/releeph/releeph-preview-branch.js', + 'type' => 'js', + 'requires' => + array( + 0 => 'javelin-behavior', + 1 => 'javelin-dom', + 2 => 'javelin-stratcom', + 3 => 'javelin-uri', + 4 => 'javelin-util', + ), + 'disk' => '/rsrc/js/application/releeph/releeph-preview-branch.js', + ), + 'javelin-behavior-releeph-request-state-change' => + array( + 'uri' => '/res/38f96ba8/rsrc/js/application/releeph/releeph-request-state-change.js', + 'type' => 'js', + 'requires' => + array( + 0 => 'javelin-behavior', + 1 => 'javelin-dom', + 2 => 'javelin-stratcom', + 3 => 'javelin-util', + 4 => 'phabricator-keyboard-shortcut', + 5 => 'phabricator-notification', + ), + 'disk' => '/rsrc/js/application/releeph/releeph-request-state-change.js', + ), + 'javelin-behavior-releeph-request-typeahead' => + array( + 'uri' => '/res/b52096e2/rsrc/js/application/releeph/releeph-request-typeahead.js', + 'type' => 'js', + 'requires' => + array( + 0 => 'javelin-behavior', + 1 => 'javelin-util', + 2 => 'javelin-dom', + 3 => 'javelin-typeahead', + 4 => 'javelin-tokenizer', + 5 => 'javelin-typeahead-preloaded-source', + 6 => 'javelin-typeahead-ondemand-source', + 7 => 'javelin-dom', + 8 => 'javelin-stratcom', + 9 => 'javelin-util', + ), + 'disk' => '/rsrc/js/application/releeph/releeph-request-typeahead.js', + ), 'javelin-behavior-repository-crossreference' => array( 'uri' => '/res/4b5fab1c/rsrc/js/application/repository/repository-crossreference.js', @@ -2637,7 +2685,7 @@ celerity_register_resource_map(array( ), 'paste-css' => array( - 'uri' => '/res/5081cf13/rsrc/css/application/paste/paste.css', + 'uri' => '/res/044639be/rsrc/css/application/paste/paste.css', 'type' => 'css', 'requires' => array( @@ -3099,7 +3147,7 @@ celerity_register_resource_map(array( ), 'phabricator-source-code-view-css' => array( - 'uri' => '/res/9373e769/rsrc/css/layout/phabricator-source-code-view.css', + 'uri' => '/res/979d5280/rsrc/css/layout/phabricator-source-code-view.css', 'type' => 'css', 'requires' => array( @@ -3336,7 +3384,7 @@ celerity_register_resource_map(array( ), 'pholio-css' => array( - 'uri' => '/res/bc10bf21/rsrc/css/application/pholio/pholio.css', + 'uri' => '/res/b0947e46/rsrc/css/application/pholio/pholio.css', 'type' => 'css', 'requires' => array( @@ -3433,6 +3481,87 @@ celerity_register_resource_map(array( ), 'disk' => '/rsrc/js/raphael/g.raphael.line.js', ), + 'releeph-branch' => + array( + 'uri' => '/res/6ad6420d/rsrc/css/application/releeph/releeph-branch.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-branch.css', + ), + 'releeph-colors' => + array( + 'uri' => '/res/dff4b26a/rsrc/css/application/releeph/releeph-colors.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-colors.css', + ), + 'releeph-core' => + array( + 'uri' => '/res/853f4a73/rsrc/css/application/releeph/releeph-core.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-core.css', + ), + 'releeph-intents' => + array( + 'uri' => '/res/4e73e9dd/rsrc/css/application/releeph/releeph-intents.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-intents.css', + ), + 'releeph-preview-branch' => + array( + 'uri' => '/res/65e5dece/rsrc/css/application/releeph/releeph-preview-branch.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-preview-branch.css', + ), + 'releeph-project' => + array( + 'uri' => '/res/b9376e59/rsrc/css/application/releeph/releeph-project.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-project.css', + ), + 'releeph-request-differential-create-dialog' => + array( + 'uri' => '/res/4df30ce1/rsrc/css/application/releeph/releeph-request-differential-create-dialog.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-request-differential-create-dialog.css', + ), + 'releeph-request-typeahead-css' => + array( + 'uri' => '/res/9c9a1acf/rsrc/css/application/releeph/releeph-request-typeahead.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-request-typeahead.css', + ), + 'releeph-status' => + array( + 'uri' => '/res/588529df/rsrc/css/application/releeph/releeph-status.css', + 'type' => 'css', + 'requires' => + array( + ), + 'disk' => '/rsrc/css/application/releeph/releeph-status.css', + ), 'setup-issue-css' => array( 'uri' => '/res/efbb3673/rsrc/css/application/config/setup-issue.css', diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index a13b97b6ec..f223749053 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -187,6 +187,19 @@ phutil_register_library_map(array( 'ConduitAPI_phriction_info_Method' => 'applications/phriction/conduit/ConduitAPI_phriction_info_Method.php', 'ConduitAPI_project_Method' => 'applications/project/conduit/ConduitAPI_project_Method.php', 'ConduitAPI_project_query_Method' => 'applications/project/conduit/ConduitAPI_project_query_Method.php', + 'ConduitAPI_releeph_Method' => 'applications/releeph/conduit/ConduitAPI_releeph_Method.php', + 'ConduitAPI_releeph_getbranches_Method' => 'applications/releeph/conduit/ConduitAPI_releeph_getbranches_Method.php', + 'ConduitAPI_releeph_projectinfo_Method' => 'applications/releeph/conduit/ConduitAPI_releeph_projectinfo_Method.php', + 'ConduitAPI_releeph_request_Method' => 'applications/releeph/conduit/ConduitAPI_releeph_request_Method.php', + 'ConduitAPI_releephwork_canpush_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_canpush_Method.php', + 'ConduitAPI_releephwork_getauthorinfo_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_getauthorinfo_Method.php', + 'ConduitAPI_releephwork_getbranch_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_getbranch_Method.php', + 'ConduitAPI_releephwork_getbranchcommitmessage_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_getbranchcommitmessage_Method.php', + 'ConduitAPI_releephwork_getcommitmessage_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_getcommitmessage_Method.php', + 'ConduitAPI_releephwork_getorigcommitmessage_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_getorigcommitmessage_Method.php', + 'ConduitAPI_releephwork_nextrequest_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_nextrequest_Method.php', + 'ConduitAPI_releephwork_record_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_record_Method.php', + 'ConduitAPI_releephwork_recordpickstatus_Method' => 'applications/releeph/conduit/work/ConduitAPI_releephwork_recordpickstatus_Method.php', 'ConduitAPI_remarkup_process_Method' => 'applications/remarkup/conduit/ConduitAPI_remarkup_process_Method.php', 'ConduitAPI_repository_Method' => 'applications/repository/conduit/ConduitAPI_repository_Method.php', 'ConduitAPI_repository_create_Method' => 'applications/repository/conduit/ConduitAPI_repository_create_Method.php', @@ -333,6 +346,7 @@ phutil_register_library_map(array( 'DifferentialPathFieldSpecification' => 'applications/differential/field/specification/DifferentialPathFieldSpecification.php', 'DifferentialPeopleMenuEventListener' => 'applications/differential/events/DifferentialPeopleMenuEventListener.php', 'DifferentialPrimaryPaneView' => 'applications/differential/view/DifferentialPrimaryPaneView.php', + 'DifferentialReleephRequestFieldSpecification' => 'applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php', 'DifferentialRemarkupRule' => 'applications/differential/remarkup/DifferentialRemarkupRule.php', 'DifferentialReplyHandler' => 'applications/differential/DifferentialReplyHandler.php', 'DifferentialResultsTableView' => 'applications/differential/view/DifferentialResultsTableView.php', @@ -690,6 +704,8 @@ phutil_register_library_map(array( 'PhabricatorApplicationPhriction' => 'applications/phriction/application/PhabricatorApplicationPhriction.php', 'PhabricatorApplicationPonder' => 'applications/ponder/application/PhabricatorApplicationPonder.php', 'PhabricatorApplicationProject' => 'applications/project/application/PhabricatorApplicationProject.php', + 'PhabricatorApplicationReleeph' => 'applications/releeph/application/PhabricatorApplicationReleeph.php', + 'PhabricatorApplicationReleephConfigOptions' => 'applications/releeph/config/PhabricatorApplicationReleephConfigOptions.php', 'PhabricatorApplicationRepositories' => 'applications/repository/application/PhabricatorApplicationRepositories.php', 'PhabricatorApplicationSettings' => 'applications/settings/application/PhabricatorApplicationSettings.php', 'PhabricatorApplicationSlowvote' => 'applications/slowvote/application/PhabricatorApplicationSlowvote.php', @@ -1561,6 +1577,73 @@ phutil_register_library_map(array( 'PonderVoteEditor' => 'applications/ponder/editor/PonderVoteEditor.php', 'PonderVoteSaveController' => 'applications/ponder/controller/PonderVoteSaveController.php', 'QueryFormattingTestCase' => 'infrastructure/storage/__tests__/QueryFormattingTestCase.php', + 'ReleephActiveProjectListView' => 'applications/releeph/view/project/list/ReleephActiveProjectListView.php', + 'ReleephAuthorFieldSpecification' => 'applications/releeph/field/specification/ReleephAuthorFieldSpecification.php', + 'ReleephBranch' => 'applications/releeph/storage/ReleephBranch.php', + 'ReleephBranchAccessController' => 'applications/releeph/controller/branch/ReleephBranchAccessController.php', + 'ReleephBranchBoxView' => 'applications/releeph/view/branch/ReleephBranchBoxView.php', + 'ReleephBranchCommitFieldSpecification' => 'applications/releeph/field/specification/ReleephBranchCommitFieldSpecification.php', + 'ReleephBranchCreateController' => 'applications/releeph/controller/branch/ReleephBranchCreateController.php', + 'ReleephBranchEditController' => 'applications/releeph/controller/branch/ReleephBranchEditController.php', + 'ReleephBranchEditor' => 'applications/releeph/editor/ReleephBranchEditor.php', + 'ReleephBranchNamePreviewController' => 'applications/releeph/controller/branch/ReleephBranchNamePreviewController.php', + 'ReleephBranchPreviewView' => 'applications/releeph/view/branch/ReleephBranchPreviewView.php', + 'ReleephBranchTemplate' => 'applications/releeph/view/branch/ReleephBranchTemplate.php', + 'ReleephBranchViewController' => 'applications/releeph/controller/branch/ReleephBranchViewController.php', + 'ReleephCommitFinder' => 'applications/releeph/commitfinder/ReleephCommitFinder.php', + 'ReleephCommitFinderException' => 'applications/releeph/commitfinder/ReleephCommitFinderException.php', + 'ReleephCommitMessageFieldSpecification' => 'applications/releeph/field/specification/ReleephCommitMessageFieldSpecification.php', + 'ReleephController' => 'applications/releeph/controller/ReleephController.php', + 'ReleephDAO' => 'applications/releeph/storage/ReleephDAO.php', + 'ReleephDefaultFieldSelector' => 'applications/releeph/field/selector/ReleephDefaultFieldSelector.php', + 'ReleephDefaultUserView' => 'applications/releeph/view/user/ReleephDefaultUserView.php', + 'ReleephDiffChurnFieldSpecification' => 'applications/releeph/field/specification/ReleephDiffChurnFieldSpecification.php', + 'ReleephDiffMessageFieldSpecification' => 'applications/releeph/field/specification/ReleephDiffMessageFieldSpecification.php', + 'ReleephDiffSizeFieldSpecification' => 'applications/releeph/field/specification/ReleephDiffSizeFieldSpecification.php', + 'ReleephDifferentialRevisionDetailRenderer' => 'applications/releeph/differential/ReleephDifferentialRevisionDetailRenderer.php', + 'ReleephEvent' => 'applications/releeph/storage/event/ReleephEvent.php', + 'ReleephFieldParseException' => 'applications/releeph/field/exception/ReleephFieldParseException.php', + 'ReleephFieldSelector' => 'applications/releeph/field/selector/ReleephFieldSelector.php', + 'ReleephFieldSpecification' => 'applications/releeph/field/specification/ReleephFieldSpecification.php', + 'ReleephFieldSpecificationIncompleteException' => 'applications/releeph/field/exception/ReleephFieldSpecificationIncompleteException.php', + 'ReleephInactiveProjectListView' => 'applications/releeph/view/project/list/ReleephInactiveProjectListView.php', + 'ReleephIntentFieldSpecification' => 'applications/releeph/field/specification/ReleephIntentFieldSpecification.php', + 'ReleephLevelFieldSpecification' => 'applications/releeph/field/specification/ReleephLevelFieldSpecification.php', + 'ReleephObjectHandleLoader' => 'applications/releeph/ReleephObjectHandleLoader.php', + 'ReleephOriginalCommitFieldSpecification' => 'applications/releeph/field/specification/ReleephOriginalCommitFieldSpecification.php', + 'ReleephPHIDConstants' => 'applications/releeph/ReleephPHIDConstants.php', + 'ReleephProject' => 'applications/releeph/storage/ReleephProject.php', + 'ReleephProjectActionController' => 'applications/releeph/controller/project/ReleephProjectActionController.php', + 'ReleephProjectCreateController' => 'applications/releeph/controller/project/ReleephProjectCreateController.php', + 'ReleephProjectEditController' => 'applications/releeph/controller/project/ReleephProjectEditController.php', + 'ReleephProjectListController' => 'applications/releeph/controller/project/ReleephProjectListController.php', + 'ReleephProjectView' => 'applications/releeph/view/ReleephProjectView.php', + 'ReleephProjectViewController' => 'applications/releeph/controller/project/ReleephProjectViewController.php', + 'ReleephReasonFieldSpecification' => 'applications/releeph/field/specification/ReleephReasonFieldSpecification.php', + 'ReleephRequest' => 'applications/releeph/storage/ReleephRequest.php', + 'ReleephRequestActionController' => 'applications/releeph/controller/request/ReleephRequestActionController.php', + 'ReleephRequestCreateController' => 'applications/releeph/controller/request/ReleephRequestCreateController.php', + 'ReleephRequestDifferentialCreateController' => 'applications/releeph/controller/request/ReleephRequestDifferentialCreateController.php', + 'ReleephRequestEditController' => 'applications/releeph/controller/request/ReleephRequestEditController.php', + 'ReleephRequestEditor' => 'applications/releeph/editor/ReleephRequestEditor.php', + 'ReleephRequestEvent' => 'applications/releeph/storage/request/ReleephRequestEvent.php', + 'ReleephRequestEventListView' => 'applications/releeph/view/requestevent/ReleephRequestEventListView.php', + 'ReleephRequestException' => 'applications/releeph/storage/request/exception/ReleephRequestException.php', + 'ReleephRequestHeaderListView' => 'applications/releeph/view/request/header/ReleephRequestHeaderListView.php', + 'ReleephRequestHeaderView' => 'applications/releeph/view/request/header/ReleephRequestHeaderView.php', + 'ReleephRequestIntentsView' => 'applications/releeph/view/request/ReleephRequestIntentsView.php', + 'ReleephRequestMail' => 'applications/releeph/editor/mail/ReleephRequestMail.php', + 'ReleephRequestStatusView' => 'applications/releeph/view/request/ReleephRequestStatusView.php', + 'ReleephRequestTypeaheadControl' => 'applications/releeph/view/request/ReleephRequestTypeaheadControl.php', + 'ReleephRequestTypeaheadController' => 'applications/releeph/controller/request/ReleephRequestTypeaheadController.php', + 'ReleephRequestViewController' => 'applications/releeph/controller/request/ReleephRequestViewController.php', + 'ReleephRequestorFieldSpecification' => 'applications/releeph/field/specification/ReleephRequestorFieldSpecification.php', + 'ReleephRevisionFieldSpecification' => 'applications/releeph/field/specification/ReleephRevisionFieldSpecification.php', + 'ReleephRiskFieldSpecification' => 'applications/releeph/field/specification/ReleephRiskFieldSpecification.php', + 'ReleephSeverityFieldSpecification' => 'applications/releeph/field/specification/ReleephSeverityFieldSpecification.php', + 'ReleephStatusFieldSpecification' => 'applications/releeph/field/specification/ReleephStatusFieldSpecification.php', + 'ReleephSummaryFieldSpecification' => 'applications/releeph/field/specification/ReleephSummaryFieldSpecification.php', + 'ReleephUserView' => 'applications/releeph/view/user/ReleephUserView.php', ), 'function' => array( @@ -1758,6 +1841,19 @@ phutil_register_library_map(array( 'ConduitAPI_phriction_info_Method' => 'ConduitAPI_phriction_Method', 'ConduitAPI_project_Method' => 'ConduitAPIMethod', 'ConduitAPI_project_query_Method' => 'ConduitAPI_project_Method', + 'ConduitAPI_releeph_Method' => 'ConduitAPIMethod', + 'ConduitAPI_releeph_getbranches_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releeph_projectinfo_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releeph_request_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_canpush_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_getauthorinfo_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_getbranch_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_getbranchcommitmessage_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_getcommitmessage_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_getorigcommitmessage_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_nextrequest_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_record_Method' => 'ConduitAPI_releeph_Method', + 'ConduitAPI_releephwork_recordpickstatus_Method' => 'ConduitAPI_releeph_Method', 'ConduitAPI_remarkup_process_Method' => 'ConduitAPIMethod', 'ConduitAPI_repository_Method' => 'ConduitAPIMethod', 'ConduitAPI_repository_create_Method' => 'ConduitAPI_repository_Method', @@ -1899,6 +1995,7 @@ phutil_register_library_map(array( 'DifferentialPathFieldSpecification' => 'DifferentialFieldSpecification', 'DifferentialPeopleMenuEventListener' => 'PhutilEventListener', 'DifferentialPrimaryPaneView' => 'AphrontView', + 'DifferentialReleephRequestFieldSpecification' => 'DifferentialFieldSpecification', 'DifferentialRemarkupRule' => 'PhabricatorRemarkupRuleObject', 'DifferentialReplyHandler' => 'PhabricatorMailReplyHandler', 'DifferentialResultsTableView' => 'AphrontView', @@ -2216,6 +2313,8 @@ phutil_register_library_map(array( 'PhabricatorApplicationPhriction' => 'PhabricatorApplication', 'PhabricatorApplicationPonder' => 'PhabricatorApplication', 'PhabricatorApplicationProject' => 'PhabricatorApplication', + 'PhabricatorApplicationReleeph' => 'PhabricatorApplication', + 'PhabricatorApplicationReleephConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorApplicationRepositories' => 'PhabricatorApplication', 'PhabricatorApplicationSettings' => 'PhabricatorApplication', 'PhabricatorApplicationSlowvote' => 'PhabricatorApplication', @@ -3107,5 +3206,65 @@ phutil_register_library_map(array( 'PonderVoteEditor' => 'PhabricatorEditor', 'PonderVoteSaveController' => 'PonderController', 'QueryFormattingTestCase' => 'PhabricatorTestCase', + 'ReleephActiveProjectListView' => 'AphrontView', + 'ReleephAuthorFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephBranch' => 'ReleephDAO', + 'ReleephBranchAccessController' => 'ReleephController', + 'ReleephBranchBoxView' => 'AphrontView', + 'ReleephBranchCommitFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephBranchCreateController' => 'ReleephController', + 'ReleephBranchEditController' => 'ReleephController', + 'ReleephBranchEditor' => 'PhabricatorEditor', + 'ReleephBranchNamePreviewController' => 'PhabricatorController', + 'ReleephBranchPreviewView' => 'AphrontFormControl', + 'ReleephBranchViewController' => 'ReleephController', + 'ReleephCommitFinderException' => 'Exception', + 'ReleephCommitMessageFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephController' => 'PhabricatorController', + 'ReleephDAO' => 'PhabricatorLiskDAO', + 'ReleephDefaultFieldSelector' => 'ReleephFieldSelector', + 'ReleephDefaultUserView' => 'ReleephUserView', + 'ReleephDiffChurnFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephDiffMessageFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephDiffSizeFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephEvent' => 'ReleephDAO', + 'ReleephFieldParseException' => 'Exception', + 'ReleephFieldSpecificationIncompleteException' => 'Exception', + 'ReleephInactiveProjectListView' => 'AphrontView', + 'ReleephIntentFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephLevelFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephObjectHandleLoader' => 'ObjectHandleLoader', + 'ReleephOriginalCommitFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephProject' => 'ReleephDAO', + 'ReleephProjectActionController' => 'ReleephController', + 'ReleephProjectCreateController' => 'ReleephController', + 'ReleephProjectEditController' => 'ReleephController', + 'ReleephProjectListController' => 'PhabricatorController', + 'ReleephProjectView' => 'AphrontView', + 'ReleephProjectViewController' => 'ReleephController', + 'ReleephReasonFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephRequest' => 'ReleephDAO', + 'ReleephRequestActionController' => 'ReleephController', + 'ReleephRequestCreateController' => 'ReleephController', + 'ReleephRequestDifferentialCreateController' => 'ReleephController', + 'ReleephRequestEditController' => 'ReleephController', + 'ReleephRequestEditor' => 'PhabricatorEditor', + 'ReleephRequestEvent' => 'ReleephDAO', + 'ReleephRequestEventListView' => 'AphrontView', + 'ReleephRequestException' => 'Exception', + 'ReleephRequestHeaderListView' => 'AphrontView', + 'ReleephRequestHeaderView' => 'AphrontView', + 'ReleephRequestIntentsView' => 'AphrontView', + 'ReleephRequestStatusView' => 'AphrontView', + 'ReleephRequestTypeaheadControl' => 'AphrontFormControl', + 'ReleephRequestTypeaheadController' => 'PhabricatorTypeaheadDatasourceController', + 'ReleephRequestViewController' => 'ReleephController', + 'ReleephRequestorFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephRevisionFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephRiskFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephSeverityFieldSpecification' => 'ReleephLevelFieldSpecification', + 'ReleephStatusFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephSummaryFieldSpecification' => 'ReleephFieldSpecification', + 'ReleephUserView' => 'AphrontView', ), )); diff --git a/src/applications/releeph/ReleephObjectHandleLoader.php b/src/applications/releeph/ReleephObjectHandleLoader.php new file mode 100644 index 0000000000..553ead7a51 --- /dev/null +++ b/src/applications/releeph/ReleephObjectHandleLoader.php @@ -0,0 +1,92 @@ + $phids) { + switch ($type) { + case ReleephPHIDConstants::PHID_TYPE_RERQ: + $object = new ReleephRequest(); + + $instances = $object->loadAllWhere('phid in (%Ls)', $phids); + $instances = mpull($instances, null, 'getPHID'); + + foreach ($phids as $phid) { + $instance = $instances[$phid]; + $handle = new PhabricatorObjectHandle(); + $handle->setPHID($phid); + $handle->setType($type); + $handle->setURI('/RQ'.$instance->getID()); + + $name = 'RQ'.$instance->getID(); + $handle->setName($name); + $handle->setFullName($name.': '.$instance->getSummaryForDisplay()); + $handle->setComplete(true); + + $handles[$phid] = $handle; + } + break; + + case ReleephPHIDConstants::PHID_TYPE_REBR: + $object = new ReleephBranch(); + + $branches = $object->loadAllWhere('phid IN (%Ls)', $phids); + $branches = mpull($branches, null, 'getPHID'); + + foreach ($phids as $phid) { + $branch = $branches[$phid]; + $handle = new PhabricatorObjectHandle(); + $handle->setPHID($phid); + $handle->setType($type); + $handle->setURI($branch->getURI()); + $handle->setName($branch->getBasename()); + $handle->setFullName($branch->getName()); + $handle->setComplete(true); + $handles[$phid] = $handle; + } + break; + + case ReleephPHIDConstants::PHID_TYPE_REPR: + $object = new ReleephProject(); + + $instances = $object->loadAllWhere('phid IN (%Ls)', $phids); + $instances = mpull($instances, null, 'getPHID'); + + foreach ($phids as $phid) { + $instance = $instances[$phid]; + $handle = new PhabricatorObjectHandle(); + $handle->setPHID($phid); + $handle->setType($type); + $handle->setURI($instance->getURI()); + $handle->setName($instance->getName()); // no fullName for proejcts + $handle->setComplete(true); + $handles[$phid] = $handle; + } + break; + + default: + throw new Exception('unknown type '.$type); + } + } + + return $handles; + } + +} diff --git a/src/applications/releeph/ReleephPHIDConstants.php b/src/applications/releeph/ReleephPHIDConstants.php new file mode 100644 index 0000000000..6265da6c83 --- /dev/null +++ b/src/applications/releeph/ReleephPHIDConstants.php @@ -0,0 +1,9 @@ +[1-9]\d*)' => 'ReleephRequestViewController', + '/releeph/' => array( + '' => 'ReleephProjectListController', + 'project/' => array( + '' => 'ReleephProjectListController', + 'inactive/' => 'ReleephProjectListController', + 'create/' => 'ReleephProjectCreateController', + '(?P[1-9]\d*)/' => array( + '' => 'ReleephProjectViewController', + 'closedbranches/' => 'ReleephProjectViewController', + 'edit/' => 'ReleephProjectEditController', + 'cutbranch/' => 'ReleephBranchCreateController', + 'action/(?P.+)/' => 'ReleephProjectActionController', + ), + ), + 'branch/' => array( + 'edit/(?P[1-9]\d*)/' => + 'ReleephBranchEditController', + '(?Pclose|re-open)/(?P[1-9]\d*)/' => + 'ReleephBranchAccessController', + 'preview/' => 'ReleephBranchNamePreviewController', + + // Left in, just in case the by-name stuff fails! + '(?P[^/]+)/' => + 'ReleephBranchViewController', + ), + 'request/' => array( + '(?P[1-9]\d*)/' => 'ReleephRequestViewController', + 'create/' => 'ReleephRequestCreateController', + 'differentialcreate/' => array( + 'D(?P[1-9]\d*)' => + 'ReleephRequestDifferentialCreateController', + ), + 'edit/(?P[1-9]\d*)/' => + 'ReleephRequestEditController', + 'action/(?P.+)/(?P[1-9]\d*)/' => + 'ReleephRequestActionController', + 'typeahead/' => + 'ReleephRequestTypeaheadController', + ), + + // Branch navigation made pretty, as it's the most common: + '(?P[^/]+)/(?P[^/]+)/' => array( + '' => 'ReleephBranchViewController', + 'edit/' => 'ReleephBranchEditController', + 'request/' => 'ReleephRequestCreateController', + '(?Pclose|re-open)/' => 'ReleephBranchAccessController', + ), + ) + ); + } + +} diff --git a/src/applications/releeph/commitfinder/ReleephCommitFinder.php b/src/applications/releeph/commitfinder/ReleephCommitFinder.php new file mode 100644 index 0000000000..3212944419 --- /dev/null +++ b/src/applications/releeph/commitfinder/ReleephCommitFinder.php @@ -0,0 +1,74 @@ +releephProject = $rp; + return $this; + } + + public function fromPartial($partial_string) { + // Look for diffs + $matches = array(); + if (preg_match('/^D([1-9]\d*)$/', $partial_string, $matches)) { + $diff_id = $matches[1]; + $diff_rev = id(new DifferentialRevision())->load($diff_id); + if (!$diff_rev) { + throw new ReleephCommitFinderException( + "{$partial_string} does not refer to an existing diff."); + } + $commit_phids = $diff_rev->loadCommitPHIDs(); + + if (!$commit_phids) { + throw new ReleephCommitFinderException( + "{$partial_string} has no commits associated with it yet."); + } + + $commits = id(new PhabricatorRepositoryCommit())->loadAllWhere( + 'phid IN (%Ls) ORDER BY epoch ASC', + $commit_phids); + return head($commits); + } + + // Look for a raw commit number, or r. + $repository = $this->releephProject->loadPhabricatorRepository(); + $dr_data = null; + $matches = array(); + if (preg_match('/^r(?P[A-Z]+)(?P\w+)$/', + $partial_string, $matches)) { + $callsign = $matches['callsign']; + if ($callsign != $repository->getCallsign()) { + throw new ReleephCommitFinderException(sprintf( + "%s is in a different repository to this Releeph project (%s).", + $partial_string, + $repository->getCallsign())); + } else { + $dr_data = $matches; + } + } else { + $dr_data = array( + 'callsign' => $repository->getCallsign(), + 'commit' => $partial_string + ); + } + + try { + $dr = DiffusionRequest::newFromDictionary($dr_data); + } catch (Exception $ex) { + $message = "No commit matches {$partial_string}: ".$ex->getMessage(); + throw new ReleephCommitFinderException($message); + } + + $phabricator_repository_commit = $dr->loadCommit(); + + if (!$phabricator_repository_commit) { + throw new ReleephCommitFinderException( + "The commit {$partial_string} doesn't exist in this repository."); + } + + return $phabricator_repository_commit; + } + +} diff --git a/src/applications/releeph/commitfinder/ReleephCommitFinderException.php b/src/applications/releeph/commitfinder/ReleephCommitFinderException.php new file mode 100644 index 0000000000..8250de5927 --- /dev/null +++ b/src/applications/releeph/commitfinder/ReleephCommitFinderException.php @@ -0,0 +1,3 @@ +>'; + } + + public function defineErrorTypes() { + return array( + ); + } + + protected function execute(ConduitAPIRequest $request) { + $results = array(); + + $projects = id(new ReleephProject())->loadAllWhere('isActive = 1'); + + foreach ($projects as $project) { + $repository = $project->loadOneRelative( + id(new PhabricatorRepository()), + 'id', + 'getRepositoryID'); + + $branches = $project->loadRelatives( + id(new ReleephBranch()), + 'releephProjectID', + 'getID', + 'isActive = 1'); + + foreach ($branches as $branch) { + $full_branch_name = $branch->getName(); + + $cut_point_commit = $branch->loadOneRelative( + id(new PhabricatorRepositoryCommit()), + 'phid', + 'getCutPointCommitPHID'); + + $results[] = array( + 'project' => $project->getName(), + 'repository' => $repository->getCallsign(), + 'branch' => $branch->getBasename(), + 'fullBranchName' => $full_branch_name, + 'symbolicName' => $branch->getSymbolicName(), + 'cutPoint' => $branch->getCutPointCommitIdentifier(), + ); + } + } + + return $results; + } +} diff --git a/src/applications/releeph/conduit/ConduitAPI_releeph_projectinfo_Method.php b/src/applications/releeph/conduit/ConduitAPI_releeph_projectinfo_Method.php new file mode 100644 index 0000000000..950e629530 --- /dev/null +++ b/src/applications/releeph/conduit/ConduitAPI_releeph_projectinfo_Method.php @@ -0,0 +1,96 @@ + 'optional string', + ); + } + + public function defineReturnType() { + return 'dict'; + } + + public function defineErrorTypes() { + return array( + "ERR_UNKNOWN_ARC" => + "The given Arcanist project name doesn't exist in the ". + "installation of Phabricator you are accessing.", + ); + } + + protected function execute(ConduitAPIRequest $request) { + $arc_project_name = $request->getValue('arcProjectName'); + if ($arc_project_name) { + $arc_project = id(new PhabricatorRepositoryArcanistProject()) + ->loadOneWhere('name = %s', $arc_project_name); + if (!$arc_project) { + throw id(new ConduitException("ERR_UNKNOWN_ARC")) + ->setErrorDescription( + "Unknown Arcanist project '{$arc_project_name}': ". + "are you using the correct Conduit URI?"); + } + + $releeph_projects = id(new ReleephProject()) + ->loadAllWhere('arcanistProjectID = %d', $arc_project->getID()); + } else { + $releeph_projects = id(new ReleephProject())->loadAll(); + } + + $releeph_projects = mfilter($releeph_projects, 'getIsActive'); + + $result = array(); + foreach ($releeph_projects as $releeph_project) { + $selector = $releeph_project->getReleephFieldSelector(); + $fields = $selector->getFieldSpecifications(); + + $fields_info = array(); + foreach ($fields as $field) { + $field->setReleephProject($releeph_project); + if ($field->isEditable()) { + $key = $field->getKeyForConduit(); + $fields_info[$key] = array( + 'class' => get_class($field), + 'name' => $field->getName(), + 'key' => $key, + 'arcHelp' => $field->renderHelpForArcanist(), + ); + } + } + + $releeph_branches = mfilter( + id(new ReleephBranch()) + ->loadAllWhere('releephProjectID = %d', $releeph_project->getID()), + 'getIsActive'); + + $releeph_branches_struct = array(); + foreach ($releeph_branches as $branch) { + $releeph_branches_struct[] = array( + 'branchName' => $branch->getName(), + 'projectName' => $releeph_project->getName(), + 'projectPHID' => $releeph_project->getPHID(), + 'branchPHID' => $branch->getPHID(), + ); + } + + $result[] = array( + 'projectName' => $releeph_project->getName(), + 'projectPHID' => $releeph_project->getPHID(), + 'branches' => $releeph_branches_struct, + 'fields' => $fields_info, + ); + } + + return $result; + } + + +} diff --git a/src/applications/releeph/conduit/ConduitAPI_releeph_request_Method.php b/src/applications/releeph/conduit/ConduitAPI_releeph_request_Method.php new file mode 100644 index 0000000000..68b2a3010f --- /dev/null +++ b/src/applications/releeph/conduit/ConduitAPI_releeph_request_Method.php @@ -0,0 +1,130 @@ + 'required string', + 'things' => 'required string', + 'fields' => 'dict', + ); + } + + public function defineReturnType() { + return 'dict'; + } + + public function defineErrorTypes() { + return array( + "ERR_BRANCH" => 'Unknown Releeph branch.', + "ERR_FIELD_PARSE" => 'Unable to parse a Releeph field.', + ); + } + + protected function execute(ConduitAPIRequest $request) { + $branch_phid = $request->getValue('branchPHID'); + $releeph_branch = id(new ReleephBranch()) + ->loadOneWhere('phid = %s', $branch_phid); + + if (!$releeph_branch) { + throw id(new ConduitException("ERR_BRANCH"))->setErrorDescription( + "No ReleephBranch found with PHID {$branch_phid}!"); + } + + $releeph_project = $releeph_branch->loadReleephProject(); + + // Find the requested commit identifiers + $requested_commits = array(); + $things = $request->getValue('things'); + $finder = id(new ReleephCommitFinder()) + ->setReleephProject($releeph_project); + foreach ($things as $thing) { + try { + $requested_commits[$thing] = $finder->fromPartial($thing); + } catch (ReleephCommitFinderException $ex) { + throw id(new ConduitException('ERR_NO_MATCHES')) + ->setErrorDescription($ex->getMessage()); + } + } + + // Find any existing requests that clash on the commit id, for this branch + $existing_releeph_requests = id(new ReleephRequest())->loadAllWhere( + 'requestCommitPHID IN (%Ls) AND branchID = %d', + mpull($requested_commits, 'getPHID'), + $releeph_branch->getID()); + $existing_releeph_requests = mpull( + $existing_releeph_requests, + null, + 'getRequestCommitPHID'); + + $selector = $releeph_project->getReleephFieldSelector(); + $fields = $selector->getFieldSpecifications(); + foreach ($fields as $field) { + $field + ->setReleephProject($releeph_project) + ->setReleephBranch($releeph_branch); + } + + $results = array(); + foreach ($requested_commits as $thing => $commit) { + $phid = $commit->getPHID(); + $handles = id(new PhabricatorObjectHandleData(array($phid))) + ->setViewer($request->getUser()) + ->loadHandles(); + $name = id($handles[$phid])->getName(); + + $releeph_request = null; + + $existing_releeph_request = idx($existing_releeph_requests, $phid); + if ($existing_releeph_request) { + $releeph_request = $existing_releeph_request; + } else { + $releeph_request = new ReleephRequest(); + foreach ($fields as $field) { + if (!$field->isEditable()) { + continue; + } + $field->setReleephRequest($releeph_request); + try { + $field->setValueFromConduitAPIRequest($request); + } catch (ReleephFieldParseException $ex) { + throw id(new ConduitException('ERR_FIELD_PARSE')) + ->setErrorDescription($ex->getMessage()); + } + } + id(new ReleephRequestEditor($releeph_request)) + ->setActor($request->getUser()) + ->create($commit, $releeph_branch); + } + + $releeph_branch->populateReleephRequestHandles( + $request->getUser(), + array($releeph_request)); + $rq_handles = $releeph_request->getHandles(); + $requestor_phid = $releeph_request->getRequestUserPHID(); + $requestor = $rq_handles[$requestor_phid]->getName(); + + $url = PhabricatorEnv::getProductionURI('/RQ'.$releeph_request->getID()); + + $results[$thing] = array( + 'thing' => $thing, + 'branch' => $releeph_branch->getDisplayNameWithDetail(), + 'commitName' => $name, + 'commitID' => $commit->getCommitIdentifier(), + 'url' => $url, + 'requestID' => $releeph_request->getID(), + 'requestor' => $requestor, + 'requestTime' => $releeph_request->getDateCreated(), + 'existing' => $existing_releeph_request !== null, + ); + } + + return $results; + } + +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_canpush_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_canpush_Method.php new file mode 100644 index 0000000000..d37bf85297 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_canpush_Method.php @@ -0,0 +1,39 @@ + 'required string', + ); + } + + public function defineReturnType() { + return 'bool'; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $releeph_project = id(new ReleephProject()) + ->loadOneWhere('phid = %s', $request->getValue('projectPHID')); + + if (!$releeph_project->getPushers()) { + return true; + } else { + $user = $request->getUser(); + return $releeph_project->isPusher($user); + } + } +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getauthorinfo_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getauthorinfo_Method.php new file mode 100644 index 0000000000..62b594c781 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getauthorinfo_Method.php @@ -0,0 +1,43 @@ + 'required string', + 'vcsType' => 'required string', + ); + } + + public function defineReturnType() { + return 'nonempty string'; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $user = id(new PhabricatorUser()) + ->loadOneWhere('phid = %s', $request->getValue('userPHID')); + + $email = $user->loadPrimaryEmailAddress(); + if (is_numeric($email)) { + $email = $user->getUserName().'@fb.com'; + } + + return sprintf( + '%s <%s>', + $user->getRealName(), + $email); + } +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getbranch_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getbranch_Method.php new file mode 100644 index 0000000000..9b9f57e40d --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getbranch_Method.php @@ -0,0 +1,52 @@ + 'required string', + ); + } + + public function defineReturnType() { + return 'dict'; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $branch = id(new ReleephBranch()) + ->loadOneWhere('phid = %s', $request->getValue('branchPHID')); + + $cut_phid = $branch->getCutPointCommitPHID(); + $phids = array($cut_phid); + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $project = $branch->loadReleephProject(); + $repo = $project->loadPhabricatorRepository(); + + return array( + 'branchName' => $branch->getName(), + 'branchPHID' => $branch->getPHID(), + 'vcsType' => $repo->getVersionControlSystem(), + 'cutCommitID' => $branch->getCutPointCommitIdentifier(), + 'cutCommitName' => $handles[$cut_phid]->getName(), + 'creatorPHID' => $branch->getCreatedByUserPHID(), + 'trunk' => $project->getTrunkBranch(), + ); + } + +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getbranchcommitmessage_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getbranchcommitmessage_Method.php new file mode 100644 index 0000000000..5549591fc1 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getbranchcommitmessage_Method.php @@ -0,0 +1,94 @@ + 'required string', + ); + } + + public function defineReturnType() { + return 'nonempty string'; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $branch = id(new ReleephBranch()) + ->loadOneWhere('phid = %s', $request->getValue('branchPHID')); + + $project = $branch->loadReleephProject(); + + $creator_phid = $branch->getCreatedByUserPHID(); + $cut_phid = $branch->getCutPointCommitPHID(); + + $phids = array( + $branch->getPHID(), + $project->getPHID(), + $creator_phid, + $cut_phid, + ); + + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $h_branch = $handles[$branch->getPHID()]; + $h_project = $handles[$project->getPHID()]; + + // Not as customizable as a ReleephRequest's commit message. It doesn't + // really need to be. + $commit_message = array(); + $commit_message[] = $h_branch->getFullName(); + $commit_message[] = $h_branch->getURI(); + + $commit_message[] = "Cut Point: ".$handles[$cut_phid]->getName(); + + $cut_point_pr_commit = id(new PhabricatorRepositoryCommit()) + ->loadOneWhere('phid = %s', $cut_phid); + $cut_point_commit_date = strftime( + '%Y-%m-%d %H:%M:%S%z', + $cut_point_pr_commit->getEpoch()); + $commit_message[] = "Cut Point Date: {$cut_point_commit_date}"; + + $commit_message[] = "Created By: ".$handles[$creator_phid]->getName(); + + $project_uri = $project->getURI(); + $commit_message[] = "Project: ".$h_project->getName()." ".$project_uri; + + /** + * Required for 090-limit_new_branch_creations.sh in + * admin/scripts/git/hosting/hooks/update.d (in the E repo): + * + * http://fburl.com/2372545 + * + * The commit message must have a line saying: + * + * @new-branch: + * + */ + $repo = $project->loadPhabricatorRepository(); + switch ($repo->getVersionControlSystem()) { + case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: + $commit_message[] = sprintf( + '@new-branch: %s', + $branch->getName()); + break; + } + + return implode("\n\n", $commit_message); + } + +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getcommitmessage_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getcommitmessage_Method.php new file mode 100644 index 0000000000..3241369579 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getcommitmessage_Method.php @@ -0,0 +1,90 @@ + 'required string', + 'action' => 'required enum<"pick", "revert">', + ); + } + + public function defineReturnType() { + return 'dict'; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $releeph_request = id(new ReleephRequest()) + ->loadOneWhere('phid = %s', $request->getValue('requestPHID')); + + $action = $request->getValue('action'); + + $title = $releeph_request->getSummaryForDisplay(); + + $commit_message = array(); + + $project = $releeph_request->loadReleephProject(); + $branch = $releeph_request->loadReleephBranch(); + + $selector = $project->getReleephFieldSelector(); + $fields = $selector->getFieldSpecifications(); + $fields = $selector->sortFieldsForCommitMessage($fields); + + foreach ($fields as $field) { + $field + ->setUser($request->getUser()) + ->setReleephProject($project) + ->setReleephBranch($branch) + ->setReleephRequest($releeph_request); + + $label = null; + $value = null; + + switch ($action) { + case 'pick': + if ($field->shouldAppearOnCommitMessage()) { + $label = $field->renderLabelForCommitMessage(); + $value = $field->renderValueForCommitMessage(); + } + break; + + case 'revert': + if ($field->shouldAppearOnRevertMessage()) { + $label = $field->renderLabelForRevertMessage(); + $value = $field->renderValueForRevertMessage(); + } + break; + } + + if ($label && $value) { + if (strpos($value, "\n") !== false || + substr($value, 0, 2) === ' ') { + $commit_message[] = "{$label}:\n{$value}"; + } else { + $commit_message[] = "{$label}: {$value}"; + } + } + } + + return array( + 'title' => $title, + 'body' => implode("\n\n", $commit_message), + ); + } + +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getorigcommitmessage_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getorigcommitmessage_Method.php new file mode 100644 index 0000000000..4c6efe55e2 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_getorigcommitmessage_Method.php @@ -0,0 +1,35 @@ + 'required string', + ); + } + + public function defineReturnType() { + return 'nonempty string'; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $commit = id(new PhabricatorRepositoryCommit()) + ->loadOneWhere('phid = %s', $request->getValue('commitPHID')); + $commit_data = $commit->loadCommitData(); + $commit_message = $commit_data->getCommitMessage(); + return trim($commit_message); + } +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_nextrequest_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_nextrequest_Method.php new file mode 100644 index 0000000000..c17dbbdcb3 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_nextrequest_Method.php @@ -0,0 +1,208 @@ + 'required int', + 'seen' => 'required list', + ); + } + + public function defineReturnType() { + return ''; + } + + public function defineErrorTypes() { + return array( + 'ERR-NOT-PUSHER' => + 'You are not listed as a pusher for thie Releeph project!', + ); + } + + protected function execute(ConduitAPIRequest $request) { + $seen = $request->getValue('seen'); + + $branch = id(new ReleephBranch()) + ->loadOneWhere('phid = %s', $request->getValue('branchPHID')); + + $project = $branch->loadReleephProject(); + + $needs_pick = array(); + $needs_revert = array(); + + $releeph_requests = $branch->loadReleephRequests($request->getUser()); + + foreach ($releeph_requests as $candidate) { + $phid = $candidate->getPHID(); + if (idx($seen, $phid)) { + continue; + } + + $should = $candidate->shouldBeInBranch(); + $in = $candidate->getInBranch(); + if ($should && !$in) { + $needs_pick[] = $candidate; + } + if (!$should && $in) { + $needs_revert[] = $candidate; + } + } + + /** + * Sort both needs_pick and needs_revert in ascending commit order, as + * discovered by Phabricator (using the `id` column to perform that + * ordering). + * + * This is easy for $needs_pick as the ordinal is stored. It is hard for + * reverts, as we have to look that information up. + */ + $needs_pick = msort($needs_pick, 'getRequestCommitOrdinal'); + $needs_revert = $this->sortReverts($needs_revert); + + /** + * Do reverts first in reverse order, then the picks in original-commit + * order. + * + * This seems like the correct thing to do, but there may be a better + * algorithm for the releephwork.nextrequest Conduit call that orders + * things better. + * + * We could also button-mash our way through everything that failed (at the + * end of the run) to try failed things again. + */ + $releeph_request = null; + $action = null; + if ($needs_revert) { + $releeph_request = last($needs_revert); + $action = 'revert'; + $commit_id = $releeph_request->getCommitIdentifier(); + $commit_phid = $releeph_request->getCommitPHID(); + } elseif ($needs_pick) { + $releeph_request = head($needs_pick); + $action = 'pick'; + $commit_id = $releeph_request->getRequestCommitIdentifier(); + $commit_phid = $releeph_request->getRequestCommitPHID(); + } else { + // Return early if there's nothing to do! + return array(); + } + + // Build the response + $phids = array(); + $phids[] = $commit_phid; + + $diff_phid = null; + $diff_rev_id = null; + $diff_rev = $releeph_request->loadDifferentialRevision(); + if ($diff_rev) { + $diff_phid = $diff_rev->getPHID(); + $phids[] = $diff_phid; + $diff_rev_id = $diff_rev->getID(); + } + + $phids[] = $releeph_request->getPHID(); + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $diff_name = null; + if ($diff_rev) { + $diff_name = $handles[$diff_phid]->getName(); + } + + // Calculate the new-author information (if any) + $new_author = null; + $new_author_phid = null; + switch ($project->getDetail('commitWithAuthor')) { + case ReleephProject::COMMIT_AUTHOR_NONE: + break; + + case ReleephProject::COMMIT_AUTHOR_FROM_DIFF: + if ($diff_rev) { + $new_author_phid = $diff_rev->getAuthorPHID(); + } else { + $pr_commit = $releeph_request->loadPhabricatorRepositoryCommit(); + if ($pr_commit) { + $new_author_phid = $pr_commit->getAuthorPHID(); + } + } + break; + + case ReleephProject::COMMIT_AUTHOR_REQUESTOR: + $new_author_phid = $releeph_request->getRequestUserPHID(); + break; + } + + return array( + 'requestID' => $releeph_request->getID(), + 'requestPHID' => $releeph_request->getPHID(), + 'requestName' => $handles[$releeph_request->getPHID()]->getName(), + 'requestorPHID' => $releeph_request->getRequestUserPHID(), + 'action' => $action, + 'diffRevID' => $diff_rev_id, + 'diffName' => $diff_name, + 'commitIdentifier' => $commit_id, + 'commitPHID' => $commit_phid, + 'commitName' => $handles[$commit_phid]->getName(), + 'needsRevert' => mpull($needs_revert, 'getID'), + 'needsPick' => mpull($needs_pick, 'getID'), + 'newAuthorPHID' => $new_author_phid, + ); + } + + /** + * Sort an array of ReleephRequests, that have been picked into a branch, in + * the order in which they were picked to the branch. + */ + private function sortReverts(array $releeph_requests) { + if (!$releeph_requests) { + return array(); + } + + // ReleephRequests, keyed by + $releeph_requests = mpull($releeph_requests, null, 'getCommitIdentifier'); + + $commits = id(new PhabricatorRepositoryCommit()) + ->loadAllWhere( + 'commitIdentifier IN (%Ls)', + mpull($releeph_requests, 'getCommitIdentifier')); + + // A map of => + $surrogate = mpull($commits, 'getID', 'getCommitIdentifier'); + + $unparsed = array(); + $result = array(); + + foreach ($releeph_requests as $commit_id => $releeph_request) { + $ordinal = idx($surrogate, $commit_id); + if ($ordinal) { + $result[$ordinal] = $releeph_request; + } else { + $unparsed[] = $releeph_request; + } + } + + // Sort $result in ascending order + ksort($result); + + // Unparsed commits we'll just have to guess, based on time + $unparsed = msort($unparsed, 'getDateModified'); + + return array_merge($result, $unparsed); + } +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_record_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_record_Method.php new file mode 100644 index 0000000000..0b8bd3a1a8 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_record_Method.php @@ -0,0 +1,42 @@ +recordSuccessfulCommit()."; + } + + public function defineParamTypes() { + return array( + 'requestPHID' => 'required string', + 'action' => 'required enum<"pick", "revert">', + 'commitIdentifier' => 'required string', + ); + } + + public function defineReturnType() { + return 'void'; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $action = $request->getValue('action'); + $new_commit_id = $request->getValue('commitIdentifier'); + + $releeph_request = id(new ReleephRequest()) + ->loadOneWhere('phid = %s', $request->getValue('requestPHID')); + + id(new ReleephRequestEditor($releeph_request)) + ->setActor($request->getUser()) + ->recordSuccessfulCommit($action, $new_commit_id); + } + +} diff --git a/src/applications/releeph/conduit/work/ConduitAPI_releephwork_recordpickstatus_Method.php b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_recordpickstatus_Method.php new file mode 100644 index 0000000000..93fbcee068 --- /dev/null +++ b/src/applications/releeph/conduit/work/ConduitAPI_releephwork_recordpickstatus_Method.php @@ -0,0 +1,63 @@ +changePickStatus()."; + } + + public function defineParamTypes() { + return array( + 'requestPHID' => 'required string', + 'action' => 'required enum<"pick", "revert">', + 'ok' => 'required bool', + 'dryRun' => 'optional bool', + 'details' => 'optional dict', + ); + } + + public function defineReturnType() { + return ''; + } + + public function defineErrorTypes() { + return array(); + } + + protected function execute(ConduitAPIRequest $request) { + $action = $request->getValue('action'); + $ok = $request->getValue('ok'); + $dry_run = $request->getValue('dryRun'); + $details = $request->getValue('details', array()); + + switch ($request->getValue('action')) { + case 'pick': + $pick_status = $ok + ? ReleephRequest::PICK_OK + : ReleephRequest::PICK_FAILED; + break; + + case 'revert': + $pick_status = $ok + ? ReleephRequest::REVERT_OK + : ReleephRequest::REVERT_FAILED; + break; + + default: + throw new Exception("Unknown action {$action}!"); + } + + $releeph_request = id(new ReleephRequest()) + ->loadOneWhere('phid = %s', $request->getValue('requestPHID')); + + id(new ReleephRequestEditor($releeph_request)) + ->setActor($request->getUser()) + ->changePickStatus($pick_status, $dry_run, $details); + } + +} diff --git a/src/applications/releeph/config/PhabricatorApplicationReleephConfigOptions.php b/src/applications/releeph/config/PhabricatorApplicationReleephConfigOptions.php new file mode 100644 index 0000000000..75cc322473 --- /dev/null +++ b/src/applications/releeph/config/PhabricatorApplicationReleephConfigOptions.php @@ -0,0 +1,64 @@ +newOption('releeph.installed', 'bool', false) + ->setSummary(pht('Enable the Releeph application.')) + ->setDescription( + pht( + "Releeph, a tool for managing release branches, will eventually ". + "fit in to the Phabricator suite as a general purpose tool. ". + "However Releeph is currently unstable in multiple ways that may ". + "not migrate properly for you: the code is still in alpha stage ". + "of design, the storage format is likely to change in unexpected ". + "ways, and the workflows presented are very specific to a core ". + "set of alpha testers at Facebook. For the time being you are ". + "strongly discouraged from relying on Releeph being at all ". + "stable.")), + $this->newOption( + 'releeph.field-selector', + 'class', + 'ReleephDefaultFieldSelector') + ->setBaseClass('ReleephFieldSelector') + ->setSummary(pht('Field selector class')) + ->setDescription( + pht( + "Control which fields are available when making a new Releeph ". + "request, and which are then shown in the Releeph UI.")), + $this->newOption( + 'releeph.user-view', + 'class', + 'ReleephDefaultUserView') + ->setBaseClass('ReleephUserView') + ->setSummary(pht('Extra markup when rendering usernames')) + ->setDescription( + pht( + "A wrapper to render Phabricator users in Releeph, with custom ". + "markup. For example, Facebook extends this to render additional ". + "information about requestors, to each Releeph project's ". + "pushers.")), + $this->newOption( + 'releeph.default-branch-template', + 'string', + 'releases/%P/%p-%Y%m%d-%v') + ->setDescription( + pht( + "The default branch template for new branches in unconfigured ". + "Releeph projects. This is also configurable on a per-project ". + "basis.")), + ); + } + + +} diff --git a/src/applications/releeph/controller/ReleephController.php b/src/applications/releeph/controller/ReleephController.php new file mode 100644 index 0000000000..22a8214532 --- /dev/null +++ b/src/applications/releeph/controller/ReleephController.php @@ -0,0 +1,122 @@ +load($project_id); + if (!$project) { + throw new Exception( + "ReleephProject with id '{$project_id}' not found!"); + } + } elseif ($project_name) { + $project = id(new ReleephProject()) + ->loadOneWhere('name = %s', $project_name); + if (!$project) { + throw new Exception( + "ReleephProject with name '{$project_name}' not found!"); + } + } + + // Branch + $branch = null; + $branch_id = idx($data, 'branchID'); + $branch_name = idx($data, 'branchName'); + if ($branch_id) { + $branch = id(new ReleephBranch())->load($branch_id); + if (!$branch) { + throw new Exception("Branch with id '{$branch_id}' not found!"); + } + } elseif ($branch_name) { + if (!$project) { + throw new Exception( + "You cannot refer to a branch by name without also referring ". + "to a ReleephProject (branch names are only unique in projects)."); + } + $branch = id(new ReleephBranch())->loadOneWhere( + 'basename = %s AND releephProjectID = %d', + $branch_name, + $project->getID()); + if (!$branch) { + throw new Exception( + "ReleephBranch with basename '{$branch_name}' not found ". + "in project '{$project->getName()}'!"); + } + } + + // Request + $request = null; + $request_id = idx($data, 'requestID'); + if ($request_id) { + $request = id(new ReleephRequest())->load($request_id); + if (!$request) { + throw new Exception( + "ReleephRequest with id '{$request_id}' not found!"); + } + } + + // Fill in the gaps + if ($request && !$branch) { + $branch = $request->loadReleephBranch(); + } + + if ($branch && !$project) { + $project = $branch->loadReleephProject(); + } + + // Set! + $this->releephProject = $project; + $this->releephBranch = $branch; + $this->releephRequest = $request; + } + + protected function getReleephProject() { + if (!$this->releephProject) { + throw new Exception( + 'This controller did not load a ReleephProject from the URL $data.'); + } + return $this->releephProject; + } + + protected function getReleephBranch() { + if (!$this->releephBranch) { + throw new Exception( + 'This controller did not load a ReleephBranch from the URL $data.'); + } + return $this->releephBranch; + } + + protected function getReleephRequest() { + if (!$this->releephRequest) { + throw new Exception( + 'This controller did not load a ReleephRequest from the URL $data.'); + } + return $this->releephRequest; + } + + public function buildStandardPageResponse($view, array $data) { + $page = $this->buildStandardPageView(); + + $page->setApplicationName('Releeph'); + $page->setBaseURI('/releeph/'); + $page->setTitle(idx($data, 'title')); + $page->setGlyph("\xD3\x82"); + $page->appendChild($view); + + $response = new AphrontWebpageResponse(); + return $response->setContent($page->render()); + } + +} diff --git a/src/applications/releeph/controller/branch/ReleephBranchAccessController.php b/src/applications/releeph/controller/branch/ReleephBranchAccessController.php new file mode 100644 index 0000000000..bcaf86e964 --- /dev/null +++ b/src/applications/releeph/controller/branch/ReleephBranchAccessController.php @@ -0,0 +1,61 @@ +action = $data['action']; + parent::willProcessRequest($data); + } + + public function processRequest() { + $rph_branch = $this->getReleephBranch(); + $request = $this->getRequest(); + + $active_uri = '/releeph/project/'.$rph_branch->getReleephProjectID().'/'; + $inactive_uri = $active_uri.'inactive/'; + + switch ($this->action) { + case 'close': + $is_active = false; + $origin_uri = $active_uri; + break; + + case 're-open': + $is_active = true; + $origin_uri = $inactive_uri; + break; + + default: + throw new Exception("Unknown action '{$this->action}'!"); + break; + } + + if ($request->isDialogFormPost()) { + id(new ReleephBranchEditor()) + ->setActor($request->getUser()) + ->setReleephBranch($rph_branch) + ->changeBranchAccess($is_active ? 1 : 0); + return id(new AphrontRedirectResponse()) + ->setURI($origin_uri); + } + + $button_text = ucfirst($this->action).' Branch'; + $message = hsprintf( + '

Really %s the branch %s?

', + $this->action, + $rph_branch->getBasename()); + + + $dialog = new AphrontDialogView(); + $dialog + ->setUser($request->getUser()) + ->setTitle('Confirm') + ->appendChild($message) + ->addSubmitButton($button_text) + ->addCancelButton($origin_uri); + + return id(new AphrontDialogResponse())->setDialog($dialog); + } +} diff --git a/src/applications/releeph/controller/branch/ReleephBranchCreateController.php b/src/applications/releeph/controller/branch/ReleephBranchCreateController.php new file mode 100644 index 0000000000..6031c0388a --- /dev/null +++ b/src/applications/releeph/controller/branch/ReleephBranchCreateController.php @@ -0,0 +1,105 @@ +getReleephProject(); + + $request = $this->getRequest(); + + $cut_point = $request->getStr('cutPoint'); + $symbolic_name = $request->getStr('symbolicName'); + + if (!$cut_point) { + $repository = $releeph_project->loadPhabricatorRepository(); + switch ($repository->getVersionControlSystem()) { + case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: + break; + + case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: + $cut_point = $releeph_project->getTrunkBranch(); + break; + } + } + + $e_cut = true; + $errors = array(); + + $branch_date_control = id(new AphrontFormDateControl()) + ->setUser($request->getUser()) + ->setName('templateDate') + ->setLabel('Date') + ->setCaption('The date used for filling out the branch template.') + ->setInitialTime(AphrontFormDateControl::TIME_START_OF_DAY); + $branch_date = $branch_date_control->readValueFromRequest($request); + + if ($request->isFormPost()) { + $cut_commit = null; + if (!$cut_point) { + $e_cut = 'Required'; + $errors[] = 'You must give a branch cut point'; + } else { + try { + $finder = id(new ReleephCommitFinder()) + ->setReleephProject($releeph_project); + $cut_commit = $finder->fromPartial($cut_point); + } catch (Exception $e) { + $e_cut = 'Invalid'; + $errors[] = $e->getMessage(); + } + } + + if (!$errors) { + $branch = id(new ReleephBranchEditor()) + ->setReleephProject($releeph_project) + ->setActor($request->getUser()) + ->newBranchFromCommit( + $cut_commit, + $branch_date, + $symbolic_name); + + return id(new AphrontRedirectResponse()) + ->setURI($branch->getURI()); + } + } + + $error_view = array(); + if ($errors) { + $error_view = new AphrontErrorView(); + $error_view->setErrors($errors); + $error_view->setTitle('Form Errors'); + } + + $form = id(new AphrontFormView()) + ->setUser($request->getUser()) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Symbolic name') + ->setName('symbolicName') + ->setValue($symbolic_name) + ->setCaption('Mutable alternate name, for easy reference, '. + '(e.g. "LATEST")')) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Cut point') + ->setName('cutPoint') + ->setValue($cut_point) + ->setError($e_cut) + ->setCaption( + 'A commit ID for your repo type, or a Diffusion ID like "rE123"')) + ->appendChild($branch_date_control) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue('Cut Branch') + ->addCancelButton($releeph_project->getURI())); + + $panel = id(new AphrontPanelView()) + ->appendChild($form) + ->setHeader('Cut Branch') + ->setWidth(AphrontPanelView::WIDTH_FORM); + + return $this->buildStandardPageResponse( + array($error_view, $panel), + array('title' => 'Cut new branch')); + } +} diff --git a/src/applications/releeph/controller/branch/ReleephBranchEditController.php b/src/applications/releeph/controller/branch/ReleephBranchEditController.php new file mode 100644 index 0000000000..5501ee3e08 --- /dev/null +++ b/src/applications/releeph/controller/branch/ReleephBranchEditController.php @@ -0,0 +1,136 @@ +getRequest(); + $releeph_branch = $this->getReleephBranch(); + $branch_name = $request->getStr( + 'branchName', + $releeph_branch->getName()); + $symbolic_name = $request->getStr( + 'symbolicName', + $releeph_branch->getSymbolicName()); + + $e_existing_with_same_branch_name = false; + $errors = array(); + + if ($request->isFormPost()) { + $existing_with_same_branch_name = + id(new ReleephBranch()) + ->loadOneWhere( + 'id != %d AND releephProjectID = %d AND name = %s', + $releeph_branch->getID(), + $releeph_branch->getReleephProjectID(), + $branch_name); + + if ($existing_with_same_branch_name) { + $errors[] = sprintf( + "The branch name %s is currently taken. Please use another name. ", + $branch_name); + $e_existing_with_same_branch_name = 'Error'; + } + + if (!$errors) { + $existing_with_same_symbolic_name = + id(new ReleephBranch()) + ->loadOneWhere( + 'id != %d AND releephProjectID = %d AND symbolicName = %s', + $releeph_branch->getID(), + $releeph_branch->getReleephProjectID(), + $symbolic_name); + + $releeph_branch->openTransaction(); + $releeph_branch + ->setName($branch_name) + ->setBasename(last(explode('/', $branch_name))) + ->setSymbolicName($symbolic_name); + + if ($existing_with_same_symbolic_name) { + $existing_with_same_symbolic_name + ->setSymbolicName(null) + ->save(); + } + + $releeph_branch->save(); + $releeph_branch->saveTransaction(); + + return id(new AphrontRedirectResponse()) + ->setURI('/releeph/project/'.$releeph_branch->getReleephProjectID()); + } + } + + $phids = array(); + + $phids[] = $creator_phid = $releeph_branch->getCreatedByUserPHID(); + $phids[] = $cut_commit_phid = $releeph_branch->getCutPointCommitPHID(); + + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $form = id(new AphrontFormView()) + ->setUser($request->getUser()) + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel('Branch name') + ->setValue($branch_name)) + ->appendChild( + id(new AphrontFormMarkupControl()) + ->setLabel('Cut point') + ->setValue($handles[$cut_commit_phid]->renderLink())) + ->appendChild( + id(new AphrontFormMarkupControl()) + ->setLabel('Created by') + ->setValue($handles[$creator_phid]->renderLink())) + ->appendChild( + id(new AphrontFormTextControl) + ->setLabel('Symbolic Name') + ->setName('symbolicName') + ->setValue($symbolic_name) + ->setCaption('Mutable alternate name, for easy reference, '. + '(e.g. "LATEST")')) + ->appendChild(hsprintf( + '
' . + 'In dire situations where the branch name is wrong, ' . + 'you can edit it in the database by changing the field below. ' . + 'If you do this, it is very important that you change your ' . + 'branch\'s name in the VCS to reflect the new name in Releeph, ' . + 'otherwise a catastrophe of previously unheard-of magnitude ' . + 'will befall your project.')) + ->appendChild( + id(new AphrontFormTextControl) + ->setLabel('New branch name') + ->setName('branchName') + ->setValue($branch_name) + ->setError($e_existing_with_same_branch_name)) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->addCancelButton($releeph_branch->getURI()) + ->setValue('Save')); + + $error_view = null; + if ($errors) { + $error_view = id(new AphrontErrorView()) + ->setSeverity(AphrontErrorView::SEVERITY_ERROR) + ->setErrors($errors) + ->setTitle('Errors'); + } + + $title = hsprintf( + 'Edit branch %s', + $releeph_branch->getDisplayNameWithDetail()); + + $panel = id(new AphrontPanelView()) + ->setHeader($title) + ->appendChild($form) + ->setWidth(AphrontPanelView::WIDTH_FORM); + + return $this->buildStandardPageResponse( + array( + $error_view, + $panel, + ), + array('title' => $title)); + } +} diff --git a/src/applications/releeph/controller/branch/ReleephBranchNamePreviewController.php b/src/applications/releeph/controller/branch/ReleephBranchNamePreviewController.php new file mode 100644 index 0000000000..5606542bfc --- /dev/null +++ b/src/applications/releeph/controller/branch/ReleephBranchNamePreviewController.php @@ -0,0 +1,46 @@ +getRequest(); + + $is_symbolic = $request->getBool('isSymbolic'); + $template = $request->getStr('template'); + + if (!$is_symbolic && !$template) { + $template = ReleephBranchTemplate::getDefaultTemplate(); + } + + $arc_project_id = $request->getInt('arcProjectID'); + $fake_commit_handle = + ReleephBranchTemplate::getFakeCommitHandleFor($arc_project_id); + + list($name, $errors) = id(new ReleephBranchTemplate()) + ->setCommitHandle($fake_commit_handle) + ->setReleephProjectName($request->getStr('projectName')) + ->setSymbolic($is_symbolic) + ->interpolate($template); + + $markup = ''; + + if ($name) { + $markup = phutil_tag( + 'div', + array('class' => 'name'), + $name); + } + + if ($errors) { + $markup .= phutil_tag( + 'div', + array('class' => 'error'), + head($errors)); + } + + return id(new AphrontAjaxResponse()) + ->setContent(array('markup' => $markup)); + } + +} diff --git a/src/applications/releeph/controller/branch/ReleephBranchViewController.php b/src/applications/releeph/controller/branch/ReleephBranchViewController.php new file mode 100644 index 0000000000..f01f149874 --- /dev/null +++ b/src/applications/releeph/controller/branch/ReleephBranchViewController.php @@ -0,0 +1,94 @@ +getRequest(); + + $releeph_branch = $this->getReleephBranch(); + $releeph_project = $this->getReleephProject(); + $all_releeph_requests = $releeph_branch->loadReleephRequests( + $request->getUser()); + + $selector = $releeph_project->getReleephFieldSelector(); + $fields = $selector->arrangeFieldsForSelectForm( + $selector->getFieldSpecifications()); + + $form = id(new AphrontFormView()) + ->setMethod('GET') + ->setUser($request->getUser()); + + $filtered_releeph_requests = $all_releeph_requests; + foreach ($fields as $field) { + $all_releeph_requests_without_this_field = $all_releeph_requests; + foreach ($fields as $other_field) { + if ($other_field != $field) { + $other_field->selectReleephRequestsHook( + $request, + $all_releeph_requests_without_this_field); + + } + } + + $field->appendSelectControlsHook( + $form, + $request, + $all_releeph_requests, + $all_releeph_requests_without_this_field); + + $field->selectReleephRequestsHook( + $request, + $filtered_releeph_requests); + } + + $form->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue('Filter')); + + $list = id(new ReleephRequestHeaderListView()) + ->setOriginType('branch') + ->setUser($request->getUser()) + ->setAphrontRequest($this->getRequest()) + ->setReleephProject($releeph_project) + ->setReleephBranch($releeph_branch) + ->setReleephRequests($filtered_releeph_requests); + + $filter = id(new AphrontListFilterView()) + ->appendChild($form); + + $crumbs = $this->buildApplicationCrumbs() + ->addCrumb( + id(new PhabricatorCrumbView()) + ->setName($releeph_project->getName()) + ->setHref($releeph_project->getURI())) + ->addCrumb( + id(new PhabricatorCrumbView()) + ->setName($releeph_branch->getDisplayNameWithDetail()) + ->setHref($releeph_branch->getURI())); + + // Don't show the request button for inactive (closed) branches + if ($releeph_branch->isActive()) { + $create_uri = $releeph_branch->getURI('request/'); + $crumbs->addAction( + id(new PhabricatorMenuItemView()) + ->setHref($create_uri) + ->setName('Request Pick') + ->setIcon('create')); + } + + return $this->buildStandardPageResponse( + array( + $crumbs, + $filter, + $list + ), + array( + 'title' => + $releeph_project->getName(). + ' - '. + $releeph_branch->getDisplayName(). + ' requests' + )); + } + +} diff --git a/src/applications/releeph/controller/project/ReleephProjectActionController.php b/src/applications/releeph/controller/project/ReleephProjectActionController.php new file mode 100644 index 0000000000..4e91107ea0 --- /dev/null +++ b/src/applications/releeph/controller/project/ReleephProjectActionController.php @@ -0,0 +1,63 @@ +action = $data['action']; + } + + public function processRequest() { + $request = $this->getRequest(); + + $action = $this->action; + $rph_project = $this->getReleephProject(); + + switch ($action) { + case 'deactivate': + if ($request->isDialogFormPost()) { + $rph_project->deactivate($request->getUser())->save(); + return id(new AphrontRedirectResponse())->setURI('/releeph'); + } + + $dialog = id(new AphrontDialogView()) + ->setUser($request->getUser()) + ->setTitle('Really deactivate Releeph Project?') + ->appendChild(hsprintf( + '

Really deactivate the Releeph project %s?', + $rph_project->getName())) + ->appendChild(hsprintf( + '

It will still exist, but '. + 'will be hidden from the list of active projects.

')) + ->addSubmitButton('Deactivate Releeph Project') + ->addCancelButton($request->getRequestURI()); + + return id(new AphrontDialogResponse())->setDialog($dialog); + + case 'activate': + $rph_project->setIsActive(1)->save(); + return id(new AphrontRedirectResponse())->setURI('/releeph'); + + case 'delete': + if ($request->isDialogFormPost()) { + $rph_project->delete(); + return id(new AphrontRedirectResponse()) + ->setURI('/releeph/project/inactive'); + } + + $dialog = id(new AphrontDialogView()) + ->setUser($request->getUser()) + ->setTitle('Really delete Releeph Project?') + ->appendChild(hsprintf( + '

Really delete the "%s" Releeph project? '. + 'This cannot be undone!

', + $rph_project->getName())) + ->addSubmitButton('Delete Releeph Project') + ->addCancelButton($request->getRequestURI()); + return id(new AphrontDialogResponse())->setDialog($dialog); + + } + } +} diff --git a/src/applications/releeph/controller/project/ReleephProjectCreateController.php b/src/applications/releeph/controller/project/ReleephProjectCreateController.php new file mode 100644 index 0000000000..2dbfb72c55 --- /dev/null +++ b/src/applications/releeph/controller/project/ReleephProjectCreateController.php @@ -0,0 +1,149 @@ +getRequest(); + $name = trim($request->getStr('name')); + $trunk_branch = trim($request->getStr('trunkBranch')); + $arc_pr_id = $request->getInt('arcPrID'); + + + // Only allow arc projects with repositories. Sort and re-key by ID. + $arc_projects = id(new PhabricatorRepositoryArcanistProject())->loadAll(); + $arc_projects = mpull( + msort( + mfilter($arc_projects, 'getRepositoryID'), + 'getName'), + null, + 'getID'); + + $e_name = true; + $e_trunk_branch = true; + $errors = array(); + + if ($request->isFormPost()) { + if (!$name) { + $e_name = 'Required'; + $errors[] = + 'Your releeph project should have a simple descriptive name.'; + } + + if (!$trunk_branch) { + $e_trunk_branch = 'Required'; + $errors[] = + 'You must specify which branch you will be picking from.'; + } + + $all_names = mpull(id(new ReleephProject())->loadAll(), 'getName'); + + if (in_array($name, $all_names)) { + $errors[] = "Releeph project name {$name} is already taken"; + } + + $arc_project = $arc_projects[$arc_pr_id]; + $pr_repository = $arc_project->loadRepository(); + + if (!$errors) { + $releeph_project = id(new ReleephProject()) + ->setName($name) + ->setTrunkBranch($trunk_branch) + ->setRepositoryID($pr_repository->getID()) + ->setRepositoryPHID($pr_repository->getPHID()) + ->setArcanistProjectID($arc_project->getID()) + ->setCreatedByUserPHID($request->getUser()->getPHID()) + ->setIsActive(1) + ->save(); + + return id(new AphrontRedirectResponse())->setURI('/releeph/'); + } + } + + $error_view = null; + if ($errors) { + $error_view = new AphrontErrorView(); + $error_view->setErrors($errors); + $error_view->setTitle('Form Errors'); + } + + // Make our own optgroup select control + $arc_project_choices = array(); + $pr_repositories = mpull( + msort( + array_filter( + // Some arc-projects don't have repositories + mpull($arc_projects, 'loadRepository')), + 'getName'), + null, + 'getID'); + + foreach ($pr_repositories as $pr_repo_id => $pr_repository) { + $options = array(); + foreach ($arc_projects as $arc_project) { + if ($arc_project->getRepositoryID() == $pr_repo_id) { + $options[$arc_project->getID()] = $arc_project->getName(); + } + } + $arc_project_choices[$pr_repository->getName()] = $options; + } + + $project_name_input = id(new AphrontFormTextControl()) + ->setLabel('Name') + ->setDisableAutocomplete(true) + ->setName('name') + ->setValue($name) + ->setError($e_name) + ->setCaption('A name like "Thrift" but not "Thrift releases".'); + + $arc_project_input = id(new AphrontFormSelectControl()) + ->setLabel('Arc Project') + ->setName('arcPrID') + ->setValue($arc_pr_id) + ->setCaption(hsprintf( + "If your Arc project isn't listed, associate it with a repository %s", + phutil_tag( + 'a', + array( + 'href' => '/repository/', + 'target' => '_blank', + ), + 'here'))) + ->setOptions($arc_project_choices); + + $branch_name_preview = id(new ReleephBranchPreviewView()) + ->setLabel('Example Branch') + ->addControl('projectName', $project_name_input) + ->addControl('arcProjectID', $arc_project_input) + ->addStatic('template', '') + ->addStatic('isSymbolic', false); + + $form = id(new AphrontFormView()) + ->setUser($request->getUser()) + ->appendChild($project_name_input) + ->appendChild($arc_project_input) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Trunk') + ->setName('trunkBranch') + ->setValue($trunk_branch) + ->setError($e_trunk_branch) + ->setCaption('The development branch, '. + 'from which requests will be picked.')) + ->appendChild($branch_name_preview) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->addCancelButton('/releeph/project/') + ->setValue('Create')); + + $panel = id(new AphrontPanelView()) + ->setHeader('Create Releeph Project') + ->appendChild($form) + ->setWidth(AphrontPanelView::WIDTH_FORM); + + return $this->buildStandardPageResponse( + array($error_view, $panel), + array( + 'title' => 'Create new Releeph Project' + )); + } +} diff --git a/src/applications/releeph/controller/project/ReleephProjectEditController.php b/src/applications/releeph/controller/project/ReleephProjectEditController.php new file mode 100644 index 0000000000..1dcb128e61 --- /dev/null +++ b/src/applications/releeph/controller/project/ReleephProjectEditController.php @@ -0,0 +1,388 @@ +getRequest(); + + $e_name = true; + $e_trunk_branch = true; + $e_branch_template = false; + $errors = array(); + + $project_name = $request->getStr('name', + $this->getReleephProject()->getName()); + + $phabricator_project_id = $request->getInt('projectID', + $this->getReleephProject()->getProjectID()); + $trunk_branch = $request->getStr('trunkBranch', + $this->getReleephProject()->getTrunkBranch()); + $branch_template = $request->getStr('branchTemplate'); + if ($branch_template === null) { + $branch_template = + $this->getReleephProject()->getDetail('branchTemplate'); + } + $pick_failure_instructions = $request->getStr('pickFailureInstructions', + $this->getReleephProject()->getDetail('pick_failure_instructions')); + $commit_author = $request->getStr('commitWithAuthor', + $this->getReleephProject()->getDetail('commitWithAuthor')); + $test_paths = $request->getStr('testPaths'); + if ($test_paths !== null) { + $test_paths = array_filter(explode("\n", $test_paths)); + } else { + $test_paths = $this->getReleephProject()->getDetail('testPaths', array()); + } + + $field_selector = $request->getStr('fieldSelector', + get_class($this->getReleephProject()->getReleephFieldSelector())); + + $release_counter = $request->getInt( + 'releaseCounter', + $this->getReleephProject()->getCurrentReleaseNumber()); + + $arc_project_id = $this->getReleephProject()->getArcanistProjectID(); + + if ($request->isFormPost()) { + $pusher_phids = $request->getArr('pushers'); + + if (!$project_name) { + $e_name = 'Required'; + $errors[] = + 'Your releeph project should have a simple descriptive name'; + } + + if (!$trunk_branch) { + $e_trunk_branch = 'Required'; + $errors[] = + 'You must specify which branch you will be picking from.'; + } + + if ($release_counter && !is_int($release_counter)) { + $errors[] = "Release counter must be a positive integer!"; + } + + $other_releeph_projects = id(new ReleephProject()) + ->loadAllWhere('id <> %d', $this->getReleephProject()->getID()); + $other_releeph_project_names = mpull($other_releeph_projects, + 'getName', 'getID'); + + if (in_array($project_name, $other_releeph_project_names)) { + $errors[] = "Releeph project name {$project_name} is already taken"; + } + + foreach ($test_paths as $test_path) { + $result = @preg_match($test_path, ''); + $is_a_valid_regexp = $result !== false; + if (!$is_a_valid_regexp) { + $errors[] = 'Please provide a valid regular expression: '. + "{$test_path} is not valid"; + } + } + + $project = $this->getReleephProject() + ->setProjectID($phabricator_project_id) + ->setTrunkBranch($trunk_branch) + ->setDetail('pushers', $pusher_phids) + ->setDetail('pick_failure_instructions', $pick_failure_instructions) + ->setDetail('field_selector', $field_selector) + ->setDetail('branchTemplate', $branch_template) + ->setDetail('commitWithAuthor', $commit_author) + ->setDetail('testPaths', $test_paths); + + if ($release_counter) { + $project->setDetail('releaseCounter', $release_counter); + } + + $fake_commit_handle = + ReleephBranchTemplate::getFakeCommitHandleFor($arc_project_id); + + if ($branch_template) { + list($branch_name, $template_errors) = id(new ReleephBranchTemplate()) + ->setCommitHandle($fake_commit_handle) + ->setReleephProjectName($project_name) + ->interpolate($branch_template); + + if ($template_errors) { + $e_branch_template = 'Invalid!'; + foreach ($template_errors as $template_error) { + $errors[] = "Template error: {$template_error}"; + } + } + } + + if (!$errors) { + $project->save(); + + return id(new AphrontRedirectResponse()) + ->setURI('/releeph/project/'); + } + } + + $error_view = null; + if ($errors) { + $error_view = new AphrontErrorView(); + $error_view->setErrors($errors); + $error_view->setTitle('Form Errors'); + } + + $projects = mpull( + id(new PhabricatorProject())->loadAll(), + 'getName', + 'getID'); + + $projects[0] = '-'; // no project associated, that's ok + + $pusher_phids = $request->getArr( + 'pushers', + $this->getReleephProject()->getDetail('pushers', array())); + + $handles = id(new PhabricatorObjectHandleData($pusher_phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $pusher_tokens = array(); + foreach ($pusher_phids as $phid) { + $pusher_tokens[$phid] = $handles[$phid]->getFullName(); + } + + $basic_inset = id(new AphrontFormInsetView()) + ->setTitle('Basics') + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Name') + ->setName('name') + ->setValue($project_name) + ->setError($e_name) + ->setCaption('A name like "Thrift" but not "Thrift releases".')) + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel('Repository') + ->setValue( + $this + ->getReleephProject() + ->loadPhabricatorRepository() + ->getName())) + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel('Arc Project') + ->setValue( + $this->getReleephProject()->loadArcanistProject()->getName())) + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel('Releeph Project PHID') + ->setValue( + $this->getReleephProject()->getPHID())) + ->appendChild( + id(new AphrontFormSelectControl()) + ->setLabel('Phabricator Project') + ->setValue($phabricator_project_id) + ->setName('projectID') + ->setOptions($projects)) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Trunk') + ->setValue($trunk_branch) + ->setName('trunkBranch') + ->setError($e_trunk_branch)) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Release counter') + ->setValue($release_counter) + ->setName('releaseCounter') + ->setCaption( + "Used by the command line branch cutter's %N field")) + ->appendChild( + id(new AphrontFormTextAreaControl()) + ->setLabel('Pick Instructions') + ->setValue($pick_failure_instructions) + ->setName('pickFailureInstructions') + ->setCaption( + "Instructions for pick failures, which will be used " . + "in emails generated by failed picks")) + ->appendChild( + id(new AphrontFormTextAreaControl()) + ->setLabel('Tests paths') + ->setValue(implode("\n", $test_paths)) + ->setName('testPaths') + ->setCaption( + 'List of strings that all test files contain in their path '. + 'in this project. One string per line. '. + 'Examples: \'__tests__\', \'/javatests/\'...')); + + $pushers_inset = id(new AphrontFormInsetView()) + ->setTitle('Pushers') + ->appendChild( + 'Pushers are allowed to approve Releeph requests to be committed. '. + 'to this project\'s branches. If you leave this blank then anyone '. + 'is allowed to approve requests.') + ->appendChild( + id(new AphrontFormTokenizerControl()) + ->setLabel('Pushers') + ->setName('pushers') + ->setDatasource('/typeahead/common/users/') + ->setValue($pusher_tokens)); + + $field_selector_options = array(); + $field_selector_symbols = id(new PhutilSymbolLoader()) + ->setType('class') + ->setConcreteOnly(true) + ->setAncestorClass('ReleephFieldSelector') + ->selectAndLoadSymbols(); + foreach ($field_selector_symbols as $symbol) { + $selector_name = $symbol['name']; + $field_selector_options[$selector_name] = $selector_name; + } + + $field_selector_blurb = hsprintf( + "If you you have additional information to render about Releeph ". + "requests, or want to re-arrange the UI, implement a ". + "ReleephFieldSelector and select it here."); + + $fields_inset = id(new AphrontFormInsetView()) + ->setTitle('Fields') + ->appendChild($field_selector_blurb) + ->appendChild( + id(new AphrontFormSelectControl()) + ->setLabel('Selector') + ->setName('fieldSelector') + ->setValue($field_selector) + ->setOptions($field_selector_options)); + + $commit_author_inset = $this->buildCommitAuthorInset($commit_author); + + // Build the Template inset + $markup_engine = PhabricatorMarkupEngine::newDifferentialMarkupEngine(); + + // From DifferentialUnitFieldSpecification... + $markup_engine->setConfig('viewer', $request->getUser()); + + $help_markup = phutil_tag( + 'div', + array( + 'class' => 'phabricator-remarkup', + ), + phutil_safe_html( + $markup_engine->markupText(ReleephBranchTemplate::getHelpRemarkup()))); + + $branch_template_input = id(new AphrontFormTextControl()) + ->setName('branchTemplate') + ->setValue($branch_template) + ->setLabel('Template') + ->setError($e_branch_template) + ->setCaption( + "Leave this blank to use your installation's default."); + + $branch_template_preview = id(new ReleephBranchPreviewView()) + ->setLabel('Preview') + ->addControl('template', $branch_template_input) + ->addStatic('arcProjectID', $arc_project_id) + ->addStatic('isSymbolic', false) + ->addStatic('projectName', $this->getReleephProject()->getName()); + + $template_inset = id(new AphrontFormInsetView()) + ->setTitle('Branch Cutting') + ->appendChild( + 'Provide a pattern for creating new branches.') + ->appendChild($branch_template_input) + ->appendChild($branch_template_preview) + ->appendChild($help_markup); + + // Build the form + $form = id(new AphrontFormView()) + ->setUser($request->getUser()) + ->appendChild($basic_inset) + ->appendChild($pushers_inset) + ->appendChild($fields_inset) + ->appendChild($commit_author_inset) + ->appendChild($template_inset); + + $form + ->appendChild( + id(new AphrontFormSubmitControl()) + ->addCancelButton('/releeph/project/') + ->setValue('Save')); + + $panel = id(new AphrontPanelView()) + ->setHeader('Edit Releeph Project') + ->appendChild($form) + ->setWidth(AphrontPanelView::WIDTH_FORM); + + return $this->buildStandardPageResponse( + array($error_view, $panel), + array('title' => 'Edit Releeph Project')); + } + + private function buildCommitAuthorInset($current) { + $vcs_type = $this->getReleephProject() + ->loadPhabricatorRepository() + ->getVersionControlSystem(); + + switch ($vcs_type) { + case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: + case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: + break; + + case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: + return; + break; + } + + $vcs_name = PhabricatorRepositoryType::getNameForRepositoryType($vcs_type); + + $help_markup = hsprintf(<<arc releeph, they will be +listed as the committer of the code committed to release +branches. + +%s allows you to specify a separate author when committing code. Some +tools use the author of a commit (rather than the committer) when they need to +notify someone about a build or test failure. + +Releeph can use one of the following to set the author of the +commits it makes: +EOTEXT + , $vcs_name); + + $trunk = $this->getReleephProject()->getTrunkBranch(); + + $options = array( + array( + 'value' => ReleephProject::COMMIT_AUTHOR_FROM_DIFF, + 'label' => 'Original Author', + 'caption' => + "The author of the original commit in {$trunk}.", + ), + array( + 'value' => ReleephProject::COMMIT_AUTHOR_REQUESTOR, + 'label' => 'Requestor', + 'caption' => + "The person who requested that this code go into the release.", + ), + array( + 'value' => ReleephProject::COMMIT_AUTHOR_NONE, + 'label' => "None", + 'caption' => + "Only record the default committer information.", + ), + ); + + if (!$current) { + $current = ReleephProject::COMMIT_AUTHOR_FROM_DIFF; + } + + $control = id(new AphrontFormRadioButtonControl()) + ->setLabel('Author') + ->setName('commitWithAuthor') + ->setValue($current); + + foreach ($options as $dict) { + $control->addButton($dict['value'], $dict['label'], $dict['caption']); + } + + return id(new AphrontFormInsetView()) + ->setTitle('Authors') + ->appendChild($help_markup) + ->appendChild($control); + } + +} diff --git a/src/applications/releeph/controller/project/ReleephProjectListController.php b/src/applications/releeph/controller/project/ReleephProjectListController.php new file mode 100644 index 0000000000..e00709d086 --- /dev/null +++ b/src/applications/releeph/controller/project/ReleephProjectListController.php @@ -0,0 +1,70 @@ +getRequest()->getRequestURI()->getPath(); + $is_active = strpos($path, 'inactive/') === false; + + $releeph_projects = mfilter( + id(new ReleephProject())->loadAll(), + 'getIsActive', + !$is_active); + $releeph_projects = msort($releeph_projects, 'getName'); + + $releeph_projects_set = new LiskDAOSet(); + foreach ($releeph_projects as $releeph_project) { + $releeph_projects_set->addToSet($releeph_project); + } + + $panel = new AphrontPanelView(); + + if ($is_active) { + $view_inactive_link = phutil_tag( + 'a', + array( + 'href' => '/releeph/project/inactive/', + ), + 'View inactive projects'); + $panel + ->setHeader(hsprintf( + 'Active Releeph Projects · %s', $view_inactive_link)) + ->appendChild( + id(new ReleephActiveProjectListView()) + ->setUser($this->getRequest()->getUser()) + ->setReleephProjects($releeph_projects)); + } else { + $view_active_link = phutil_tag( + 'a', + array( + 'href' => '/releeph/project/' + ), + 'View active projects'); + $panel + ->setHeader(hsprintf( + 'Inactive Releeph Projects · %s', $view_active_link)) + ->appendChild( + id(new ReleephInactiveProjectListView()) + ->setUser($this->getRequest()->getUser()) + ->setReleephProjects($releeph_projects)); + } + + if ($is_active) { + $create_new_project_button = phutil_tag( + 'a', + array( + 'href' => '/releeph/project/create/', + 'class' => 'green button', + ), + 'Create New Project'); + $panel->addButton($create_new_project_button); + } + + return $this->buildStandardPageResponse( + $panel, + array( + 'title' => 'List Releeph Projects' + )); + } + +} diff --git a/src/applications/releeph/controller/project/ReleephProjectViewController.php b/src/applications/releeph/controller/project/ReleephProjectViewController.php new file mode 100644 index 0000000000..2706822468 --- /dev/null +++ b/src/applications/releeph/controller/project/ReleephProjectViewController.php @@ -0,0 +1,45 @@ +getReleephProject(); + $releeph_branches = id(new ReleephBranch()) + ->loadAllWhere('releephProjectID = %d', + $releeph_project->getID()); + + $path = $this->getRequest()->getRequestURI()->getPath(); + $is_open_branches = strpos($path, 'closedbranches/') === false; + + $view = id(new ReleephProjectView()) + ->setShowOpenBranches($is_open_branches) + ->setUser($this->getRequest()->getUser()) + ->setReleephProject($releeph_project) + ->setBranches($releeph_branches); + + $crumbs = $this->buildApplicationCrumbs() + ->addCrumb( + id(new PhabricatorCrumbView()) + ->setName($releeph_project->getName()) + ->setHref($releeph_project->getURI())); + + if ($releeph_project->getIsActive()) { + $crumbs->addAction( + id(new PhabricatorMenuItemView()) + ->setHref($releeph_project->getURI('cutbranch')) + ->setName('Cut New Branch') + ->setIcon('create')); + } + + return $this->buildStandardPageResponse( + array( + $crumbs, + $view, + ), + array( + 'title' => $releeph_project->getName().' Releeph Project' + )); + } + +} diff --git a/src/applications/releeph/controller/request/ReleephRequestActionController.php b/src/applications/releeph/controller/request/ReleephRequestActionController.php new file mode 100644 index 0000000000..f7229cda2e --- /dev/null +++ b/src/applications/releeph/controller/request/ReleephRequestActionController.php @@ -0,0 +1,71 @@ +action = $data['action']; + } + + public function processRequest() { + $request = $this->getRequest(); + + $releeph_branch = $this->getReleephBranch(); + $releeph_request = $this->getReleephRequest(); + + $releeph_branch->populateReleephRequestHandles( + $request->getUser(), array($releeph_request)); + + $action = $this->action; + + $user = $request->getUser(); + + $origin_uri = $releeph_request->loadReleephBranch()->getURI(); + + $editor = id(new ReleephRequestEditor($releeph_request)) + ->setActor($user); + + switch ($action) { + case 'want': + case 'pass': + static $action_map = array( + 'want' => ReleephRequest::INTENT_WANT, + 'pass' => ReleephRequest::INTENT_PASS); + $intent = $action_map[$action]; + $editor->changeUserIntent($user, $intent); + break; + + case 'mark-manually-picked': + $editor->markManuallyActioned('pick'); + break; + + case 'mark-manually-reverted': + $editor->markManuallyActioned('revert'); + break; + + default: + throw new Exception("unknown or unimplemented action {$action}"); + } + + // If we're adding a new user to userIntents, we'll have to re-populate + // request handles to load that user's data. + // + // This is cheap enough to do every time. + $this->getReleephBranch()->populateReleephRequestHandles( + $user, array($releeph_request)); + + $list = id(new ReleephRequestHeaderListView()) + ->setReleephProject($this->getReleephProject()) + ->setReleephBranch($this->getReleephBranch()) + ->setReleephRequests(array($releeph_request)) + ->setUser($request->getUser()) + ->setAphrontRequest($this->getRequest()) + ->setOriginType('request'); + + return id(new AphrontAjaxResponse())->setContent(array( + 'markup' => head($list->renderInner()) + )); + } +} diff --git a/src/applications/releeph/controller/request/ReleephRequestCreateController.php b/src/applications/releeph/controller/request/ReleephRequestCreateController.php new file mode 100644 index 0000000000..21415fc54a --- /dev/null +++ b/src/applications/releeph/controller/request/ReleephRequestCreateController.php @@ -0,0 +1,165 @@ +getRequest(); + + // We arrived via /releeph/request/create/?branchID=$id + $releeph_branch_id = $request->getInt('branchID'); + if ($releeph_branch_id) { + $releeph_branch = id(new ReleephBranch())->load($releeph_branch_id); + } else { + // We arrived via /releeph/$project/$branch/request. + // + // If this throws an Exception, then somethind weird happened. + $releeph_branch = $this->getReleephBranch(); + } + + $releeph_project = $releeph_branch->loadReleephProject(); + $repo = $releeph_project->loadPhabricatorRepository(); + + $request_identifier = $request->getStr('requestIdentifierRaw'); + $e_request_identifier = true; + + $releeph_request = new ReleephRequest(); + + $errors = array(); + + $selector = $releeph_project->getReleephFieldSelector(); + $fields = $selector->getFieldSpecifications(); + foreach ($fields as $field) { + $field + ->setReleephProject($releeph_project) + ->setReleephBranch($releeph_branch) + ->setReleephRequest($releeph_request); + } + + if ($request->isFormPost()) { + foreach ($fields as $field) { + if ($field->isEditable()) { + try { + $field->setValueFromAphrontRequest($request); + } catch (ReleephFieldParseException $ex) { + $errors[] = $ex->getMessage(); + } + } + } + + $pr_commit = null; + $finder = id(new ReleephCommitFinder()) + ->setReleephProject($releeph_project); + try { + $pr_commit = $finder->fromPartial($request_identifier); + } catch (Exception $e) { + $e_request_identifier = 'Invalid'; + $errors[] = + "Request {$request_identifier} is probably not a valid commit"; + $errors[] = $e->getMessage(); + } + + $pr_commit_data = null; + if (!$errors) { + $pr_commit_data = $pr_commit->loadCommitData(); + if (!$pr_commit_data) { + $e_request_identifier = 'Not parsed yet'; + $errors[] = "The requested commit hasn't been parsed yet."; + } + } + + if (!$errors) { + $existing = id(new ReleephRequest()) + ->loadOneWhere('requestCommitPHID = %s AND branchID = %d', + $pr_commit->getPHID(), $releeph_branch->getID()); + + if ($existing) { + return id(new AphrontRedirectResponse()) + ->setURI('/releeph/request/edit/'.$existing->getID(). + '?existing=1'); + } + + id(new ReleephRequestEditor($releeph_request)) + ->setActor($request->getUser()) + ->create($pr_commit, $releeph_branch); + + return id(new AphrontRedirectResponse()) + ->setURI($releeph_branch->getURI()); + } + } + + $error_view = null; + if ($errors) { + $error_view = new AphrontErrorView(); + $error_view->setErrors($errors); + $error_view->setTitle('Form Errors'); + } + + // For the typeahead + $branch_cut_point = id(new PhabricatorRepositoryCommit()) + ->loadOneWhere( + 'phid = %s', + $releeph_branch->getCutPointCommitPHID()); + + // Build the form + $form = id(new AphrontFormView()) + ->setUser($request->getUser()); + + $origin = null; + $diff_rev_id = $request->getStr('D'); + if ($diff_rev_id) { + $diff_rev = id(new DifferentialRevision())->load($diff_rev_id); + $origin = '/D'.$diff_rev->getID(); + $title = sprintf( + 'D%d: %s', + $diff_rev_id, + $diff_rev->getTitle()); + $form + ->addHiddenInput('requestIdentifierRaw', 'D'.$diff_rev_id) + ->appendChild( + id(new AphrontFormStaticControl()) + ->setLabel('Diff') + ->setValue($title)); + } else { + $origin = $releeph_branch->getURI(); + $form->appendChild( + id(new ReleephRequestTypeaheadControl()) + ->setName('requestIdentifierRaw') + ->setLabel('Commit ID') + ->setRepo($repo) + ->setValue($request_identifier) + ->setError($e_request_identifier) + ->setStartTime($branch_cut_point->getEpoch()) + ->setCaption( + 'Start typing to autocomplete on commit title, '. + 'or give a Phabricator commit identifier like rFOO1234')); + } + + // Fields + foreach ($fields as $field) { + if ($field->isEditable()) { + $control = $field->renderEditControl($request); + $form->appendChild($control); + } + } + + $form + ->appendChild( + id(new AphrontFormSubmitControl()) + ->addCancelButton($origin) + ->setValue('Request')); + + $panel = id(new AphrontPanelView()) + ->setHeader( + 'Request for '. + $releeph_branch->getDisplayNameWithDetail()) + ->setWidth(AphrontPanelView::WIDTH_FORM) + ->appendChild($form); + + return $this->buildStandardPageResponse( + array($error_view, $panel), + array('title' => 'Request pick')); + } + +} diff --git a/src/applications/releeph/controller/request/ReleephRequestDifferentialCreateController.php b/src/applications/releeph/controller/request/ReleephRequestDifferentialCreateController.php new file mode 100644 index 0000000000..9da50fa846 --- /dev/null +++ b/src/applications/releeph/controller/request/ReleephRequestDifferentialCreateController.php @@ -0,0 +1,99 @@ +load($diff_rev_id); + if (!$diff_rev) { + throw new Exception(sprintf('D%d not found!', $diff_rev_id)); + } + $this->revision = $diff_rev; + } + + public function processRequest() { + $request = $this->getRequest(); + $user = $request->getUser(); + + $arc_project = id(new PhabricatorRepositoryArcanistProject()) + ->loadOneWhere('phid = %s', $this->revision->getArcanistProjectPHID()); + + $projects = id(new ReleephProject())->loadAllWhere( + 'arcanistProjectID = %d AND isActive = 1', + $arc_project->getID()); + if (!$projects) { + throw new ReleephRequestException(sprintf( + "D%d belongs to the '%s' Arcanist project, ". + "which is not part of any Releeph project!", + $this->revision->getID(), + $arc_project->getName())); + } + + $branches = id(new ReleephBranch())->loadAllWhere( + 'releephProjectID IN (%Ld) AND isActive = 1', + mpull($projects, 'getID')); + if (!$branches) { + throw new ReleephRequestException(sprintf( + "D%d could be in the Releeph project(s) %s, ". + "but this project / none of these projects have open branches.", + $this->revision->getID(), + implode(', ', mpull($projects, 'getName')))); + } + + if (count($branches) === 1) { + return id(new AphrontRedirectResponse()) + ->setURI($this->buildReleephRequestURI(head($branches))); + } + + $projects = msort( + mpull($projects, null, 'getID'), + 'getName'); + + $branch_groups = mgroup($branches, 'getReleephProjectID'); + + require_celerity_resource('releeph-request-differential-create-dialog'); + $dialog = id(new AphrontDialogView()) + ->setUser($user) + ->setTitle('Choose Releeph Branch') + ->setClass('releeph-request-differential-create-dialog') + ->addCancelButton('/D'.$request->getStr('D')); + + $dialog->appendChild( + "This differential revision changes code that is associated ". + "with multiple Releeph branches. ". + "Please select the branch where you would like this code to be picked."); + + foreach ($branch_groups as $project_id => $branches) { + $project = idx($projects, $project_id); + $dialog->appendChild( + phutil_tag( + 'h1', + array(), + $project->getName())); + $branches = msort($branches, 'getBasename'); + foreach ($branches as $branch) { + $uri = $this->buildReleephRequestURI($branch); + $dialog->appendChild( + phutil_tag( + 'a', + array( + 'href' => $uri, + ), + $branch->getDisplayNameWithDetail())); + } + } + + return id(new AphrontDialogResponse) + ->setDialog($dialog); + } + + private function buildReleephRequestURI(ReleephBranch $branch) { + return id(new PhutilURI('/releeph/request/create/')) + ->setQueryParam('branchID', $branch->getID()) + ->setQueryParam('D', $this->revision->getID()); + } + +} diff --git a/src/applications/releeph/controller/request/ReleephRequestEditController.php b/src/applications/releeph/controller/request/ReleephRequestEditController.php new file mode 100644 index 0000000000..eefe3bf601 --- /dev/null +++ b/src/applications/releeph/controller/request/ReleephRequestEditController.php @@ -0,0 +1,132 @@ +getRequest(); + + $releeph_branch = $this->getReleephBranch(); + $releeph_request = $this->getReleephRequest(); + + $releeph_branch->populateReleephRequestHandles( + $request->getUser(), array($releeph_request)); + + $phids = array(); + $phids[] = $releeph_request->getRequestCommitPHID(); + $phids[] = $releeph_request->getRequestUserPHID(); + $phids[] = $releeph_request->getCommittedByUserPHID(); + + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $age_string = phabricator_format_relative_time( + time() - $releeph_request->getDateCreated()); + + // Warn the user if we see this + $notice_view = null; + if ($request->getInt('existing')) { + $notice_messages = array( + 'You are editing an existing pick request!', + hsprintf( + "Requested %s ago by %s", + $age_string, + $handles[$releeph_request->getRequestUserPHID()]->renderLink()) + ); + $notice_view = id(new AphrontErrorView()) + ->setSeverity(AphrontErrorView::SEVERITY_NOTICE) + ->setErrors($notice_messages); + } + + // epriestley: Is it common to pass around a referer URL to + // return from whence one came? [...] + // If you only have two places, maybe consider some parameter + // rather than the full URL. + switch ($request->getStr('origin')) { + case 'request': + $origin_uri = '/RQ'.$releeph_request->getID(); + break; + + case 'branch': + default: + $origin_uri = $releeph_request->loadReleephBranch()->getURI(); + break; + } + + $errors = array(); + + $selector = $this->getReleephProject()->getReleephFieldSelector(); + $fields = $selector->getFieldSpecifications(); + foreach ($fields as $field) { + $field + ->setReleephProject($this->getReleephProject()) + ->setReleephBranch($this->getReleephBranch()) + ->setReleephRequest($this->getReleephRequest()); + } + + if ($request->isFormPost()) { + foreach ($fields as $field) { + if ($field->isEditable()) { + try { + $field->setValueFromAphrontRequest($request); + } catch (ReleephFieldParseException $ex) { + $errors[] = $ex->getMessage(); + } + } + } + + if (!$errors) { + $releeph_request->save(); + return id(new AphrontRedirectResponse())->setURI($origin_uri); + } + } + + /** + * Build the rest of the page + */ + $error_view = null; + if ($errors) { + $error_view = new AphrontErrorView(); + $error_view->setErrors($errors); + $error_view->setTitle('Form Errors'); + } + + $form = id(new AphrontFormView()) + ->setUser($request->getUser()) + ->appendChild( + id(new AphrontFormMarkupControl()) + ->setLabel('Original Commit') + ->setValue( + $handles[$releeph_request->getRequestCommitPHID()]->renderLink())) + ->appendChild( + id(new AphrontFormMarkupControl()) + ->setLabel('Requestor') + ->setValue(hsprintf( + '%s %s ago', + $handles[$releeph_request->getRequestUserPHID()]->renderLink(), + $age_string))); + + // Fields + foreach ($fields as $field) { + if ($field->isEditable()) { + $control = $field->renderEditControl($request); + $form->appendChild($control); + } + } + + $form + ->appendChild( + id(new AphrontFormSubmitControl()) + ->addCancelButton($origin_uri, 'Cancel') + ->setValue('Save')); + + $panel = id(new AphrontPanelView()) + ->setHeader('Edit Pick Request') + ->setWidth(AphrontPanelView::WIDTH_FORM) + ->appendChild($form); + + return $this->buildStandardPageResponse( + array($notice_view, $error_view, $panel), + array('title', 'Edit Pick Request')); + } +} diff --git a/src/applications/releeph/controller/request/ReleephRequestTypeaheadController.php b/src/applications/releeph/controller/request/ReleephRequestTypeaheadController.php new file mode 100644 index 0000000000..c86060c4b0 --- /dev/null +++ b/src/applications/releeph/controller/request/ReleephRequestTypeaheadController.php @@ -0,0 +1,92 @@ +getRequest(); + + $query = $request->getStr('q'); + $repo_id = $request->getInt('repo'); + $since = $request->getInt('since'); + $limit = $request->getInt('limit'); + + $now = time(); + $data = array(); + + // Dummy instances used for getting connections, table names, etc. + $pr_commit = new PhabricatorRepositoryCommit(); + $pr_commit_data = new PhabricatorRepositoryCommitData(); + + $conn = $pr_commit->establishConnection('r'); + + $rows = queryfx_all( + $conn, + 'SELECT + rc.phid as commitPHID, + rc.authorPHID, + rcd.authorName, + SUBSTRING(rcd.commitMessage, 1, 100) AS shortMessage, + rc.commitIdentifier, + rc.epoch + FROM %T rc + INNER JOIN %T rcd ON rcd.commitID = rc.id + WHERE repositoryID = %d + AND rc.epoch >= %d + AND ( + rcd.commitMessage LIKE %~ + OR + rc.commitIdentifier LIKE %~ + ) + ORDER BY rc.epoch DESC + LIMIT %d', + $pr_commit->getTableName(), + $pr_commit_data->getTableName(), + $repo_id, + $since, + $query, + $query, + $limit); + + foreach ($rows as $row) { + $full_commit_id = $row['commitIdentifier']; + $short_commit_id = substr($full_commit_id, 0, 12); + $first_line = $this->getFirstLine($row['shortMessage']); + $data[] = array( + $full_commit_id, + $short_commit_id, + $row['authorName'], + phabricator_format_relative_time($now - $row['epoch']), + $first_line, + ); + } + + return id(new AphrontAjaxResponse()) + ->setContent($data); + } + + /** + * Split either at the first new line, or a bunch of dashes. + * + * Really just a legacy from old Releeph Daemon commit messages where I used + * to say: + * + * Commit of FOO for BAR + * ------------ + * This does X Y Z + * + */ + private function getFirstLine($commit_message_fragment) { + static $separators = array('-------', "\n"); + $string = ltrim($commit_message_fragment); + $first_line = $string; + foreach ($separators as $separator) { + if ($pos = strpos($string, $separator)) { + $first_line = substr($string, 0, $pos); + break; + } + } + return $first_line; + } + +} diff --git a/src/applications/releeph/controller/request/ReleephRequestViewController.php b/src/applications/releeph/controller/request/ReleephRequestViewController.php new file mode 100644 index 0000000000..f0cd2661fe --- /dev/null +++ b/src/applications/releeph/controller/request/ReleephRequestViewController.php @@ -0,0 +1,99 @@ +getRequest(); + + $uri_path = $request->getRequestURI()->getPath(); + $legacy_prefix = '/releeph/request/'; + if (strncmp($uri_path, $legacy_prefix, strlen($legacy_prefix)) === 0) { + return id(new AphrontRedirectResponse()) + ->setURI('/RQ'.$this->getReleephRequest()->getID()); + } + + $releeph_request = $this->getReleephRequest(); + $releeph_branch = $this->getReleephBranch(); + $releeph_project = $this->getReleephProject(); + + $releeph_branch->populateReleephRequestHandles( + $request->getUser(), array($releeph_request)); + + $rq_view = + id(new ReleephRequestHeaderListView()) + ->setReleephProject($releeph_project) + ->setReleephBranch($releeph_branch) + ->setReleephRequests(array($releeph_request)) + ->setUser($request->getUser()) + ->setAphrontRequest($this->getRequest()) + ->setReloadOnStateChange(true) + ->setOriginType('request'); + + $events = $releeph_request->loadEvents(); + $phids = array_mergev(mpull($events, 'extractPHIDs')); + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $rq_event_list_view = + id(new ReleephRequestEventListView()) + ->setUser($request->getUser()) + ->setEvents($events) + ->setHandles($handles); + + // Handle comment submit + $origin_uri = '/RQ'.$releeph_request->getID(); + if ($request->isFormPost()) { + id(new ReleephRequestEditor($releeph_request)) + ->setActor($request->getUser()) + ->addComment($request->getStr('comment')); + return id(new AphrontRedirectResponse())->setURI($origin_uri); + } + + $form = id(new AphrontFormView()) + ->setUser($request->getUser()) + ->appendChild( + id(new AphrontFormTextAreaControl()) + ->setName('comment')) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->addCancelButton($origin_uri, 'Cancel') + ->setValue("Submit")); + + $rq_comment_form = id(new AphrontPanelView()) + ->setHeader('Add a comment') + ->setWidth(AphrontPanelView::WIDTH_FULL) + ->appendChild($form); + + $title = hsprintf("RQ%d: %s", + $releeph_request->getID(), + $releeph_request->getSummaryForDisplay()); + + $crumbs = $this->buildApplicationCrumbs() + ->addCrumb( + id(new PhabricatorCrumbView()) + ->setName($releeph_project->getName()) + ->setHref($releeph_project->getURI())) + ->addCrumb( + id(new PhabricatorCrumbView()) + ->setName($releeph_branch->getDisplayNameWithDetail()) + ->setHref($releeph_branch->getURI())) + ->addCrumb( + id(new PhabricatorCrumbView()) + ->setName('RQ'.$releeph_request->getID()) + ->setHref('/RQ'.$releeph_request->getID())); + + return $this->buildStandardPageResponse( + array( + $crumbs, + array( + $rq_view, + $rq_event_list_view, + $rq_comment_form + ) + ), + array( + 'title' => $title + )); + } +} diff --git a/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php b/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php new file mode 100644 index 0000000000..840241747a --- /dev/null +++ b/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php @@ -0,0 +1,348 @@ +" headers in commits created by + * arc-releeph so that RQs committed by arc-releeph have real + * PhabricatorRepositoryCommits associated with them (instaed of just the SHA + * of the commit, as seen by the pusher). + * + * 2: If requestors want to commit directly to their release branch, they can + * use this header to (i) indicate on a differential revision that this + * differential revision is for the release branch, and (ii) when they land + * their diff on to the release branch manually, the ReleephRequest is + * automatically updated (instead of having to use the "Mark Manually Picked" + * button.) + * + */ +final class DifferentialReleephRequestFieldSpecification + extends DifferentialFieldSpecification { + + const ACTION_PICKS = 'picks'; + const ACTION_REVERTS = 'reverts'; + + private $releephAction; + private $releephPHIDs = array(); + + public function getStorageKey() { + return 'releeph:actions'; + } + + public function getValueForStorage() { + return json_encode(array( + 'releephAction' => $this->releephAction, + 'releephPHIDs' => $this->releephPHIDs, + )); + } + + public function setValueFromStorage($json) { + if ($json) { + $dict = json_decode($json, true); + $this->releephAction = idx($dict, 'releephAction'); + $this->releephPHIDs = idx($dict, 'releephPHIDs'); + } + return $this; + } + + public function shouldAppearOnRevisionView() { + return true; + } + + public function renderLabelForRevisionView() { + return 'Releeph'; + } + + public function getRequiredHandlePHIDs() { + return mpull($this->loadReleephRequests(), 'getPHID'); + } + + public function renderValueForRevisionView() { + static $tense = array( + self::ACTION_PICKS => array( + 'future' => 'Will pick', + 'past' => 'Picked', + ), + self::ACTION_REVERTS => array( + 'future' => 'Will revert', + 'past' => 'Reverted', + ), + ); + + $releeph_requests = $this->loadReleephRequests(); + if (!$releeph_requests) { + return null; + } + + $status = $this->getRevision()->getStatus(); + if ($status == ArcanistDifferentialRevisionStatus::CLOSED) { + $verb = $tense[$this->releephAction]['past']; + } else { + $verb = $tense[$this->releephAction]['future']; + } + + $parts = hsprintf('%s...', $verb); + foreach ($releeph_requests as $releeph_request) { + $parts->appendHTML(phutil_tag('br')); + $parts->appendHTML( + $this->getHandle($releeph_request->getPHID())->renderLink()); + } + + return $parts; + } + + public function shouldAppearOnCommitMessage() { + return true; + } + + public function getCommitMessageKey() { + return 'releephActions'; + } + + public function setValueFromParsedCommitMessage($dict) { + $this->releephAction = $dict['releephAction']; + $this->releephPHIDs = $dict['releephPHIDs']; + return $this; + } + + public function renderValueForCommitMessage($is_edit) { + $releeph_requests = $this->loadReleephRequests(); + if (!$releeph_requests) { + return null; + } + + $parts = array($this->releephAction); + foreach ($releeph_requests as $releeph_request) { + $parts[] = 'RQ'.$releeph_request->getID(); + } + + return implode(' ', $parts); + } + + /** + * Releeph fields should look like: + * + * Releeph: picks RQ1 RQ2, RQ3 + * Releeph: reverts RQ1 + */ + public function parseValueFromCommitMessage($value) { + /** + * Releeph commit messages look like this (but with more blank lines, + * omitted here): + * + * Make CaptainHaddock more reasonable + * Releeph: picks RQ1 + * Requested By: edward + * Approved By: edward (requestor) + * Request Reason: x + * Summary: Make the Haddock implementation more reasonable. + * Test Plan: none + * Reviewers: user1 + * + * Some of these fields are recognized by Differential (e.g. "Requested + * By"). They are folded up into the "Releeph" field, parsed by this + * class. As such $value includes more than just the first-line: + * + * "picks RQ1\n\nRequested By: edward\n\nApproved By: edward (requestor)" + * + * To hack around this, just consider the first line of $value when + * determining what Releeph actions the parsed commit is performing. + */ + $first_line = head(array_filter(explode("\n", $value))); + + $tokens = preg_split('/\s*,?\s+/', $first_line); + $raw_action = array_shift($tokens); + $action = strtolower($raw_action); + + if (!$action) { + return null; + } + + switch ($action) { + case self::ACTION_REVERTS: + case self::ACTION_PICKS: + break; + + default: + throw new DifferentialFieldParseException( + "Commit message contains unknown Releeph action '{$raw_action}'!"); + break; + } + + $releeph_requests = array(); + foreach ($tokens as $token) { + $match = array(); + if (!preg_match('/^(?:RQ)?(\d+)$/i', $token, $match)) { + $label = $this->renderLabelForCommitMessage(); + throw new DifferentialFieldParseException( + "Commit message contains unparseable ". + "Releeph request token '{$token}'!"); + } + + $id = (int) $match[1]; + $releeph_request = id(new ReleephRequest())->load($id); + + if (!$releeph_request) { + throw new DifferentialFieldParseException( + "Commit message references non existent releeph request: {$value}!"); + } + + $releeph_requests[] = $releeph_request; + } + + if (count($releeph_requests) > 1) { + $rqs_seen = array(); + $groups = array(); + foreach ($releeph_requests as $releeph_request) { + $releeph_branch = $releeph_request->loadReleephBranch(); + $branch_name = $releeph_branch->getName(); + $rq_id = 'RQ'.$releeph_request->getID(); + + if (idx($rqs_seen, $rq_id)) { + throw new DifferentialFieldParseException( + "Commit message refers to {$rq_id} multiple times!"); + } + $rqs_seen[$rq_id] = true; + + if (!isset($groups[$branch_name])) { + $groups[$branch_name] = array(); + } + $groups[$branch_name][] = $rq_id; + } + + if (count($groups) > 1) { + $lists = array(); + foreach ($groups as $branch_name => $rq_ids) { + $lists[] = implode(', ', $rq_ids).' in '.$branch_name; + } + throw new DifferentialFieldParseException( + "Commit message references multiple Releeph requests, ". + "but the requests are in different branches: ". + implode('; ', $lists)); + } + } + + $phids = mpull($releeph_requests, 'getPHID'); + + $data = array( + 'releephAction' => $action, + 'releephPHIDs' => $phids, + ); + return $data; + } + + public function renderLabelForCommitMessage() { + return 'Releeph'; + } + + public function shouldAppearOnCommitMessageTemplate() { + return false; + } + + public function didParseCommit(PhabricatorRepository $repo, + PhabricatorRepositoryCommit $commit, + PhabricatorRepositoryCommitData $data) { + + $releeph_requests = $this->loadReleephRequests(); + + if (!$releeph_requests) { + return; + } + + $releeph_branch = head($releeph_requests)->loadReleephBranch(); + if (!$this->isCommitOnBranch($repo, $commit, $releeph_branch)) { + return; + } + + foreach ($releeph_requests as $releeph_request) { + if ($this->releephAction === self::ACTION_PICKS) { + $action = 'pick'; + } else { + $action = 'revert'; + } + + $actor_phid = coalesce( + $data->getCommitDetail('committerPHID'), + $data->getCommitDetail('authorPHID')); + + $actor = id(new PhabricatorUser()) + ->loadOneWhere('phid = %s', $actor_phid); + + id(new ReleephRequestEditor($releeph_request)) + ->setActor($actor) + ->discoverCommit($action, $commit, $data); + } + } + + private function loadReleephRequests() { + if (!$this->releephPHIDs) { + return array(); + } else { + return id(new ReleephRequest()) + ->loadAllWhere('phid IN (%Ls)', $this->releephPHIDs); + } + } + + private function isCommitOnBranch(PhabricatorRepository $repo, + PhabricatorRepositoryCommit $commit, + ReleephBranch $releeph_branch) { + + switch ($repo->getVersionControlSystem()) { + case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: + list($output) = $repo->execxLocalCommand( + 'branch --all --no-color --contains %s', + $commit->getCommitIdentifier()); + + $remote_prefix = 'remotes/origin/'; + $branches = array(); + foreach (array_filter(explode("\n", $output)) as $line) { + $tokens = explode(' ', $line); + $ref = last($tokens); + if (strncmp($ref, $remote_prefix, strlen($remote_prefix)) === 0) { + $branch = substr($ref, strlen($remote_prefix)); + $branches[$branch] = $branch; + } + } + + return idx($branches, $releeph_branch->getName()); + break; + + case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: + $change_query = DiffusionPathChangeQuery::newFromDiffusionRequest( + DiffusionRequest::newFromDictionary(array( + 'repository' => $repo, + 'commit' => $commit->getCommitIdentifier(), + ))); + $path_changes = $change_query->loadChanges(); + $commit_paths = mpull($path_changes, 'getPath'); + + $branch_path = $releeph_branch->getName(); + + $in_branch = array(); + $ex_branch = array(); + foreach ($commit_paths as $path) { + if (strncmp($path, $branch_path, strlen($branch_path)) === 0) { + $in_branch[] = $path; + } else { + $ex_branch[] = $path; + } + } + + if ($in_branch && $ex_branch) { + $error = sprintf( + "CONFUSION: commit %s in %s contains %d path change(s) that were ". + "part of a Releeph branch, but also has %d path change(s) not ". + "part of a Releeph branch!", + $commit->getCommitIdentifier(), + $repo->getCallsign(), + count($in_branch), + count($ex_branch)); + phlog($error); + } + + return !empty($in_branch); + break; + } + } + +} diff --git a/src/applications/releeph/differential/ReleephDifferentialRevisionDetailRenderer.php b/src/applications/releeph/differential/ReleephDifferentialRevisionDetailRenderer.php new file mode 100644 index 0000000000..fbff6983f9 --- /dev/null +++ b/src/applications/releeph/differential/ReleephDifferentialRevisionDetailRenderer.php @@ -0,0 +1,39 @@ +loadArcanistProject(); // 93us + if (!$arc_project) { + return; + } + + $releeph_projects = id(new ReleephProject())->loadAllWhere( + 'arcanistProjectID = %d AND isActive = 1', + $arc_project->getID()); + + if (!$releeph_projects) { + return; + } + + $releeph_branches = id(new ReleephBranch())->loadAllWhere( + 'releephProjectID IN (%Ld) AND isActive = 1', + mpull($releeph_projects, 'getID')); + + if (!$releeph_branches) { + return; + } + + $uri = new PhutilURI( + '/releeph/request/differentialcreate/D'.$revision->getID()); + return array( + 'name' => 'Releeph Request', + 'sigil' => 'workflow', + 'href' => $uri, + 'icon' => 'fork', + ); + } + +} diff --git a/src/applications/releeph/editor/ReleephBranchEditor.php b/src/applications/releeph/editor/ReleephBranchEditor.php new file mode 100644 index 0000000000..d3f679502e --- /dev/null +++ b/src/applications/releeph/editor/ReleephBranchEditor.php @@ -0,0 +1,106 @@ +releephProject = $rp; + return $this; + } + + public function setReleephBranch(ReleephBranch $branch) { + $this->releephBranch = $branch; + return $this; + } + + public function newBranchFromCommit(PhabricatorRepositoryCommit $cut_point, + $branch_date, + $symbolic_name = null) { + + $template = $this->releephProject->getDetail('branchTemplate'); + if (!$template) { + $template = ReleephBranchTemplate::getRequiredDefaultTemplate(); + } + + $cut_point_handle = head( + id(new PhabricatorObjectHandleData(array($cut_point->getPHID()))) + // We'll assume that whoever found the $cut_point has passed privacy + // checks. + ->setViewer($this->requireActor()) + ->loadHandles()); + + list($name, $errors) = id(new ReleephBranchTemplate()) + ->setCommitHandle($cut_point_handle) + ->setBranchDate($branch_date) + ->setReleephProjectName($this->releephProject->getName()) + ->interpolate($template); + + $basename = last(explode('/', $name)); + + $table = id(new ReleephBranch()); + $transaction = $table->openTransaction(); + $branch = id(new ReleephBranch()) + ->setName($name) + ->setBasename($basename) + ->setReleephProjectID($this->releephProject->getID()) + ->setCreatedByUserPHID($this->requireActor()->getPHID()) + ->setCutPointCommitIdentifier($cut_point->getCommitIdentifier()) + ->setCutPointCommitPHID($cut_point->getPHID()) + ->setIsActive(1) + ->setDetail('branchDate', $branch_date) + ->save(); + + /** + * Steal the symbolic name from any other branch that has it (in this + * project). + */ + if ($symbolic_name) { + $others = id(new ReleephBranch())->loadAllWhere( + 'releephProjectID = %d', + $this->releephProject->getID()); + foreach ($others as $other) { + if ($other->getSymbolicName() == $symbolic_name) { + $other + ->setSymbolicName(null) + ->save(); + } + } + $branch + ->setSymbolicName($symbolic_name) + ->save(); + } + + id(new ReleephEvent()) + ->setType(ReleephEvent::TYPE_BRANCH_CREATE) + ->setActorPHID($this->requireActor()->getPHID()) + ->setReleephProjectID($this->releephProject->getID()) + ->setReleephBranchID($branch->getID()) + ->save(); + + $table->saveTransaction(); + return $branch; + } + + // aka "close" and "reopen" + public function changeBranchAccess($is_active) { + $branch = $this->releephBranch; + $branch->openTransaction(); + + $branch + ->setIsActive((int)$is_active) + ->save(); + + id(new ReleephEvent()) + ->setType(ReleephEvent::TYPE_BRANCH_ACCESS) + ->setActorPHID($this->requireActor()->getPHID()) + ->setReleephProjectID($branch->getReleephProjectID()) + ->setReleephBranchID($branch->getID()) + ->setDetail('isActive', $is_active) + ->save(); + + $branch->saveTransaction(); + } + +} diff --git a/src/applications/releeph/editor/ReleephRequestEditor.php b/src/applications/releeph/editor/ReleephRequestEditor.php new file mode 100644 index 0000000000..8cb0945e2f --- /dev/null +++ b/src/applications/releeph/editor/ReleephRequestEditor.php @@ -0,0 +1,408 @@ +releephRequest = $rq; + } + + public function setSilentUpdate($silent) { + $this->silentUpdate = $silent; + return $this; + } + + +/* -( ReleephRequest edit methods )---------------------------------------- */ + + /** + * Request a PhabricatorRepositoryCommit to be committed to the given + * ReleephBranch. + */ + public function create(PhabricatorRepositoryCommit $commit, + ReleephBranch $branch) { + + // We can't use newEvent() / commit() abstractions, so do what those + // helpers do manually. + $requestor = $this->requireActor(); + + $rq = $this->releephRequest; + $rq->openTransaction(); + + $rq + ->setBranchID($branch->getID()) + ->setRequestCommitIdentifier($commit->getCommitIdentifier()) + ->setRequestCommitPHID($commit->getPHID()) + ->setRequestCommitOrdinal($commit->getID()) + ->setInBranch(0) + ->setRequestUserPHID($requestor->getPHID()) + ->setUserIntent($requestor, ReleephRequest::INTENT_WANT) + ->save(); + + $event = id(new ReleephRequestEvent()) + ->setType(ReleephRequestEvent::TYPE_CREATE) + ->setActorPHID($requestor->getPHID()) + ->setStatusBefore(null) + ->setStatusAfter($rq->getStatus()) + ->setReleephRequestID($rq->getID()) + ->setDetail('commitPHID', $commit->getPHID()) + ->save(); + + $rq->saveTransaction(); + + // Mail + if (!$this->silentUpdate) { + $project = $this->releephRequest->loadReleephProject(); + $mail = id(new ReleephRequestMail()) + ->setReleephRequest($this->releephRequest) + ->setReleephProject($project) + ->setEvents(array($event)) + ->setSenderAndRecipientPHID($requestor->getPHID()) + ->addTos(ReleephRequestMail::ENT_ALL_PUSHERS) + ->addCCs(ReleephRequestMail::ENT_REQUESTOR) + ->send(); + } + } + + /** + * Record whether the PhabricatorUser wants or passes on this request. + */ + public function changeUserIntent(PhabricatorUser $user, $intent) { + $project = $this->releephRequest->loadReleephProject(); + $is_pusher = $project->isPusher($user); + + $event = $this->newEvent() + ->setType(ReleephRequestEvent::TYPE_USER_INTENT) + ->setDetail('userPHID', $user->getPHID()) + ->setDetail('wasPusher', $is_pusher) + ->setDetail('newIntent', $intent); + + $this->releephRequest + ->setUserIntent($user, $intent); + + $this->commit(); + + // Mail if this is 'interesting' + if (!$this->silentUpdate && + $event->getStatusBefore() != $event->getStatusAfter()) { + + $project = $this->releephRequest->loadReleephProject(); + $mail = id(new ReleephRequestMail()) + ->setReleephRequest($this->releephRequest) + ->setReleephProject($project) + ->setEvents(array($event)) + ->setSenderAndRecipientPHID($this->requireActor()->getPHID()) + ->addTos(ReleephRequestMail::ENT_REQUESTOR) + ->addCCs(ReleephRequestMail::ENT_INTERESTED_PUSHERS) + ->send(); + } + } + + /** + * Record the results of someone trying to pick or revert a request in their + * local repository, to give advance warning that something doesn't pick or + * revert cleanly. + */ + public function changePickStatus($pick_status, $dry_run, $details) { + $event = $this->newEvent() + ->setType(ReleephRequestEvent::TYPE_PICK_STATUS) + ->setDetail('newPickStatus', $pick_status) + ->setDetail('commitDetails', $details); + $this->releephRequest->setPickStatus($pick_status); + $this->commit(); + + // Failures should generate an email + if (!$this->silentUpdate && + !$dry_run && + ($pick_status == ReleephRequest::PICK_FAILED || + $pick_status == ReleephRequest::REVERT_FAILED)) { + + $project = $this->releephRequest->loadReleephProject(); + $mail = id(new ReleephRequestMail()) + ->setReleephRequest($this->releephRequest) + ->setReleephProject($project) + ->setEvents(array($event)) + ->setSenderAndRecipientPHID($this->requireActor()->getPHID()) + ->addTos(ReleephRequestMail::ENT_REQUESTOR) + ->addCCs(ReleephRequestMail::ENT_ACTORS) + ->addCCs(ReleephRequestMail::ENT_INTERESTED_PUSHERS) + ->send(); + } + } + + /** + * Record that a request was committed locally, and is about to be pushed to + * the remote repository. + * + * This lets us mark a ReleephRequest as being in a branch in real time so + * that no one else tries to pick it. + * + * When the daemons discover this commit in the repository with + * DifferentialReleephRequestFieldSpecification, we'll be able to recrod the + * commit's PHID as well. That process is slow though, and + * we don't want to wait a whole minute before marking something as cleanly + * picked or reverted. + */ + public function recordSuccessfulCommit($action, $new_commit_id) { + $table = $this->releephRequest; + $table->openTransaction(); + + $actor = $this->requireActor(); + + $event = id(new ReleephRequestEvent()) + ->setReleephRequestID($this->releephRequest->getID()) + ->setActorPHID($actor->getPHID()) + ->setType(ReleephRequestEvent::TYPE_COMMIT) + ->setDetail('action', $action) + ->setDetail('newCommitIdentifier', $new_commit_id) + ->save(); + + switch ($action) { + case 'pick': + $this->releephRequest + ->setInBranch(1) + ->setPickStatus(ReleephRequest::PICK_OK) + ->setCommitIdentifier($new_commit_id) + ->setCommitPHID(null) + ->setCommittedByUserPHID($actor->getPHID()) + ->save(); + break; + + case 'revert': + $this->releephRequest + ->setInBranch(0) + ->setPickStatus(ReleephRequest::REVERT_OK) + ->setCommitIdentifier(null) + ->setCommitPHID(null) + ->setCommittedByUserPHID(null) + ->save(); + break; + + default: + $table->killTransaction(); + throw new Exception("Unknown action {$action}!"); + break; + } + + $table->saveTransaction(); + + // Don't spam people about local commits -- we'll do that with + // discoverCommit() instead! + } + + /** + * Mark this request as picked or reverted based on discovering it in the + * branch. We have a PhabricatorRepositoryCommit, so we're able to + * setCommitPHID on the ReleephRequest (unlike recordSuccessfulCommit()). + */ + public function discoverCommit( + $action, + PhabricatorRepositoryCommit $commit, + PhabricatorRepositoryCommitData $data) { + + $table = $this->releephRequest; + $table->openTransaction(); + $table->beginWriteLocking(); + + $past_events = id(new ReleephRequestEvent())->loadAllWhere( + 'releephRequestID = %d AND type = %s', + $this->releephRequest->getID(), + ReleephRequestEvent::TYPE_DISCOVERY); + + foreach ($past_events as $past_event) { + if ($past_event->getDetail('newCommitIdentifier') + == $commit->getCommitIdentifier()) { + + // Avoid re-discovery if reparsing! + $table->endWriteLocking(); + $table->killTransaction(); + return; + } + } + + $actor = $this->requireActor(); + + $event = id(new ReleephRequestEvent()) + ->setReleephRequestID($this->releephRequest->getID()) + ->setActorPHID($actor->getPHID()) + ->setType(ReleephRequestEvent::TYPE_DISCOVERY) + ->setDateCreated($commit->getEpoch()) + ->setDetail('action', $action) + ->setDetail('newCommitIdentifier', $commit->getCommitIdentifier()) + ->setDetail('newCommitPHID', $commit->getPHID()) + ->setDetail('authorPHID', $data->getCommitDetail('authorPHID')) + ->setDetail('committerPHID', $data->getCommitDetail('committerPHID')) + ->save(); + + switch ($action) { + case 'pick': + $this->releephRequest + ->setInBranch(1) + ->setPickStatus(ReleephRequest::PICK_OK) + ->setCommitIdentifier($commit->getCommitIdentifier()) + ->setCommitPHID($commit->getPHID()) + ->setCommittedByUserPHID($actor->getPHID()) + ->save(); + break; + + case 'revert': + $this->releephRequest + ->setInBranch(0) + ->setPickStatus(ReleephRequest::REVERT_OK) + ->setCommitIdentifier(null) + ->setCommitPHID(null) + ->setCommittedByUserPHID(null) + ->save(); + break; + + default: + $table->killTransaction(); + throw new Exception("Unknown action {$action}!"); + break; + } + + $table->endWriteLocking(); + $table->saveTransaction(); + + // Mail + if (!$this->silentUpdate) { + $project = $this->releephRequest->loadReleephProject(); + $mail = id(new ReleephRequestMail()) + ->setReleephRequest($this->releephRequest) + ->setReleephProject($project) + ->setEvents(array($event)) + ->setSenderAndRecipientPHID($this->requireActor()->getPHID()) + ->addTos(ReleephRequestMail::ENT_REQUESTOR) + ->addCCs(ReleephRequestMail::ENT_ACTORS) + ->addCCs(ReleephRequestMail::ENT_INTERESTED_PUSHERS) + ->send(); + } + } + + public function addComment($comment) { + $event = $this->newEvent() + ->setType(ReleephRequestEvent::TYPE_COMMENT) + ->setDetail('comment', $comment); + $this->commit(); + + // Mail + if (!$this->silentUpdate) { + $project = $this->releephRequest->loadReleephProject(); + $mail = id(new ReleephRequestMail()) + ->setReleephRequest($this->releephRequest) + ->setReleephProject($project) + ->setEvents(array($event)) + ->setSenderAndRecipientPHID($this->requireActor()->getPHID()) + ->addTos(ReleephRequestMail::ENT_REQUESTOR) + ->addCCs(ReleephRequestMail::ENT_ACTORS) + ->addCCs(ReleephRequestMail::ENT_INTERESTED_PUSHERS) + ->send(); + } + } + + public function markManuallyActioned($action) { + $event = $this->newEvent() + ->setType(ReleephRequestEvent::TYPE_MANUAL_ACTION) + ->setDetail('action', $action); + + $actor = $this->requireActor(); + $project = $this->releephRequest->loadReleephProject(); + $requestor_phid = $this->releephRequest->getRequestUserPHID(); + if (!$project->isPusher($actor) && + $actor->getPHID() !== $requestor_phid) { + + throw new Exception( + "Only pushers or requestors can mark requests as ". + "manually picked or reverted!"); + } + + switch ($action) { + case 'pick': + $in_branch = true; + $intent = ReleephRequest::INTENT_WANT; + break; + + case 'revert': + $in_branch = false; + $intent = ReleephRequest::INTENT_PASS; + break; + + default: + throw new Exception("Unknown action {$action}!"); + break; + } + + $this->releephRequest + ->setInBranch((int)$in_branch) + ->setUserIntent($this->getActor(), $intent); + + $this->commit(); + + // Mail + if (!$this->silentUpdate) { + $project = $this->releephRequest->loadReleephProject(); + $mail = id(new ReleephRequestMail()) + ->setReleephRequest($this->releephRequest) + ->setReleephProject($project) + ->setEvents(array($event)) + ->setSenderAndRecipientPHID($this->requireActor()->getPHID()) + ->addTos(ReleephRequestMail::ENT_REQUESTOR) + ->addCCs(ReleephRequestMail::ENT_INTERESTED_PUSHERS) + ->send(); + } + } + +/* -( Implementation )----------------------------------------------------- */ + + /** + * Create and return a new ReleephRequestEvent bound to the editor's + * ReleephRequest, inside a transaction. + * + * When you call commit(), the event and this editor's ReleephRequest (along + * with any changes you made to the ReleephRequest) are saved and the + * transaction committed. + */ + private function newEvent() { + $actor = $this->requireActor(); + + if ($this->event) { + throw new Exception("You have already called newEvent()!"); + } + $rq = $this->releephRequest; + $rq->openTransaction(); + + $this->event = id(new ReleephRequestEvent()) + ->setReleephRequestID($rq->getID()) + ->setActorPHID($actor->getPHID()) + ->setStatusBefore($rq->getStatus()); + + return $this->event; + } + + private function commit() { + if (!$this->event) { + throw new Exception("You must call newEvent first!"); + } + $rq = $this->releephRequest; + $this->event + ->setStatusAfter($rq->getStatus()) + ->save(); + $rq->save(); + $rq->saveTransaction(); + $this->event = null; + } + +} diff --git a/src/applications/releeph/editor/mail/ReleephRequestMail.php b/src/applications/releeph/editor/mail/ReleephRequestMail.php new file mode 100644 index 0000000000..a06f6f3342 --- /dev/null +++ b/src/applications/releeph/editor/mail/ReleephRequestMail.php @@ -0,0 +1,213 @@ +releephRequest = $rq; + return $this; + } + + public function setReleephProject(ReleephProject $rp) { + $this->releephProject = $rp; + return $this; + } + + public function setEvents(array $events) { + assert_instances_of($events, 'ReleephRequestEvent'); + $this->events = $events; + return $this; + } + + public function setSenderAndRecipientPHID($sender_phid) { + $this->sender = $sender_phid; + $this->tos[] = $sender_phid; + return $this; + } + + public function addTos($entity) { + $this->tos = array_merge( + $this->tos, + $this->getEntityPHIDs($entity)); + return $this; + } + + public function addCcs($entity) { + $this->ccs = array_merge( + $this->tos, + $this->getEntityPHIDs($entity)); + return $this; + } + + public function send() { + $this->buildMail()->save(); + } + + public function buildMail() { + return id(new PhabricatorMetaMTAMail()) + ->setSubject($this->renderSubject()) + ->setBody($this->buildBody()->render()) + ->setFrom($this->sender) + ->addTos($this->tos) + ->addCCs($this->ccs); + } + + private function getEntityPHIDs($entity) { + $phids = array(); + switch ($entity) { + // The requestor + case self::ENT_REQUESTOR: + $phids[] = $this->releephRequest->getRequestUserPHID(); + break; + + // People on the original diff + case self::ENT_DIFF: + $commit = $this->releephRequest->loadPhabricatorRepositoryCommit(); + $commit_data = $commit->loadCommitData(); + if ($commit_data) { + $phids[] = $commit_data->getCommitDetail('reviewerPHID'); + $phids[] = $commit_data->getCommitDetail('authorPHID'); + } + break; + + // All pushers for this project + case self::ENT_ALL_PUSHERS: + $phids = array_merge( + $phids, + $this->releephProject->getPushers()); + break; + + // Pushers who have explicitly wanted or passed on this request + case self::ENT_INTERESTED_PUSHERS: + $all_pushers = $this->releephProject->getPushers(); + $intents = $this->releephRequest->getUserIntents(); + foreach ($all_pushers as $pusher) { + if (idx($intents, $pusher)) { + $phids[] = $pusher; + } + } + break; + + // Anyone who created our list of events + case self::ENT_ACTORS: + $phids = array_merge( + $phids, + mpull($this->events, 'getActorPHID')); + break; + + default: + throw new Exception( + "Unknown entity type {$entity}!"); + break; + } + + return array_filter($phids); + } + + private function buildBody() { + $body = new PhabricatorMetaMTAMailBody(); + $rq = $this->releephRequest; + + // Events and comments + $phids = array( + $rq->getPHID(), + ); + foreach ($this->events as $event) { + $phids = array_merge($phids, $event->extractPHIDs()); + } + $handles = id(new PhabricatorObjectHandleData($phids)) + // By the time we're generating email, we can assume that whichever + // entitties are receving the email are authorized to see the loaded + // handles! + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->loadHandles(); + + $raw_events = id(new ReleephRequestEventListView()) + ->setUser(PhabricatorUser::getOmnipotentUser()) + ->setHandles($handles) + ->setEvents($this->events) + ->renderForEmail(); + + $body->addRawSection($raw_events); + + $project = $rq->loadReleephProject(); + $branch = $rq->loadReleephBranch(); + + /** + * If any of the events we are emailing about were TYPE_PICK_STATUS where + * the newPickStatus was a pick failure (and/or a revert failure?), include + * pick failure instructions. + */ + $pick_failure_events = array(); + foreach ($this->events as $event) { + if ($event->getType() == ReleephRequestEvent::TYPE_PICK_STATUS && + $event->getDetail('newPickStatus') == ReleephRequest::PICK_FAILED) { + + $pick_failure_events[] = $event; + } + } + + if ($pick_failure_events) { + $instructions = $project->getDetail('pick_failure_instructions'); + if ($instructions) { + $body->addTextSection('PICK FAILURE INSTRUCTIONS', $instructions); + } + } + + // Common stuff at the end + $body->addTextSection( + 'RELEEPH REQUEST', + $handles[$rq->getPHID()]->getFullName()."\n". + PhabricatorEnv::getProductionURI('/RQ'.$rq->getID())); + + $project_and_branch = sprintf( + '%s - %s', + $project->getName(), + $branch->getDisplayNameWithDetail()); + + $body->addTextSection( + 'RELEEPH BRANCH', + $project_and_branch."\n". + $branch->getURI()); + + // But verbose stuff at the *very* end! + foreach ($pick_failure_events as $event) { + $failure_details = $event->getDetail('commitDetails'); + if ($failure_details) { + $body->addRawSection('PICK FAILURE DETAILS'); + foreach ($failure_details as $heading => $data) { + $body->addTextSection($heading, $data); + } + } + } + + return $body; + } + + private function renderSubject() { + $rq = $this->releephRequest; + $id = $rq->getID(); + $summary = $rq->getSummaryForDisplay(); + return "RQ{$id}: {$summary}"; + } + +} diff --git a/src/applications/releeph/field/exception/ReleephFieldParseException.php b/src/applications/releeph/field/exception/ReleephFieldParseException.php new file mode 100644 index 0000000000..fcb513df13 --- /dev/null +++ b/src/applications/releeph/field/exception/ReleephFieldParseException.php @@ -0,0 +1,12 @@ +getName(); + parent::__construct("{$name}: {$message}"); + } + +} diff --git a/src/applications/releeph/field/exception/ReleephFieldSpecificationIncompleteException.php b/src/applications/releeph/field/exception/ReleephFieldSpecificationIncompleteException.php new file mode 100644 index 0000000000..6e67dcf350 --- /dev/null +++ b/src/applications/releeph/field/exception/ReleephFieldSpecificationIncompleteException.php @@ -0,0 +1,11 @@ + self::selectFields($fields, array( + 'ReleephAuthorFieldSpecification', + 'ReleephRevisionFieldSpecification', + 'ReleephOriginalCommitFieldSpecification', + 'ReleephDiffSizeFieldSpecification', + 'ReleephDiffChurnFieldSpecification', + )), + 'right' => self::selectFields($fields, array( + 'ReleephRequestorFieldSpecification', + 'ReleephSeverityFieldSpecification', + 'ReleephStatusFieldSpecification', + 'ReleephIntentFieldSpecification', + 'ReleephBranchCommitFieldSpecification', + )) + ), + + // Bottom group + array( + 'left' => self::selectFields($fields, array( + 'ReleephDiffMessageFieldSpecification', + )), + 'right' => self::selectFields($fields, array( + 'ReleephReasonFieldSpecification', + )) + ) + ); + } + + public function arrangeFieldsForSelectForm(array $fields) { + self::selectFields($fields, array( + 'ReleephStatusFieldSpecification', + 'ReleephSeverityFieldSpecification', + 'ReleephRequestorFieldSpecification', + )); + } + + public function sortFieldsForCommitMessage(array $fields) { + self::selectFields($fields, array( + 'ReleephCommitMessageFieldSpecification', + 'ReleephRequestorFieldSpecification', + 'ReleephIntentFieldSpecification', + 'ReleephReasonFieldSpecification', + )); + } + +} diff --git a/src/applications/releeph/field/selector/ReleephFieldSelector.php b/src/applications/releeph/field/selector/ReleephFieldSelector.php new file mode 100644 index 0000000000..3c7afe90de --- /dev/null +++ b/src/applications/releeph/field/selector/ReleephFieldSelector.php @@ -0,0 +1,53 @@ + + } + + abstract public function getFieldSpecifications(); + + abstract public function arrangeFieldsForHeaderView(array $fields); + + abstract public function arrangeFieldsForSelectForm(array $fields); + + public function sortFieldsForCommitMessage(array $fields) { + assert_instances_of($fields, 'ReleephFieldSpecification'); + return $fields; + } + + protected static function selectFields(array $fields, array $classes) { + assert_instances_of($fields, 'ReleephFieldSpecification'); + + $map = array(); + foreach ($fields as $field) { + $map[get_class($field)] = $field; + } + + $result = array(); + foreach ($classes as $class) { + $field = idx($map, $class); + if (!$field) { + throw new Exception( + "Tried to select a in instance of '{$class}' but that field ". + "is not configured for this project!"); + } + + if (idx($result, $class)) { + throw new Exception( + "You have asked to select the field '{$class}' ". + "more than once!"); + } + + $result[$class] = $field; + } + + return $result; + } + +} diff --git a/src/applications/releeph/field/specification/ReleephAuthorFieldSpecification.php b/src/applications/releeph/field/specification/ReleephAuthorFieldSpecification.php new file mode 100644 index 0000000000..610ecb2d5c --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephAuthorFieldSpecification.php @@ -0,0 +1,39 @@ +loadPhabricatorRepositoryCommit(); + if ($commit) { + $author_phid = $commit->getAuthorPHID(); + self::$authorMap[$releeph_request->getPHID()] = $author_phid; + } + } + + ReleephUserView::getNewInstance() + ->setUser($this->getUser()) + ->setReleephProject($this->getReleephProject()) + ->load(self::$authorMap); + } + + public function getName() { + return 'Author'; + } + + public function renderValueForHeaderView() { + $rr = $this->getReleephRequest(); + $author_phid = idx(self::$authorMap, $rr->getPHID()); + if ($author_phid) { + return ReleephUserView::getNewInstance() + ->setRenderUserPHID($author_phid) + ->render(); + } else { + return 'Unknown Author'; + } + } + +} diff --git a/src/applications/releeph/field/specification/ReleephBranchCommitFieldSpecification.php b/src/applications/releeph/field/specification/ReleephBranchCommitFieldSpecification.php new file mode 100644 index 0000000000..4aa2c93f25 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephBranchCommitFieldSpecification.php @@ -0,0 +1,30 @@ +getReleephRequest(); + if (!$rr->getInBranch()) { + return null; + } + + $c_phid = $rr->getCommitPHID(); + $c_id = $rr->getCommitIdentifier(); + + if ($c_phid) { + $handles = $rr->getHandles(); + $val = $handles[$c_phid]->renderLink(); + } else if ($c_id) { + $val = $c_id; + } else { + $val = '???'; + } + return $val; + } + +} diff --git a/src/applications/releeph/field/specification/ReleephCommitMessageFieldSpecification.php b/src/applications/releeph/field/specification/ReleephCommitMessageFieldSpecification.php new file mode 100644 index 0000000000..3ad528f3c0 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephCommitMessageFieldSpecification.php @@ -0,0 +1,46 @@ +renderCommonLabel(); + } + + public function renderValueForCommitMessage() { + return $this->renderCommonValue( + DifferentialReleephRequestFieldSpecification::ACTION_PICKS); + } + + public function shouldAppearOnRevertMessage() { + return true; + } + + public function renderLabelForRevertMessage() { + return $this->renderCommonLabel(); + } + + public function renderValueForRevertMessage() { + return $this->renderCommonValue( + DifferentialReleephRequestFieldSpecification::ACTION_REVERTS); + } + + private function renderCommonLabel() { + return id(new DifferentialReleephRequestFieldSpecification()) + ->renderLabelForCommitMessage(); + } + + private function renderCommonValue($action) { + $rq = 'RQ'.$this->getReleephRequest()->getID(); + return "{$action} {$rq}"; + } + +} diff --git a/src/applications/releeph/field/specification/ReleephDiffChurnFieldSpecification.php b/src/applications/releeph/field/specification/ReleephDiffChurnFieldSpecification.php new file mode 100644 index 0000000000..b8f4310a37 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephDiffChurnFieldSpecification.php @@ -0,0 +1,77 @@ +getReleephRequest()->loadDifferentialRevision(); + if (!$diff_rev) { + return null; + } + + $diff_rev = $this->getReleephRequest()->loadDifferentialRevision(); + $comments = $diff_rev->loadRelatives( + new DifferentialComment(), + 'revisionID'); + + $counts = array(); + foreach ($comments as $comment) { + $action = $comment->getAction(); + if (!isset($counts[$action])) { + $counts[$action] = 0; + } + $counts[$action] += 1; + } + + // 'none' action just means a plain comment + $comments = idx($counts, 'none', 0); + $rejections = idx($counts, 'reject', 0); + $updates = idx($counts, 'update', 0); + + $points = + self::REJECTIONS_WEIGHT * $rejections + + self::COMMENTS_WEIGHT * $comments + + self::UPDATES_WEIGHT * $updates; + + if ($points === 0) { + $points = 0.15 * self::MAX_POINTS; + $blurb = 'Silent diff'; + } else { + $parts = array(); + if ($rejections) { + $parts[] = pht('%d rejection(s)', $rejections); + } + if ($comments) { + $parts[] = pht('%d comment(s)', $comments); + } + if ($updates) { + $parts[] = pht('%d update(s)', $updates); + } + + if (count($parts) === 0) { + $blurb = ''; + } else if (count($parts) === 1) { + $blurb = head($parts); + } else { + $last = array_pop($parts); + $blurb = implode(', ', $parts).' and '.$last; + } + } + + return id(new AphrontProgressBarView()) + ->setValue($points) + ->setMax(self::MAX_POINTS) + ->setCaption($blurb) + ->render(); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephDiffMessageFieldSpecification.php b/src/applications/releeph/field/specification/ReleephDiffMessageFieldSpecification.php new file mode 100644 index 0000000000..9d87d616c0 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephDiffMessageFieldSpecification.php @@ -0,0 +1,37 @@ +getReleephRequest() + ->loadPhabricatorRepositoryCommitData(); + if (!$commit_data) { + return ''; + } + + $engine = PhabricatorMarkupEngine::newDifferentialMarkupEngine(); + $engine->setConfig('viewer', $this->getUser()); + $markup = phutil_tag( + 'div', + array( + 'class' => 'phabricator-remarkup', + ), + $engine->markupText($commit_data->getCommitMessage())); + + return id(new AphrontNoteView()) + ->setTitle('Commit Message') + ->appendChild($markup) + ->render(); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephDiffSizeFieldSpecification.php b/src/applications/releeph/field/specification/ReleephDiffSizeFieldSpecification.php new file mode 100644 index 0000000000..586db1b1d0 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephDiffSizeFieldSpecification.php @@ -0,0 +1,112 @@ +getReleephRequest()->loadDifferentialRevision(); + if (!$diff_rev) { + return ''; + } + + $diffs = $diff_rev->loadRelatives( + new DifferentialDiff(), + 'revisionID', + 'getID', + 'creationMethod <> "commit"'); + + $all_changesets = array(); + $most_recent_changesets = null; + foreach ($diffs as $diff) { + $changesets = $diff->loadRelatives(new DifferentialChangeset(), 'diffID'); + $all_changesets += $changesets; + $most_recent_changesets = $changesets; + } + + // The score is based on all changesets for all versions of this diff + $all_changes = $this->countLinesAndPaths($all_changesets); + $points = + self::LINES_WEIGHT * $all_changes['code']['lines'] + + self::PATHS_WEIGHT * count($all_changes['code']['paths']); + + // The blurb is just based on the most recent version of the diff + $mr_changes = $this->countLinesAndPaths($most_recent_changesets); + + $test_tag = ''; + if ($mr_changes['tests']['paths']) { + Javelin::initBehavior('phabricator-tooltips'); + require_celerity_resource('aphront-tooltip-css'); + + $test_blurb = + pht('%d line(s)', $mr_changes['tests']['lines']).' and '. + pht('%d path(s)', count($mr_changes['tests']['paths'])). + " contain changes to test code:\n"; + foreach ($mr_changes['tests']['paths'] as $mr_test_path) { + $test_blurb .= pht("%s\n", $mr_test_path); + } + + $test_tag = javelin_tag( + 'span', + array( + 'sigil' => 'has-tooltip', + 'meta' => array( + 'tip' => $test_blurb, + 'align' => 'E', + 'size' => 'auto'), + 'style' => ''), + ' + tests'); + } + + $blurb = hsprintf("%s%s.", + pht('%d line(s)', $mr_changes['code']['lines']).' and '. + pht('%d path(s)', count($mr_changes['code']['paths'])).' over '. + pht('%d diff(s)', count($diffs)), + $test_tag); + + return id(new AphrontProgressBarView()) + ->setValue($points) + ->setMax(self::MAX_POINTS) + ->setCaption($blurb) + ->render(); + } + + private function countLinesAndPaths(array $changesets) { + assert_instances_of($changesets, 'DifferentialChangeset'); + $lines = 0; + $paths_touched = array(); + $test_lines = 0; + $test_paths_touched = array(); + + foreach ($changesets as $ch) { + if ($this->getReleephProject()->isTestFile($ch->getFilename())) { + $test_lines += $ch->getAddLines() + $ch->getDelLines(); + $test_paths_touched[] = $ch->getFilename(); + } else { + $lines += $ch->getAddLines() + $ch->getDelLines(); + $paths_touched[] = $ch->getFilename(); + } + } + return array( + 'code' => array( + 'lines' => $lines, + 'paths' => array_unique($paths_touched), + ), + 'tests' => array( + 'lines' => $test_lines, + 'paths' => array_unique($test_paths_touched), + ) + ); + } +} diff --git a/src/applications/releeph/field/specification/ReleephFieldSpecification.php b/src/applications/releeph/field/specification/ReleephFieldSpecification.php new file mode 100644 index 0000000000..992808f12f --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephFieldSpecification.php @@ -0,0 +1,359 @@ +getStorageKey() !== null; + } + + /** + * This will be called many times if you are using **Selecting**. In + * particular, for N selecting fields, selectReleephRequests() is called + * N-squared times, each time for R ReleephRequests. + */ + final public function getValue() { + $key = $this->getRequiredStorageKey(); + return $this->getReleephRequest()->getDetail($key); + } + + final public function setValue($value) { + $key = $this->getRequiredStorageKey(); + return $this->getReleephRequest()->setDetail($key, $value); + } + + /** + * @throws ReleephFieldParseException, to show an error. + */ + public function validate($value) { + return; + } + + +/* -( Header View )-------------------------------------------------------- */ + + /** + * Return a label for use in rendering the fields table. If you return null, + * the renderLabelForHeaderView data will span both columns. + */ + public function renderLabelForHeaderView() { + return $this->getName(); + } + + public function renderValueForHeaderView() { + $key = $this->getRequiredStorageKey(); + return $this->getReleephRequest()->getDetail($key); + } + + +/* -( Edit View )---------------------------------------------------------- */ + + public function renderEditControl(AphrontRequest $request) { + throw new ReleephFieldSpecificationIncompleteException($this); + } + + public function setValueFromAphrontRequest(AphrontRequest $request) { + $data = $request->getRequestData(); + $value = idx($data, $this->getRequiredStorageKey()); + $this->validate($value); + $this->setValue($value); + } + + +/* -( Conduit )------------------------------------------------------------ */ + + public function getKeyForConduit() { + return $this->getRequiredStorageKey(); + } + + public function getValueForConduit() { + return $this->getValue(); + } + + public function setValueFromConduitAPIRequest(ConduitAPIRequest $request) { + $value = idx( + $request->getValue('fields', array()), + $this->getRequiredStorageKey()); + $this->validate($value); + $this->setValue($value); + } + + +/* -( Arcanist )----------------------------------------------------------- */ + + public function renderHelpForArcanist() { + return ''; + } + + +/* -( Context )------------------------------------------------------------ */ + + private $releephProject; + private $releephBranch; + private $releephRequest; + private $user; + + final public function setReleephProject(ReleephProject $rp) { + $this->releephProject = $rp; + return $this; + } + + final public function setReleephBranch(ReleephBranch $rb) { + $this->releephRequest = $rb; + return $this; + } + + final public function setReleephRequest(ReleephRequest $rr) { + $this->releephRequest = $rr; + return $this; + } + + final public function setUser(PhabricatorUser $user) { + $this->user = $user; + return $this; + } + + final public function getReleephProject() { + return $this->releephProject; + } + + final public function getReleephBranch() { + return $this->releephBranch; + } + + final public function getReleephRequest() { + return $this->releephRequest; + } + + final public function getUser() { + return $this->user; + } + + +/* -( Bulk loading )------------------------------------------------------- */ + + public function bulkLoad(array $releeph_requests) { + } + + +/* -( Selecting )---------------------------------------------------------- */ + + /** + * Append select controls to the given form. + * + * You are given: + * + * - the AphrontFormView to append to; + * + * - the AphrontRequest, so you can make use of the value currently selected + * in the form; + * + * - $all_releeph_requests: an array of all the ReleephRequests without any + * selection based filtering; and + * + * - $all_releeph_requests_without_this_field: an array of ReleephRequests + * that have been selected by all the other select controls on this page. + * + * The example in ReleephLevelFieldSpecification shows how to use these. + * $all_releeph_requests lets you find out all the values of a field in all + * ReleephRequests, so you can render controls for every known value. + * + * $all_releeph_requests_without_this_field lets you count how many + * ReleephRequests could be affected by this field's select control, after + * all the other fields have made their selections. + * ReleephLevelFieldSpecification uses this to render a preview count for + * each select button, and disables the button completely (but still renders + * it) if it couldn't possibly select anything. + */ + protected function appendSelectControls( + AphrontFormView $form, + AphrontRequest $request, + array $all_releeph_requests, + array $all_releeph_requests_without_this_field) { + + return null; + } + + /** + * Filter the $releeph_requests using the data you set with your form + * controls, and which is now available in the provided AphrontRequest. + */ + protected function selectReleephRequests(AphrontRequest $request, + array &$releeph_requests) { + return null; + } + + /** + * If you have PHIDs that can be used in an AphrontFormTokenizerControl, + * return true here, return the PHIDs in getSelectablePHIDs(), and return the + * URL the Tokenizer should use for the form control in + * getSelectTokenizerDatasource(). + * + * This is a cheap alternative to implementing appendSelectControls() and + * selectReleephRequests() in full. + */ + protected function hasSelectablePHIDs() { + return false; + } + + protected function getSelectablePHIDs() { + throw new ReleephFieldSpecificationIncompleteException($this); + } + + protected function getSelectTokenizerDatasource() { + throw new ReleephFieldSpecificationIncompleteException($this); + } + + +/* -( Commit Messages )---------------------------------------------------- */ + + public function shouldAppearOnCommitMessage() { + return false; + } + + public function renderLabelForCommitMessage() { + throw new ReleephFieldSpecificationIncompleteException($this); + } + + public function renderValueForCommitMessage() { + throw new ReleephFieldSpecificationIncompleteException($this); + } + + public function shouldAppearOnRevertMessage() { + return false; + } + + public function renderLabelForRevertMessage() { + return $this->renderLabelForCommitMessage(); + } + + public function renderValueForRevertMessage() { + return $this->renderValueForCommitMessage(); + } + + +/* -( Implementation )----------------------------------------------------- */ + + protected function getRequiredStorageKey() { + $key = $this->getStorageKey(); + if ($key === null) { + throw new ReleephFieldSpecificationIncompleteException($this); + } + if (strpos($key, '.') !== false) { + /** + * Storage keys are reused for form controls, and periods in form control + * names break HTML forms. + */ + throw new Exception( + "You can't use '.' in storage keys!"); + } + return $key; + } + + /** + * The "hook" functions ##appendSelectControlsHook()## and + * ##selectReleephRequestsHook()## are used with ##hasSelectablePHIDs()##, to + * use the tokenizing helpers if ##hasSelectablePHIDs()## returns true. + */ + public function appendSelectControlsHook( + AphrontFormView $form, + AphrontRequest $request, + array $all_releeph_requests, + array $all_releeph_requests_without_this_field) { + + if ($this->hasSelectablePHIDs()) { + $this->appendTokenizingSelectControl( + $form, + $request, + $all_releeph_requests, + $all_releeph_requests_without_this_field); + } else { + $this->appendSelectControls( + $form, + $request, + $all_releeph_requests, + $all_releeph_requests_without_this_field); + } + } + + // See above + public function selectReleephRequestsHook(AphrontRequest $request, + array &$releeph_requests) { + + if ($this->hasSelectablePHIDs()) { + $this->selectReleephRequestsFromTokens( + $request, + $releeph_requests); + } else { + $this->selectReleephRequests( + $request, + $releeph_requests); + } + } + + private function appendTokenizingSelectControl( + AphrontFormView $form, + AphrontRequest $request, + array $all_releeph_requests, + array $all_releeph_requests_without_this_field) { + + $key = urlencode(strtolower($this->getName())); + $selected_phids = $request->getArr($key); + $handles = id(new PhabricatorObjectHandleData($selected_phids)) + ->setViewer($request->getUser()) + ->loadHandles(); + + $tokens = array(); + foreach ($selected_phids as $phid) { + $tokens[$phid] = $handles[$phid]->getFullName(); + } + + $datasource = $this->getSelectTokenizerDatasource(); + $control = + id(new AphrontFormTokenizerControl()) + ->setDatasource($datasource) + ->setName($key) + ->setLabel($this->getName()) + ->setValue($tokens); + + $form->appendChild($control); + } + + private function selectReleephRequestsFromTokens(AphrontRequest $request, + array &$releeph_requests) { + + $key = urlencode(strtolower($this->getName())); + $selected_phids = $request->getArr($key); + if (!$selected_phids) { + return; + } + + $selected_phid_lookup = array(); + foreach ($selected_phids as $phid) { + $selected_phid_lookup[$phid] = $phid; + } + + $filtered = array(); + foreach ($releeph_requests as $releeph_request) { + $rq_phids = $this + ->setReleephRequest($releeph_request) + ->getSelectablePHIDs(); + foreach ($rq_phids as $rq_phid) { + if (idx($selected_phid_lookup, $rq_phid)) { + $filtered[] = $releeph_request; + break; + } + } + } + + $releeph_requests = $filtered; + } + +} diff --git a/src/applications/releeph/field/specification/ReleephIntentFieldSpecification.php b/src/applications/releeph/field/specification/ReleephIntentFieldSpecification.php new file mode 100644 index 0000000000..6ba85271c2 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephIntentFieldSpecification.php @@ -0,0 +1,81 @@ +setReleephRequest($this->getReleephRequest()) + ->setReleephProject($this->getReleephProject()) + ->render(); + } + + public function shouldAppearOnCommitMessage() { + return true; + } + + public function shouldAppearOnRevertMessage() { + return true; + } + + public function renderLabelForCommitMessage() { + return "Approved By"; + } + + public function renderLabelForRevertMessage() { + return "Rejected By"; + } + + public function renderValueForCommitMessage() { + return $this->renderIntentsForCommitMessage(ReleephRequest::INTENT_WANT); + } + + public function renderValueForRevertMessage() { + return $this->renderIntentsForCommitMessage(ReleephRequest::INTENT_PASS); + } + + private function renderIntentsForCommitMessage($print_intent) { + $intents = $this->getReleephRequest()->getUserIntents(); + + $requestor = $this->getReleephRequest()->getRequestUserPHID(); + $pusher_phids = $this->getReleephProject()->getPushers(); + + $phids = array_unique($pusher_phids + array_keys($intents)); + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($this->getUser()) + ->loadHandles(); + + $tokens = array(); + foreach ($phids as $phid) { + $intent = idx($intents, $phid); + if ($intent == $print_intent) { + $name = $handles[$phid]->getName(); + $is_pusher = in_array($phid, $pusher_phids); + $is_requestor = $phid == $requestor; + + if ($is_pusher) { + if ($is_requestor) { + $token = "{$name} (pusher and requestor)"; + } else { + $token = "{$name} (pusher)"; + } + } else { + if ($is_requestor) { + $token = "{$name} (requestor)"; + } else { + $token = $name; + } + } + + $tokens[] = $token; + } + } + + return implode(', ', $tokens); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephLevelFieldSpecification.php b/src/applications/releeph/field/specification/ReleephLevelFieldSpecification.php new file mode 100644 index 0000000000..e1866d24b5 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephLevelFieldSpecification.php @@ -0,0 +1,228 @@ +getValue(); + $level = $this->getCanonicalLevel($raw_level); + return $this->getNameForLevel($level); + } + + public function renderEditControl(AphrontRequest $request) { + $control_name = $this->getRequiredStorageKey(); + $all_levels = $this->getLevels(); + + $level = $request->getStr($control_name); + + if (!$level) { + $level = $this->getCanonicalLevel($this->getValue()); + } + + if (!$level) { + $level = $this->getDefaultLevel(); + } + + $control = id(new AphrontFormRadioButtonControl()) + ->setLabel('Level') + ->setName($control_name) + ->setValue($level); + + if ($this->error) { + $control->setError($this->error); + } elseif ($this->getDefaultLevel()) { + $control->setError(true); + } + + foreach ($all_levels as $level) { + $name = $this->getNameForLevel($level); + $description = $this->getDescriptionForLevel($level); + $control->addButton($level, $name, $description); + } + + return $control; + } + + public function renderHelpForArcanist() { + $text = ''; + $levels = $this->getLevels(); + $default = $this->getDefaultLevel(); + foreach ($levels as $level) { + $name = $this->getNameForLevel($level); + $description = $this->getDescriptionForLevel($level); + $default_marker = ' '; + if ($level === $default) { + $default_marker = '*'; + } + $text .= " {$default_marker} **{$name}**\n"; + $text .= phutil_console_wrap($description."\n", 8); + } + return $text; + } + + public function validate($value) { + if ($value === null) { + $this->error = 'Required'; + $label = $this->getName(); + throw new ReleephFieldParseException( + $this, + "You must provide a {$label} level"); + } + + $levels = $this->getLevels(); + if (!in_array($value, $levels)) { + $label = $this->getName(); + throw new ReleephFieldParseException( + $this, + "Level '{$value}' is not a valid {$label} level in this project."); + } + } + + public function setValueFromConduitAPIRequest(ConduitAPIRequest $request) { + $key = $this->getRequiredStorageKey(); + $label = $this->getName(); + $name = idx($request->getValue('fields', array()), $key); + + if (!$name) { + $level = $this->getDefaultLevel(); + if (!$level) { + throw new ReleephFieldParseException( + $this, + "No value given for {$label}, ". + "and no default is given for this level!"); + } + } else { + $level = $this->getLevelByName($name); + } + + if (!$level) { + throw new ReleephFieldParseException( + $this, + "Unknown {$label} level name '{$name}'"); + } + $this->setValue($level); + } + + private $nameMap = array(); + + public function getLevelByName($name) { + // Build this once + if (!$this->nameMap) { + foreach ($this->getLevels() as $level) { + $level_name = $this->getNameForLevel($level); + $this->nameMap[$level_name] = $level; + } + } + return idx($this->nameMap, $name); + } + + protected function appendSelectControls( + AphrontFormView $form, + AphrontRequest $request, + array $all_releeph_requests, + array $all_releeph_requests_without_this_field) { + + $buttons = array(null => 'All'); + + // Add in known level/names + foreach ($this->getLevels() as $level) { + $name = $this->getNameForLevel($level); + $buttons[$name] = $name; + } + + // Add in any names we've seen in the wild, as well. + foreach ($all_releeph_requests as $releeph_request) { + $raw_level = $this->setReleephRequest($releeph_request)->getValue(); + if (!$raw_level) { + // The ReleephRequest might not have a level set + continue; + } + $level = $this->getCanonicalLevel($raw_level); + $name = $this->getNameForLevel($level); + $buttons[$name] = $name; + } + + $key = $this->getRequiredStorageKey(); + $current = $request->getStr($key); + + $counters = array(null => count($all_releeph_requests_without_this_field)); + foreach ($all_releeph_requests_without_this_field as $releeph_request) { + $raw_level = $this->setReleephRequest($releeph_request)->getValue(); + if (!$raw_level) { + // The ReleephRequest might not have a level set + continue; + } + $level = $this->getCanonicalLevel($raw_level); + $name = $this->getNameForLevel($level); + + if (!isset($counters[$name])) { + $counters[$name] = 0; + } + $counters[$name]++; + } + + $control = id(new AphrontFormCountedToggleButtonsControl()) + ->setLabel($this->getName()) + ->setValue($current) + ->setBaseURI($request->getRequestURI(), $key) + ->setButtons($buttons) + ->setCounters($counters); + + $form + ->appendChild($control) + ->addHiddenInput($key, $current); + } + + protected function selectReleephRequests(AphrontRequest $request, + array &$releeph_requests) { + $key = $this->getRequiredStorageKey(); + $current = $request->getStr($key); + + if (!$current) { + return; + } + + $filtered = array(); + foreach ($releeph_requests as $releeph_request) { + $raw_level = $this->setReleephRequest($releeph_request)->getValue(); + $level = $this->getCanonicalLevel($raw_level); + $name = $this->getNameForLevel($level); + if ($name == $current) { + $filtered[] = $releeph_request; + } + } + + $releeph_requests = $filtered; + } + +} diff --git a/src/applications/releeph/field/specification/ReleephOriginalCommitFieldSpecification.php b/src/applications/releeph/field/specification/ReleephOriginalCommitFieldSpecification.php new file mode 100644 index 0000000000..3f4ec6ae1f --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephOriginalCommitFieldSpecification.php @@ -0,0 +1,16 @@ +getReleephRequest(); + $handles = $rr->getHandles(); + return $handles[$rr->getRequestCommitPHID()]->renderLink(); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephReasonFieldSpecification.php b/src/applications/releeph/field/specification/ReleephReasonFieldSpecification.php new file mode 100644 index 0000000000..7b805cd490 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephReasonFieldSpecification.php @@ -0,0 +1,78 @@ +getValue(); + if (!$reason) { + return ''; + } + + $engine = PhabricatorMarkupEngine::newDifferentialMarkupEngine(); + $engine->setConfig('viewer', $this->getUser()); + $markup = phutil_tag( + 'div', + array( + 'class' => 'phabricator-remarkup', + ), + $engine->markupText($reason)); + + return id(new AphrontNoteView()) + ->setTitle('Reason') + ->appendChild($markup) + ->render(); + } + + private $error = true; + + public function renderEditControl(AphrontRequest $request) { + $reason = $request->getStr('reason', $this->getValue()); + return id(new AphrontFormTextAreaControl()) + ->setLabel('Reason') + ->setName('reason') + ->setError($this->error) + ->setValue($reason); + } + + public function validate($reason) { + if (!$reason) { + $this->error = 'Required'; + throw new ReleephFieldParseException( + $this, + "You must give a reason for your request."); + } + } + + public function renderHelpForArcanist() { + $text = + "Fully explain why you are requesting this code be included ". + "in the next release.\n"; + return phutil_console_wrap($text, 8); + } + + public function shouldAppearOnCommitMessage() { + return true; + } + + public function renderLabelForCommitMessage() { + return 'Request Reason'; + } + + public function renderValueForCommitMessage() { + return $this->getValue(); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephRequestorFieldSpecification.php b/src/applications/releeph/field/specification/ReleephRequestorFieldSpecification.php new file mode 100644 index 0000000000..aed8b01863 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephRequestorFieldSpecification.php @@ -0,0 +1,59 @@ +setUser($this->getUser()) + ->setReleephProject($this->getReleephProject()) + ->load($phids); + } + + public function getName() { + return 'Requestor'; + } + + public function renderValueForHeaderView() { + $phid = $this->getReleephRequest()->getRequestUserPHID(); + return ReleephUserView::getNewInstance() + ->setRenderUserPHID($phid) + ->render(); + } + + public function hasSelectablePHIDs() { + return true; + } + + public function getSelectTokenizerDatasource() { + return '/typeahead/common/users/'; + } + + public function getSelectablePHIDs() { + return array( + $this->getReleephRequest()->getRequestUserPHID(), + ); + } + + public function shouldAppearOnCommitMessage() { + return true; + } + + public function shouldAppearOnRevertMessage() { + return true; + } + + public function renderLabelForCommitMessage() { + return "Requested By"; + } + + public function renderValueForCommitMessage() { + $phid = $this->getReleephRequest()->getRequestUserPHID(); + $handles = id(new PhabricatorObjectHandleData(array($phid))) + ->setViewer($this->getUser()) + ->loadHandles(); + return $handles[$phid]->getName(); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephRevisionFieldSpecification.php b/src/applications/releeph/field/specification/ReleephRevisionFieldSpecification.php new file mode 100644 index 0000000000..80b0d0f99d --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephRevisionFieldSpecification.php @@ -0,0 +1,37 @@ +getReleephRequest() + ->loadPhabricatorRepositoryCommitData(); + if (!$data) { + return null; + } + + $phid = $data->getCommitDetail('differential.revisionPHID'); + if (!$phid) { + return null; + } + + $handles = $this->getReleephRequest()->getHandles(); + $handle = $handles[$phid]; + $link = $handle + // Hack to remove the strike-through rendering of diff links + ->setStatus(null) + ->renderLink(); + return phutil_tag( + 'div', + array( + 'class' => 'releeph-header-text-truncated', + ), + $link); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephRiskFieldSpecification.php b/src/applications/releeph/field/specification/ReleephRiskFieldSpecification.php new file mode 100644 index 0000000000..7ab3a33a18 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephRiskFieldSpecification.php @@ -0,0 +1,63 @@ + 'Completely safe to pick this request.', + 'SOME' => 'There is some risk this could break things, but not much.', + 'HIGH' => 'This is pretty risky, but is also very important.', + ); + + public function getName() { + return 'Riskiness'; + } + + public function getStorageKey() { + return 'risk'; + } + + public function renderLabelForHeaderView() { + return 'Riskiness'; + } + + private $error = true; + + public function renderEditControl(AphrontRequest $request) { + $value = $request->getStr('risk', $this->getValue()); + $buttons = id(new AphrontFormRadioButtonControl()) + ->setLabel('Riskiness') + ->setName('risk') + ->setError($this->error) + ->setValue($value); + foreach (self::$defaultRisks as $value => $description) { + $buttons->addButton($value, $value, $description); + } + return $buttons; + } + + public function validate($risk) { + if (!$risk) { + $this->error = 'Required'; + throw new ReleephFieldParseException( + $this, + "No risk was given, which probably means we've changed the set ". + "of valid risks since you made this request. Please pick one."); + } + if (!idx(self::$defaultRisks, $risk)) { + throw new ReleephFieldParseException( + $this, + "Unknown risk '{$risk}'."); + } + } + + public function renderHelpForArcanist() { + $help = ''; + foreach (self::$defaultRisks as $name => $description) { + $help .= " **{$name}**\n"; + $help .= phutil_console_wrap($description."\n", 8); + } + return $help; + } + +} diff --git a/src/applications/releeph/field/specification/ReleephSeverityFieldSpecification.php b/src/applications/releeph/field/specification/ReleephSeverityFieldSpecification.php new file mode 100644 index 0000000000..9b362cbfd4 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephSeverityFieldSpecification.php @@ -0,0 +1,46 @@ + 'HOTFIX', + self::RELEASE => 'RELEASE', + ); + return idx($names, $level, $level); + } + + public function getDescriptionForLevel($level) { + static $descriptions = array( + self::HOTFIX => + 'Needs merging and fixing right now.', + self::RELEASE => + 'Required for the currently rolling release.', + ); + return idx($descriptions, $level); + } + +} diff --git a/src/applications/releeph/field/specification/ReleephStatusFieldSpecification.php b/src/applications/releeph/field/specification/ReleephStatusFieldSpecification.php new file mode 100644 index 0000000000..202e5aba86 --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephStatusFieldSpecification.php @@ -0,0 +1,89 @@ +setReleephRequest($this->getReleephRequest()) + ->render(); + } + + private static $filters = array( + 'req' => ReleephRequest::STATUS_REQUESTED, + 'app' => ReleephRequest::STATUS_NEEDS_PICK, + 'rej' => ReleephRequest::STATUS_REJECTED, + 'abn' => ReleephRequest::STATUS_ABANDONED, + 'mer' => ReleephRequest::STATUS_PICKED, + 'rrq' => ReleephRequest::STATUS_NEEDS_REVERT, + 'rev' => ReleephRequest::STATUS_REVERTED, + ); + + protected function appendSelectControls( + AphrontFormView $form, + AphrontRequest $request, + array $all_releeph_requests, + array $all_releeph_requests_without_this_field) { + + $filter_names = array( + null => 'All', + ); + + foreach (self::$filters as $code => $status) { + $name = ReleephRequest::getStatusDescriptionFor($status); + $filter_names[$code] = $name; + } + + $key = 'status'; + $code = $request->getStr($key); + $current_status = idx(self::$filters, $code); + + $codes = array_flip(self::$filters); + + $counters = array(null => count($all_releeph_requests_without_this_field)); + foreach ($all_releeph_requests_without_this_field as $releeph_request) { + $this_status = $releeph_request->getStatus(); + $this_code = idx($codes, $this_status); + if (!isset($counters[$this_code])) { + $counters[$this_code] = 0; + } + $counters[$this_code]++; + } + + $control = id(new AphrontFormCountedToggleButtonsControl()) + ->setLabel($this->getName()) + ->setValue($code) + ->setBaseURI($request->getRequestURI(), $key) + ->setButtons($filter_names) + ->setCounters($counters); + + $form + ->appendChild($control) + ->addHiddenInput($key, $code); + } + + protected function selectReleephRequests(AphrontRequest $request, + array &$releeph_requests) { + + $key = 'status'; + $code = $request->getStr($key); + if (!$code) { + return; + } + + $current_status = idx(self::$filters, $code); + + $filtered = array(); + foreach ($releeph_requests as $releeph_request) { + if ($releeph_request->getStatus() == $current_status) { + $filtered[] = $releeph_request; + } + } + $releeph_requests = $filtered; + } + +} diff --git a/src/applications/releeph/field/specification/ReleephSummaryFieldSpecification.php b/src/applications/releeph/field/specification/ReleephSummaryFieldSpecification.php new file mode 100644 index 0000000000..7ff398604e --- /dev/null +++ b/src/applications/releeph/field/specification/ReleephSummaryFieldSpecification.php @@ -0,0 +1,46 @@ +getStr('summary', $this->getValue()); + return id(new AphrontFormTextControl()) + ->setLabel('Summary') + ->setName('summary') + ->setError($this->error) + ->setValue($summary) + ->setCaption( + 'Leave this blank to use the original commit title'); + } + + public function renderHelpForArcanist() { + $text = + "A one-line title summarizing this request. ". + "Leave blank to use the original commit title.\n"; + return phutil_console_wrap($text, 8); + } + + public function validate($summary) { + if ($summary && strlen($summary) > self::MAX_SUMMARY_LENGTH) { + $this->error = 'Too long!'; + throw new ReleephFieldParseException( + $this, sprintf( + 'Please keep your summary to under %d characters.', + self::MAX_SUMMARY_LENGTH)); + } + } + +} diff --git a/src/applications/releeph/storage/ReleephBranch.php b/src/applications/releeph/storage/ReleephBranch.php new file mode 100644 index 0000000000..904cb30769 --- /dev/null +++ b/src/applications/releeph/storage/ReleephBranch.php @@ -0,0 +1,154 @@ + true, + self::CONFIG_SERIALIZATION => array( + 'details' => self::SERIALIZATION_JSON, + ), + ) + parent::getConfiguration(); + } + + public function generatePHID() { + return PhabricatorPHID::generateNewPHID( + ReleephPHIDConstants::PHID_TYPE_REBR); + } + + public function getDetail($key, $default = null) { + return idx($this->getDetails(), $key, $default); + } + + public function setDetail($key, $value) { + $this->details[$key] = $value; + return $this; + } + + public function willWriteData(array &$data) { + // If symbolicName is omitted, set it to the basename. + // + // This means that we can enforce symbolicName as a UNIQUE column in the + // DB. We'll interpret symbolicName === basename as meaning "no symbolic + // name". + // + // SYMBOLIC_NAME_NOTE + if (!$data['symbolicName']) { + $data['symbolicName'] = $data['basename']; + } + parent::willWriteData($data); + } + + public function getSymbolicName() { + // See SYMBOLIC_NAME_NOTE above for why this is needed + if ($this->symbolicName == $this->getBasename()) { + return ''; + } + return $this->symbolicName; + } + + public function setSymbolicName($name) { + if ($name) { + parent::setSymbolicName($name); + } else { + parent::setSymbolicName($this->getBasename()); + } + return $this; + } + + public function getDisplayName() { + if ($sn = $this->getSymbolicName()) { + return $sn; + } + return $this->getBasename(); + } + + public function getDisplayNameWithDetail() { + $n = $this->getBasename(); + if ($sn = $this->getSymbolicName()) { + return "{$sn} ({$n})"; + } else { + return $n; + } + } + + public function getURI($path = null) { + $components = array( + '/releeph', + rawurlencode($this->loadReleephProject()->getName()), + rawurlencode($this->getBasename()), + $path + ); + return PhabricatorEnv::getProductionURI(implode('/', $components)); + } + + public function loadReleephProject() { + return $this->loadOneRelative( + new ReleephProject(), + 'id', + 'getReleephProjectID'); + } + + private function loadReleephRequestHandles(PhabricatorUser $user, $reqs) { + $phids_to_phetch = array(); + foreach ($reqs as $rr) { + $phids_to_phetch[] = $rr->getRequestCommitPHID(); + $phids_to_phetch[] = $rr->getRequestUserPHID(); + $phids_to_phetch[] = $rr->getCommitPHID(); + + $intents = $rr->getUserIntents(); + if ($intents) { + foreach ($intents as $user_phid => $intent) { + $phids_to_phetch[] = $user_phid; + } + } + + $request_commit = $rr->loadPhabricatorRepositoryCommit(); + if ($request_commit) { + $phids_to_phetch[] = $request_commit->getAuthorPHID(); + $phids_to_phetch[] = $rr->loadRequestCommitDiffPHID(); + } + } + $handles = id(new PhabricatorObjectHandleData($phids_to_phetch)) + ->setViewer($user) + ->loadHandles(); + return $handles; + } + + public function populateReleephRequestHandles(PhabricatorUser $user, $reqs) { + $handles = $this->loadReleephRequestHandles($user, $reqs); + foreach ($reqs as $req) { + $req->setHandles($handles); + } + } + + public function loadReleephRequests(PhabricatorUser $user) { + $reqs = $this->loadRelatives(new ReleephRequest(), 'branchID'); + $this->populateReleephRequestHandles($user, $reqs); + return $reqs; + } + + public function isActive() { + return $this->getIsActive(); + } + +} diff --git a/src/applications/releeph/storage/ReleephDAO.php b/src/applications/releeph/storage/ReleephDAO.php new file mode 100644 index 0000000000..638e16a73d --- /dev/null +++ b/src/applications/releeph/storage/ReleephDAO.php @@ -0,0 +1,9 @@ + true, + self::CONFIG_SERIALIZATION => array( + 'details' => self::SERIALIZATION_JSON, + ), + ) + parent::getConfiguration(); + } + + public function generatePHID() { + return PhabricatorPHID::generateNewPHID( + ReleephPHIDConstants::PHID_TYPE_REPR); + } + + public function getDetail($key, $default = null) { + return idx($this->details, $key, $default); + } + + public function getURI($path = null) { + $components = array( + '/releeph/project', + $this->getID(), + $path + ); + return PhabricatorEnv::getProductionURI(implode('/', $components)); + } + + public function setDetail($key, $value) { + $this->details[$key] = $value; + return $this; + } + + public function willSaveObject() { + // Do this first, to generate the PHID + parent::willSaveObject(); + + $banned_names = $this->getBannedNames(); + if (in_array($this->name, $banned_names)) { + throw new Exception(sprintf( + "The name '%s' is in the list of banned project names!", + $this->name, + implode(', ', $banned_names))); + } + + if (!$this->getDetail('releaseCounter')) { + $this->setDetail('releaseCounter', 0); + } + } + + public function loadPhabricatorProject() { + if ($id = $this->getProjectID()) { + return id(new PhabricatorProject())->load($id); + } + return id(new PhabricatorProject())->makeEphemeral(); // dummy + } + + public function loadArcanistProject() { + return $this->loadOneRelative( + new PhabricatorRepositoryArcanistProject(), + 'id', + 'getArcanistProjectID'); + } + + public function getPushers() { + return $this->getDetail('pushers', array()); + } + + public function isPusherPHID($phid) { + $pusher_phids = $this->getDetail('pushers', array()); + return in_array($phid, $pusher_phids); + } + + public function isPusher(PhabricatorUser $user) { + return $this->isPusherPHID($user->getPHID()); + } + + public function loadPhabricatorRepository() { + return $this->loadOneRelative( + new PhabricatorRepository(), + 'id', + 'getRepositoryID'); + } + + public function getCurrentReleaseNumber() { + $current_release_numbers = array(); + + // From the project... + $current_release_numbers[] = $this->getDetail('releaseCounter', 0); + + // From any branches... + $branches = id(new ReleephBranch())->loadAllWhere( + 'releephProjectID = %d', $this->getID()); + if ($branches) { + $release_numbers = array(); + foreach ($branches as $branch) { + $current_release_numbers[] = $branch->getDetail('releaseNumber', 0); + } + } + + return max($current_release_numbers); + } + + public function getReleephFieldSelector() { + $class = $this->getDetail('field_selector'); + if (!$class) { + $key = 'releeph.field-selector'; + $class = PhabricatorEnv::getEnvConfig($key); + } + + if ($class) { + return newv($class, array()); + } else { + return new ReleephDefaultFieldSelector(); + } + } + + /** + * Wrapper to setIsActive() that logs who deactivated a project + */ + public function deactivate(PhabricatorUser $actor) { + return $this + ->setIsActive(0) + ->setDetail('last_deactivated_user', $actor->getPHID()) + ->setDetail('last_deactivated_time', time()); + } + + // Hide this from the public + private function setIsActive($v) { + return parent::setIsActive($v); + } + + private function getBannedNames() { + return array( + 'branch', // no one's tried this... yet! + ); + } + + public function isTestFile($filename) { + $test_paths = $this->getDetail('testPaths', array()); + + foreach ($test_paths as $test_path) { + if (preg_match($test_path, $filename)) { + return true; + } + } + return false; + } +} diff --git a/src/applications/releeph/storage/ReleephRequest.php b/src/applications/releeph/storage/ReleephRequest.php new file mode 100644 index 0000000000..d7bb013048 --- /dev/null +++ b/src/applications/releeph/storage/ReleephRequest.php @@ -0,0 +1,309 @@ +getPusherIntent() == self::INTENT_WANT && + /** + * We use "!= pass" instead of "== want" in case the requestor intent is + * not present. In other words, only revert if the requestor explicitly + * passed. + */ + $this->getRequestorIntent() != self::INTENT_PASS; + } + + /** + * Will return INTENT_WANT if any pusher wants this request, and no pusher + * passes on this request. + */ + public function getPusherIntent() { + $project = $this->loadReleephProject(); + if (!$project->getPushers()) { + return self::INTENT_WANT; + } + + $found_pusher_want = false; + foreach ($this->userIntents as $phid => $intent) { + if ($project->isPusherPHID($phid)) { + if ($intent == self::INTENT_PASS) { + return self::INTENT_PASS; + } + + $found_pusher_want = true; + } + } + + if ($found_pusher_want) { + return self::INTENT_WANT; + } else { + return null; + } + } + + public function getRequestorIntent() { + return idx($this->userIntents, $this->requestUserPHID); + } + + public function getStatus() { + return $this->calculateStatus(); + } + + private function calculateStatus() { + if ($this->shouldBeInBranch()) { + if ($this->getInBranch()) { + return self::STATUS_PICKED; + } else { + return self::STATUS_NEEDS_PICK; + } + } else { + if ($this->getInBranch()) { + return self::STATUS_NEEDS_REVERT; + } else { + $has_been_in_branch = $this->getCommitIdentifier(); + // Regardless of why we reverted something, always say reverted if it + // was once in the branch. + if ($has_been_in_branch) { + return self::STATUS_REVERTED; + } elseif ($this->getPusherIntent() === ReleephRequest::INTENT_PASS) { + // Otherwise, if it has never been in the branch, explicitly say why: + return self::STATUS_REJECTED; + } elseif ($this->getRequestorIntent() === ReleephRequest::INTENT_WANT) { + return self::STATUS_REQUESTED; + } else { + return self::STATUS_ABANDONED; + } + } + } + } + + public static function getStatusDescriptionFor($status) { + static $descriptions = array( + self::STATUS_REQUESTED => 'Requested', + self::STATUS_REJECTED => 'Rejected', + self::STATUS_ABANDONED => 'Abandoned', + self::STATUS_PICKED => 'Picked', + self::STATUS_REVERTED => 'Reverted', + self::STATUS_NEEDS_PICK => 'Needs Pick', + self::STATUS_NEEDS_REVERT => 'Needs Revert', + ); + return idx($descriptions, $status, '??'); + } + + public static function getStatusClassSuffixFor($status) { + $description = self::getStatusDescriptionFor($status); + $class = str_replace(' ', '-', strtolower($description)); + return $class; + } + + +/* -( Lisk mechanics )----------------------------------------------------- */ + + public function getConfiguration() { + return array( + self::CONFIG_AUX_PHID => true, + self::CONFIG_SERIALIZATION => array( + 'details' => self::SERIALIZATION_JSON, + 'userIntents' => self::SERIALIZATION_JSON, + ), + ) + parent::getConfiguration(); + } + + public function generatePHID() { + return PhabricatorPHID::generateNewPHID( + ReleephPHIDConstants::PHID_TYPE_RERQ); + } + + +/* -( Helpful accessors )--------------------------------------------------- */ + + public function setHandles($handles) { + $this->handles = $handles; + return $this; + } + + public function getHandles() { + if (!$this->handles) { + throw new Exception( + "You must call ReleephBranch::populateReleephRequestHandles() first"); + } + return $this->handles; + } + + public function getDetail($key, $default = null) { + return idx($this->getDetails(), $key, $default); + } + + public function setDetail($key, $value) { + $this->details[$key] = $value; + return $this; + } + + public function getReason() { + // Backward compatibility: reason used to be called comments + $reason = $this->getDetail('reason'); + if (!$reason) { + return $this->getDetail('comments'); + } + return $reason; + } + + public function getSummary() { + /** + * Instead, you can use: + * - getDetail('summary') // the actual user-chosen summary + * - getSummaryForDisplay() // falls back to the original commit title + * + * Or for the fastidious: + * - id(new ReleephSummaryFieldSpecification()) + * ->setReleephRequest($rr) + * ->getValue() // programmatic equivalent to getDetail() + */ + throw new Exception( + "getSummary() has been deprecated!"); + } + + /** + * Allow a null summary, and fall back to the title of the commit. + */ + public function getSummaryForDisplay() { + $summary = $this->getDetail('summary'); + + if (!$summary) { + $pr_commit_data = $this->loadPhabricatorRepositoryCommitData(); + if ($pr_commit_data) { + $message_lines = explode("\n", $pr_commit_data->getCommitMessage()); + $message_lines = array_filter($message_lines); + $summary = head($message_lines); + } + } + + if (!$summary) { + $summary = '(no summary given and commit message empty or unparsed)'; + } + + return $summary; + } + + public function loadRequestCommitDiffPHID() { + $commit_data = $this->loadPhabricatorRepositoryCommitData(); + if (!$commit_data) { + return null; + } + return $commit_data->getCommitDetail('differential.revisionPHID'); + } + + +/* -( Loading external objects )------------------------------------------- */ + + public function loadReleephBranch() { + return $this->loadOneRelative( + new ReleephBranch(), + 'id', + 'getBranchID'); + } + + public function loadReleephProject() { + return $this->loadReleephBranch()->loadReleephProject(); + } + + public function loadEvents() { + return $this->loadRelatives( + new ReleephRequestEvent(), + 'releephRequestID', + 'getID', + '(1 = 1) ORDER BY dateCreated, id'); + } + + public function loadPhabricatorRepositoryCommit() { + return $this->loadOneRelative( + new PhabricatorRepositoryCommit(), + 'phid', + 'getRequestCommitPHID'); + } + + public function loadPhabricatorRepositoryCommitData() { + return $this->loadOneRelative( + new PhabricatorRepositoryCommitData(), + 'commitID', + 'getRequestCommitOrdinal'); + } + + public function loadDifferentialRevision() { + return $this->loadOneRelative( + new DifferentialRevision(), + 'phid', + 'loadRequestCommitDiffPHID'); + } + + +/* -( State change helpers )----------------------------------------------- */ + + public function setUserIntent(PhabricatorUser $user, $intent) { + $this->userIntents[$user->getPHID()] = $intent; + return $this; + } + + +/* -( Migrating to status-less ReleephRequests )--------------------------- */ + + protected function didReadData() { + if ($this->userIntents === null) { + $this->userIntents = array(); + } + } + + public function setStatus($value) { + throw new Exception('`status` is now deprecated!'); + } + + +/* -( Make magic Lisk methods private )------------------------------------ */ + + private function setUserIntents(array $ar) { + return parent::setUserIntents($ar); + } + +} diff --git a/src/applications/releeph/storage/event/ReleephEvent.php b/src/applications/releeph/storage/event/ReleephEvent.php new file mode 100644 index 0000000000..e3333ec4e6 --- /dev/null +++ b/src/applications/releeph/storage/event/ReleephEvent.php @@ -0,0 +1,39 @@ + array( + 'details' => self::SERIALIZATION_JSON, + ), + ) + parent::getConfiguration(); + } + + public function getDetail($key, $default = null) { + return idx($this->details, $key, $default); + } + + public function setDetail($key, $value) { + $this->details[$key] = $value; + return $this; + } + + protected function willSaveObject() { + parent::willSaveObject(); + if (!$this->epoch) { + $this->epoch = $this->dateCreated; + } + } + +} diff --git a/src/applications/releeph/storage/request/ReleephRequestEvent.php b/src/applications/releeph/storage/request/ReleephRequestEvent.php new file mode 100644 index 0000000000..b427516d46 --- /dev/null +++ b/src/applications/releeph/storage/request/ReleephRequestEvent.php @@ -0,0 +1,94 @@ + array( + 'details' => self::SERIALIZATION_JSON, + ), + ) + parent::getConfiguration(); + } + + public function getDetail($key, $default = null) { + return idx($this->details, $key, $default); + } + + public function setDetail($key, $value) { + $this->details[$key] = $value; + return $this; + } + + private function setDetails(array $details) { + throw new Exception('Use setDetail()!'); + } + + public function setStatusBefore($status) { + return $this->setDetail('oldStatus', $status); + } + + public function setStatusAfter($status) { + return $this->setDetail('newStatus', $status); + } + + public function getStatusBefore() { + return $this->getDetail('oldStatus'); + } + + public function getStatusAfter() { + return $this->getDetail('newStatus'); + } + + public function getComment() { + return $this->getDetail('comment'); + } + + public function extractPHIDs() { + $phids = array(); + $phids[] = $this->actorPHID; + foreach ($this->details as $key => $value) { + if (strpos($key, 'PHID') !== false || strpos($key, 'phid') !== false) { + $phids[] = $value; + } + } + return $phids; + } + + public function canGroupWith(ReleephRequestEvent $next) { + if ($this->getActorPHID() != $next->getActorPHID()) { + return false; + } + + if ($this->getComment() && $next->getComment()) { + return false; + } + + // Break the chain if the next event changes the status + if ($next->getStatusBefore() != $next->getStatusAfter()) { + return false; + } + + // Don't group if the next event starts off with a different status to the + // one we ended with. This probably shouldn't ever happen. + if ($this->getStatusAfter() != $next->getStatusBefore()) { + return false; + } + + return true; + } + +} diff --git a/src/applications/releeph/storage/request/exception/ReleephRequestException.php b/src/applications/releeph/storage/request/exception/ReleephRequestException.php new file mode 100644 index 0000000000..6f22f8c29a --- /dev/null +++ b/src/applications/releeph/storage/request/exception/ReleephRequestException.php @@ -0,0 +1,3 @@ +showOpenBranches = $active; + return $this; + } + + public function setReleephProject($releeph_project) { + $this->releephProject = $releeph_project; + return $this; + } + + public function setBranches($branches) { + $this->releephBranches = $branches; + return $this; + } + + public function render() { + $releeph_project = $this->releephProject; + + if ($this->showOpenBranches) { + $releeph_branches = mfilter($this->releephBranches, 'getIsActive'); + } else { + $releeph_branches = mfilter($this->releephBranches, 'getIsActive', true); + } + + // Load all relevant PHID handles + $phids = array_merge( + array( + $this->releephProject->getPHID(), + $this->releephProject->getRepositoryPHID(), + ), + mpull($releeph_branches, 'getCreatedByUserPHID'), + mpull($releeph_branches, 'getCutPointCommitPHID'), + $releeph_project->getPushers()); + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($this->getUser()) + ->loadHandles(); + + // Sort branches, which requires the handles above + $releeph_branches = self::sortBranches($releeph_branches, $handles); + + // The header + $repository_phid = $releeph_project->getRepositoryPHID(); + + $header = hsprintf( + '%s in %s repository', + $releeph_project->getName(), + $handles[$repository_phid]->renderLink()); + + if ($this->showOpenBranches) { + $view_other_link = phutil_tag( + 'a', + array( + 'href' => $releeph_project->getURI('closedbranches/'), + ), + 'View closed branches'); + } else { + $view_other_link = phutil_tag( + 'a', + array( + 'href' => $releeph_project->getURI(), + ), + 'View open branches'); + } + + $header = hsprintf("%s · %s", $header, $view_other_link); + + // The "create branch" button + $create_branch_url = $releeph_project->getURI('cutbranch/'); + + // Pushers info + $pushers_info = array(); + $pushers = $releeph_project->getPushers(); + require_celerity_resource('releeph-project'); + if ($pushers) { + $pushers_info[] = phutil_tag('h2', array(), 'Pushers'); + foreach ($pushers as $user_phid) { + $handle = $handles[$user_phid]; + $div = phutil_tag( + 'div', + array( + 'class' => 'releeph-pusher', + 'style' => 'background-image: url('.$handle->getImageURI().');', + ), + phutil_tag( + 'div', + array( + 'class' => 'releeph-pusher-body', + ), + $handles[$user_phid]->renderLink())); + $pushers_info[] = $div; + } + + $pushers_info[] = hsprintf('
'); + } + + // Put it all together + $panel = id(new AphrontPanelView()) + ->setHeader($header) + ->appendChild(phutil_implode_html('', $pushers_info)); + + foreach ($releeph_branches as $ii => $releeph_branch) { + $box = id(new ReleephBranchBoxView()) + ->setUser($this->user) + ->setHandles($handles) + ->setReleephBranch($releeph_branch) + ->setNamed(); + + if ($ii === 0) { + $box->setLatest(); + } + $panel->appendChild($box); + } + + return $panel->render(); + } + + /** + * Sort branches by the point at which they were cut, newest cut points + * first. + * + * If branches share a cut point, sort newest branch first. + */ + private static function sortBranches($branches, $handles) { + // Group by commit phid + $groups = mgroup($branches, 'getCutPointCommitPHID'); + + // Convert commit phid to a commit timestamp + $ar = array(); + foreach ($groups as $cut_phid => $group) { + $handle = $handles[$cut_phid]; + // Pack (timestamp, group-with-this-timestamp) pairs into $ar + $ar[] = array( + $handle->getTimestamp(), + msort($group, 'getDateCreated') + ); + } + + $branches = array(); + // Sort by timestamp, pull groups, and flatten into one big group + foreach (ipull(isort($ar, 0), 1) as $group) { + $branches = array_merge($branches, $group); + } + + return array_reverse($branches); + } + +} diff --git a/src/applications/releeph/view/branch/ReleephBranchBoxView.php b/src/applications/releeph/view/branch/ReleephBranchBoxView.php new file mode 100644 index 0000000000..54b756d3f8 --- /dev/null +++ b/src/applications/releeph/view/branch/ReleephBranchBoxView.php @@ -0,0 +1,225 @@ +releephBranch = $br; + return $this; + } + + // Primary highlighted branch + public function setLatest() { + $this->isLatest = true; + return $this; + } + + // Secondary highlighted branch(es) + public function setNamed() { + $this->isNamed = true; + return $this; + } + + public function setHandles($handles) { + $this->handles = $handles; + return $this; + } + + public function render() { + $br = $this->releephBranch; + + require_celerity_resource('releeph-branch'); + return phutil_tag( + 'div', + array( + 'class' => 'releeph-branch-box'. + ($this->isNamed ? ' releeph-branch-box-named' : ''). + ($this->isLatest ? ' releeph-branch-box-latest' : ''), + ), + array( + $this->renderNames(), + $this->renderDatesTable(), + // "float: right" means the ordering here is weird + $this->renderButtons(), + $this->renderStatisticsTable(), + phutil_tag( + 'div', + array( + 'style' => 'clear:both;', + ), + ''))); + } + + private function renderNames() { + $br = $this->releephBranch; + + return phutil_tag( + 'div', + array( + 'class' => 'names', + ), + array( + phutil_tag( + 'h1', + array(), + $br->getDisplayName()), + phutil_tag( + 'h2', + array(), + $br->getName()))); + } + + private function renderDatesTable() { + $br = $this->releephBranch; + $branch_commit_handle = $this->handles[$br->getCutPointCommitPHID()]; + + $properties = array(); + $properties['Created by'] = + + $cut_age = phabricator_format_relative_time( + time() - $branch_commit_handle->getTimestamp()); + + return phutil_tag( + 'div', + array( + 'class' => 'date-info', + ), + array( + $this->handles[$br->getCreatedByUserPHID()]->renderLink(), + phutil_tag('br'), + phutil_tag( + 'a', + array( + 'href' => $branch_commit_handle->getURI(), + ), + $cut_age.' old'))); + } + + private function renderStatisticsTable() { + $statistics = array(); + + $requests = $this->releephBranch->loadReleephRequests($this->getUser()); + foreach ($requests as $request) { + $status = $request->getStatus(); + if (!isset($statistics[$status])) { + $statistics[$status] = 0; + } + $statistics[$status]++; + } + + static $col_groups = 3; + + $cells = array(); + foreach ($statistics as $status => $count) { + $description = ReleephRequest::getStatusDescriptionFor($status); + $cells[] = phutil_tag('th', array(), $count); + $cells[] = phutil_tag('td', array(), $description); + } + + $rows = array(); + while ($cells) { + $row_cells = array(); + for ($ii = 0; $ii < 2 * $col_groups; $ii++) { + $row_cells[] = array_shift($cells); + } + $rows[] = phutil_tag('tr', array(), $row_cells); + } + + if (!$rows) { + $rows = hsprintf('%s', 'none'); + } + + return phutil_tag( + 'div', + array( + 'class' => 'request-statistics', + ), + phutil_tag( + 'table', + array(), + $rows)); + } + + private function renderButtons() { + $br = $this->releephBranch; + + $buttons = array(); + + $buttons[] = phutil_tag( + 'a', + array( + 'class' => 'small grey button', + 'href' => $br->getURI(), + ), + 'View Requests'); + + $repo = $br->loadReleephProject()->loadPhabricatorRepository(); + if (!$repo) { + $buttons[] = phutil_tag( + 'a', + array( + 'class' => 'small button disabled', + ), + "Diffusion \xE2\x86\x97"); + } else { + $diffusion_request = DiffusionRequest::newFromDictionary(array( + 'repository' => $repo, + )); + $diffusion_branch_uri = $diffusion_request->generateURI(array( + 'action' => 'branch', + 'branch' => $br->getName(), + )); + $diffusion_button_class = 'small grey button'; + + $buttons[] = phutil_tag( + 'a', + array( + 'class' => $diffusion_button_class, + 'target' => '_blank', + 'href' => $diffusion_branch_uri, + ), + "Diffusion \xE2\x86\x97"); + } + + $releeph_project = $br->loadReleephProject(); + if (!$releeph_project->getPushers() || + $releeph_project->isPusher($this->user)) { + + $buttons[] = phutil_tag( + 'a', + array( + 'class' => 'small blue button', + 'href' => $br->getURI('edit/'), + ), + 'Edit'); + + if ($br->isActive()) { + $button_text = "Close"; + $href = $br->getURI('close/'); + } else { + $button_text = "Re-open"; + $href = $br->getURI('re-open/'); + } + $buttons[] = javelin_tag( + 'a', + array( + 'class' => 'small blue button', + 'href' => $href, + 'sigil' => 'workflow', + ), + $button_text); + } + + return phutil_tag( + 'div', + array( + 'class' => 'buttons', + ), + $buttons); + } + +} diff --git a/src/applications/releeph/view/branch/ReleephBranchPreviewView.php b/src/applications/releeph/view/branch/ReleephBranchPreviewView.php new file mode 100644 index 0000000000..8f5f344f69 --- /dev/null +++ b/src/applications/releeph/view/branch/ReleephBranchPreviewView.php @@ -0,0 +1,60 @@ +setID($celerity_id); + $this->dynamics[$param_name] = $celerity_id; + return $this; + } + + public function addStatic($param_name, $value) { + $this->statics[$param_name] = $value; + return $this; + } + + public function getCustomControlClass() { + require_celerity_resource('releeph-preview-branch'); + return 'releeph-preview-branch'; + } + + public function renderInput() { + static $required_params = array( + 'arcProjectID', + 'projectName', + 'isSymbolic', + 'template', + ); + + $all_params = array_merge($this->statics, $this->dynamics); + foreach ($required_params as $param_name) { + if (idx($all_params, $param_name) === null) { + throw new Exception( + "'{$param_name}' is not set as either a static or dynamic!"); + } + } + + $output_id = celerity_generate_unique_node_id(); + + Javelin::initBehavior('releeph-preview-branch', array( + 'uri' => '/releeph/branch/preview/', + 'outputID' => $output_id, + 'params' => array( + 'static' => $this->statics, + 'dynamic' => $this->dynamics, + ) + )); + + return phutil_tag( + 'div', + array( + 'id' => $output_id, + ), + ''); + } + +} diff --git a/src/applications/releeph/view/branch/ReleephBranchTemplate.php b/src/applications/releeph/view/branch/ReleephBranchTemplate.php new file mode 100644 index 0000000000..cef13ee256 --- /dev/null +++ b/src/applications/releeph/view/branch/ReleephBranchTemplate.php @@ -0,0 +1,241 @@ +load($arc_project_id); + if (!$arc_project) { + throw new Exception( + "No Arc project found with id '{$arc_project_id}'!"); + } + + $repository = $arc_project->loadRepository(); + return id(new PhabricatorObjectHandle()) + ->setName($repository->formatCommitName('100000000000')); + } + + private $commitHandle; + private $branchDate = null; + private $projectName; + private $isSymbolic; + + public function setCommitHandle(PhabricatorObjectHandle $handle) { + $this->commitHandle = $handle; + return $this; + } + + public function setBranchDate($branch_date) { + $this->branchDate = $branch_date; + return $this; + } + + public function setReleephProjectName($project_name) { + $this->projectName = $project_name; + return $this; + } + + public function setSymbolic($is_symbolic) { + $this->isSymbolic = $is_symbolic; + return $this; + } + + public function interpolate($template) { + if (!$this->projectName) { + return array('', array()); + } + + list($name, $name_errors) = $this->interpolateInner( + $template, + $this->isSymbolic); + + if ($this->isSymbolic) { + return array($name, $name_errors); + } else { + $validate_errors = $this->validateAsBranchName($name); + $errors = array_merge($name_errors, $validate_errors); + return array($name, $errors); + } + } + + public static function getHelpRemarkup() { + return << releases/2012-30-16-rHERGE32cd512a52b7 + +Include a second hierarchy if you share your repository with other projects: + + lang=none + releases/%P/%p-release-%Y%m%d-%V + => releases/Tintin/tintin-release-20121116-32cd512a52b7 + +Keep your branch names simple, avoiding strange punctuation, most of which is +forbidden or escaped anyway: + + lang=none, counterexample + releases//..clown-releases..//`date --iso=seconds`-$(sudo halt) + +Include the date early in your template, in an order which sorts properly: + + lang=none + releases/%Y%m%d-%v + => releases/20121116-rHERGE32cd512a52b7 (good!) + + releases/%V-%m.%d.%Y + => releases/32cd512a52b7-11.16.2012 (awful!) + + +EOTEXT + ; + } + + /* + * xsprintf() would be useful here, but that's for formatting concrete lists + * of things in a certain way... + * + * animal_printf('%A %A %A', $dog1, $dog2, $dog3); + * + * ...rather than interpolating percent-control-strings like strftime does. + */ + private function interpolateInner($template, $is_symbolic) { + $name = $template; + $errors = array(); + + $safe_project_name = str_replace(' ', '-', $this->projectName); + $short_commit_id = last( + preg_split('/r[A-Z]+/', $this->commitHandle->getName())); + + $interpolations = array(); + for ($ii = 0; $ii < strlen($name); $ii++) { + $char = substr($name, $ii, 1); + $prev = null; + if ($ii > 0) { + $prev = substr($name, $ii - 1, 1); + } + $next = substr($name, $ii + 1, 1); + if ($next && $char == '%' && $prev != '%') { + $interpolations[$ii] = $next; + } + } + + $variable_interpolations = array(); + + $reverse_interpolations = $interpolations; + krsort($reverse_interpolations); + + if ($this->branchDate) { + $branch_date = $this->branchDate; + } else { + $branch_date = $this->commitHandle->getTimestamp(); + } + + foreach ($reverse_interpolations as $position => $code) { + $replacement = null; + switch ($code) { + case 'v': + $replacement = $this->commitHandle->getName(); + $is_variable = true; + break; + + case 'V': + $replacement = $short_commit_id; + $is_variable = true; + break; + + case 'P': + $replacement = $safe_project_name; + $is_variable = false; + break; + + case 'p': + $replacement = strtolower($safe_project_name); + $is_variable = false; + break; + + default: + // Format anything else using strftime() + $replacement = strftime("%{$code}", $branch_date); + $is_variable = true; + break; + } + + if ($is_variable) { + $variable_interpolations[] = $code; + } + $name = substr_replace($name, $replacement, $position, 2); + } + + if (!$is_symbolic && !$variable_interpolations) { + $errors[] = "Include additional interpolations that aren't static!"; + } + + return array($name, $errors); + } + + private function validateAsBranchName($name) { + $errors = array(); + + if (preg_match('{^/}', $name) || preg_match('{/$}', $name)) { + $errors[] = "Branches cannot begin or end with '/'"; + } + + if (preg_match('{//+}', $name)) { + $errors[] = "Branches cannot contain multiple consective '/'"; + } + + $parts = array_filter(explode('/', $name)); + foreach ($parts as $index => $part) { + $part_error = null; + if (preg_match('{^\.}', $part) || preg_match('{\.$}', $part)) { + $errors[] = "Path components cannot begin or end with '.'"; + } elseif (preg_match('{^(?!\w)}', $part)) { + $errors[] = "Path components must begin with an alphanumeric"; + } elseif (!preg_match('{^\w ([\w-_%\.]* [\w-_%])?$}x', $part)) { + $errors[] = + "Path components may only contain alphanumerics ". + "or '-', '_', or '.'"; + } + } + + return $errors; + } + +} diff --git a/src/applications/releeph/view/project/list/ReleephActiveProjectListView.php b/src/applications/releeph/view/project/list/ReleephActiveProjectListView.php new file mode 100644 index 0000000000..6b9d533f2b --- /dev/null +++ b/src/applications/releeph/view/project/list/ReleephActiveProjectListView.php @@ -0,0 +1,102 @@ +releephProjects = $releeph_projects; + return $this; + } + + public function render() { + $rows = array(); + foreach ($this->releephProjects as $releeph_project) { + $project_uri = $releeph_project->getURI(); + + $name_link = phutil_tag( + 'a', + array( + 'href' => $project_uri, + 'style' => 'font-weight: bold;', + ), + $releeph_project->getName()); + + $edit_button = phutil_tag( + 'a', + array( + 'href' => $releeph_project->getURI('edit/'), + 'class' => 'small grey button', + ), + 'Edit'); + + $deactivate_button = javelin_tag( + 'a', + array( + 'href' => $releeph_project->getURI('action/deactivate/'), + 'class' => 'small grey button', + 'sigil' => 'workflow', + ), + 'Remove'); + + $arc_project = $releeph_project->loadArcanistProject(); + if ($arc_project) { + $arc_project_name = $arc_project->getName(); + } else { + $arc_project_name = phutil_tag( + 'i', + array(), + 'Deleted Arcanist Project'); + } + + $repo = $releeph_project->loadPhabricatorRepository(); + + if ($repo) { + $vcs_type = + PhabricatorRepositoryType::getNameForRepositoryType( + $repo->getVersionControlSystem()); + + $rows[] = array( + $name_link, + $repo->getName(), + $arc_project_name, + $vcs_type, + $edit_button, + $deactivate_button, + ); + } else { + $rows[] = array( + $name_link, + phutil_tag('i', array(), 'Deleted Repository'), + $arc_project_name, + null, + null, + $deactivate_button, + ); + } + } + + $table = new AphrontTableView($rows); + + $table->setHeaders(array( + 'Name', + 'Repository', + 'Arcanist Project', + 'Type', + '', + '' + )); + + $table->setColumnClasses(array( + null, + null, + 'wide', + null, + 'action', + 'action' + )); + + return $table->render(); + } + +} diff --git a/src/applications/releeph/view/project/list/ReleephInactiveProjectListView.php b/src/applications/releeph/view/project/list/ReleephInactiveProjectListView.php new file mode 100644 index 0000000000..765145e207 --- /dev/null +++ b/src/applications/releeph/view/project/list/ReleephInactiveProjectListView.php @@ -0,0 +1,112 @@ +releephProjects = $releeph_projects; + return $this; + } + + public function render() { + $rows = array(); + + $phids = array(); + foreach ($this->releephProjects as $releeph_project) { + $phids[] = $releeph_project->getCreatedByUserPHID(); + if ($phid = $releeph_project->getDetail('last_deactivated_user')) { + $phids[] = $phid; + } + } + + $handles = id(new PhabricatorObjectHandleData($phids)) + ->setViewer($this->getUser()) + ->loadHandles(); + + foreach ($this->releephProjects as $releeph_project) { + $repository = $releeph_project->loadPhabricatorRepository(); + + if (!$repository) { + // Ignore projects referring to repositories that have been deleted. + continue; + } + + $activate_link = javelin_tag( + 'a', + array( + 'href' => $releeph_project->getURI('action/activate/'), + 'class' => 'small grey button', + 'sigil' => 'workflow', + ), + 'Revive'); + + $delete_link = javelin_tag( + 'a', + array( + 'href' => $releeph_project->getURI('action/delete/'), + 'class' => 'small grey button', + 'sigil' => 'workflow', + ), + 'Delete'); + + $rows[] = array( + $releeph_project->getName(), + $repository->getName(), + $this->renderCreationInfo($releeph_project, $handles), + $this->renderDeletionInfo($releeph_project, $handles), + $activate_link, + $delete_link, + ); + } + + $table = new AphrontTableView($rows); + + $table->setHeaders(array( + 'Name', + 'Repository', + 'Created', + 'Deleted', + '', + '', + )); + + $table->setColumnClasses(array( + null, + null, + null, + 'wide', + 'action', + 'action', + )); + + return $table->render(); + } + + private function renderCreationInfo($releeph_project, $handles) { + $creator = $handles[$releeph_project->getCreatedByUserPHID()]; + $when = $releeph_project->getDateCreated(); + return hsprintf( + '%s by %s', + phabricator_relative_date($when, $this->user), + $creator->getName()); + } + + private function renderDeletionInfo($releeph_project, $handles) { + $deleted_on = $releeph_project->getDetail('last_deactivated_time'); + + $deleted_by_name = null; + $deleted_by_phid = $releeph_project->getDetail('last_deactivated_user'); + if ($deleted_by_phid) { + $deleted_by_name = $handles[$deleted_by_phid]->getName(); + } else { + $deleted_by_name = 'unknown'; + } + + return hsprintf( + '%s by %s', + phabricator_relative_date($deleted_on, $this->user), + $deleted_by_name); + } + +} diff --git a/src/applications/releeph/view/request/ReleephRequestIntentsView.php b/src/applications/releeph/view/request/ReleephRequestIntentsView.php new file mode 100644 index 0000000000..b9c78680ab --- /dev/null +++ b/src/applications/releeph/view/request/ReleephRequestIntentsView.php @@ -0,0 +1,104 @@ +releephRequest = $rq; + return $this; + } + + public function setReleephProject(ReleephProject $rp) { + $this->releephProject = $rp; + return $this; + } + + public function render() { + require_celerity_resource('releeph-intents'); + + return phutil_tag( + 'div', + array( + 'class' => 'releeph-intents', + ), + array( + $this->renderIntentList(ReleephRequest::INTENT_WANT), + $this->renderIntentList(ReleephRequest::INTENT_PASS) + )); + } + + private function renderIntentList($render_intent) { + if (!$this->releephProject) { + throw new Exception("Must call setReleephProject() first!"); + } + + $project = $this->releephProject; + $request = $this->releephRequest; + $handles = $request->getHandles(); + + $is_want = $render_intent == ReleephRequest::INTENT_WANT; + $should = $request->shouldBeInBranch(); + + $pusher_links = array(); + $user_links = array(); + + $intents = $request->getUserIntents(); + foreach ($intents as $user_phid => $user_intent) { + if ($user_intent == $render_intent) { + $is_pusher = $project->isPusherPHID($user_phid); + + if ($is_pusher) { + $pusher_links[] = phutil_tag( + 'span', + array( + 'class' => 'pusher' + ), + $handles[$user_phid]->renderLink()); + } else { + $class = 'bystander'; + if ($request->getRequestUserPHID() == $user_phid) { + $class = 'requestor'; + } + $user_links[] = phutil_tag( + 'span', + array( + 'class' => $class, + ), + $handles[$user_phid]->renderLink()); + } + } + } + + // Don't render anything + if (!$pusher_links && !$user_links) { + return null; + } + + $links = array_merge($pusher_links, $user_links); + if ($links) { + $markup = $links; + } else { + $markup = array(' '); + } + + // Stick an arrow up front + $arrow_class = 'arrow '.$render_intent; + array_unshift($markup, phutil_tag( + 'div', + array( + 'class' => $arrow_class, + ), + '')); + + return phutil_tag( + 'div', + array( + 'class' => 'intents', + ), + $markup); + } + + +} diff --git a/src/applications/releeph/view/request/ReleephRequestStatusView.php b/src/applications/releeph/view/request/ReleephRequestStatusView.php new file mode 100644 index 0000000000..4076f037f6 --- /dev/null +++ b/src/applications/releeph/view/request/ReleephRequestStatusView.php @@ -0,0 +1,53 @@ +releephRequest = $rq; + return $this; + } + + public function render() { + require_celerity_resource('releeph-status'); + + $request = $this->releephRequest; + $status = $request->getStatus(); + $pick_status = $request->getPickStatus(); + + $description = ReleephRequest::getStatusDescriptionFor($status); + + $warning = null; + + if ($status == ReleephRequest::STATUS_NEEDS_PICK) { + if ($pick_status == ReleephRequest::PICK_FAILED) { + $warning = 'Last pick failed!'; + } + } elseif ($status == ReleephRequest::STATUS_NEEDS_REVERT) { + if ($pick_status == ReleephRequest::REVERT_FAILED) { + $warning = 'Last revert failed!'; + } + } + + return phutil_tag( + 'div', + array( + 'class' => 'releeph-status', + ), + array( + phutil_tag( + 'div', + array( + 'class' => 'description', + ), + $description), + phutil_tag( + 'div', + array( + 'class' => 'warning', + ), + $warning))); + } + +} diff --git a/src/applications/releeph/view/request/ReleephRequestTypeaheadControl.php b/src/applications/releeph/view/request/ReleephRequestTypeaheadControl.php new file mode 100644 index 0000000000..a6f0355fd0 --- /dev/null +++ b/src/applications/releeph/view/request/ReleephRequestTypeaheadControl.php @@ -0,0 +1,58 @@ +repo = $repo; + return $this; + } + + public function setStartTime($epoch) { + $this->startTime = $epoch; + return $this; + } + + public function getCustomControlClass() { + return 'releeph-request-typeahead'; + } + + public function renderInput() { + $id = celerity_generate_unique_node_id(); + + $div = phutil_tag( + 'div', + array( + 'style' => 'position: relative;', + 'id' => $id, + ), + phutil_tag( + 'input', + array( + 'autocomplete' => 'off', + 'type' => 'text', + 'name' => $this->getName(), + ), + '')); + + require_celerity_resource('releeph-request-typeahead-css'); + + Javelin::initBehavior('releeph-request-typeahead', array( + 'id' => $id, + 'src' => '/releeph/request/typeahead/', + 'placeholder' => 'Type a commit id or first line of commit message...', + 'value' => $this->getValue(), + 'aux' => array( + 'repo' => $this->repo->getID(), + 'callsign' => $this->repo->getCallsign(), + 'since' => $this->startTime, + 'limit' => 16, + ) + )); + + return $div; + } + +} diff --git a/src/applications/releeph/view/request/header/ReleephRequestHeaderListView.php b/src/applications/releeph/view/request/header/ReleephRequestHeaderListView.php new file mode 100644 index 0000000000..98dd9d44b2 --- /dev/null +++ b/src/applications/releeph/view/request/header/ReleephRequestHeaderListView.php @@ -0,0 +1,113 @@ +originType = $origin; + return $this; + } + + public function setReleephProject(ReleephProject $rp) { + $this->releephProject = $rp; + return $this; + } + + public function setReleephBranch(ReleephBranch $rb) { + $this->releephBranch = $rb; + return $this; + } + + public function setReleephRequests(array $requests) { + assert_instances_of($requests, 'ReleephRequest'); + $this->releephRequests = $requests; + return $this; + } + + public function setAphrontRequest(AphrontRequest $request) { + $this->aphrontRequest = $request; + return $this; + } + + public function setReloadOnStateChange($bool) { + $this->reload = $bool; + return $this; + } + + public function render() { + $views = $this->renderInner(); + require_celerity_resource('phabricator-notification-css'); + Javelin::initBehavior('releeph-request-state-change', array( + 'reload' => $this->reload, + )); + + $error_view = null; + if ($this->errors) { + $error_view = id(new AphrontErrorView()) + ->setTitle('Bulk load errors') + ->setSeverity(AphrontErrorView::SEVERITY_WARNING) + ->setErrors($this->errors) + ->render(); + } + + $list = phutil_tag( + 'div', + array( + 'data-sigil' => 'releeph-request-header-list', + ), + $views); + + return $this->renderSingleView(array( + $error_view, + $list)); + } + + /** + * Required for generating markup for ReleephRequestActionController. + * + * That controller just needs the markup, and doesn't need to start the + * javelin behavior. + */ + public function renderInner() { + $selector = $this->releephProject->getReleephFieldSelector(); + $fields = $selector->getFieldSpecifications(); + foreach ($fields as $field) { + $field + ->setReleephProject($this->releephProject) + ->setReleephBranch($this->releephBranch) + ->setUser($this->user); + try { + $field->bulkLoad($this->releephRequests); + } catch (Exception $ex) { + $this->errors[] = $ex; + } + } + + $field_groups = $selector->arrangeFieldsForHeaderView($fields); + + $views = array(); + foreach ($this->releephRequests as $releeph_request) { + $views[] = id(new ReleephRequestHeaderView()) + ->setUser($this->user) + ->setAphrontRequest($this->aphrontRequest) + ->setOriginType($this->originType) + ->setReleephProject($this->releephProject) + ->setReleephBranch($this->releephBranch) + ->setReleephRequest($releeph_request) + ->setReleephFieldGroups($field_groups) + ->render(); + } + + return $views; + } + +} diff --git a/src/applications/releeph/view/request/header/ReleephRequestHeaderView.php b/src/applications/releeph/view/request/header/ReleephRequestHeaderView.php new file mode 100644 index 0000000000..87753e5b8a --- /dev/null +++ b/src/applications/releeph/view/request/header/ReleephRequestHeaderView.php @@ -0,0 +1,334 @@ +aphrontRequest = $request; + return $this; + } + + public function setReleephProject(ReleephProject $rp) { + $this->releephProject = $rp; + return $this; + } + + public function setReleephBranch(ReleephBranch $rb) { + $this->releephBranch = $rb; + return $this; + } + + public function setReleephRequest(ReleephRequest $rr) { + $this->releephRequest = $rr; + return $this; + } + + public function setOriginType($origin) { + // For the Edit controller + $this->originType = $origin; + return $this; + } + + public function setReleephFieldGroups(array $field_groups) { + $this->fieldGroups = $field_groups; + return $this; + } + + protected function getOrigin() { + return $this->originType; + } + + public function render() { + require_celerity_resource('releeph-core'); + $all_properties_table = $this->renderFields(); + + require_celerity_resource('releeph-colors'); + $status = $this->releephRequest->getStatus(); + $rr_div_class = + 'releeph-request-header '. + 'releeph-request-header-border '. + 'releeph-border-color-'.ReleephRequest::getStatusClassSuffixFor($status); + + $hidden_link = phutil_tag( + 'a', + array( + 'href' => '/RQ'.$this->releephRequest->getID(), + 'target' => '_blank', + 'data-sigil' => 'hidden-link', + ), + ''); + + $focus_char = phutil_tag( + 'div', + array( + 'class' => 'focus-char', + 'data-sigil' => 'focus-char', + ), + "\xE2\x98\x86"); + + $rr_div = phutil_tag( + 'div', + array( + 'data-sigil' => 'releeph-request-header', + 'class' => $rr_div_class, + ), + array( + phutil_tag( + 'div', + array(), + array( + phutil_tag( + 'h1', + array(), + array( + $focus_char, + $this->renderTitleLink(), + $hidden_link + )), + $all_properties_table, + )), + phutil_tag( + 'div', + array( + 'class' => 'button-divider', + ), + $this->renderActionButtonsTable()))); + + return $rr_div; + } + + private function renderFields() { + $field_row_groups = $this->fieldGroups; + + $trs = array(); + foreach ($field_row_groups as $field_column_group) { + $tds = array(); + foreach ($field_column_group as $side => $fields) { + $rows = array(); + foreach ($fields as $field) { + $rows[] = $this->renderOneField($field); + } + $pane = phutil_tag( + 'table', + array( + 'class' => 'fields', + ), + $rows); + $tds[] = phutil_tag( + 'td', + array( + 'class' => 'side '.$side, + ), + $pane); + } + $trs[] = phutil_tag( + 'tr', + array(), + $tds); + } + + return phutil_tag( + 'table', + array( + 'class' => 'panes', + ), + $trs); + } + + private function renderOneField(ReleephFieldSpecification $field) { + $field + ->setUser($this->user) + ->setReleephProject($this->releephProject) + ->setReleephBranch($this->releephBranch) + ->setReleephRequest($this->releephRequest); + + $label = $field->renderLabelForHeaderView(); + try { + $value = $field->renderValueForHeaderView(); + } catch (Exception $ex) { + if ($this->aphrontRequest->getInt(self::THROW_PARAM)) { + throw $ex; + } else { + $value = $this->renderExceptionIcon($ex); + } + } + + if ($value) { + if (!$label) { + return phutil_tag( + 'tr', + array(), + phutil_tag('td', array('colspan' => 2), $value)); + } else { + return phutil_tag( + 'tr', + array(), + array( + phutil_tag('th', array(), $label), + phutil_tag('td', array(), $value))); + } + } + } + + private function renderExceptionIcon(Exception $ex) { + Javelin::initBehavior('phabricator-tooltips'); + require_celerity_resource('aphront-tooltip-css'); + $throw_uri = $this + ->aphrontRequest + ->getRequestURI() + ->setQueryParam(self::THROW_PARAM, 1); + + $message = $ex->getMessage(); + if (!$message) { + $message = get_class($ex).' with no message.'; + } + + return javelin_tag( + 'a', + array( + 'class' => 'releeph-field-error', + 'sigil' => 'has-tooltip', + 'meta' => array( + 'tip' => $message, + 'size' => 400, + 'align' => 'E', + ), + 'href' => $throw_uri, + ), + '!!!'); + } + + private function renderTitleLink() { + $rq_id = $this->releephRequest->getID(); + $summary = $this->releephRequest->getSummaryForDisplay(); + return phutil_tag( + 'a', + array( + 'href' => '/RQ'.$rq_id, + ), + hsprintf( + 'RQ%d: %s', + $rq_id, + $summary)); + } + + private function renderActionButtonsTable() { + $left_buttons = array(); + $right_buttons = array(); + + $user_phid = $this->user->getPHID(); + $is_pusher = $this->releephProject->isPusherPHID($user_phid); + $is_requestor = $this->releephRequest->getRequestUserPHID() === $user_phid; + + $current_intent = idx( + $this->releephRequest->getUserIntents(), + $this->user->getPHID()); + + if ($is_pusher) { + $left_buttons[] = $this->renderIntentButton(true, 'Approve', 'green'); + $left_buttons[] = $this->renderIntentButton(false, 'Reject'); + } else { + if ($is_requestor) { + $right_buttons[] = $this->renderIntentButton(true, 'Request'); + $right_buttons[] = $this->renderIntentButton(false, 'Remove'); + } else { + $right_buttons[] = $this->renderIntentButton(true, 'Want'); + $right_buttons[] = $this->renderIntentButton(false, 'Pass'); + } + } + + // Allow the pusher to mark a request as manually picked or reverted. + if ($is_pusher || $is_requestor) { + if ($this->releephRequest->getInBranch()) { + $left_buttons[] = $this->renderActionButton( + 'Mark Manually Reverted', + 'mark-manually-reverted'); + } else { + $left_buttons[] = $this->renderActionButton( + 'Mark Manually Picked', + 'mark-manually-picked'); + } + } + + $right_buttons[] = phutil_tag( + 'a', + array( + 'href' => '/releeph/request/edit/'.$this->releephRequest->getID(). + '?origin='.$this->originType, + 'class' => 'small blue button', + ), + 'Edit'); + + if (!$left_buttons && !$right_buttons) { + return; + } + + $cells = array(); + foreach ($left_buttons as $button) { + $cells[] = phutil_tag('td', array('align' => 'left'), $button); + } + $cells[] = phutil_tag('td', array('class' => 'wide'), ''); + foreach ($right_buttons as $button) { + $cells[] = phutil_tag('td', array('align' => 'right'), $button); + } + + $table = phutil_tag( + 'table', + array( + 'class' => 'buttons', + ), + phutil_tag( + 'tr', + array(), + $cells)); + + return $table; + } + + private function renderIntentButton($want, $name, $class = null) { + $current_intent = idx( + $this->releephRequest->getUserIntents(), + $this->user->getPHID()); + + if ($current_intent) { + // If this is a "want" button, and they already want it, disable the + // button (and vice versa for the "pass" case.) + if (($want && $current_intent == ReleephRequest::INTENT_WANT) || + (!$want && $current_intent == ReleephRequest::INTENT_PASS)) { + + $class .= ' disabled'; + } + } + + $action = $want ? 'want' : 'pass'; + return $this->renderActionButton($name, $action, $class); + } + + private function renderActionButton($name, $action, $class=null) { + $attributes = array( + 'class' => 'small button '.$class, + 'sigil' => 'releeph-request-state-change '.$action, + 'meta' => null, + ); + + if ($class != 'disabled') { + // NB the trailing slash on $uri is critical, otherwise the URI will + // redirect to one with a slash, which will turn our GET into a POST. + $attributes['meta'] = sprintf( + '/releeph/request/action/%s/%d/', + $action, + $this->releephRequest->getID()); + } + + return javelin_tag('a', $attributes, $name); + } + +} diff --git a/src/applications/releeph/view/requestevent/ReleephRequestEventListView.php b/src/applications/releeph/view/requestevent/ReleephRequestEventListView.php new file mode 100644 index 0000000000..ab0bca7d57 --- /dev/null +++ b/src/applications/releeph/view/requestevent/ReleephRequestEventListView.php @@ -0,0 +1,266 @@ +events = $events; + return $this; + } + + public function setHandles(array $handles) { + assert_instances_of($handles, 'PhabricatorObjectHandle'); + $this->handles = $handles; + return $this; + } + + public function render() { + $views = array(); + + $discovered_commits = array(); + foreach ($this->events as $event) { + $commit_id = $event->getDetail('newCommitIdentifier'); + switch ($event->getType()) { + case ReleephRequestEvent::TYPE_DISCOVERY: + $discovered_commits[$commit_id] = true; + break; + } + } + + $markup_engine = PhabricatorMarkupEngine::newDifferentialMarkupEngine(); + $markup_engine->setConfig('viewer', $this->getUser()); + + foreach ($this->events as $event) { + $description = $this->describeEvent($event); + if (!$description) { + continue; + } + + if ($event->getType() === ReleephRequestEvent::TYPE_COMMIT) { + $commit_id = $event->getDetail('newCommitIdentifier'); + if (idx($discovered_commits, $commit_id)) { + continue; + } + } + + $actor_handle = $this->handles[$event->getActorPHID()]; + $description = $this->describeEvent($event); + $action = phutil_tag( + 'div', + array(), + array( + $actor_handle->renderLink(), + ' ', + $description)); + + $view = id(new PhabricatorTransactionView()) + ->setUser($this->user) + ->setImageURI($actor_handle->getImageURI()) + ->setEpoch($event->getDateCreated()) + ->setActions(array($action)) + ->addClass($this->getTransactionClass($event)); + + $comment = $this->getEventComment($event); + if ($comment) { + $markup = phutil_tag( + 'div', + array( + 'class' => 'phabricator-remarkup', + ), + phutil_safe_html( + $markup_engine->markupText($comment))); + $view->appendChild($markup); + } + + $views[] = $view; + } + + return phutil_tag( + 'div', + array( + 'class' => 'releeph-request-event-list', + ), + $views); + } + + public function renderForEmail() { + $items = array(); + foreach ($this->events as $event) { + $description = $this->describeEvent($event); + if (!$description) { + continue; + } + $actor = $this->handles[$event->getActorPHID()]->getName(); + $items[] = $actor.' '.$description; + + $comment = $this->getEventComment($event); + if ($comment) { + $items[] = preg_replace('/^/m', ' ', $comment); + } + } + + return implode("\n\n", $items); + } + + private function describeEvent(ReleephRequestEvent $event) { + $type = $event->getType(); + + switch ($type) { + case ReleephRequestEvent::TYPE_CREATE: + return "created this request."; + break; + + case ReleephRequestEvent::TYPE_STATUS: + $status = $event->getStatusAfter(); + return sprintf( + "updated status to %s.", + ReleephRequest::getStatusDescriptionFor($status)); + break; + + case ReleephRequestEvent::TYPE_USER_INTENT: + $intent = $event->getDetail('newIntent'); + $was_pusher = $event->getDetail('wasPusher'); + if ($intent == ReleephRequest::INTENT_WANT) { + if ($was_pusher) { + $verb = "approved"; + } else { + $verb = "wanted"; + } + } else { + if ($was_pusher) { + $verb = "rejected"; + } else { + $verb = "passed on"; + } + } + return "{$verb} this request."; + break; + + case ReleephRequestEvent::TYPE_PICK_STATUS: + $pick_status = $event->getDetail('newPickStatus'); + switch ($pick_status) { + case ReleephRequest::PICK_FAILED: + return "found a conflict when picking."; + break; + + case ReleephRequest::REVERT_FAILED: + return "found a conflict when reverting."; + break; + + case ReleephRequest::PICK_OK: + case ReleephRequest::REVERT_OK: + // (nothing) + break; + + default: + return "changed pick-status to {$pick_status}."; + break; + } + break; + + case ReleephRequestEvent::TYPE_MANUAL_ACTION: + $action = $event->getDetail('action'); + return "claimed to have manually {$action}ed this request."; + break; + + case ReleephRequestEvent::TYPE_COMMIT: + $action = $event->getDetail('action'); + if ($action) { + return "{$action}ed this request."; + } else { + return "did something with this request."; + } + break; + + case ReleephRequestEvent::TYPE_DISCOVERY: + $action = $event->getDetail('action'); + if ($action) { + return "{$action}ed this request."; + } else { + // It's unlikely we'll have action-less TYPE_DISCOVERY events, but I + // used this during testing and I guess it's a useful safety net. + return "discovered this request in the branch."; + } + break; + + case ReleephRequestEvent::TYPE_COMMENT: + return "commented on this request."; + break; + + default: + return "did event of type {$type}."; + break; + } + } + + private function getEventComment(ReleephRequestEvent $event) { + switch ($event->getType()) { + case ReleephRequestEvent::TYPE_CREATE: + $commit_phid = $event->getDetail('commitPHID'); + return sprintf( + "Commit %s was requested.", + $this->handles[$commit_phid]->getName()); + break; + + case ReleephRequestEvent::TYPE_STATUS: + case ReleephRequestEvent::TYPE_USER_INTENT: + case ReleephRequestEvent::TYPE_PICK_STATUS: + case ReleephRequestEvent::TYPE_MANUAL_ACTION: + // no comment! + break; + + case ReleephRequestEvent::TYPE_COMMIT: + return sprintf( + "Closed by commit %s.", + $event->getDetail('newCommitIdentifier')); + break; + + case ReleephRequestEvent::TYPE_DISCOVERY: + $author_phid = $event->getDetail('authorPHID'); + $commit_phid = $event->getDetail('newCommitPHID'); + if ($author_phid && $author_phid != $event->getActorPHID()) { + return sprintf( + "Closed by commit %s (with author set to @%s).", + $this->handles[$commit_phid]->getName(), + $this->handles[$author_phid]->getName()); + } else { + return sprintf( + 'Closed by commit %s.', + $this->handles[$commit_phid]->getName()); + } + break; + + case ReleephRequestEvent::TYPE_COMMENT: + return $event->getComment(); + break; + } + } + + private function getTransactionClass($event) { + switch ($event->getType()) { + case ReleephRequestEvent::TYPE_COMMIT: + case ReleephRequestEvent::TYPE_DISCOVERY: + $action = $event->getDetail('action'); + if ($action == 'pick') { + return 'releeph-border-color-picked'; + } else { + return 'releeph-border-color-abandoned'; + } + break; + + case ReleephRequestEvent::TYPE_COMMENT: + return 'releeph-border-color-comment'; + break; + + default: + $status_after = $event->getStatusAfter(); + $class_suffix = ReleephRequest::getStatusClassSuffixFor($status_after); + return ' releeph-border-color-'.$class_suffix; + break; + } + } + +} diff --git a/src/applications/releeph/view/user/ReleephDefaultUserView.php b/src/applications/releeph/view/user/ReleephDefaultUserView.php new file mode 100644 index 0000000000..93819cdad3 --- /dev/null +++ b/src/applications/releeph/view/user/ReleephDefaultUserView.php @@ -0,0 +1,9 @@ +getHandle()->renderLink(); + } + +} diff --git a/src/applications/releeph/view/user/ReleephUserView.php b/src/applications/releeph/view/user/ReleephUserView.php new file mode 100644 index 0000000000..129c846bac --- /dev/null +++ b/src/applications/releeph/view/user/ReleephUserView.php @@ -0,0 +1,74 @@ + $phid) { + if (!idx(self::$seen, $phid)) { + $todo[$key] = $phid; + self::$seen[$phid] = true; + } + } + + if ($todo) { + self::$handles = array_merge( + self::$handles, + id(new PhabricatorObjectHandleData($todo)) + ->setViewer($this->getUser()) + ->loadHandles()); + $this->loadInner($todo); + } + } + + private $phid; + private $releephProject; + + final public function setRenderUserPHID($phid) { + $this->phid = $phid; + return $this; + } + + final public function setReleephProject(ReleephProject $project) { + $this->releephProject = $project; + return $this; + } + + final protected function getRenderUserPHID() { + return $this->phid; + } + + final protected function getReleephProject() { + return $this->releephProject; + } + + final protected function getHandle() { + return self::$handles[$this->phid]; + } + +} diff --git a/src/infrastructure/internationalization/PhabricatorBaseEnglishTranslation.php b/src/infrastructure/internationalization/PhabricatorBaseEnglishTranslation.php index 743be93d34..6cf673a379 100644 --- a/src/infrastructure/internationalization/PhabricatorBaseEnglishTranslation.php +++ b/src/infrastructure/internationalization/PhabricatorBaseEnglishTranslation.php @@ -32,6 +32,8 @@ abstract class PhabricatorBaseEnglishTranslation 'COMMIT(S)' => array('COMMIT', 'COMMITS'), '%d line(s)' => array('%d line', '%d lines'), + '%d path(s)' => array('%d path', '%d paths'), + '%d diff(s)' => array('%d diff', '%d diffs'), 'added %d commit(s): %s' => array( 'added commit: %2$s', @@ -286,6 +288,10 @@ abstract class PhabricatorBaseEnglishTranslation ), ), + '%d comment(s)' => array('%d comment', '%d comments'), + '%d rejection(s)' => array('%d rejection', '%d rejections'), + '%d update(s)' => array('%d update', '%d updates'), + ); } diff --git a/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php b/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php index 2d4d5ec191..7a3a96fb7e 100644 --- a/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php +++ b/src/infrastructure/storage/patch/PhabricatorBuiltinPatchList.php @@ -171,6 +171,10 @@ final class PhabricatorBuiltinPatchList extends PhabricatorSQLPatchList { 'type' => 'db', 'name' => 'token', ), + 'db.releeph' => array( + 'type' => 'db', + 'name' => 'releeph', + ), '0000.legacy.sql' => array( 'type' => 'sql', 'name' => $this->getPatchPath('0000.legacy.sql'), @@ -1165,6 +1169,10 @@ final class PhabricatorBuiltinPatchList extends PhabricatorSQLPatchList { 'type' => 'sql', 'name' => $this->getPatchPath('20130304.lintauthor.sql'), ), + 'releeph.sql' => array( + 'type' => 'sql', + 'name' => $this->getPatchPath('releeph.sql'), + ), ); } diff --git a/webroot/rsrc/css/application/releeph/releeph-branch.css b/webroot/rsrc/css/application/releeph/releeph-branch.css new file mode 100644 index 0000000000..2bc7c83a74 --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-branch.css @@ -0,0 +1,79 @@ +/** + * @provides releeph-branch + */ + +.releeph-branch-box { + margin-bottom: .5em; + padding: .5em .5em .5em; + + border: 2px solid #d5d5d5; + /*border-top-color: #D5D5D5; + border-right-color: #BBB; + border-bottom-color: #A4A4A4; + border-left-color: #BBB;*/ + + background: #bbb; +} + +/* Types of branch */ + +.releeph-branch-box-named { + background: #ddd; +} + +.releeph-branch-box-latest { + background: #ffd; +} + +/* Branch symbolic name and full name */ + +.releeph-branch-box .names { + width: 25em; + float: left; + margin-bottom: 1em; +} + +.releeph-branch-box .names h1 { + font-size: 125%; + padding: 0px; +} + +.releeph-branch-box .names h2 { + font-weight: normal; + font-size: 85%; +} + +/* Date info */ + +.releeph-branch-box .date-info { + width: 10%; + float: left; + color: #555; + margin-bottom: .3em; +} + +/* Statistics table */ + +.releeph-branch-box .request-statistics { + float: right; + padding-right: 2em; + font-size: 85%; +} + +.releeph-branch-box .request-statistics th { + width: 1em; + text-align: right; + padding-right: .4em; + padding-left: .4em; +} + +.releeph-branch-box .request-statistics td { + white-space: nowrap; + font-style: italic; +} + +/* Buttons */ + +.releeph-branch-box .buttons { + float: right; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-colors.css b/webroot/rsrc/css/application/releeph/releeph-colors.css new file mode 100644 index 0000000000..481ff7031b --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-colors.css @@ -0,0 +1,35 @@ +/** + * @provides releeph-colors + */ + +.releeph-border-color-failed { + border-color: #d2d; +} + +.releeph-border-color-requested { + border-color: #ddd; +} + +.releeph-border-color-comment { + border-color: #ddd; +} + +.releeph-border-color-needs-pick { + border-color: #096; +} + +.releeph-border-color-rejected { + border-color: #d00; +} + +.releeph-border-color-needs-revert { + border-color: #d00; +} + +.releeph-border-color-abandoned { + border-color: #222; +} + +.releeph-border-color-picked { + border-color: #069; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-core.css b/webroot/rsrc/css/application/releeph/releeph-core.css new file mode 100644 index 0000000000..fdeb2810c9 --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-core.css @@ -0,0 +1,199 @@ +/** + * @provides releeph-core + */ + +.releeph-request-header { + margin: .5em 2em 3em; + + /** + * Copied from the old .differential-panel, present in commit + * f04d8ab1a747dc9719d378d9286088b677ce224c + * + * (As is the

code below) + */ + max-width: 1120px; + border: 1px solid #666622; + background: #efefdf; + padding: 15px 20px; + font-size: 13px; +} + +.releeph-request-header h1 { + width: 100%; + border-bottom: 1px solid #aaaa99; + padding-bottom: 8px; + margin-bottom: 8px; + position: relative; +} + +.releeph-request-header .focus-char { + left: -10px; + display: none; + float: left; + position: absolute; + top: 0px; + left: -1em; + + font-weight: bold; + + color: #880; + font-family: "Hiragino Kaku Gothic Pro", "Osaka", "Zapf Dingbats"; +} + +.releeph-request-header.focus .focus-char { + display: block; +} + +.releeph-request-header-border { + border-width: 1px 10px 1px; + border-color: #ddd; +} + + +/* Laying out properties / fields */ + +.releeph-request-header table.panes { + width: 100%; +} + +.releeph-request-header table.panes td.side { + width: 50%; + max-width: 1em; +} + +.releeph-request-header table.panes td.side.left { + padding-right: 20px; + border-right: 3px solid #bbb; +} + +.releeph-request-header table.panes td.side.right { + padding-left: 20px; +} + +.releeph-request-header table.panes td.side table.fields { + width: 100%; +} + +.releeph-request-header table.panes td.side table.fields tr { + vertical-align: middle; +} + +.releeph-request-header table.panes td.side table.fields th { + font-weight: bold; + text-align: right; + padding-right: 1em; + white-space: nowrap; +} + +.releeph-request-header table.panes td.side table.fields td { + width: 100%; /* wide! */ + max-width: 1em; +} + + +/* Buttons */ + +.releeph-request-header .button-divider { + clear: both; + margin-top: 1.5em; + border-top: 1px solid #bbb; +} + +.releeph-request-header .buttons { + width: 100%; +} + +.releeph-request-header .buttons tr { + padding: 1em; + margin: 3em; +} + +.releeph-request-header .buttons td { + padding: 1em .5em 0.2em; +} + +.releeph-request-header .buttons td.wide { + width: 100%; +} + +/* Colors: match differential colors */ + +.releeph-request-comment { + border-color: #ddd; +} + +.releeph-request-comment-pusher { + background: #8DEE8D; + border-color: #096; +} + +.releeph-request-comment-pusher div { + background: #8DEE8D; +} + +/* The diff size bar */ + +.releeph-request-header .diff-bar { + border: 0px; +} + +.releeph-request-header .diff-bar div { + width: 100px; + border: 1px solid; + border-top-color: #A4A4A4; + border-right-color: #BBB; + border-bottom-color: #D5D5D5; + border-left-color: #BBB; + background: white; + float: left; + margin-right: 1em; +} + +.releeph-request-header .diff-bar div div { + height: 10px; +} + +.releeph-request-header .diff-bar span { + color: #555; +} + +/* Rendering pick / commit errors, etc. */ + +.releeph-request-pick-failed-event h1:before { + content: '\2014 '; +} + +.releeph-request-pick-failed-event h1:after { + content: ' \2014'; +} + +.releeph-request-pick-failed-event h1 { + padding: 3px 10px 3px; + margin-bottom: 0.5em; + background: #ffb; + font-size: small; +} + +.releeph-request-pick-failed-event div { + font-family: monospace; + margin-bottom: 1.5em; + padding-left: 1em; + width: 70em; +} + +/* History view of request */ + +.releeph-request-event-list { + margin: .5em 2em .5em; +} + + +/* Shorten long header-text */ + +.releeph-header-text-truncated { + width: 100%; + float: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-intents.css b/webroot/rsrc/css/application/releeph/releeph-intents.css new file mode 100644 index 0000000000..9e7691149b --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-intents.css @@ -0,0 +1,37 @@ +/** + * @provides releeph-intents + */ + +.releeph-intents .intents { + clear: left; + width: 100%; + margin-top: 3px; +} + +.releeph-intents .arrow { + float: left; + clear: left; + margin-right: 0.4em; + padding: 8px; + background: transparent 0 0 no-repeat; +} + +.releeph-intents .arrow.want { + background-image: url('/rsrc/custom/image/icon/tango/go-next.png'); +} + +.releeph-intents .arrow.pass { + background-image: url('/rsrc/custom/image/icon/tango/go-previous-gray.png'); +} + +.releeph-intents a { + margin-right: 0.4em; +} + +.releeph-intents .pusher { + font-weight: bold; +} + +.releeph-intents .requestor { + font-weight: normal; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-preview-branch.css b/webroot/rsrc/css/application/releeph/releeph-preview-branch.css new file mode 100644 index 0000000000..2bce6632d4 --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-preview-branch.css @@ -0,0 +1,29 @@ +/** + * @provides releeph-preview-branch + */ + +.releeph-preview-branch { + min-height: 4em; + position: relative; +} + +.releeph-preview-branch .error { + padding-left: 22px; + background-repeat: no-repeat; + background-size: 16px auto; + background-image: url(/rsrc/custom/image/releeph/releeph_warning.png); + float: left; + position: absolute; + top: 2.5em; +} + +.releeph-preview-branch .name { + clear: both; + float: left; + position: absolute; + font-family: monospace; + font-size: 9pt !important; + background: white; + top: 0.7em; + padding: 2px; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-project.css b/webroot/rsrc/css/application/releeph/releeph-project.css new file mode 100644 index 0000000000..3a14751bdd --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-project.css @@ -0,0 +1,25 @@ +/** + * @provides releeph-project + */ + +/** + * ...from aphront-transaction.css + */ + +.releeph-pusher { + background: 2px 2px no-repeat; + margin-top: 1em; + margin-bottom: 1.25em; + margin-right: 1em; + min-height: 50px; + padding: 2px 0px; + + background-color: white; + border: 2px solid gray; + float: left; +} + +.releeph-pusher-body { + margin-left: 54px; + padding: 1em; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-request-differential-create-dialog.css b/webroot/rsrc/css/application/releeph/releeph-request-differential-create-dialog.css new file mode 100644 index 0000000000..7101f78789 --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-request-differential-create-dialog.css @@ -0,0 +1,17 @@ +/** + * @provides releeph-request-differential-create-dialog + */ + +.releeph-request-differential-create-dialog h1 { + color: gray; + font-style: italic; + font-size: 16px; + margin-top: 0.8em; +} + +.releeph-request-differential-create-dialog a { + font-weight: bold; + margin-left: 2em; + display: block; + margin-top: 1em; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-request-typeahead.css b/webroot/rsrc/css/application/releeph/releeph-request-typeahead.css new file mode 100644 index 0000000000..91a81acea6 --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-request-typeahead.css @@ -0,0 +1,27 @@ +/** + * @provides releeph-request-typeahead-css + */ + +.releeph-request-typeahead .commit-id { + color: #aaf; /* blue... */ + font-family: monospace; + font-size: 100%; + display: block; + float: left; +} + +.releeph-request-typeahead .author-info { + color: #080; /* ...and green, for search results! */ + text-align: right; + display: block; + float: right; + padding-left: 1em; +} + +.releeph-request-typeahead .focused .author-info { + color: #8b8; +} + +.releeph-request-typeahead .summary { + clear: both; +} diff --git a/webroot/rsrc/css/application/releeph/releeph-status.css b/webroot/rsrc/css/application/releeph/releeph-status.css new file mode 100644 index 0000000000..f41330e977 --- /dev/null +++ b/webroot/rsrc/css/application/releeph/releeph-status.css @@ -0,0 +1,26 @@ +/** + * @provides releeph-status + */ + +.releeph-status .description { + background: #d3d3d3; + padding: 2px 6px 3px; + margin-right: 4px; + margin-bottom: 5px; + display: block; + float: left; + border-radius: 8px; + -moz-border-radius: 8px; + -webkit-border-radius: 8px; + text-decoration: none; +} + +.releeph-status .warning { + margin-top: 2px; + margin-left: 0.8em; + float: left; + padding-left: 22px; + background-repeat: no-repeat; + background-size: 16px auto; + background-image: url(/rsrc/custom/image/releeph/releeph_warning.png); +} diff --git a/webroot/rsrc/js/application/releeph/releeph-preview-branch.js b/webroot/rsrc/js/application/releeph/releeph-preview-branch.js new file mode 100644 index 0000000000..76e406bd73 --- /dev/null +++ b/webroot/rsrc/js/application/releeph/releeph-preview-branch.js @@ -0,0 +1,49 @@ +/** + * @provides javelin-behavior-releeph-preview-branch + * @requires javelin-behavior + * javelin-dom + * javelin-stratcom + * javelin-uri + * javelin-util + */ + +JX.behavior('releeph-preview-branch', function(config) { + + var uri = JX.$U(config.uri); + for (param_name in config.params.static) { + var value = config.params.static[param_name]; + uri.setQueryParam(param_name, value); + } + + var output = JX.$(config.outputID); + + var dynamics = config.params.dynamic; + + function renderPreview() { + for (param_name in dynamics) { + var node_id = dynamics[param_name]; + var input = JX.$(node_id); + uri.setQueryParam(param_name, input.value); + } + var request = new JX.Request(uri, function(response) { + JX.DOM.setContent(output, JX.$H(response.markup)); + }); + request.send(); + } + + renderPreview(); + + for (ii in dynamics) { + var node_id = dynamics[ii]; + var input = JX.$(node_id); + JX.DOM.listen( + input, + ['keyup', 'click', 'change'], + null, + function(e) { + renderPreview(); + } + ); + } + +}); diff --git a/webroot/rsrc/js/application/releeph/releeph-request-state-change.js b/webroot/rsrc/js/application/releeph/releeph-request-state-change.js new file mode 100644 index 0000000000..286d527b84 --- /dev/null +++ b/webroot/rsrc/js/application/releeph/releeph-request-state-change.js @@ -0,0 +1,145 @@ +/** + * @provides javelin-behavior-releeph-request-state-change + * @requires javelin-behavior + * javelin-dom + * javelin-stratcom + * javelin-util + * phabricator-keyboard-shortcut + * phabricator-notification + */ + +JX.behavior('releeph-request-state-change', function(config) { + var root = JX.DOM.find(document, 'div', 'releeph-request-header-list'); + + function getRequestHeaderNodes() { + return JX.DOM.scry(root, 'div', 'releeph-request-header'); + } + + /** + * Keyboard navigation + */ + var keynav_cursor = -1; + var notification = new JX.Notification(); + + function keynavJump(manager, delta) { + // Calculate this everytime, because the DOM changes. + var headers = getRequestHeaderNodes(); + keynav_cursor += delta; + + if (keynav_cursor < 0) { + keynav_cursor = -1; + window.scrollTo(0); + keynavMarkup(); + return; + } + + if (keynav_cursor >= headers.length) { + keynav_cursor = headers.length - 1; + } + + var focus = headers[keynav_cursor]; + manager.scrollTo(focus); + + keynavMarkup(); + } + + function keynavMarkup() { + var headers = getRequestHeaderNodes(); + for (ii in headers) { + JX.DOM.alterClass(headers[ii], 'focus', ii == keynav_cursor); + } + } + + function keynavAction(manager, action_name) { + var headers = getRequestHeaderNodes(); + var header = headers[keynav_cursor]; + + if (keynav_cursor < 0) { + return; + } + + var sigil = action_name; + var button = JX.DOM.find(header, 'a', sigil); + if (button) { + button.click(); + } + } + + function keynavNavigateToRequestPage() { + var headers = getRequestHeaderNodes(); + var header = headers[keynav_cursor]; + JX.DOM.find(header, 'a', 'hidden-link').click(); + } + + new JX.KeyboardShortcut('j', 'Jump to next request.') + .setHandler(function(manager) { + keynavJump(manager, +1); + }) + .register(); + + new JX.KeyboardShortcut('k', 'Jump to previous request.') + .setHandler(function(manager) { + keynavJump(manager, -1); + }) + .register(); + + new JX.KeyboardShortcut('a', 'Approve the selected request.') + .setHandler(function(manager) { + keynavAction(manager, 'want'); + }) + .register(); + + new JX.KeyboardShortcut('r', 'Reject the selected request.') + .setHandler(function(manager) { + keynavAction(manager, 'pass'); + }) + .register(); + + new JX.KeyboardShortcut('g', "Open selected request's page in a new tab.") + .setHandler(function(manager) { + keynavNavigateToRequestPage(); + }) + .register(); + + + /** + * AJAXy state changes for request buttons. + */ + function request_action(node, url) { + var request = new JX.Request(url, function(response) { + if (config.reload) { + window.location.reload(); + } else { + var markup = JX.$H(response.markup); + JX.DOM.replace(node, markup); + keynavMarkup(); + } + }); + + request.send(); + } + + JX.Stratcom.listen( + 'click', + 'releeph-request-state-change', + function(e) { + var button = e.getNode('releeph-request-state-change'); + var node = e.getNode('releeph-request-header'); + var url = e.getNodeData('releeph-request-state-change'); + + // If this button has no action, or we've already responded to the first + // click... + if (!url || button.disabled) { + return; + } + + // There's a race condition here though :( + + JX.DOM.alterClass(button, 'disabled', true); + button.disabled = true; + + e.prevent(); + request_action(node, url); + } + ); +}); diff --git a/webroot/rsrc/js/application/releeph/releeph-request-typeahead.js b/webroot/rsrc/js/application/releeph/releeph-request-typeahead.js new file mode 100644 index 0000000000..fd932dd73b --- /dev/null +++ b/webroot/rsrc/js/application/releeph/releeph-request-typeahead.js @@ -0,0 +1,84 @@ +/** + * @provides javelin-behavior-releeph-request-typeahead + * @requires javelin-behavior + * javelin-util + * javelin-dom + * javelin-typeahead + * javelin-tokenizer + * javelin-typeahead-preloaded-source + * javelin-typeahead-ondemand-source + * javelin-dom + * javelin-stratcom + * javelin-util + */ + +JX.behavior('releeph-request-typeahead', function(config) { + var root = JX.$(config.id); + var datasource = new JX.TypeaheadOnDemandSource(config.src); + var callsign = config.aux.callsign; + + datasource.setAuxiliaryData(config.aux); + + datasource.setTransformer( + function(object) { + var full_commit_id = object[0]; + var short_commit_id = object[1]; + var author = object[2]; + var ago = object[3]; + var summary = object[4]; + + var callsign_commit_id = 'r' + callsign + short_commit_id; + + var box = + JX.$N( + 'div', + {}, + [ + JX.$N( + 'div', + { className: 'commit-id' }, + callsign_commit_id + ), + JX.$N( + 'div', + { className: 'author-info' }, + ago + ' ago by ' + author + ), + JX.$N( + 'div', + { className: 'summary' }, + summary + ), + ] + ); + + return { + name: callsign_commit_id, + tokenizable: callsign_commit_id + ' '+ short_commit_id + ' ' + summary, + display: box, + uri: null, + id: full_commit_id + }; + }); + + /** + * The default normalizer removes useful control characters that would help + * out search. For example, I was just trying to search for a commit with + * the string "a_file" in the message, which was normalized to "afile". + */ + datasource.setNormalizer(function(query) { + return query; + }); + + datasource.setMaximumResultCount(config.aux.limit); + + var typeahead = new JX.Typeahead(root); + typeahead.setDatasource(datasource); + + var placeholder = config.value || config.placeholder; + if (placeholder) { + typeahead.setPlaceholder(placeholder); + } + + typeahead.start(); +});