Add support for the PublishAt field

The purpose of the field is to let users set a custom date when the
report should be published. This overrides the default disclosure flow.

Change-Id: I9e01c3f9dd558dc7d3641fc774dd12b3a5b60967
diff --git a/vulnzybot.go b/vulnzybot.go
index a70ade3..459c7b5 100644
--- a/vulnzybot.go
+++ b/vulnzybot.go
@@ -43,6 +43,10 @@
 	Status string
 	// Date that the status was modified
 	StatusModified time.Time
+	// Whether a PublishAt field exists
+	HasPublishAt bool
+	// Custom date when the vulnerability should be published. Takes precedence over the default disclosure flow
+	PublishAt time.Time
 	// Date when the vulnerability report was sent to the vendor
 	Reported time.Time
 	// Number of days since the reported date when the vulnerability should be automatically disclosed
@@ -51,6 +55,8 @@
 	DeadlineDuration time.Duration
 	// Date when the vulnerability report should be published if it has been fixed
 	PublishDateIfFixed time.Time
+	// Whether DeadlineTime could be calculated or not. If false, the vulnerability should not be published if not fixed
+	HasValidDeadlineTime bool
 	// Deadline when the vulnerability report should be published if not fixed
 	DeadlineTime         time.Time
 	HasDoNotPublishLabel bool
@@ -59,7 +65,7 @@
 
 type Action struct {
 	MustPerform bool
-	// |Reason| can be `disclosureDeadline` (the deadline has been exceeded), `fixedDeadline` (the issue has been fixed and |deadlineAfterFixed| has passed)
+	// |Reason| can be `disclosureDeadline` (the deadline has been exceeded), `fixedDeadline` (the issue has been fixed and |deadlineAfterFixed| has passed), `customDisclosureDeadline` (the custom PublishAt date has passed)
 	Reason string
 	// |Type| can be `warning` (warning before actually disclosing the report), `disclosure` (the report is actually disclosed)
 	Type string
@@ -79,6 +85,7 @@
 
 	deadlineFound := false
 	reportedFound := false
+	report.HasPublishAt = false
 	for _, field := range issue.FieldValues {
 		if field.FieldName == "Deadline" {
 			deadlineFound = true
@@ -96,16 +103,24 @@
 				return nil, fmt.Errorf("Error parsing Reported field: %v")
 			}
 		}
-	}
-	if !deadlineFound {
-		return nil, fmt.Errorf("The Deadline field isn't set.")
-	}
-	if !reportedFound {
-		return nil, fmt.Errorf("The Reported field isn't set.")
+
+		if field.FieldName == "PublishAt" {
+			report.PublishAt, err = time.Parse(dateLayout, field.FieldValue)
+			if err != nil {
+				return nil, fmt.Errorf("Error parsing PublishAt field: %v")
+			}
+			report.PublishAt = report.PublishAt.Add(gracePeriod)
+			report.HasPublishAt = true
+		}
 	}
 
 	report.PublishDateIfFixed = report.StatusModified.Add(deadlineAfterFixed + gracePeriod)
-	report.DeadlineTime = report.Reported.Add(report.DeadlineDuration + gracePeriod)
+	if reportedFound && deadlineFound {
+		report.HasValidDeadlineTime = true
+		report.DeadlineTime = report.Reported.Add(report.DeadlineDuration + gracePeriod)
+	} else {
+		report.HasValidDeadlineTime = false
+	}
 
 	report.HasDoNotPublishLabel = slices.Contains(issue.Labels, doNotPublishLabel)
 	report.HasRestrictedLabel = slices.Contains(issue.Labels, restrictedLabel)
@@ -124,17 +139,30 @@
 
 	// Take into account that warnings are published in advance (according to warningPeriod)
 	var (
-		publishDateIfFixed time.Time
-		deadlineTime       time.Time
+		customPublishAtDate time.Time
+		publishDateIfFixed  time.Time
+		deadlineTime        time.Time
 	)
 	if isWarning {
+		customPublishAtDate = report.PublishAt.Add(-warningPeriod)
 		publishDateIfFixed = report.PublishDateIfFixed.Add(-warningPeriod)
 		deadlineTime = report.DeadlineTime.Add(-warningPeriod)
 	} else {
+		customPublishAtDate = report.PublishAt
 		publishDateIfFixed = report.PublishDateIfFixed
 		deadlineTime = report.DeadlineTime
 	}
 
+	if report.HasPublishAt {
+		if (!isWarning && customPublishAtDate.Before(now)) || (isWarning && dateEqual(customPublishAtDate, now)) {
+			return &Action{
+				MustPerform: true,
+				Reason:      "customDisclosureDeadline",
+				Type:        actionType,
+			}
+		}
+		return &Action{MustPerform: false}
+	}
 	if report.Status == "Fixed" || report.Status == "Verified" {
 		if (!isWarning && publishDateIfFixed.Before(now)) || (isWarning && dateEqual(publishDateIfFixed, now)) {
 			return &Action{
@@ -145,7 +173,7 @@
 		}
 		return &Action{MustPerform: false}
 	}
-	if (!isWarning && deadlineTime.Before(now)) || (isWarning && dateEqual(deadlineTime, now)) {
+	if report.HasValidDeadlineTime && (!isWarning && deadlineTime.Before(now)) || (isWarning && dateEqual(deadlineTime, now)) {
 		return &Action{
 			MustPerform: true,
 			Reason:      "disclosureDeadline",
@@ -255,6 +283,12 @@
 		} else {
 			message = fmt.Sprintf("**The %d-day deadline has been exceeded** -- automatically publishing the vulnerability report.", report.DeadlineDays)
 		}
+	} else if action.Reason == "customDisclosureDeadline" {
+		if action.Type == "warning" {
+			message = fmt.Sprintf("This report will be published in %d days, as set in the PublishAt field.\n\n_Please unset `PublishAt` or add the `%s` label if you want to stop the automatic disclosure._", warningPeriod/(24*time.Hour), doNotPublishLabel)
+		} else {
+			message = "**The PublishAt date has passed** -- automatically publishing the vulnerability report."
+		}
 	}
 	err := publishReport(message, report, action, monorailSvc, ctx)
 	if err != nil {