1#include <JenkinsJobPanel.h>
2
3#include <BuildGeneralInfoFetcher.h>
4#include <CheckBox.h>
5#include <ButtonLink.hpp>
6#include <QPinnableTabWidget.h>
7#include <JobDetailsFetcher.h>
8#include <DiffHelper.h>
9
10#include <QLogger.h>
11
12#include <QTimer>
13#include <QAuthenticator>
14#include <QUrlQuery>
15#include <QComboBox>
16#include <QLineEdit>
17#include <QPlainTextEdit>
18#include <QUrl>
19#include <QFile>
20#include <QLabel>
21#include <QGroupBox>
22#include <QGridLayout>
23#include <QScrollArea>
24#include <QRadioButton>
25#include <QButtonGroup>
26#include <QButtonGroup>
27#include <QStandardPaths>
28#include <QNetworkAccessManager>
29#include <QNetworkReply>
30#include <QMessageBox>
31#include <QPushButton>
32#include <QDesktopServices>
33#include <QMessageBox>
34
35using namespace QLogger;
36
37namespace Jenkins
38{
39
40JenkinsJobPanel::JenkinsJobPanel(const IFetcher::Config &config, QWidget *parent)
41 : QFrame(parent)
42 , mConfig(config)
43 , mName(new ButtonLink())
44 , mUrl(new ButtonLink(tr("Open job in Jenkins...")))
45 , mBuild(new QPushButton(tr("Trigger build")))
46 , mManager(new QNetworkAccessManager(this))
47{
48 setObjectName("JenkinsJobPanel");
49
50 mName->setObjectName("JenkinsJobPanelTitle");
51
52 mScrollFrame = new QFrame();
53 mScrollFrame->setObjectName("TransparentScrollArea");
54
55 mLastBuildFrame = new QFrame();
56 const auto lastBuildScrollArea = new QScrollArea();
57 lastBuildScrollArea->setWidget(mLastBuildFrame);
58 lastBuildScrollArea->setWidgetResizable(true);
59 lastBuildScrollArea->setFixedHeight(140);
60 lastBuildScrollArea->setStyleSheet("background: #404142;");
61
62 const auto scrollArea = new QScrollArea();
63 scrollArea->setWidget(mScrollFrame);
64 scrollArea->setWidgetResizable(true);
65 scrollArea->setStyleSheet("background: #404142;");
66
67 mTabWidget = new QPinnableTabWidget();
68 mTabWidget->addPinnedTab(scrollArea, "Previous builds");
69 mTabWidget->setContextMenuPolicy(Qt::NoContextMenu);
70
71 mBuild->setVisible(false);
72 mBuild->setObjectName("applyActionBtn");
73
74 const auto linkLayout = new QHBoxLayout();
75 linkLayout->setContentsMargins(QMargins());
76 linkLayout->setSpacing(0);
77 linkLayout->addWidget(mUrl);
78 linkLayout->addStretch();
79 linkLayout->addWidget(mBuild);
80
81 const auto layout = new QVBoxLayout(this);
82 layout->setContentsMargins(QMargins());
83 layout->setSpacing(10);
84 layout->addWidget(mName);
85 layout->addLayout(linkLayout);
86 layout->addWidget(lastBuildScrollArea);
87 layout->addWidget(mTabWidget);
88
89 connect(mName, &ButtonLink::clicked, this, [this]() {
90 if (mRequestedJob.name.startsWith("PR-"))
91 {
92 const auto num = mRequestedJob.name.split("-").last().toInt();
93 emit gotoPullRequest(num);
94 }
95 else
96 emit gotoBranch(mRequestedJob.name);
97 });
98 connect(mUrl, &ButtonLink::clicked, this, [this]() { QDesktopServices::openUrl(mRequestedJob.url); });
99 connect(mBuild, &QPushButton::clicked, this, &JenkinsJobPanel::triggerBuild);
100}
101
102void JenkinsJobPanel::loadJobInfo(const JenkinsJobInfo &job)
103{
104 if (mTmpBuildsCounter != 0 && job == mRequestedJob)
105 {
106 QMessageBox::warning(
107 this, tr("Request in progress"),
108 tr("There is a request in progress. Please, wait until the builds and stages for this job have been loaded"));
109 }
110 else
111 {
112 for (const auto &widget : qAsConst(mTempWidgets))
113 delete widget;
114
115 mTempWidgets.clear();
116
117 delete mBuildListLayout;
118 mBuildListLayout = nullptr;
119
120 delete mLastBuildLayout;
121 mLastBuildLayout = nullptr;
122
123 mTabWidget->setCurrentIndex(0);
124 mTabBuildMap.clear();
125
126 for (auto i = 1; i < mTabWidget->count(); ++i)
127 mTabWidget->removeTab(i);
128
129 mRequestedJob = job;
130 mName->setText(mRequestedJob.name);
131
132 mTmpBuildsCounter = mRequestedJob.builds.count();
133
134 if (mRequestedJob.builds.isEmpty())
135 mBuild->setVisible(true);
136 else
137 {
138 mBuild->setVisible(false);
139 for (const auto &build : qAsConst(mRequestedJob.builds))
140 {
141 const auto buildFetcher = new BuildGeneralInfoFetcher(mConfig, build, this);
142 connect(buildFetcher, &BuildGeneralInfoFetcher::signalBuildInfoReceived, this,
143 [this](const JenkinsJobBuildInfo &build) { appendJobsData(mRequestedJob.name, build); });
144 connect(buildFetcher, &BuildGeneralInfoFetcher::signalBuildInfoReceived, buildFetcher,
145 &BuildGeneralInfoFetcher::deleteLater);
146
147 buildFetcher->triggerFetch();
148 }
149 }
150 }
151}
152
153void JenkinsJobPanel::appendJobsData(const QString &jobName, const JenkinsJobBuildInfo &build)
154{
155 if (jobName != mRequestedJob.name)
156 return;
157
158 auto iter = std::find(mRequestedJob.builds.begin(), mRequestedJob.builds.end(), build);
159
160 if (iter == mRequestedJob.builds.end())
161 mRequestedJob.builds.append(build);
162 else
163 *iter = build;
164
165 --mTmpBuildsCounter;
166
167 if (mTmpBuildsCounter == 0)
168 {
169 mBuildListLayout = new QVBoxLayout(mScrollFrame);
170 mBuildListLayout->setContentsMargins(QMargins());
171 mBuildListLayout->setSpacing(10);
172
173 mLastBuildLayout = new QHBoxLayout(mLastBuildFrame);
174 mLastBuildLayout->setContentsMargins(10, 10, 10, 10);
175 mLastBuildLayout->setSpacing(10);
176
177 std::sort(mRequestedJob.builds.begin(), mRequestedJob.builds.end(),
178 [](const JenkinsJobBuildInfo &build1, const JenkinsJobBuildInfo &build2) {
179 return build1.number > build2.number;
180 });
181
182 const auto build = mRequestedJob.builds.takeFirst();
183
184 fillBuildLayout(build, mLastBuildLayout);
185
186 for (const auto &build : qAsConst(mRequestedJob.builds))
187 {
188 const auto stagesLayout = new QHBoxLayout();
189 stagesLayout->setContentsMargins(10, 10, 10, 10);
190 stagesLayout->setSpacing(10);
191
192 fillBuildLayout(build, stagesLayout);
193
194 mBuildListLayout->addLayout(stagesLayout);
195 }
196
197 const auto hasCustomBuildConfig = !mRequestedJob.configFields.isEmpty();
198
199 mBuild->setVisible(!hasCustomBuildConfig);
200
201 if (hasCustomBuildConfig)
202 createBuildConfigPanel();
203
204 mBuildListLayout->addStretch();
205 }
206}
207
208void JenkinsJobPanel::fillBuildLayout(const JenkinsJobBuildInfo &build, QHBoxLayout *layout)
209{
210 const auto mark = new ButtonLink(QString::number(build.number));
211 mark->setToolTip(build.result);
212 mark->setFixedSize(30, 30);
213 mark->setStyleSheet(QString("QLabel{"
214 "background: %1;"
215 "border-radius: 15px;"
216 "qproperty-alignment: AlignCenter;"
217 "}")
218 .arg(Jenkins::resultColor(build.result).name()));
219 connect(mark, &ButtonLink::clicked, this, [this, build]() {
220 showArtifacts(build);
221 requestFile(build);
222 });
223
224 mTempWidgets.append(mark);
225
226 layout->addWidget(mark);
227
228 for (const auto &stage : build.stages)
229 {
230 QTime t = QTime(0, 0, 0).addMSecs(stage.duration);
231 const auto tStr = t.toString("HH:mm:ss.zzz");
232 const auto label = new QLabel(QString("%1").arg(stage.name));
233 label->setToolTip(tStr);
234 label->setObjectName("StageStatus");
235 label->setFixedSize(100, 80);
236 label->setWordWrap(true);
237 label->setStyleSheet(QString("QLabel{"
238 "background: %1;"
239 "color: white;"
240 "border-radius: 10px;"
241 "border-bottom-right-radius: 0px;"
242 "border-bottom-left-radius: 0px;"
243 "qproperty-alignment: AlignCenter;"
244 "padding: 5px;"
245 "}")
246 .arg(Jenkins::resultColor(stage.result).name()));
247 mTempWidgets.append(label);
248
249 const auto time = new QLabel(tStr);
250 time->setToolTip(tStr);
251 time->setFixedSize(100, 20);
252 time->setStyleSheet(QString("QLabel{"
253 "background: %1;"
254 "color: white;"
255 "border-radius: 10px;"
256 "border-top-right-radius: 0px;"
257 "border-top-left-radius: 0px;"
258 "qproperty-alignment: AlignCenter;"
259 "padding: 5px;"
260 "}")
261 .arg(Jenkins::resultColor(stage.result).name()));
262 mTempWidgets.append(time);
263
264 const auto stageLayout = new QVBoxLayout();
265 stageLayout->setContentsMargins(QMargins());
266 stageLayout->setSpacing(0);
267 stageLayout->addWidget(label);
268 stageLayout->addWidget(time);
269 stageLayout->addStretch();
270
271 layout->addLayout(stageLayout);
272 }
273
274 layout->addStretch();
275}
276
277void JenkinsJobPanel::requestFile(const JenkinsJobBuildInfo &build)
278{
279 if (mTabBuildMap.contains(build.number))
280 mTabWidget->setCurrentIndex(mTabBuildMap.value(build.number));
281 else
282 {
283 auto urlStr = build.url;
284 urlStr.append("/consoleText");
285 QNetworkRequest request(urlStr);
286
287 if (!mConfig.user.isEmpty() && !mConfig.token.isEmpty())
288 request.setRawHeader(QByteArray("Authorization"),
289 QString("Basic %1:%2").arg(mConfig.user, mConfig.token).toLocal8Bit().toBase64());
290
291 const auto reply = mManager->get(request);
292 connect(reply, &QNetworkReply::finished, this, [this, number = build.number]() { storeFile(number); });
293 }
294}
295
296void JenkinsJobPanel::storeFile(int buildNumber)
297{
298 const auto reply = qobject_cast<QNetworkReply *>(sender());
299 const auto data = reply->readAll();
300
301 if (!data.isEmpty())
302 {
303 const auto text = new QPlainTextEdit(QString::fromUtf8(data));
304 text->setReadOnly(true);
305 text->setObjectName("JenkinsOutput");
306 mTempWidgets.append(text);
307
308 const auto find = new QLineEdit();
309 find->setPlaceholderText(tr("Find text... "));
310 connect(find, &QLineEdit::editingFinished, this,
311 [this, text, find]() { DiffHelper::findString(find->text(), text, this); });
312 mTempWidgets.append(find);
313
314 const auto frame = new QFrame();
315 frame->setObjectName("JenkinsOutput");
316
317 const auto layout = new QVBoxLayout(frame);
318 layout->setContentsMargins(10, 10, 10, 10);
319 layout->setSpacing(10);
320 layout->addWidget(find);
321 layout->addWidget(text);
322
323 const auto index = mTabWidget->addTab(frame, QString("Output for #%1").arg(buildNumber));
324 mTabWidget->setCurrentIndex(index);
325
326 mTabBuildMap.insert(buildNumber, index);
327 }
328
329 reply->deleteLater();
330}
331
332void JenkinsJobPanel::createBuildConfigPanel()
333{
334 const auto buildFrame = new QFrame();
335 buildFrame->setObjectName("buildFrame");
336 buildFrame->setStyleSheet("#buildFrame { background: #404142; }");
337
338 const auto buildLayout = new QGridLayout(buildFrame);
339 buildLayout->setContentsMargins(10, 10, 10, 10);
340 buildLayout->setSpacing(10);
341
342 auto row = 0;
343
344 for (const auto &config : qAsConst(mRequestedJob.configFields))
345 {
346 buildLayout->addWidget(new QLabel(config.name), row, 0);
347
348 if (config.fieldType == JobConfigFieldType::Bool)
349 {
350 const auto check = new CheckBox();
351 check->setChecked(config.defaultValue.toBool());
352 mBuildValues[config.name] = qMakePair(JobConfigFieldType::Bool, config.defaultValue);
353 connect(check, &CheckBox::stateChanged, this, [this, name = config.name](int checkState) {
354 mBuildValues[name] = qMakePair(JobConfigFieldType::Bool, checkState == Qt::Checked);
355 });
356
357 buildLayout->addWidget(check, row, 1);
358 }
359 else if (config.fieldType == JobConfigFieldType::String)
360 {
361 const auto lineEdit = new QLineEdit();
362 lineEdit->setText(config.defaultValue.toString());
363 mBuildValues[config.name] = qMakePair(JobConfigFieldType::Bool, config.defaultValue);
364 connect(lineEdit, &QLineEdit::textChanged, this, [this, name = config.name](const QString &text) {
365 mBuildValues[name] = qMakePair(JobConfigFieldType::String, text);
366 });
367
368 buildLayout->addWidget(lineEdit, row, 1);
369 }
370 else if (config.fieldType == JobConfigFieldType::Choice)
371 {
372 const auto combo = new QComboBox();
373 combo->addItems(config.choicesValues);
374 mBuildValues[config.name] = qMakePair(JobConfigFieldType::Bool, config.defaultValue);
375 connect(combo, &QComboBox::currentTextChanged, this, [this, name = config.name](const QString &text) {
376 mBuildValues[name] = qMakePair(JobConfigFieldType::String, text);
377 });
378
379 if (!config.defaultValue.toString().isEmpty())
380 combo->setCurrentText(config.defaultValue.toString());
381
382 buildLayout->addWidget(combo, row, 1);
383 }
384
385 ++row;
386 }
387
388 const auto btnsLayout = new QHBoxLayout();
389 btnsLayout->setContentsMargins(QMargins());
390 btnsLayout->setSpacing(0);
391
392 const auto btn = new QPushButton(tr("Build"));
393 btn->setFixedSize(100, 30);
394 btn->setObjectName("applyActionBtn");
395 connect(btn, &QPushButton::clicked, this, &JenkinsJobPanel::triggerBuild);
396
397 btnsLayout->addWidget(btn);
398 btnsLayout->addStretch();
399
400 buildLayout->addLayout(btnsLayout, row, 0, 1, 2);
401 buildLayout->addItem(new QSpacerItem(1, 1, QSizePolicy::Expanding, QSizePolicy::Fixed), row, 3);
402
403 mTabWidget->addPinnedTab(buildFrame, tr("Build with params"));
404}
405
406void JenkinsJobPanel::triggerBuild()
407{
408 const auto endpoint = QString::fromUtf8(mRequestedJob.builds.isEmpty() ? "build" : "buildWithParameters");
409 QUrl url(mRequestedJob.url.endsWith("/") ? QString("%1%2").arg(mRequestedJob.url, endpoint)
410 : QString("%1/%2").arg(mRequestedJob.url, endpoint));
411 QNetworkRequest request(url);
412
413 if (!mConfig.user.isEmpty() && !mConfig.token.isEmpty())
414 {
415 const auto data = QString("%1:%2").arg(mConfig.user, mConfig.token).toLocal8Bit().toBase64();
416 request.setRawHeader("Authorization", QString(QString::fromUtf8("Basic ") + data).toLocal8Bit());
417 }
418
419 QUrlQuery query;
420
421 const auto end = mBuildValues.constEnd();
422 for (auto iter = mBuildValues.constBegin(); iter != end; ++iter)
423 {
424 const auto value = iter->second.toString();
425 if (!value.isEmpty())
426 query.addQueryItem(iter.key(), value);
427 }
428
429 const auto queryData = query.query().toUtf8();
430 mManager->post(request, queryData);
431
432 QTimer::singleShot(10000, this, [this]() {
433 const auto jobRequest = new JobDetailsFetcher(mConfig, mRequestedJob);
434 connect(jobRequest, &JobDetailsFetcher::signalJobDetailsRecieved, this, [this](const JenkinsJobInfo &newInfo) {
435 mRequestedJob.builds = newInfo.builds;
436 mRequestedJob.configFields = newInfo.configFields;
437 mRequestedJob.healthStatus = newInfo.healthStatus;
438
439 loadJobInfo(mRequestedJob);
440 });
441 connect(jobRequest, &JobDetailsFetcher::signalJobDetailsRecieved, jobRequest, &JobDetailsFetcher::deleteLater);
442
443 jobRequest->triggerFetch();
444 });
445
446 mBuild->setVisible(false);
447
448 QMessageBox::information(this, tr("Update requested"),
449 tr("The build has been triggered and the information will be refreshed in 10 secs."));
450}
451
452void JenkinsJobPanel::showArtifacts(const JenkinsJobBuildInfo &build)
453{
454 const auto artifactsLayout = new QVBoxLayout();
455 artifactsLayout->setContentsMargins(QMargins());
456 artifactsLayout->setSpacing(10);
457
458 const auto artifactsFrame = new QFrame();
459 artifactsFrame->setLayout(artifactsLayout);
460 artifactsFrame->setObjectName("artifactsFrame");
461 artifactsFrame->setStyleSheet("#artifactsFrame{ background: #404142; }");
462
463 const auto scroll = new QScrollArea();
464 scroll->setWidget(artifactsFrame);
465 scroll->setWidgetResizable(true);
466 scroll->setObjectName("artifactsFrame");
467 scroll->setStyleSheet("#artifactsFrame{ background: #404142; }");
468
469 for (const auto &artifact : build.artifacts)
470 {
471 const auto fileLink = new ButtonLink(artifact.fileName);
472 connect(fileLink, &ButtonLink::clicked, this,
473 [this, artifact, num = build.number]() { downloadArtifact(artifact, num); });
474 artifactsLayout->addWidget(fileLink);
475 }
476
477 mTabWidget->addTab(scroll, tr("Artifacts for #%1").arg(build.number));
478}
479
480void JenkinsJobPanel::downloadArtifact(const JenkinsJobBuildInfo::Artifact &artifact, int number)
481{
482 QNetworkRequest request(artifact.url);
483
484 if (!mConfig.user.isEmpty() && !mConfig.token.isEmpty())
485 {
486 const auto data = QString("%1:%2").arg(mConfig.user, mConfig.token).toLocal8Bit().toBase64();
487 request.setRawHeader("Authorization", QString(QString::fromUtf8("Basic ") + data).toLocal8Bit());
488 }
489
490 const auto reply = mManager->get(request);
491 connect(reply, &QNetworkReply::finished, this,
492 [this, fileName = artifact.fileName, number] { storeArtifact(fileName, number); });
493}
494
495void JenkinsJobPanel::storeArtifact(const QString &fileName, int buildNumber)
496{
497 const auto reply = qobject_cast<QNetworkReply *>(sender());
498 const auto data = reply->readAll();
499
500 if (!data.isEmpty())
501 {
502 auto fullPath = QString("%1/%2_%3")
503 .arg(QStandardPaths::writableLocation(QStandardPaths::DownloadLocation),
504 QString::number(buildNumber), fileName);
505 QFile f(fullPath);
506
507 if (f.exists())
508 {
509 QMessageBox::warning(this, tr("File already exists!"),
510 tr("The file already exists in %1.")
511 .arg(QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)));
512 }
513 else if (f.open(QIODevice::WriteOnly))
514 {
515 f.write(data);
516 f.close();
517
518 QMessageBox::information(
519 this, tr("File downloaded!"),
520 tr("The file (%1) has been downloaded in: %2")
521 .arg(fileName, QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)));
522 }
523 }
524 else
525 QMessageBox::warning(this, tr("File download error!"), tr("The file (%1) couldn't be downloaded.").arg(fileName));
526}
527
528}
529