* @package mr * @link https://www.zentao.net * @property gitlabModel $gitlab */ class mrModel extends model { public $moduleName = 'mr'; public function __construct($appName = '') { parent::__construct($appName); if($this->app->rawModule == 'pullreq') $this->moduleName = 'pullreq'; } /** * 获取合并请求列表. * Get MR list of gitlab project. * * @param string $mode * @param string $param * @param string $orderBy * @param array $filterProjects * @param int $repoID * @param int $objectID * @param object $pager * @access public * @return array */ public function getList(string $mode = 'all', string $param = 'all', string $orderBy = 'id_desc', array $filterProjects = array(), int $repoID = 0, int $objectID = 0, ?object $pager = null): array { $filterProjectSql = ''; if(!$this->app->user->admin && !empty($filterProjects)) { foreach($filterProjects as $hostID => $projectID) { $filterProjectSql .= "(hostID = {$hostID} AND sourceProject = '{$projectID}') OR "; } if($filterProjectSql) $filterProjectSql = '(' . substr($filterProjectSql, 0, -3) . ')'; // Remove last or. } if($this->app->tab == 'project') { $executionIdList = $this->loadModel('execution')->fetchExecutionList($objectID, 'all'); if(!empty($executionIdList)) $objectID = array_merge(array_keys($executionIdList), array($objectID)); } return $this->dao->select('*')->from(TABLE_MR) ->where('deleted')->eq('0') ->beginIF($mode == 'status' && $param != 'all')->andWhere('status')->eq($param)->fi() ->beginIF($mode == 'assignee' && $param != 'all')->andWhere('assignee')->eq($param)->fi() ->beginIF($mode == 'creator' && $param != 'all')->andWhere('createdBy')->eq($param)->fi() ->beginIF($filterProjectSql)->andWhere($filterProjectSql)->fi() ->beginIF($repoID)->andWhere('repoID')->eq($repoID)->fi() ->beginIF($this->moduleName == 'mr')->andWhere('isFlow')->eq('0')->fi() ->beginIF($this->moduleName == 'pullreq')->andWhere('isFlow')->eq('1')->fi() ->beginIF($objectID && $this->moduleName == 'mr')->andWhere('executionID')->in($objectID)->fi() ->orderBy($orderBy) ->page($pager) ->fetchAll('id'); } /** * 根据代码库ID获取合并请求列表. * Get MR list by repoID. * * @access public * @return array */ public function getPairs(int $repoID): array { return $this->dao->select('id,title') ->from(TABLE_MR) ->where('deleted')->eq('0') ->andWhere('repoID')->eq($repoID) ->orderBy('id') ->fetchPairs('id', 'title'); } /** * 获取Gitea服务器的项目. * Get gitea projects. * * @param int $hostID * @access public * @return array */ public function getGiteaProjects(int $hostID = 0): array { $projects = $this->loadModel('gitea')->apiGetProjects($hostID); return array($hostID => helper::arrayColumn($projects, null, 'full_name')); } /** * 获取Gogs服务器的项目. * Get gogs projects. * * @param int $hostID * @access public * @return array */ public function getGogsProjects(int $hostID = 0): array { $projects = $this->loadModel('gogs')->apiGetProjects($hostID); return array($hostID => helper::arrayColumn($projects, null, 'full_name')); } /** * 获取Gitlab服务器的项目. * Get gitlab projects. * * @param int $hostID * @param array $projectIdList * @access public * @return array */ public function getGitlabProjects(int $hostID = 0, array $projectIdList = array()): array { if(!$this->app->user->admin) { $gitlabUsers = $this->loadModel('pipeline')->getProviderPairsByAccount('gitlab'); if(!isset($gitlabUsers[$hostID])) return array(); } $minProject = $maxProject = 0; /* Mysql string to int. */ $MR = $this->dao->select('min(CAST(sourceProject AS DECIMAL)) as minSource, MAX(CAST(sourceProject AS DECIMAL)) as maxSource,MIN(CAST(targetProject AS DECIMAL)) as minTarget,MAX(CAST(targetProject AS DECIMAL)) as maxTarget')->from(TABLE_MR) ->where('deleted')->eq('0') ->andWhere('hostID')->eq($hostID) ->beginIF($projectIdList)->andWhere('sourceProject', true)->in($projectIdList) ->orWhere('targetProject')->in($projectIdList) ->markRight(1) ->fi() ->fetch(); if(empty($MR->minSource) && empty($MR->minTarget)) return array(); $minProject = min($MR->minSource, $MR->minTarget); $maxProject = max($MR->maxSource, $MR->maxTarget); /* If not an administrator, need to obtain group member information. */ $groupIDList = array(0 => 0); $this->loadModel('gitlab'); if(!$this->app->user->admin) { $groups = $this->gitlab->apiGetGroups($hostID, 'name_asc', 'reporter'); foreach($groups as $group) $groupIDList[] = $group->id; } $allProjectPairs = array(); $allProjects = $this->gitlab->apiGetProjects($hostID, 'false', (int)$minProject, (int)$maxProject); foreach($allProjects as $project) { if($projectIdList && !in_array($project->id, $projectIdList)) continue; if(!$this->gitlab->checkUserAccess($hostID, 0, $project, $groupIDList, 'reporter')) continue; $project->isDeveloper = $this->gitlab->checkUserAccess($hostID, 0, $project, $groupIDList, 'developer'); $allProjectPairs[$hostID][$project->id] = $project; } return $allProjectPairs; } /** * 创建合并请求。 * Create MR function. * * @param object $MR * @access public * @return array */ public function create(object $MR): array { $result = $this->checkSameOpened($MR->hostID, $MR->sourceProject, $MR->sourceBranch, $MR->targetProject, $MR->targetBranch); if($result['result'] == 'fail') return $result; $MRID = $this->insertMr($MR); if(dao::isError()) return array('result' => 'fail', 'message' => dao::getError()); $this->loadModel('action')->create($this->moduleName, $MRID, 'opened'); if($MR->needCI && $MR->jobID) $this->execJob($MRID, (int)$MR->jobID); $rawMR = $this->apiCreateMR($MR->hostID, $MR); /** * Another open merge request already exists for this source branch. * The type of variable `$rawMR->message` is array. */ if(isset($rawMR->message) and !isset($rawMR->iid)) { $this->dao->delete()->from(TABLE_MR)->where('id')->eq($MRID)->exec(); $errorMessage = $this->convertApiError($rawMR->message); return array('result' => 'fail', 'message' => sprintf($this->lang->mr->apiError->createMR, $errorMessage)); } /* Create MR failed. */ if(!isset($rawMR->iid)) { $this->dao->delete()->from(TABLE_MR)->where('id')->eq($MRID)->exec(); return array('result' => 'fail', 'message' => $this->lang->mr->createFailedFromAPI); } /* Create a todo item for this MR. */ if(empty($MR->jobID)) $this->apiCreateMRTodo($MR->hostID, $MR->targetProject, $rawMR->iid); $newMR = new stdclass(); $newMR->mriid = $rawMR->iid; $newMR->status = $rawMR->state == 'open' ? 'opened' : $rawMR->state; $newMR->mergeStatus = $rawMR->merge_status; $this->dao->update(TABLE_MR)->data($newMR)->where('id')->eq($MRID)->autoCheck()->exec(); /* Link stories,bugs and tasks. */ $MR->id = $MRID; $MR->mriid = $newMR->mriid; $this->linkObjects($MR); if(dao::isError()) return array('result' => 'fail', 'message' => dao::getError()); $linkParams = $this->app->tab == 'execution' || $this->app->tab == 'project' ? "repoID=0&mode=status¶m=opened&objectID={$MR->executionID}" : "repoID={$MR->repoID}"; return array('result' => 'success', 'message' => $this->lang->saveSuccess, 'load' => helper::createLink($this->moduleName, 'browse', $linkParams)); } /** * 通过API创建合并请求。 * Create MR function by api. * * @access public * @return bool|int */ public function apiCreate(): int|false { $postData = fixer::input('post')->get(); $repo = $this->dao->findByID($postData->repoID)->from(TABLE_REPO)->fetch(); if(empty($repo)) { dao::$errors[] = 'No matched gitlab.'; return false; } /* Process and insert mr data. */ $MR = new stdClass(); $MR->hostID = (int)$repo->serviceHost; $MR->sourceProject = $repo->serviceProject; $MR->sourceBranch = $postData->sourceBranch; $MR->targetProject = $repo->serviceProject; $MR->targetBranch = $postData->targetBranch; $MR->diffs = $postData->diffs; $MR->title = $this->lang->mr->common . ' ' . $postData->sourceBranch . $this->lang->mr->to . $postData->targetBranch ; $MR->repoID = $repo->id; $MR->jobID = $postData->jobID; $MR->status = 'opened'; $MR->synced = '0'; $MR->needCI = '1'; $MR->hasNoConflict = $postData->mergeStatus ? '0' : '1'; $MR->mergeStatus = $postData->mergeStatus ? 'can_be_merged' : 'cannot_be_merged'; $MR->createdBy = $this->app->user->account; $MR->createdDate = date('Y-m-d H:i:s'); if($MR->sourceProject == $MR->targetProject && $MR->sourceBranch == $MR->targetBranch) { dao::$errors[] = $this->lang->mr->errorLang[1]; return false; } $result = $this->checkSameOpened($MR->hostID, (string)$MR->sourceProject, $MR->sourceBranch, (string)$MR->targetProject, $MR->targetBranch); if($result['result'] == 'fail') { dao::$errors[] = $result['message']; return false; } $MRID = $this->insertMr($MR); if(dao::isError()) return false; $this->loadModel('action')->create($this->moduleName, $MRID, 'opened'); /* Exec Job */ if($MR->hasNoConflict == '0' && $MR->mergeStatus == 'can_be_merged' && $MR->jobID) $this->execJob($MRID, (int)$MR->jobID); return $MRID; } /** * 更新合并请求。 * Edit MR function. * * @param int $MRID * @param object $MR * @access public * @return array */ public function update(int $MRID, object $MR): array { $oldMR = $this->fetchByID($MRID); $diff = array_diff_assoc((array)$oldMR, (array)$MR); if(!$oldMR) return array('result' => 'fail', 'message' => $this->lang->mr->notFound); /* The source and target branches cannot be the same. */ if($oldMR->sourceBranch == $MR->targetBranch ) return array('result' => 'fail', 'message' => $this->lang->mr->errorLang[1]); $this->dao->update(TABLE_MR)->data($MR)->checkIF($MR->needCI, 'jobID', 'notempty'); if(dao::isError()) return array('result' => 'fail', 'message' => dao::getError()); /* Exec Job */ $needExecJob = isset($diff['targetBranch']) || isset($diff['jobID']) ? true : false; if($needExecJob && isset($MR->jobID) && $MR->jobID) { $this->execJob($MRID, (int)$MR->jobID); if(dao::isError()) return array('result' => 'fail', 'message' => implode("\n", dao::getError())); } /* Known issue: `reviewer_ids` takes no effect. */ $rawMR = $this->apiUpdateMR($oldMR, $MR); if(!isset($rawMR->id) and isset($rawMR->message)) { $errorMessage = $this->convertApiError($rawMR->message); return array('result' => 'fail', 'message' => $errorMessage); } /* Update MR in Zentao database. */ $this->dao->update(TABLE_MR)->data($MR, $this->config->mr->edit->skippedFields) ->where('id')->eq($MRID) ->batchCheck($this->config->mr->edit->requiredFields, 'notempty') ->autoCheck() ->exec(); if(dao::isError()) return array('result' => 'fail', 'message' => dao::getError()); $MR = $this->fetchByID($MRID); $this->linkObjects($MR); $actionID = $this->loadModel('action')->create($this->moduleName, $MRID, 'edited'); $changes = common::createChanges($oldMR, $MR); if(!empty($changes)) { foreach($changes as &$change) if($change['field'] == 'assignee') $change['field'] = 'reviewer'; $this->action->logHistory($actionID, $changes); } $this->createMRLinkedAction($MRID, 'edit' . $this->moduleName, $MR->editedDate); if(dao::isError()) return array('result' => 'fail', 'message' => dao::getError()); if($this->session->{$this->app->tab}) $MR->executionID = $this->session->{$this->app->tab}; $linkParams = $this->app->tab == 'execution' || $this->app->tab == 'project' ? "repoID=0&mode=status¶m=opened&objectID={$MR->executionID}" : "repoID={$MR->repoID}"; return array('result' => 'success', 'message' => $needExecJob ? $this->lang->mr->triggeredCI : $this->lang->saveSuccess, 'load' => helper::createLink($this->moduleName, 'browse', $linkParams)); } /** * 更新合并请求关联信息。 * Update MR linked info. * * @param int $MRID * @param string $action createmr|editmr|removemr * @param string $actionDate * @access public * @return bool */ public function createMRLinkedAction(int $MRID, string $action, string $actionDate = ''): bool { if(empty($actionDate)) $actionDate = helper::now(); $MRAction = $actionDate . '::' . $this->app->user->account . '::' . helper::createLink($this->moduleName, 'view', "mr={$MRID}"); $this->loadModel('action'); foreach(array('story', 'task', 'bug') as $objectType) { $linkedObjects = $this->mrTao->getLinkedObjectPairs($MRID, $objectType); foreach($linkedObjects as $objectID) $this->action->create($objectType, $objectID, $action, '', $MRAction); } return !dao::isError(); } /** * 通过API同步合并请求。 * Sync MR from GitLab API to Zentao database. * * @param object $MR * @access public * @return object|false */ public function apiSyncMR(object $MR): object|false { $rawMR = $this->apiGetSingleMR($MR->repoID, $MR->mriid); /* Sync MR in ZenTao database whatever status of MR in GitLab. */ if(isset($rawMR->iid)) { $newMR = new stdclass(); $gitUsers = $this->loadModel('pipeline')->getUserBindedPairs($MR->hostID, $rawMR->gitService, 'openID,account'); foreach($this->config->mr->maps->sync as $syncField => $config) { $value = ''; list($field, $optionType, $options) = explode('|', $config); if($optionType == 'field') $value = $rawMR->$field; if($optionType == 'userPairs') { $gitUserID = ''; if(isset($rawMR->$field)) { $values = $rawMR->$field; if(isset($values[0])) $gitUserID = $values[0]->$options; } $value = zget($gitUsers, $gitUserID, ''); } if($value) $newMR->$syncField = $value; } /* For compatibility with PHP 5.4 . */ $condition = (array)$newMR; if(empty($condition)) return false; /* Update compile status of current MR object */ if(isset($MR->needCI) && $MR->needCI == '1') { $compile = $this->loadModel('compile')->getByID($MR->id); $newMR->compileStatus = $compile ? $compile->status : 'failed'; } /* Update MR in Zentao database. */ $newMR->editedBy = $this->app->user->account; $newMR->editedDate = helper::now(); $this->dao->update(TABLE_MR)->data($newMR) ->where('id')->eq($MR->id) ->exec(); } return $this->fetchByID($MR->id); } /** * 通过API批量同步合并请求。 * Batch Sync GitLab MR Database. * * @param array $MRList * @access public * @return array */ public function batchSyncMR(array $MRList): array { if(empty($MRList)) return array(); foreach($MRList as $key => $MR) { if($MR->status != 'opened') continue; $MRList[$key] = $this->apiSyncMR($MR); } return $MRList; } /** * 创建远程合并请求。 * Create MR by API. * * @param int $hostID * @param object $MR * @access public * @return object|null */ public function apiCreateMR(int $hostID, object $MR): object|null { $repo = $this->loadModel('repo')->getByID($MR->repoID); if(!$repo) return null; $openID = $this->loadModel('pipeline')->getOpenIdByAccount($hostID, strtolower($repo->SCM), $this->app->user->account); $assignee = ''; if($MR->assignee) $assignee = $this->pipeline->getOpenIdByAccount($hostID, strtolower($repo->SCM), $MR->assignee); $scm = $this->app->loadClass('scm'); $scm->setEngine($repo); $result = $scm->createMR($MR, $openID, $assignee); if(!empty($result->message)) $result->message = $this->convertApiError($result->message); return $result; } /** * 通过API查看是否有相同的合并请求。 * Get same opened mr by api. * * @param int $hostID * @param string $sourceProject * @param string $sourceBranch * @param string $targetProject * @param string $targetBranch * @access public * @return object|null */ public function apiGetSameOpened(int $hostID, string $sourceProject, string $sourceBranch, string $targetProject, string $targetBranch): object|null { if(empty($hostID) || empty($sourceProject) || empty($sourceBranch) || empty($targetProject) || empty($targetBranch)) return null; $url = sprintf($this->loadModel('gitlab')->getApiRoot((int)$hostID), "/projects/{$sourceProject}/merge_requests") . "&state=opened&source_branch={$sourceBranch}&target_branch={$targetBranch}"; $response = json_decode(commonModel::http($url)); if($response) { foreach($response as $MR) { if(empty($MR->source_project_id) || empty($MR->target_project_id)) return null; if($MR->source_project_id == $sourceProject && $MR->target_project_id == $targetProject) return $MR; } } return null; } /** * 通过API获取单个合并请求。 * Get single MR by API. * * @param int $repoID * @param int $MRID * @access public * @return object|null */ public function apiGetSingleMR(int $repoID, int $MRID): object|null { $repo = $this->loadModel('repo')->getByID($repoID); if(!$repo) return null; $scm = $this->app->loadClass('scm'); $scm->setEngine($repo); $MR = $scm->getSingleMR($MRID); if($MR) { if(!isset($MR->flow)) $MR->flow = 0; $MR->gitService = strtolower($repo->SCM); if($MR->state == 'open') $MR->state = 'opened'; } return $MR; } /** * 通过API获取合并请求的提交信息。 * Get MR commits by API. * * @param int $hostID * @param string $projectID targetProject * @param int $MRID * @access public * @return array|null */ public function apiGetMRCommits(int $hostID, string $projectID, int $MRID): array|null { $host = $this->loadModel('pipeline')->getByID($hostID); $apiRoot = $this->loadModel($host->type)->getApiRoot($hostID); if($host->type == 'gitlab') { $url = sprintf($apiRoot, "/projects/$projectID/merge_requests/$MRID/commits"); } else { $url = sprintf($apiRoot, "/repos/$projectID/pulls/$MRID/commits"); } return (array)json_decode(commonModel::http($url, null, array(), is_object($apiRoot) ? $apiRoot->header : array())); } /** * 通过API更新合并请求。 * Update MR by API. * * @param object $oldMR * @param object $MR * @access public * @return object|null */ public function apiUpdateMR(object $oldMR, object $MR): object|null { $host = $this->loadModel('pipeline')->getByID($oldMR->hostID); if(!$host) return null; if(!empty($MR->assignee)) $assignee = $this->pipeline->getOpenIdByAccount($host->id, $host->type, $MR->assignee); $apiRoot = $this->loadModel($host->type)->getApiRoot($host->id); $MRObject = new stdclass(); $MRObject->title = $MR->title; if($host->type == 'gitlab') { if(isset($MR->targetBranch)) $MRObject->target_branch = zget($MR, 'targetBranch', $oldMR->targetBranch); if(isset($MR->description)) $MRObject->description = $MR->description; if(isset($MR->remove_source_branch)) $MRObject->remove_source_branch = $MR->removeSourceBranch == '1' ? true : false; if(isset($MR->squash)) $MRObject->squash = $MR->squash == '1' ? 1 : 0; if(!empty($assignee)) $MRObject->assignee_ids = $assignee; $url = sprintf($apiRoot, "/projects/{$oldMR->sourceProject}/merge_requests/{$oldMR->mriid}"); return json_decode(commonModel::http($url, $MRObject, array(CURLOPT_CUSTOMREQUEST => 'PUT'))); } else { if(isset($MR->targetBranch)) $MRObject->base = zget($MR, 'targetBranch', $oldMR->targetBranch); if(isset($MR->description)) $MRObject->body = $MR->description; if(!empty($assignee)) $MRObject->assignee = $assignee; $url = sprintf($apiRoot, "/repos/{$oldMR->sourceProject}/pulls/{$oldMR->mriid}"); $mergeResult = json_decode(commonModel::http($url, $MRObject, array(), array(), 'json', 'PATCH')); if(isset($mergeResult->number)) $mergeResult->iid = $host->type == 'gitea' ? $mergeResult->number : $mergeResult->id; if(isset($mergeResult->mergeable)) { if($mergeResult->mergeable) $mergeResult->merge_status = 'can_be_merged'; if(!$mergeResult->mergeable) $mergeResult->merge_status = 'cannot_be_merged'; } if(isset($mergeResult->state) && $mergeResult->state == 'open') $mergeResult->state = 'opened'; if(isset($mergeResult->merged) && $mergeResult->merged) $mergeResult->state = 'merged'; return $mergeResult; } } /** * 通过API删除合并请求。 * Delete MR by API. * * @param int $hostID * @param string $projectID * @param int $MRID * @access public * @return object|null */ public function apiDeleteMR(int $hostID, string $projectID, int $MRID): object|null { $host = $this->loadModel('pipeline')->getByID($hostID); if(!$host) return null; if($host->type == 'gitlab') { $url = sprintf($this->loadModel('gitlab')->getApiRoot($hostID), "/projects/$projectID/merge_requests/$MRID"); return json_decode(commonModel::http($url, null, array(CURLOPT_CUSTOMREQUEST => 'DELETE'))); } $repoID = $this->dao->select('repoID')->from(TABLE_MR) ->where('hostID')->eq($hostID) ->andWhere('sourceProject')->eq($projectID) ->andWhere('mriid')->eq($MRID) ->andWhere('deleted')->eq('0') ->fetch('repoID'); $rowMR = $this->apiGetSingleMR((int)$repoID, $MRID); if($rowMR && $rowMR->state == 'opened') { $apiRoot = $this->loadModel($host->type)->getApiRoot($hostID); $header = is_string($apiRoot) ? array() : $apiRoot->header; if(is_object($apiRoot)) $apiRoot = $apiRoot->url; $api = "/repos/$projectID/pulls/$MRID"; return json_decode(commonModel::http(sprintf($apiRoot, $api), array('state' => 'closed'), array(), $header, 'json', 'PATCH')); } return null; } /** * 通过API关闭合并请求。 * Close MR by API. * * @param int $hostID * @param string $projectID * @param int $MRID * @access public * @return object|null */ public function apiCloseMR(int $hostID, string $projectID, int $MRID): object|null { $host = $this->loadModel('pipeline')->getByID($hostID); if(!$host) return null; if($host->type == 'gitlab') { $url = sprintf($this->loadModel('gitlab')->getApiRoot($hostID), "/projects/$projectID/merge_requests/$MRID") . '&state_event=close'; return json_decode(commonModel::http($url, null, array(CURLOPT_CUSTOMREQUEST => 'PUT'))); } else { return $this->apiDeleteMR($hostID, $projectID, $MRID); } } /** * 通过API重新打开合并请求。 * Reopen MR by API. * * @param int $hostID * @param string $projectID * @param int $MRID * @access public * @return object|null */ public function apiReopenMR(int $hostID, string $projectID, int $MRID): object|null { $host = $this->loadModel('pipeline')->getByID($hostID); if(!$host) return null; $apiRoot = $this->loadModel($host->type)->getApiRoot($hostID); if($host->type == 'gitlab') { $url = sprintf($apiRoot, "/projects/$projectID/merge_requests/$MRID") . '&state_event=reopen'; return json_decode(commonModel::http($url, null, array(CURLOPT_CUSTOMREQUEST => 'PUT'))); } else { $url = sprintf($apiRoot, "/repos/$projectID/pulls/$MRID"); return json_decode(commonModel::http($url, array('state' => 'open'), array(), array(), 'json', 'PATCH')); } } /** * 通过API接受合并请求。 * Accept MR by API. * * @param object $MR * @access public * @return object|null */ public function apiAcceptMR(object $MR): object|null { $host = $this->loadModel('pipeline')->getByID($MR->hostID); if(!$host) return null; $apiRoot = $this->loadModel($host->type)->getApiRoot($MR->hostID); if($host->type == 'gitlab') { $approveUrl = sprintf($apiRoot, "/projects/$MR->targetProject/merge_requests/$MR->mriid/approved"); commonModel::http($approveUrl, null, array(CURLOPT_CUSTOMREQUEST => 'POST')); $url = sprintf($apiRoot, "/projects/$MR->targetProject/merge_requests/$MR->mriid/merge"); return json_decode(commonModel::http($url, null, array(CURLOPT_CUSTOMREQUEST => 'PUT'))); } else { $url = sprintf($apiRoot, "/repos/$MR->targetProject/pulls/$MR->mriid/merge"); $data = array('Do' => $MR->squash == '1' ? 'squash' : 'merge'); if($MR->removeSourceBranch == '1') $data['delete_branch_after_merge'] = true; } $rowMR = json_decode(commonModel::http($url, $data, array(), array(), 'json', 'POST')); if(!isset($rowMR->massage)) { $rowMR = $this->apiGetSingleMR($MR->repoID, $MR->mriid); if($MR->removeSourceBranch == '1' && $host->type == 'gogs') $this->loadModel($host->type)->apiDeleteBranch($MR->hostID, $MR->sourceProject, $MR->sourceBranch); } return $rowMR; } /** * 获取合并请求的对比信息。 * Get MR diff versions by API. * * @param object $MR * @access public * @return array */ public function getDiffs(object $MR): array { if(!isset($MR->repoID)) return array(); $repo = $this->loadModel('repo')->getByID($MR->repoID); if(!$repo) return array(); $host = $this->loadModel('pipeline')->getByID($MR->hostID); $scm = $this->app->loadClass('scm'); $scm->setEngine($repo); $lines = array(); if($host->type == 'gitlab') { $diffVersions = array(); if($MR->synced) $diffVersions = $this->apiGetDiffVersions($MR->hostID, $MR->targetProject, $MR->mriid); foreach($diffVersions as $diffVersion) { $singleDiff = $this->apiGetSingleDiffVersion($MR->hostID, $MR->targetProject, $MR->mriid, $diffVersion->id); if($singleDiff->state == 'empty') continue; $diffs = $singleDiff->diffs; foreach($diffs as $diff) { $lines[] = sprintf("diff --git a/%s b/%s", $diff->old_path, $diff->new_path); $lines[] = sprintf("index %s ... %s %s ", $singleDiff->head_commit_sha, $singleDiff->base_commit_sha, $diff->b_mode); $lines[] = sprintf("--a/%s", $diff->old_path); $lines[] = sprintf("--b/%s", $diff->new_path); $diffLines = explode("\n", $diff->diff); foreach($diffLines as $diffLine) $lines[] = $diffLine; } } } else { $lines = $this->apiGetDiffs($MR->hostID, $MR->targetProject, $MR->mriid); } if(empty($MR->synced)) $lines = preg_replace('/^\s*$\n?\r?/m', '', $MR->diffs); if(is_string($lines)) $lines = explode("\n", $lines); return $scm->engine->parseDiff($lines); } /** * 通过API创建合并请求待办。 * Create a todo item for merge request. * * @param int $hostID * @param string $projectID * @param int $MRID * @access public * @return object|null */ public function apiCreateMRTodo(int $hostID, string $projectID, int $MRID): object|null { $url = sprintf($this->loadModel('gitlab')->getApiRoot($hostID), "/projects/$projectID/merge_requests/$MRID/todo"); return json_decode(commonModel::http($url, null, array(CURLOPT_CUSTOMREQUEST => 'POST'))); } /** * 通过API获取合并请求的对比版本信息。 * Get diff versions of MR from GitLab API. * * @param int $hostID * @param string $projectID * @param int $MRID * @access public * @return array|null */ public function apiGetDiffVersions(int $hostID, string $projectID, int $MRID): array|null { $url = sprintf($this->loadModel('gitlab')->getApiRoot($hostID), "/projects/$projectID/merge_requests/$MRID/versions"); return json_decode(commonModel::http($url)); } /** * 通过API获取合并请求的单个对比版本信息。 * Get a single diff version of MR from GitLab API. * * @param int $hostID * @param string $projectID * @param int $MRID * @param int $versionID * @access public * @return object|null */ public function apiGetSingleDiffVersion(int $hostID, string $projectID, int $MRID, int $versionID): object|null { $url = sprintf($this->loadModel('gitlab')->getApiRoot($hostID), "/projects/$projectID/merge_requests/$MRID/versions/$versionID"); return json_decode(commonModel::http($url)); } /** * 通过Gitea API获取合并请求的对比信息。 * Get diff of MR from Gitea API. * * @param int $hostID * @param string $projectID * @param int $MRID * @access public * @return string */ public function apiGetDiffs(int $hostID, string $projectID, int $MRID): string { $host = $this->loadModel('pipeline')->getByID($hostID); if(!$host || $host->type == 'gitlab') return ''; $url = sprintf($this->loadModel($host->type)->getApiRoot($hostID), "/repos/$projectID/pulls/$MRID.diff"); return commonModel::http($url); } /** * 审核合并请求。 * Reject or Approve this MR. * * @param object $MR * @param string $action approve|reject * @param string $comment * @return array */ public function approve(object $MR, string $action = 'approve', string $comment = ''): array { if(isset($MR->status) && $MR->status == 'opened') { $oldMR = clone $MR; $rawApprovalStatus = zget($MR, 'approvalStatus', ''); if($action == 'reject' && $rawApprovalStatus != 'rejected') $MR->approvalStatus = 'rejected'; if($action == 'approve' && $rawApprovalStatus != 'approved') $MR->approvalStatus = 'approved'; if(isset($MR->approvalStatus) && $rawApprovalStatus != $MR->approvalStatus) { $changes = common::createChanges($oldMR, $MR); unset($MR->editedDate); $MR->approver = $this->app->user->account; $this->dao->update(TABLE_MR)->data($MR) ->where('id')->eq($MR->id) ->exec(); if(dao::isError()) return array('result' => 'fail', 'message' => dao::getError()); /* Save approval history into db. */ $approval = new stdClass; $approval->date = helper::now(); $approval->mrID = $MR->id; $approval->account = $MR->approver; $approval->action = $action; $approval->comment = $comment; $this->dao->insert(TABLE_MRAPPROVAL)->data($approval, $this->config->mrapproval->create->skippedFields) ->batchCheck($this->config->mrapproval->create->requiredFields, 'notempty') ->autoCheck() ->exec(); if(dao::isError()) return array('result' => 'fail', 'message' => dao::getError()); $actionID = $this->loadModel('action')->create($this->moduleName, $MR->id, $action); $this->action->logHistory($actionID, $changes); return array('result' => 'success', 'message' => $this->lang->saveSuccess, 'closeModal' => true, 'load' => true); } } return array('result' => 'fail', 'message' => $this->lang->mr->repeatedOperation, 'load' => helper::createLink($this->moduleName, 'view', "mr={$MR->id}")); } /** * 关闭合并请求。 * Close this MR. * * @param object $MR * @access public * @return array */ public function close(object $MR): array { if($MR->status == 'closed') return array('result' => 'fail', 'message' => $this->lang->mr->repeatedOperation); $actionID = $this->loadModel('action')->create($this->moduleName, $MR->id, 'closed'); $rawMR = $this->apiCloseMR($MR->hostID, $MR->targetProject, $MR->mriid); $changes = common::createChanges($MR, $rawMR); $this->action->logHistory($actionID, $changes); if(isset($rawMR->state) && $rawMR->state == 'closed') return array('result' => 'success', 'message' => $this->lang->mr->closeSuccess, 'load' => 'reload'); return array('result' => 'fail', 'message' => $this->lang->fail); } /** * 重新打开合并请求。 * Reopen this MR. * * @param object $MR * @access public * @return array */ public function reopen(object $MR): array { if($MR->status == 'opened') return array('result' => 'fail', 'message' => $this->lang->mr->repeatedOperation); $this->loadModel('action')->create($this->moduleName, $MR->id, 'reopen'); $rawMR = $this->apiReopenMR($MR->hostID, $MR->targetProject, $MR->mriid); if(!empty($rawMR) && empty($rawMR->message)) return array('result' => 'success', 'message' => $this->lang->mr->reopenSuccess, 'load' => 'reload'); return array('result' => 'fail', 'message' => $this->lang->fail); } /** * 获取合并请求关联的对象。 * Get mr link list. * * @param int $MRID * @param string $type story|task|bug * @param string $orderBy * @param object $pager * @access public * @return array */ public function getLinkList(int $MRID, string $type, string $orderBy = 'id_desc', ?object $pager = null): array { if(!isset($this->config->objectTables[$type])) return array(); $orderBy = str_replace('name_', 'title_', $orderBy); if($type == 'task') $orderBy = str_replace('title_', 'name_', $orderBy); return $this->dao->select('t1.*')->from($this->config->objectTables[$type])->alias('t1') ->leftJoin(TABLE_RELATION)->alias('t2')->on('t1.id=t2.BID') ->where('t2.relation')->eq('interrated') ->andWhere('t2.AType')->eq($this->moduleName) ->andWhere('t2.AID')->eq($MRID) ->andWhere('t2.BType')->eq($type) ->andWhere('t1.deleted')->eq(0) ->orderBy($orderBy) ->page($pager) ->fetchAll('id'); } /** * 根据对象信息获取合并请求列表。 * Get linked MR pairs. * * @param int $objectID * @param string $objectType * @param string $module mr|pullreq * @access public * @return array */ public function getLinkedMRPairs(int $objectID, string $objectType = 'story', string $module = ''): array { if(!$module) $module = $this->moduleName; return $this->dao->select("t2.id,t2.title,t2.status")->from(TABLE_RELATION)->alias('t1') ->leftJoin(TABLE_MR)->alias('t2')->on('t1.AID = t2.id') ->where('t1.AType')->eq($module) ->andWhere('t1.BType')->eq($objectType) ->andWhere('t1.BID')->eq($objectID) ->andWhere('t2.id')->ne(0) ->fetchAll('id'); } /** * 合并请求关联对象。 * Create an mr link. * * @param int $MRID * @param string $type story|task|bug * @param array $objects * @access public * @return bool */ public function link(int $MRID, string $type, array $objects): bool { if(!isset($this->config->objectTables[$type])) return false; $MR = $this->fetchByID($MRID); if(!$MR) return false; /* Set link action text. */ $user = $this->loadModel('user')->getRealNameAndEmails($MR->createdBy); $comment = $MR->createdDate . '::' . zget($user, 'realname', $this->app->user->realname) . '::' . helper::createLink($this->moduleName, 'view', "mr={$MR->id}"); $this->loadModel('action'); foreach($objects as $objectID) { $relation = new stdclass(); $relation->product = 0; $relation->AType = $this->moduleName; $relation->AID = $MRID; $relation->relation = 'interrated'; $relation->BType = $type; $relation->BID = $objectID; $this->dao->replace(TABLE_RELATION)->data($relation)->exec(); $this->action->create($type, (int)$objectID, 'create' . $this->moduleName, '', $comment); } return !dao::isError(); } /** * 保存合并请求关联的对象。 * Save linked objects. * * @param object $MR * @access public * @return bool */ public function linkObjects(object $MR): bool { /* Get commits by MR. */ $commits = $this->apiGetMRCommits($MR->hostID, (string)$MR->targetProject, $MR->mriid); if(empty($commits)) return true; /* Init objects. */ $objectList = array('story' => array(), 'bug' => array(), 'task' => array()); $this->loadModel('repo'); foreach($commits as $commit) { if(empty($commit->message)) $commit->message = zget($commit, 'title', ''); $objects = $this->repo->parseComment($commit->message); $objectList['story'] = array_merge($objectList['story'], $objects['stories']); $objectList['bug'] = array_merge($objectList['bug'], $objects['bugs']); $objectList['task'] = array_merge($objectList['task'], $objects['tasks']); } $users = $this->loadModel('user')->getPairs('noletter'); $MRCreateAction = $MR->createdDate . '::' . zget($users, $MR->createdBy) . '::' . $MR->id; $product = $this->getMRProduct($MR); $this->loadModel('action'); foreach($objectList as $type => $objectIDs) { $relation = new stdclass(); $relation->product = $product ? $product->id : 0; $relation->AType = $this->moduleName; $relation->AID = $MR->id; $relation->relation = 'interrated'; $relation->BType = $type; foreach($objectIDs as $objectID) { $relation->BID = $objectID; $this->dao->replace(TABLE_RELATION)->data($relation)->exec(); $this->action->create($type, (int)$objectID, 'create' . $this->moduleName, '', $MRCreateAction); } } return !dao::isError(); } /** * 解除合并请求关联的对象。 * Unlink an mr link. * * @param int $MRID * @param string $type * @param int $objectID * @access public * @return bool */ public function unlink(int $MRID, string $type, int $objectID): bool { if(!isset($this->config->objectTables[$type])) return false; $this->dao->delete()->from(TABLE_RELATION) ->where('AType')->eq($this->moduleName) ->andWhere('AID')->eq($MRID) ->andWhere('relation')->eq('interrated') ->andWhere('BType')->eq($type) ->andWhere('BID')->eq($objectID) ->exec(); $this->loadModel('action')->create($type, $objectID, 'deletemr', '', helper::createLink($this->moduleName, 'view', "mr={$MRID}")); return !dao::isError(); } /** * 获取合并请求的产品。 * Get mr product. * * @param object $MR * @access public * @return object|false */ public function getMRProduct(object $MR): object|false { $productID = $this->dao->select('product')->from(TABLE_REPO)->where('id')->eq($MR->repoID)->fetch('product'); if(!$productID) return false; return $this->loadModel('product')->getById((int)$productID); } /** * 获取合并请求的收件人和抄送人。 * Get toList and ccList. * * @param object $MR * @access public * @return array */ public function getToAndCcList(object $MR): array { return array($MR->createdBy, $MR->assignee); } /** * 将合并的操作记录到链接 * Log merged action to links. * * @param object $MR * @access public * @return bool */ public function logMergedAction(object $MR): bool { $this->loadModel('action')->create($this->moduleName, $MR->id, 'merged' . $this->moduleName); foreach(array('story', 'bug', 'task') as $type) { $objects = $this->getLinkList($MR->id, $type); foreach($objects as $object) { $this->action->create($type, $object->id, 'merged' . $this->moduleName, '', helper::createLink($this->moduleName, 'view', "mr={$MR->id}")); } } $this->dao->update(TABLE_MR)->data(array('status' => 'merged'))->where('id')->eq($MR->id)->exec(); return !dao::isError(); } /** * 检查是否有相同的未关闭合并请求。 * Check same opened mr for source branch. * * @param int $hostID * @param string $sourceProject * @param string $sourceBranch * @param string $targetProject * @param string $targetBranch * @access public * @return array */ public function checkSameOpened(int $hostID, string $sourceProject, string $sourceBranch, string $targetProject, string $targetBranch): array { if($sourceProject == $targetProject && $sourceBranch == $targetBranch) return array('result' => 'fail', 'message' => $this->lang->mr->errorLang[1]); $dbOpenedID = $this->dao->select('id')->from(TABLE_MR) ->where('hostID')->eq($hostID) ->andWhere('sourceProject')->eq($sourceProject) ->andWhere('sourceBranch')->eq($sourceBranch) ->andWhere('targetProject')->eq($targetProject) ->andWhere('targetBranch')->eq($targetBranch) ->andWhere('status')->eq('opened') ->andWhere('deleted')->eq('0') ->fetch('id'); if(!empty($dbOpenedID)) return array('result' => 'fail', 'message' => sprintf($this->lang->mr->hasSameOpenedMR, $dbOpenedID)); $MR = $this->apiGetSameOpened($hostID, (string)$sourceProject, $sourceBranch, (string)$targetProject, $targetBranch); if($MR) return array('result' => 'fail', 'message' => sprintf($this->lang->mr->errorLang[2], $MR->iid)); return array('result' => 'success'); } /** * 解析API错误信息。 * Convert API error. * * @param array $message * @access public * @return string */ public function convertApiError(array|string $message): string { if(is_array($message)) $message = $message[0]; if(!is_string($message)) return $message; foreach($this->lang->mr->apiErrorMap as $key => $errorMsg) { if(strpos($errorMsg, '/') === 0) { $result = preg_match($errorMsg, $message, $matches); if($result) $errorMessage = sprintf(zget($this->lang->mr->errorLang, $key), zget($matches, 1, $matches[0])); } elseif($message == $errorMsg) { $errorMessage = zget($this->lang->mr->errorLang, $key, $message); } if(isset($errorMessage)) break; } return isset($errorMessage) ? $errorMessage : $message; } /** * 判断按钮是否可点击。 * Adjust the action clickable. * * @param object $MR * @param string $action * @access public * @return bool */ public static function isClickable(object $MR, string $action): bool { if($action == 'edit' && !$MR->synced) return false; if($action == 'edit') return $MR->canEdit != 'disabled'; if($action == 'delete') return $MR->canDelete != 'disabled'; return true; } /** * 根据ID删除合并请求。 * Delete MR by ID. * * @param int $MRID * @access public * @return bool */ public function deleteByID(int $MRID): bool { $MR = $this->fetchByID($MRID); if(!$MR) return false; if($MR->synced) { $res = $this->apiDeleteMR($MR->hostID, $MR->targetProject, $MR->mriid); if(isset($res->message)) dao::$errors[] = $this->convertApiError($res->message); if(dao::isError()) return false; } $this->dao->delete()->from(TABLE_MR)->where('id')->eq($MRID)->exec(); $this->loadModel('action')->create($this->moduleName, $MRID, 'deleted', '', $MR->title); $this->createMRLinkedAction($MRID, 'remove' . $this->moduleName); return !dao::isError(); } /** * 执行合并请求流水线。 * Execute MR pipeline. * * @param int $MRID * @param int $jobID * @access public * @return bool */ public function execJob(int $MRID, int $jobID): bool { if(empty($MRID) || empty($jobID)) return false; $MR = $this->fetchByID($MRID); if(!$MR) return false; $compile = $this->loadModel('job')->exec($jobID, array('sourceBranch' => $MR->sourceBranch, 'targetBranch' => $MR->targetBranch), 'commit'); if(!$compile) return false; $newMR = new stdclass(); $newMR->compileID = $compile->id; $newMR->compileStatus = $compile->status; if($newMR->compileStatus == 'failure') $newMR->status = 'closed'; if($newMR->compileStatus == 'create_fail') $newMR->status = 'closed'; $this->loadModel('repo')->saveRelation($MRID, 'mr', $compile->id, 'compile', 'mrjob'); $this->dao->update(TABLE_MR)->data($newMR)->where('id')->eq($MRID)->autoCheck()->exec(); return dao::isError(); } /** * 创建合并请求。 * Insert a merge request. * * @param object $MR * @access public * @return int|false */ public function insertMr(object $MR): int|false { $this->dao->insert(TABLE_MR)->data($MR, $this->config->mr->create->skippedFields) ->batchCheck($this->config->mr->create->requiredFields, 'notempty') ->checkIF(!empty($MR->needCI), 'jobID', 'notempty') ->exec(); if(dao::isError()) return false; return $this->dao->lastInsertID(); } }