-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathquery.go
More file actions
339 lines (297 loc) · 7.97 KB
/
query.go
File metadata and controls
339 lines (297 loc) · 7.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
var (
isoDateRe = regexp.MustCompile(`^([<>=]{0,2})(\d{4}-\d{2}-\d{2}.*)$`)
operatorRe = regexp.MustCompile(`^([<>]=?)(.+)$`)
)
// flipOperator inverts comparison operators for relative dates.
// "more than 2 weeks ago" (>) means the date is further in the past (<).
func flipOperator(op string) string {
switch op {
case ">":
return "<"
case "<":
return ">"
case ">=":
return "<="
case "<=":
return ">="
default:
return op
}
}
// parseDate converts human-readable durations to ISO 8601 dates for GitHub's search API.
func parseDate(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", nil
}
// Passthrough: already ISO format
if isoDateRe.MatchString(input) {
return input, nil
}
// Extract operator prefix
op := ""
value := input
if m := operatorRe.FindStringSubmatch(input); m != nil {
op = m[1]
value = m[2]
}
// Special keywords
now := time.Now().UTC()
switch strings.ToLower(value) {
case "today":
return ">=" + now.Format("2006-01-02"), nil
case "yesterday":
y := now.AddDate(0, 0, -1)
return op + y.Format("2006-01-02"), nil
}
// Compound relative duration parsing (e.g. "2weeks", "1y6mo", "1d12h")
s := strings.ToLower(value)
var years, months, days int
var dur time.Duration
useDateTime := false
parsed := false
var prevMultiplier int64
for len(s) > 0 {
seg := driftSegmentRe.FindStringSubmatch(s)
if seg == nil {
return "", fmt.Errorf("invalid date specification: %s", input)
}
n, err := strconv.Atoi(seg[1])
if err != nil {
return "", fmt.Errorf("invalid date specification: %s", input)
}
unit := seg[2]
mult := driftUnitMultiplier(unit)
if parsed && mult >= prevMultiplier {
return "", fmt.Errorf(
"invalid date specification: units must be in descending order: %s",
input,
)
}
switch unit {
case "y", "year", "years":
years += n
case "mo", "month", "months":
months += n
case "w", "week", "weeks":
days += n * daysPerWeek
case "d", "day", "days":
days += n
case "h", "hr", "hrs", "hour", "hours":
dur += time.Duration(n) * time.Hour
useDateTime = true
case "m", "min", "mins", "minute", "minutes":
dur += time.Duration(n) * time.Minute
useDateTime = true
case "s", "sec", "secs", "second", "seconds":
dur += time.Duration(n) * time.Second
useDateTime = true
}
prevMultiplier = mult
parsed = true
s = s[len(seg[0]):]
}
if !parsed {
return "", fmt.Errorf("invalid date specification: %s", input)
}
t := now.AddDate(-years, -months, -days)
if dur > 0 {
t = t.Add(-dur)
}
// Flip operator for relative dates
if op != "" {
op = flipOperator(op)
} else {
op = ">="
}
if useDateTime {
return op + t.Format("2006-01-02T15:04:05Z"), nil
}
return op + t.Format("2006-01-02"), nil
}
var (
driftOpRe = regexp.MustCompile(`^([<>]=?|={1,2})`)
driftSegmentRe = regexp.MustCompile(
`^(\d+)\s*(years|year|y|months|month|mo|weeks|week|w|days|day|d|hours|hour|hrs|hr|h|minutes|minute|mins|min|m|seconds|second|secs|sec|s)`,
)
)
// driftUnitMultiplier returns the multiplier in seconds for a drift unit.
func driftUnitMultiplier(unit string) int64 {
switch unit {
case "y", "year", "years":
return secsPerYear
case "mo", "month", "months":
return secsPerMonth
case "w", "week", "weeks":
return secsPerWeek
case "d", "day", "days":
return secsPerDay
case "h", "hr", "hrs", "hour", "hours":
return secsPerHour
case "m", "min", "mins", "minute", "minutes":
return secsPerMinute
case "s", "sec", "secs", "second", "seconds":
return 1
default:
return 0
}
}
// parseDrift parses a drift duration specification into operator and seconds.
// It supports compound durations like "5y2m" or "1w3d" where units must be
// in descending order. A bare integer with no unit is treated as raw seconds
// but only as a standalone value.
func parseDrift(input string) (string, int64, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", 0, fmt.Errorf("empty drift specification")
}
s := strings.ToLower(input)
// Strip optional operator prefix.
op := "<=" // default
if m := driftOpRe.FindString(s); m != "" {
op = m
s = s[len(m):]
}
if s == "" {
return "", 0, fmt.Errorf("invalid drift specification: %s", input)
}
// Try bare integer (raw seconds) - only valid as standalone value.
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
if n < 0 {
return "", 0, fmt.Errorf("invalid drift specification: negative value: %s", input)
}
return op, n, nil
}
// Loop-based compound segment parsing.
var total int64
var prevMultiplier int64
parsed := false
for len(s) > 0 {
m := driftSegmentRe.FindStringSubmatch(s)
if m == nil {
return "", 0, fmt.Errorf("invalid drift specification: %s", input)
}
n, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return "", 0, fmt.Errorf("invalid drift number: %s", m[1])
}
mult := driftUnitMultiplier(m[2])
if parsed && mult >= prevMultiplier {
return "", 0, fmt.Errorf(
"invalid drift specification: units must be in descending order: %s",
input,
)
}
total += n * mult
prevMultiplier = mult
parsed = true
s = s[len(m[0]):]
}
if !parsed {
return "", 0, fmt.Errorf("invalid drift specification: %s", input)
}
return op, total, nil
}
// formatDrift returns a human-readable description of a drift filter.
func formatDrift(op string, threshold int64) string {
dur := formatDuration(threshold)
switch op {
case "<=":
return "updated within " + dur + " of creation"
case "<":
return "updated less than " + dur + " after creation"
case ">=":
return "updated at least " + dur + " after creation"
case ">":
return "updated more than " + dur + " after creation"
case "=", "==":
if threshold == 0 {
return "never updated after creation"
}
return "updated exactly " + dur + " after creation"
default:
return op + " " + dur
}
}
// formatDuration converts seconds into the largest fitting human-readable unit.
func formatDuration(seconds int64) string {
if seconds == 0 {
return "0 seconds"
}
type unit struct {
divisor int64
single string
plural string
}
units := []unit{
{secsPerYear, "year", "years"},
{secsPerMonth, "month", "months"},
{secsPerWeek, "week", "weeks"},
{secsPerDay, "day", "days"},
{secsPerHour, "hour", "hours"},
{secsPerMinute, "minute", "minutes"},
{1, "second", "seconds"},
}
for _, u := range units {
if seconds%u.divisor == 0 {
n := seconds / u.divisor
if n == 1 {
return fmt.Sprintf("%d %s", n, u.single)
}
return fmt.Sprintf("%d %s", n, u.plural)
}
}
return fmt.Sprintf("%d seconds", seconds)
}
// buildORQualifier constructs a GitHub search OR expression for multi-value qualifiers.
// Single value: "qualifier:value"
// Multiple values: "(qualifier:v1 OR qualifier:v2 ... qualifier:vN)"
func buildORQualifier(qualifier string, values []string) string {
if len(values) == 0 {
return ""
}
if len(values) == 1 {
return qualifier + ":" + values[0]
}
parts := make([]string, len(values))
for i, v := range values {
parts[i] = qualifier + ":" + v
}
return "(" + strings.Join(parts, " OR ") + ")"
}
// buildOwnerQualifier constructs a GitHub search owner expression.
// GitHub's search API accepts user:<owner> for both personal accounts and orgs.
func buildOwnerQualifier(values []string) string {
if len(values) == 0 {
return ""
}
parts := make([]string, 0, len(values))
for _, v := range values {
parts = append(parts, "user:"+v)
}
if len(parts) == 1 {
return parts[0]
}
return "(" + strings.Join(parts, " OR ") + ")"
}
// buildExcludedOwnerQualifiers constructs negated GitHub search qualifiers that
// exclude both organization-owned and user-owned repositories.
func buildExcludedOwnerQualifiers(values []string) []string {
if len(values) == 0 {
return nil
}
const ownerQualifierKinds = 2
parts := make([]string, 0, len(values)*ownerQualifierKinds)
for _, v := range values {
parts = append(parts, "-org:"+v, "-user:"+v)
}
return parts
}