1<?php
2
3namespace dokuwiki\plugin\issuelinks\classes;
4
5/**
6 *
7 * Class Issue
8 */
9class 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