1 <?php
2 
3 namespace dokuwiki\plugin\issuelinks\classes;
4 
5 /**
6  *
7  * Class Issue
8  */
9 class Issue extends \DokuWiki_Plugin implements \JsonSerializable
10 {
11 
12     /** @var Issue[] */
13     private static $instances = [];
14     protected $issueId;
15     protected $projectId;
16     protected $isMergeRequest;
17     protected $files = [];
18     protected $serviceID = '';
19     private $summary = '';
20     private $description = '';
21     private $status = '';
22     private $type = 'unknown';
23     private $components = [];
24     private $labels = [];
25     private $priority = '';
26     private $assignee = [];
27     private $labelData = [];
28     private $versions = [];
29     private $duedate;
30     private $updated;
31     private $parent;
32     private $errors = [];
33     private $isValid;
34 
35     /**
36      * @param        $serviceName
37      * @param string $projectKey The shortkey of the project, e.g. SPR
38      * @param int    $issueId    The id of the issue, e.g. 42
39      *
40      * @param        $isMergeRequest
41      */
42     private function __construct($serviceName, $projectKey, $issueId, $isMergeRequest)
43     {
44         if (empty($serviceName) || empty($projectKey) || empty($issueId) || !is_numeric($issueId)) {
45             throw new \InvalidArgumentException('Empty value passed to Issue constructor');
46         }
47 
48         $this->issueId = $issueId;
49         $this->projectId = $projectKey;
50         $this->isMergeRequest = $isMergeRequest;
51         $this->serviceID = $serviceName;
52 
53 //        $this->getFromDB();
54     }
55 
56     /**
57      * Get the singleton instance of a issue
58      *
59      * @param      $serviceName
60      * @param      $projectKey
61      * @param      $issueId
62      * @param bool $isMergeRequest
63      * @param bool $forcereload create a new instace
64      *
65      * @return Issue
66      */
67     public static function getInstance(
68         $serviceName,
69         $projectKey,
70         $issueId,
71         $isMergeRequest = false,
72         $forcereload = false
73     ) {
74         $issueHash = $serviceName . $projectKey . $issueId . '!' . $isMergeRequest;
75         if (empty(self::$instances[$issueHash]) || $forcereload) {
76             self::$instances[$issueHash] = new Issue($serviceName, $projectKey, $issueId, $isMergeRequest);
77         }
78         return self::$instances[$issueHash];
79     }
80 
81     /**
82      * @return bool true if issue was found in database, false otherwise
83      */
84     public function getFromDB()
85     {
86         /** @var \helper_plugin_issuelinks_db $db */
87         $db = plugin_load('helper', 'issuelinks_db');
88         $issue = $db->loadIssue($this->serviceID, $this->projectId, $this->issueId, $this->isMergeRequest);
89         if (empty($issue)) {
90             return false;
91         }
92         $this->summary = $issue['summary'] ?: '';
93         $this->status = $issue['status'];
94         $this->type = $issue['type'];
95         $this->description = $issue['description'];
96         $this->setComponents($issue['components']);
97         $this->setLabels($issue['labels']);
98         $this->priority = $issue['priority'];
99         $this->duedate = $issue['duedate'];
100         $this->setVersions($issue['versions']);
101         $this->setUpdated($issue['updated']);
102         return true;
103     }
104 
105     public function __toString()
106     {
107         $sep = $this->pmService->getProjectIssueSeparator($this->isMergeRequest);
108         return $this->projectId . $sep . $this->issueId;
109     }
110 
111     /**
112      * @return \Exception|null
113      */
114     public function getLastError()
115     {
116         if (!end($this->errors)) {
117             return null;
118         }
119         return end($this->errors);
120     }
121 
122     /**
123      * @return bool|self
124      */
125     public function isMergeRequest($isMergeRequest = null)
126     {
127         if ($isMergeRequest === null) {
128             return $this->isMergeRequest;
129         }
130 
131         $this->isMergeRequest = $isMergeRequest;
132         return $this;
133     }
134 
135     /**
136      * Specify data which should be serialized to JSON
137      *
138      * @link  http://php.net/manual/en/jsonserializable.jsonserialize.php
139      * @return mixed data which can be serialized by <b>json_encode</b>,
140      * which is a value of any type other than a resource.
141      * @since 5.4.0
142      *
143      * @link  http://stackoverflow.com/a/4697671/3293343
144      */
145     public function jsonSerialize()
146     {
147         return [
148             'service' => $this->serviceID,
149             'project' => $this->getProject(),
150             'id' => $this->getKey(),
151             'isMergeRequest' => $this->isMergeRequest ? '1' : '0',
152             'summary' => $this->getSummary(),
153             'description' => $this->getDescription(),
154             'type' => $this->getType(),
155             'status' => $this->getStatus(),
156             'parent' => $this->getParent(),
157             'components' => $this->getComponents(),
158             'labels' => $this->getLabels(),
159             'priority' => $this->getPriority(),
160             'duedate' => $this->getDuedate(),
161             'versions' => $this->getVersions(),
162             'updated' => $this->getUpdated(),
163         ];
164     }
165 
166     /**
167      * @return string
168      */
169     public function getProject()
170     {
171         return $this->projectId;
172     }
173 
174     /**
175      * Returns the key, i.e. number, of the issue
176      *
177      * @param bool $annotateMergeRequest If true, prepends a `!` to the key of a merge requests
178      *
179      * @return int|string
180      */
181     public function getKey($annotateMergeRequest = false)
182     {
183         if ($annotateMergeRequest && $this->isMergeRequest) {
184             return '!' . $this->issueId;
185         }
186         return $this->issueId;
187     }
188 
189     public function getSummary()
190     {
191         return $this->summary;
192     }
193 
194     /**
195      * @param string $summary
196      *
197      * @return Issue
198      *
199      * todo: decide if we should test for non-empty string here
200      */
201     public function setSummary($summary)
202     {
203         $this->summary = $summary;
204         return $this;
205     }
206 
207     /**
208      * @return string
209      */
210     public function getDescription()
211     {
212         return $this->description;
213     }
214 
215     /**
216      * @param string $description
217      *
218      * @return Issue
219      */
220     public function setDescription($description)
221     {
222         $this->description = $description;
223         return $this;
224     }
225 
226     /**
227      * @return string
228      */
229     public function getType()
230     {
231         return $this->type;
232     }
233 
234     /**
235      * @param string $type
236      *
237      * @return Issue
238      */
239     public function setType($type)
240     {
241         $this->type = $type;
242         return $this;
243     }
244 
245     /**
246      * @return string
247      */
248     public function getStatus()
249     {
250         return $this->status;
251     }
252 
253     /**
254      * @param string $status
255      *
256      * @return Issue
257      */
258     public function setStatus($status)
259     {
260         $this->status = $status;
261         return $this;
262     }
263 
264     /**
265      * @return mixed
266      */
267     public function getParent()
268     {
269         return $this->parent;
270     }
271 
272     public function setParent($key)
273     {
274         $this->parent = $key;
275         return $this;
276     }
277 
278     /**
279      * @return array
280      */
281     public function getComponents()
282     {
283         return $this->components;
284     }
285 
286     /**
287      * @param array|string $components
288      *
289      * @return Issue
290      */
291     public function setComponents($components)
292     {
293         if (is_string($components)) {
294             $components = array_filter(array_map('trim', explode(',', $components)));
295         }
296         if (!empty($components[0]['name'])) {
297             $components = array_column($components, 'name');
298         }
299         $this->components = $components;
300         return $this;
301     }
302 
303     /**
304      * @return array
305      */
306     public function getLabels()
307     {
308         return $this->labels;
309     }
310 
311     /**
312      * @param array|string $labels
313      *
314      * @return Issue
315      */
316     public function setLabels($labels)
317     {
318         if (!is_array($labels)) {
319             $labels = array_filter(array_map('trim', explode(',', $labels)));
320         }
321         $this->labels = $labels;
322         return $this;
323     }
324 
325     /**
326      * @return string
327      */
328     public function getPriority()
329     {
330         return $this->priority;
331     }
332 
333     /**
334      * @param string $priority
335      *
336      * @return Issue
337      */
338     public function setPriority($priority)
339     {
340         $this->priority = $priority;
341         return $this;
342     }
343 
344     /**
345      * @return string
346      */
347     public function getDuedate()
348     {
349         return $this->duedate;
350     }
351 
352     /**
353      * Set the issues duedate
354      *
355      * @param string $duedate
356      *
357      * @return Issue
358      */
359     public function setDuedate($duedate)
360     {
361         $this->duedate = $duedate;
362         return $this;
363     }
364 
365     /**
366      * @return array
367      */
368     public function getVersions()
369     {
370         return $this->versions;
371     }
372 
373     /**
374      * @param array|string $versions
375      *
376      * @return Issue
377      */
378     public function setVersions($versions)
379     {
380         if (!is_array($versions)) {
381             $versions = array_map('trim', explode(',', $versions));
382         }
383         if (!empty($versions[0]['name'])) {
384             $versions = array_map(function ($version) {
385                 return $version['name'];
386             }, $versions);
387         }
388         $this->versions = $versions;
389         return $this;
390     }
391 
392     /**
393      * @return int
394      */
395     public function getUpdated()
396     {
397         return $this->updated;
398     }
399 
400     /**
401      * @param string|int $updated
402      *
403      * @return Issue
404      */
405     public function setUpdated($updated)
406     {
407         /** @var \helper_plugin_issuelinks_util $util */
408         $util = plugin_load('helper', 'issuelinks_util');
409         if (!$util->isValidTimeStamp($updated)) {
410             $updated = strtotime($updated);
411         }
412         $this->updated = (int)$updated;
413         return $this;
414     }
415 
416     /**
417      * Get a fancy HTML-link to issue
418      *
419      * @param bool $addSummary add the issue's summary after it status
420      *
421      * @return string
422      */
423     public function getIssueLinkHTML($addSummary = false)
424     {
425         $serviceProvider = ServiceProvider::getInstance();
426         $service = $serviceProvider->getServices()[$this->serviceID];
427         $name = $this->projectId . $service::getProjectIssueSeparator($this->isMergeRequest) . $this->issueId;
428         $url = $this->getIssueURL();
429 
430         $status = cleanID($this->getStatus());
431         if ($status) {
432             $name .= $this->getformattedIssueStatus();
433         }
434         if ($addSummary) {
435             $name .= ' ' . $this->getSummary();
436         }
437 
438         $classes = 'issuelink ' . cleanID($this->getType()) . ($this->isMergeRequest ? ' mergerequest' : '');
439 
440         $attributes = [
441             'class' => $classes,
442             'target' => '_blank',
443             'rel' => 'noopener',
444             'data-service' => $this->serviceID,
445             'data-project' => $this->projectId,
446             'data-issueid' => $this->issueId,
447             'data-ismergerequest' => $this->isMergeRequest ? '1' : '0'
448         ];
449         return "<a href=\"$url\" " . buildAttributes($attributes, true) . '>' . $this->getTypeHTML() . "$name</a>";
450     }
451 
452     /**
453      * @return string
454      */
455     public function getIssueURL()
456     {
457         $serviceProvider = ServiceProvider::getInstance();
458         $serviceClassName = $serviceProvider->getServices()[$this->serviceID];
459         $service = $serviceClassName::getInstance();
460         return $service->getIssueURL($this->projectId, $this->issueId, $this->isMergeRequest);
461     }
462 
463     /**
464      * get the status of the issue as HTML string
465      *
466      * @param string|null $status
467      *
468      * @return string
469      */
470     public function getformattedIssueStatus($status = null)
471     {
472         if ($status === null) {
473             $status = $this->getStatus();
474         }
475         $status = strtolower($status);
476         return "<span class='mm__status " . cleanID($status) . "'>$status</span>";
477     }
478 
479     /**
480      * @return string
481      */
482     public function getTypeHTML()
483     {
484         if ($this->isMergeRequest) {
485             return inlineSVG(__DIR__ . '/../images/mdi-source-pull.svg');
486         }
487         $image = $this->getMaterialDesignTypeIcon();
488         return "<img src='$image' alt='$this->type' />";
489     }
490 
491     /**
492      * ToDo: replace all with SVG
493      *
494      * @return string the path to the icon / base64 image if type unknown
495      */
496     protected function getMaterialDesignTypeIcon()
497     {
498         $typeIcon = [
499             'bug' => 'mdi-bug.png',
500             'story' => 'mdi-bookmark.png',
501             'epic' => 'mdi-flash.png',
502             'change_request' => 'mdi-plus.png',
503             'improvement' => 'mdi-arrow-up-thick.png',
504             'organisation_task' => 'mdi-calendar-text.png',
505             'technical_task' => 'mdi-source-branch.png',
506             'task' => 'mdi-check.png',
507         ];
508 
509         if (!isset($typeIcon[cleanID($this->type)])) {
510             return DOKU_URL . '/lib/plugins/issuelinks/images/mdi-help-circle-outline.png';
511         }
512 
513         return DOKU_URL . '/lib/plugins/issuelinks/images/' . $typeIcon[cleanID($this->type)];
514     }
515 
516     public function setAssignee($name, $avatar_url)
517     {
518         $this->assignee['name'] = $name;
519         $this->assignee['avatarURL'] = $avatar_url;
520     }
521 
522     public function getAdditionalDataHTML()
523     {
524         $this->getFromService();
525         $data = [];
526         if (!empty($this->assignee)) {
527             $data['avatarHTML'] = "<img src=\"{$this->assignee['avatarURL']}\" alt=\"{$this->assignee['name']}\">";
528         }
529         if (!empty($this->labelData)) {
530             $labels = $this->getLabels();
531             $data['fancyLabelsHTML'] = '';
532             foreach ($labels as $label) {
533                 $colors = '';
534                 $classes = 'label';
535                 if (isset($this->labelData[$label])) {
536                     $colors = "style=\"background-color: {$this->labelData[$label]['background-color']};";
537                     $colors .= " color: {$this->labelData[$label]['color']};\"";
538                     $classes .= ' color';
539                 }
540                 $data['fancyLabelsHTML'] .= "<span class=\"$classes\" $colors>$label</span>";
541             }
542         }
543         return $data;
544     }
545 
546     public function getFromService()
547     {
548         $serviceProvider = ServiceProvider::getInstance();
549         $serviceClassName = $serviceProvider->getServices()[$this->serviceID];
550         $service = $serviceClassName::getInstance();
551 
552         try {
553             $service->retrieveIssue($this);
554             if ($this->isValid(true)) {
555                 $this->saveToDB();
556             }
557         } catch (IssueLinksException $e) {
558             $this->errors[] = $e;
559             $this->isValid = false;
560             return false;
561         }
562         return true;
563     }
564 
565     /**
566      * Check if an issue is valid.
567      *
568      * The specific rules depend on the service and the cached value may also be set by other functions.
569      *
570      * @param bool $recheck force a validity check instead of using cached value if available
571      *
572      * @return bool
573      */
574     public function isValid($recheck = false)
575     {
576         if ($recheck || $this->isValid === null) {
577             $serviceProvider = ServiceProvider::getInstance();
578             $service = $serviceProvider->getServices()[$this->serviceID];
579             $this->isValid = $service::isIssueValid($this);
580         }
581         return $this->isValid;
582     }
583 
584     public function saveToDB()
585     {
586         /** @var \helper_plugin_issuelinks_db $db */
587         $db = plugin_load('helper', 'issuelinks_db');
588         return $db->saveIssue($this);
589     }
590 
591     public function buildTooltipHTML()
592     {
593         $html = '<aside class="issueTooltip">';
594         $html .= "<h1 class=\"issueTitle\">{$this->getSummary()}</h1>";
595         $html .= "<div class='assigneeAvatar waiting'></div>";
596 
597         /** @var \helper_plugin_issuelinks_util $util */
598         $util = plugin_load('helper', 'issuelinks_util');
599 
600         $components = $this->getComponents();
601         if (!empty($components)) {
602             $html .= '<p class="components">';
603             foreach ($components as $component) {
604                 $html .= "<span class=\"component\">$component</span>";
605             }
606             $html .= '</p>';
607         }
608 
609         $labels = $this->getLabels();
610         if (!empty($labels)) {
611             $html .= '<p class="labels">';
612             foreach ($labels as $label) {
613                 $html .= "<span class=\"label\">$label</span>";
614             }
615             $html .= '</p>';
616         }
617 
618         $html .= '<p class="descriptionTeaser">';
619         $description = $this->getDescription();
620         if ($description) {
621             $lines = explode("\n", $description);
622             $cnt = min(count($lines), 5);
623             for ($i = 0; $i < $cnt; $i += 1) {
624                 $html .= hsc($lines[$i]) . "\n";
625             }
626         } else {
627             $html .= $util->getLang('no issue description');
628         }
629         $html .= '</p>';
630 
631         /** @var \helper_plugin_issuelinks_data $data */
632         $data = $this->loadHelper('issuelinks_data');
633 
634         if (!$this->isMergeRequest) {
635             // show merge requests referencing this Issues
636             $mrs = $data->getMergeRequestsForIssue(
637                 $this->getServiceName(),
638                 $this->getProject(),
639                 $this->issueId,
640                 $this->isMergeRequest
641             );
642             if (!empty($mrs)) {
643                 $html .= '<div class="mergeRequests">';
644                 $html .= '<h2>Merge Requests</h2>';
645                 $html .= '<ul>';
646                 foreach ($mrs as $mr) {
647                     $html .= '<li>';
648                     $a = "<a href=\"$mr[url]\">$mr[summary]</a>";
649                     $html .= $this->getformattedIssueStatus($mr['status']) . ' ' . $a;
650                     $html .= '</li>';
651                 }
652                 $html .= '</ul>';
653                 $html .= '</div>';
654             }
655         }
656 
657         $linkingPages = $data->getLinkingPages(
658             $this->getServiceName(),
659             $this->getProject(),
660             $this->issueId,
661             $this->isMergeRequest
662         );
663         if (count($linkingPages)) {
664             $html .= '<div class="relatedPages��">';
665             $html .= '<h2>' . $util->getLang('linking pages') . '</h2>';
666             $html .= '<ul>';
667             foreach ($linkingPages as $linkingPage) {
668                 $html .= '<li>';
669                 $html .= html_wikilink($linkingPage['page']);
670                 $html .= '</li>';
671             }
672             $html .= '</ul>';
673             $html .= '</div>';
674         }
675 
676         $html .= '</aside>';
677         return $html;
678     }
679 
680     public function getServiceName()
681     {
682         return $this->serviceID;
683     }
684 
685     /**
686      * @param string $labelName the background color without the leading #
687      * @param string $color
688      */
689     public function setLabelData($labelName, $color)
690     {
691         $this->labelData[$labelName] = [
692             'background-color' => $color,
693             'color' => $this->calculateColor($color),
694         ];
695     }
696 
697     /**
698      * Calculate if a white or black font-color should be with the given background color
699      *
700      * https://www.w3.org/TR/WCAG20/#relativeluminancedef
701      * http://stackoverflow.com/a/3943023/3293343
702      *
703      * @param string $color the background-color, without leading #
704      *
705      * @return string
706      */
707     private function calculateColor($color)
708     {
709         /** @noinspection PrintfScanfArgumentsInspection */
710         list($r, $g, $b) = array_map(function ($color8bit) {
711             $c = $color8bit / 255;
712             if ($c <= 0.03928) {
713                 $cl = $c / 12.92;
714             } else {
715                 $cl = pow(($c + 0.055) / 1.055, 2.4);
716             }
717             return $cl;
718         }, sscanf($color, "%02x%02x%02x"));
719         if ($r * 0.2126 + $g * 0.7152 + $b * 0.0722 > 0.179) {
720             return '#000000';
721         }
722         return '#FFFFFF';
723     }
724 }
725