@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
4 * Contains logic to parse Diffusion requests, which have a complicated URI
5 * structure.
6 *
7 * @task new Creating Requests
8 * @task uri Managing Diffusion URIs
9 */
10abstract class DiffusionRequest extends Phobject {
11
12 protected $path;
13 protected $line;
14 protected $branch;
15 protected $lint;
16
17 protected $symbolicCommit;
18 protected $symbolicType;
19 protected $stableCommit;
20
21 protected $repository;
22 protected $repositoryCommit;
23 protected $repositoryCommitData;
24
25 private $isClusterRequest = false;
26 private $initFromConduit = true;
27 private $user;
28 private $branchObject = false;
29 private $refAlternatives;
30
31 final public function supportsBranches() {
32 return $this->getRepository()->supportsRefs();
33 }
34
35 abstract protected function isStableCommit($symbol);
36
37 protected function didInitialize() {
38 return null;
39 }
40
41
42/* -( Creating Requests )-------------------------------------------------- */
43
44
45 /**
46 * Create a new synthetic request from a parameter dictionary. If you need
47 * a @{class:DiffusionRequest} object in order to issue a DiffusionQuery, you
48 * can use this method to build one.
49 *
50 * Parameters are:
51 *
52 * - `repository` Repository object or identifier.
53 * - `user` Viewing user. Required if `repository` is an identifier.
54 * - `branch` Optional, branch name.
55 * - `path` Optional, file path.
56 * - `commit` Optional, commit identifier.
57 * - `line` Optional, line range.
58 *
59 * @param map $data See documentation.
60 * @return DiffusionRequest|null New request object, or null if none is
61 * found.
62 * @task new
63 */
64 final public static function newFromDictionary(array $data) {
65 $repository_key = 'repository';
66 $identifier_key = 'callsign';
67 $viewer_key = 'user';
68
69 $repository = idx($data, $repository_key);
70 $identifier = idx($data, $identifier_key);
71
72 $have_repository = ($repository !== null);
73 $have_identifier = ($identifier !== null);
74
75 if ($have_repository && $have_identifier) {
76 throw new Exception(
77 pht(
78 'Specify "%s" or "%s", but not both.',
79 $repository_key,
80 $identifier_key));
81 }
82
83 if (!$have_repository && !$have_identifier) {
84 throw new Exception(
85 pht(
86 'One of "%s" and "%s" is required.',
87 $repository_key,
88 $identifier_key));
89 }
90
91 if ($have_repository) {
92 if (!($repository instanceof PhabricatorRepository)) {
93 if (empty($data[$viewer_key])) {
94 throw new Exception(
95 pht(
96 'Parameter "%s" is required if "%s" is provided.',
97 $viewer_key,
98 $identifier_key));
99 }
100
101 $identifier = $repository;
102 $repository = null;
103 }
104 }
105
106 if ($identifier !== null) {
107 $object = self::newFromIdentifier(
108 $identifier,
109 $data[$viewer_key],
110 idx($data, 'edit'));
111 } else {
112 $object = self::newFromRepository($repository);
113 }
114
115 if (!$object) {
116 return null;
117 }
118
119 $object->initializeFromDictionary($data);
120
121 return $object;
122 }
123
124 /**
125 * Internal.
126 *
127 * @task new
128 */
129 private function __construct() {
130 // <private>
131 }
132
133
134 /**
135 * Internal. Use @{method:newFromDictionary}, not this method.
136 *
137 * @param string $identifier Repository identifier.
138 * @param PhabricatorUser $viewer Viewing user.
139 * @param bool $need_edit (optional)
140 * @return DiffusionRequest|null New request object, or null if no
141 * repository is found.
142 * @task new
143 */
144 private static function newFromIdentifier(
145 $identifier,
146 PhabricatorUser $viewer,
147 $need_edit = false) {
148
149 $query = id(new PhabricatorRepositoryQuery())
150 ->setViewer($viewer)
151 ->withIdentifiers(array($identifier))
152 ->needProfileImage(true)
153 ->needProjectPHIDs(true)
154 ->needURIs(true);
155
156 if ($need_edit) {
157 $query->requireCapabilities(
158 array(
159 PhabricatorPolicyCapability::CAN_VIEW,
160 PhabricatorPolicyCapability::CAN_EDIT,
161 ));
162 }
163
164 $repository = $query->executeOne();
165
166 if (!$repository) {
167 return null;
168 }
169
170 return self::newFromRepository($repository);
171 }
172
173
174 /**
175 * Internal. Use @{method:newFromDictionary}, not this method.
176 *
177 * @param PhabricatorRepository $repository Repository object.
178 * @return DiffusionRequest New request object.
179 * @task new
180 */
181 private static function newFromRepository(
182 PhabricatorRepository $repository) {
183
184 $map = array(
185 PhabricatorRepositoryType::REPOSITORY_TYPE_GIT => 'DiffusionGitRequest',
186 PhabricatorRepositoryType::REPOSITORY_TYPE_SVN => 'DiffusionSvnRequest',
187 PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL =>
188 'DiffusionMercurialRequest',
189 );
190
191 $class = idx($map, $repository->getVersionControlSystem());
192
193 if (!$class) {
194 throw new Exception(pht('Unknown version control system!'));
195 }
196
197 $object = new $class();
198
199 $object->repository = $repository;
200
201 return $object;
202 }
203
204
205 /**
206 * Internal. Use @{method:newFromDictionary}, not this method.
207 *
208 * @param map $data Map of parsed data.
209 * @return void
210 * @task new
211 */
212 private function initializeFromDictionary(array $data) {
213 $blob = idx($data, 'blob');
214 if (phutil_nonempty_string($blob)) {
215 $blob = self::parseRequestBlob($blob, $this->supportsBranches());
216 $data = $blob + $data;
217 }
218
219 $this->path = idx($data, 'path');
220 $this->line = idx($data, 'line');
221 $this->initFromConduit = idx($data, 'initFromConduit', true);
222 $this->lint = idx($data, 'lint');
223
224 $this->symbolicCommit = idx($data, 'commit');
225 if ($this->supportsBranches()) {
226 $this->branch = idx($data, 'branch');
227 }
228
229 if (!$this->getUser()) {
230 $user = idx($data, 'user');
231 if (!$user) {
232 throw new Exception(
233 pht(
234 'You must provide a %s in the dictionary!',
235 'PhabricatorUser'));
236 }
237 $this->setUser($user);
238 }
239
240 $this->didInitialize();
241 }
242
243 final public function setUser(PhabricatorUser $user) {
244 $this->user = $user;
245 return $this;
246 }
247 final public function getUser() {
248 return $this->user;
249 }
250
251 public function getRepository() {
252 return $this->repository;
253 }
254
255 public function setPath($path) {
256 $this->path = $path;
257 return $this;
258 }
259
260 public function getPath() {
261 return coalesce($this->path, '');
262 }
263
264 public function getLine() {
265 return $this->line;
266 }
267
268 public function getCommit() {
269
270 // TODO: Probably remove all of this.
271
272 if ($this->getSymbolicCommit() !== null) {
273 return $this->getSymbolicCommit();
274 }
275
276 return $this->getStableCommit();
277 }
278
279 /**
280 * Get the symbolic commit associated with this request.
281 *
282 * A symbolic commit may be a commit hash, an abbreviated commit hash, a
283 * branch name, a tag name, or an expression like "HEAD^^^". The symbolic
284 * commit may also be absent.
285 *
286 * This method always returns the symbol present in the original request,
287 * in unmodified form.
288 *
289 * See also @{method:getStableCommit}.
290 *
291 * @return string|null Symbolic commit, if one was present in the request.
292 */
293 public function getSymbolicCommit() {
294 return $this->symbolicCommit;
295 }
296
297
298 /**
299 * Modify the request to move the symbolic commit elsewhere.
300 *
301 * @param string $symbol New symbolic commit.
302 * @return $this
303 */
304 public function updateSymbolicCommit($symbol) {
305 $this->symbolicCommit = $symbol;
306 $this->symbolicType = null;
307 $this->stableCommit = null;
308 return $this;
309 }
310
311
312 /**
313 * Get the ref type (`commit` or `tag`) of the location associated with this
314 * request.
315 *
316 * If a symbolic commit is present in the request, this method identifies
317 * the type of the symbol. Otherwise, it identifies the type of symbol of
318 * the location the request is implicitly associated with. This will probably
319 * always be `commit`.
320 *
321 * @return string Symbolic commit type (`commit` or `tag`).
322 */
323 public function getSymbolicType() {
324 if ($this->symbolicType === null) {
325 // As a side effect, this resolves the symbolic type.
326 $this->getStableCommit();
327 }
328 return $this->symbolicType;
329 }
330
331
332 /**
333 * Retrieve the stable, permanent commit name identifying the repository
334 * location associated with this request.
335 *
336 * This returns a non-symbolic identifier for the current commit: in Git and
337 * Mercurial, a 40-character SHA1; in SVN, a revision number.
338 *
339 * See also @{method:getSymbolicCommit}.
340 *
341 * @return string Stable commit name, like a git hash or SVN revision. Not
342 * a symbolic commit reference.
343 */
344 public function getStableCommit() {
345 if (!$this->stableCommit) {
346 if ($this->isStableCommit($this->symbolicCommit)) {
347 $this->stableCommit = $this->symbolicCommit;
348 $this->symbolicType = 'commit';
349 } else {
350 $this->queryStableCommit();
351 }
352 }
353 return $this->stableCommit;
354 }
355
356
357 public function getBranch() {
358 return $this->branch;
359 }
360
361 public function getLint() {
362 return $this->lint;
363 }
364
365 protected function getArcanistBranch() {
366 return $this->getBranch();
367 }
368
369 public function loadBranch() {
370 // TODO: Get rid of this and do real Queries on real objects.
371
372 if ($this->branchObject === false) {
373 $this->branchObject = PhabricatorRepositoryBranch::loadBranch(
374 $this->getRepository()->getID(),
375 $this->getArcanistBranch());
376 }
377
378 return $this->branchObject;
379 }
380
381 public function loadCoverage() {
382 // TODO: This should also die.
383 $branch = $this->loadBranch();
384 if (!$branch) {
385 return;
386 }
387
388 $path = $this->getPath();
389 $path_map = id(new DiffusionPathIDQuery(array($path)))->loadPathIDs();
390
391 $coverage_row = queryfx_one(
392 id(new PhabricatorRepository())->establishConnection('r'),
393 'SELECT * FROM %T WHERE branchID = %d AND pathID = %d
394 ORDER BY commitID DESC LIMIT 1',
395 'repository_coverage',
396 $branch->getID(),
397 $path_map[$path]);
398
399 if (!$coverage_row) {
400 return null;
401 }
402
403 return idx($coverage_row, 'coverage');
404 }
405
406
407 public function loadCommit() {
408 if (empty($this->repositoryCommit)) {
409 $repository = $this->getRepository();
410
411 $commit = id(new DiffusionCommitQuery())
412 ->setViewer($this->getUser())
413 ->withRepository($repository)
414 ->withIdentifiers(array($this->getStableCommit()))
415 ->executeOne();
416 if ($commit) {
417 $commit->attachRepository($repository);
418 }
419 $this->repositoryCommit = $commit;
420 }
421 return $this->repositoryCommit;
422 }
423
424 public function loadCommitData() {
425 if (empty($this->repositoryCommitData)) {
426 $commit = $this->loadCommit();
427 $data = id(new PhabricatorRepositoryCommitData())->loadOneWhere(
428 'commitID = %d',
429 $commit->getID());
430 if (!$data) {
431 $data = new PhabricatorRepositoryCommitData();
432 $data->setCommitMessage(
433 pht('(This commit has not been fully parsed yet.)'));
434 }
435 $this->repositoryCommitData = $data;
436 }
437 return $this->repositoryCommitData;
438 }
439
440/* -( Managing Diffusion URIs )-------------------------------------------- */
441
442
443 public function generateURI(array $params) {
444 if (empty($params['stable'])) {
445 $default_commit = $this->getSymbolicCommit();
446 } else {
447 $default_commit = $this->getStableCommit();
448 }
449
450 $defaults = array(
451 'path' => $this->getPath(),
452 'branch' => $this->getBranch(),
453 'commit' => $default_commit,
454 'lint' => idx($params, 'lint', $this->getLint()),
455 );
456
457 foreach ($defaults as $key => $val) {
458 if (!isset($params[$key])) { // Overwrite NULL.
459 $params[$key] = $val;
460 }
461 }
462
463 return $this->getRepository()->generateURI($params);
464 }
465
466 /**
467 * Internal. Public only for unit tests.
468 *
469 * Parse the request URI into components.
470 *
471 * @param string $blob URI blob.
472 * @param bool $supports_branches True if this VCS supports branches.
473 * @return map Parsed URI.
474 *
475 * @task uri
476 */
477 public static function parseRequestBlob($blob, $supports_branches) {
478 $result = array(
479 'branch' => null,
480 'path' => null,
481 'commit' => null,
482 'line' => null,
483 );
484
485 $matches = null;
486
487 if ($supports_branches) {
488 // Consume the front part of the URI, up to the first "/". This is the
489 // path-component encoded branch name.
490 if (preg_match('@^([^/]+)/@', $blob, $matches)) {
491 $result['branch'] = phutil_unescape_uri_path_component($matches[1]);
492 $blob = substr($blob, strlen($matches[1]) + 1);
493 }
494 }
495
496 // Consume the back part of the URI, up to the first "$". Use a negative
497 // lookbehind to prevent matching '$$'. We double the '$' symbol when
498 // encoding so that files with names like "money/$100" will survive.
499 $pattern = '@(?:(?:^|[^$])(?:[$][$])*)[$]([\d,-]+)$@';
500 if (preg_match($pattern, $blob, $matches)) {
501 $result['line'] = $matches[1];
502 $blob = substr($blob, 0, -(strlen($matches[1]) + 1));
503 }
504
505 // We've consumed the line number if it exists, so unescape "$" in the
506 // rest of the string.
507 $blob = str_replace('$$', '$', $blob);
508
509 // Consume the commit name, stopping on ';;'. We allow any character to
510 // appear in commits names, as they can sometimes be symbolic names (like
511 // tag names or refs).
512 if (preg_match('@(?:(?:^|[^;])(?:;;)*);([^;].*)$@', $blob, $matches)) {
513 $result['commit'] = $matches[1];
514 $blob = substr($blob, 0, -(strlen($matches[1]) + 1));
515 }
516
517 // We've consumed the commit if it exists, so unescape ";" in the rest
518 // of the string.
519 $blob = str_replace(';;', ';', $blob);
520
521 if (strlen($blob)) {
522 $result['path'] = $blob;
523 }
524
525 if ($result['path'] !== null) {
526 $parts = explode('/', $result['path']);
527 foreach ($parts as $part) {
528 // Prevent any hyjinx since we're ultimately shipping this to the
529 // filesystem under a lot of workflows.
530 if ($part == '..') {
531 throw new Exception(pht('Invalid path URI.'));
532 }
533 }
534 }
535
536 return $result;
537 }
538
539 /**
540 * Check that the working copy of the repository is present and readable.
541 *
542 * @param string $path Path to the working copy.
543 */
544 protected function validateWorkingCopy($path) {
545 if (!is_readable(dirname($path))) {
546 $this->raisePermissionException();
547 }
548
549 if (!Filesystem::pathExists($path)) {
550 $this->raiseCloneException();
551 }
552 }
553
554 protected function raisePermissionException() {
555 $host = php_uname('n');
556 throw new DiffusionSetupException(
557 pht(
558 'The clone of this repository ("%s") on the local machine ("%s") '.
559 'could not be read. Ensure that the repository is in a '.
560 'location where the web server has read permissions.',
561 $this->getRepository()->getDisplayName(),
562 $host));
563 }
564
565 protected function raiseCloneException() {
566 $host = php_uname('n');
567 throw new DiffusionSetupException(
568 pht(
569 'The working copy for this repository ("%s") has not been cloned yet '.
570 'on this machine ("%s"). Make sure you have started the '.
571 'daemons. If this problem persists for longer than a clone should '.
572 'take, check the daemon logs (in the Daemon Console) to see if there '.
573 'were errors cloning the repository. Consult the "Diffusion User '.
574 'Guide" in the documentation for help setting up repositories.',
575 $this->getRepository()->getDisplayName(),
576 $host));
577 }
578
579 private function queryStableCommit() {
580 $types = array();
581 if ($this->symbolicCommit) {
582 $ref = $this->symbolicCommit;
583 } else {
584 if ($this->supportsBranches()) {
585 $ref = $this->getBranch();
586 $types = array(
587 PhabricatorRepositoryRefCursor::TYPE_BRANCH,
588 );
589 } else {
590 $ref = 'HEAD';
591 }
592 }
593
594 $results = $this->resolveRefs(array($ref), $types);
595
596 $matches = idx($results, $ref, array());
597 if (!$matches) {
598 $message = pht(
599 'Ref "%s" does not exist in this repository.',
600 $ref);
601 throw id(new DiffusionRefNotFoundException($message))
602 ->setRef($ref);
603 }
604
605 if (count($matches) > 1) {
606 $match = $this->chooseBestRefMatch($ref, $matches);
607 } else {
608 $match = head($matches);
609 }
610
611 $this->stableCommit = $match['identifier'];
612 $this->symbolicType = $match['type'];
613 }
614
615 public function getRefAlternatives() {
616 // Make sure we've resolved the reference into a stable commit first.
617 try {
618 $this->getStableCommit();
619 } catch (DiffusionRefNotFoundException $ex) {
620 // If we have a bad reference, just return the empty set of
621 // alternatives.
622 }
623 return $this->refAlternatives;
624 }
625
626 private function chooseBestRefMatch($ref, array $results) {
627 // First, filter out less-desirable matches.
628 $candidates = array();
629 foreach ($results as $result) {
630 // Exclude closed heads.
631 if ($result['type'] == 'branch') {
632 if (idx($result, 'closed')) {
633 continue;
634 }
635 }
636
637 $candidates[] = $result;
638 }
639
640 // If we filtered everything, undo the filtering.
641 if (!$candidates) {
642 $candidates = $results;
643 }
644
645 // TODO: Do a better job of selecting the best match?
646 $match = head($candidates);
647
648 // After choosing the best alternative, save all the alternatives so the
649 // UI can show them to the user.
650 if (count($candidates) > 1) {
651 $this->refAlternatives = $candidates;
652 }
653
654 return $match;
655 }
656
657 public function resolveRefs(array $refs, array $types = array()) {
658 // First, try to resolve refs from fast cache sources.
659 $cached_query = id(new DiffusionCachedResolveRefsQuery())
660 ->setRepository($this->getRepository())
661 ->withRefs($refs);
662
663 if ($types) {
664 $cached_query->withTypes($types);
665 }
666
667 $cached_results = $cached_query->execute();
668
669 // Throw away all the refs we resolved. Hopefully, we'll throw away
670 // everything here.
671 foreach ($refs as $key => $ref) {
672 if (isset($cached_results[$ref])) {
673 unset($refs[$key]);
674 }
675 }
676
677 // If we couldn't pull everything out of the cache, execute the underlying
678 // VCS operation.
679 if ($refs) {
680 $vcs_results = DiffusionQuery::callConduitWithDiffusionRequest(
681 $this->getUser(),
682 $this,
683 'diffusion.resolverefs',
684 array(
685 'types' => $types,
686 'refs' => $refs,
687 ));
688 } else {
689 $vcs_results = array();
690 }
691
692 return $vcs_results + $cached_results;
693 }
694
695 public function setIsClusterRequest($is_cluster_request) {
696 $this->isClusterRequest = $is_cluster_request;
697 return $this;
698 }
699
700 public function getIsClusterRequest() {
701 return $this->isClusterRequest;
702 }
703
704}