Repository navigation
Expand file tree
/
Copy pathoptions.go
More file actions
724 lines (678 loc) · 34.1 KB
/
Copy pathoptions.go
File metadata and controls
724 lines (678 loc) · 34.1 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
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
package forgeapi
import (
"log/slog"
"net/http"
"strings"
"time"
)
// The budget defaults. A standalone consumer must not have to invent page caps
// and concurrency limits for forges it has not measured, so the library ships
// them and a consumer overrides what its own poller needs.
const (
// DefaultPageBound is the page size a list asks for when no
// [WithPageBound] is given. A family whose instance or document serves
// fewer rows a page asks for at most that many, and its walk still reaches
// every row.
DefaultPageBound = 100
// DefaultListPages is the number of pages one list operation traverses
// before reporting [PartialPaginationCap].
DefaultListPages = 3
// DefaultStatusPages is the number of pages one folded commit status
// traverses. It is a bound of its own rather than the list bound, because
// the folded-status endpoint is paginated at the instance's own page size
// and neither its folded state nor its total reveals a truncation.
DefaultStatusPages = 3
// DefaultStatusReadsPerInterval is how many folded statuses one client reads
// per rolling interval of its own keeping, on the family that needs one
// request per pull request. The remainder is reported partial and reached in
// the next interval, which resumes from the [RotationCursor] the consumer
// stored, so no row starves.
DefaultStatusReadsPerInterval = 20
// DefaultMutationReserve is the budget the governor holds back from reads,
// in the instance's own units, when no [WithMutationReserve] is given. It is
// sized for a burst of mutations with their follow-up reads inside one
// window, and it is a small share of every in-scope instance's window: one
// per cent of GitHub's hourly REST window and two and a half of gitlab.com's
// per-minute one, so a client that never mutates loses no more than that.
DefaultMutationReserve = 50
// DefaultRetries is the per-request retry cap. It is an upper bound rather
// than a behaviour: the per-phase transport bounds sum to more than
// [DefaultOperationTimeout], so a stalled instance exhausts the operation
// deadline inside one attempt and the retries are reachable only on
// failures that fail fast.
DefaultRetries = 2
// DefaultReadConcurrency is the number of concurrent reads per instance.
// Two, following the upstream guidance to prefer serial over concurrent.
DefaultReadConcurrency = 2
// DefaultMutationConcurrency is the number of concurrent mutations per
// instance. One.
DefaultMutationConcurrency = 1
)
// The budget's time defaults.
const (
// DefaultOperationTimeout bounds one operation. It is applied as a context
// deadline, which is the only bound that can interrupt an attempt already
// in flight.
DefaultOperationTimeout = 20 * time.Second
// DefaultStatusTimePerInterval is the wall clock one client spends on
// folded-status reads per rolling interval of its own keeping.
DefaultStatusTimePerInterval = 60 * time.Second
// DefaultMutationInterval is the minimum gap between consecutive mutations
// on one instance, following the upstream instruction to pause between
// mutative requests. It is a library default rather than something a
// standalone consumer has to discover from a secondary rate limit.
DefaultMutationInterval = time.Second
)
// DefaultRefreshLead is how long before its expiry a rotating credential is due
// for refresh when no [WithRefreshLead] is given.
const DefaultRefreshLead = 5 * time.Minute
// Budget is what one connection is allowed to spend. It is read and never
// filled: [DefaultBudget] publishes the shipped numbers, [Settings.Budget]
// reports what one client resolved, and each field names the one option that
// moves it, so a zero passed to that option is the literal zero.
//
// The per-interval fields bound a rolling interval the client keeps itself;
// nothing on this surface marks a polling cycle. The page size is the per-call
// [WithPageBound], and the reserve held back from reads is [WithMutationReserve].
type Budget struct {
// ListPages is the page cap per list operation. [WithListPages] sets it.
ListPages int
// StatusPages is the page cap per folded commit status.
// [WithStatusPages] sets it. It is a bound of its own rather than the list
// bound, because the folded-status endpoint is paginated at the instance's
// own page size and neither its folded state nor its total reveals a
// truncation; it is also what bounds the COMPLETE fold on
// [PullRequests.ReadPR], and it prices no list path at all, since a list
// takes one page of the nested collection and marks the row.
StatusPages int
// StatusReadsPerInterval is the folded-status read cap per rolling interval,
// on the family whose folded status costs one request per pull request.
// [WithStatusReadsPerInterval] sets it. It is enforced: the read that would
// exceed it is not issued and its row is marked [PartialBudget], and the
// next interval starts each list at the row after the last one served there,
// so no row starves under a poller presenting the same lists in the same
// order. Each list rotates on its own; the [RotationCursor] the consumer
// stored carries the rotation across a restart.
StatusReadsPerInterval int
// Retries is the per-request retry cap, an upper bound rather than a
// behaviour for the reason [DefaultRetries] states. [WithRetries] sets it,
// and a zero there is no retries.
Retries int
// ReadConcurrency is the concurrent-read limit per instance.
// [WithReadConcurrency] sets it.
ReadConcurrency int
// MutationConcurrency is the concurrent-mutation limit per instance.
// [WithMutationConcurrency] sets it.
MutationConcurrency int
// OperationTimeout bounds one operation, applied as a context deadline.
// [WithOperationTimeout] sets it.
OperationTimeout time.Duration
// StatusTimePerInterval is the wall clock one rolling interval of
// folded-status work is allowed. [WithStatusTimePerInterval] sets it. It is
// enforced like StatusReadsPerInterval: a read that cannot be issued inside
// the interval's remaining clock is not sent and its row is marked
// [PartialBudget]. OperationTimeout bounds the operation each read belongs to,
// and a list whose folds reach it returns with the unread rows marked
// [PartialBudget]. A consumer that wants its whole polling cycle bounded brings
// its own context.
StatusTimePerInterval time.Duration
// MutationInterval is the minimum gap between mutations.
// [WithMutationInterval] sets it.
MutationInterval time.Duration
}
// DefaultBudget returns the budget an unconfigured client resolves: every field
// at its own Default* constant, the value each option's Default: clause names.
// It is a record to read, in the shape [ReservedHeaders] uses, never an input:
// a consumer moves a number one option at a time. Each call returns a fresh
// value.
func DefaultBudget() Budget {
return Budget{
ListPages: DefaultListPages,
StatusPages: DefaultStatusPages,
StatusReadsPerInterval: DefaultStatusReadsPerInterval,
Retries: DefaultRetries,
ReadConcurrency: DefaultReadConcurrency,
MutationConcurrency: DefaultMutationConcurrency,
OperationTimeout: DefaultOperationTimeout,
StatusTimePerInterval: DefaultStatusTimePerInterval,
MutationInterval: DefaultMutationInterval,
}
}
// Counters is how the library's own event counts reach a consumer: a struct of
// functions over this package's own types, so no metrics library appears in any
// signature and a consumer wires whatever it already has.
//
// Every field is optional; a nil function means that event is not counted. The
// set is frozen at 1.0, so a new counter is a new field, which is additive.
type Counters struct {
// UnknownEnumValue fires for every upstream enumeration value outside a
// mapping table, beside the log line naming it, which is how a maintainer
// learns an upstream enumeration grew. It carries the family and field and
// never the value, which is upstream-controlled and unbounded and would hand
// a labelled metric unbounded cardinality; the log line names the value,
// bounded and rune-sanitized.
UnknownEnumValue func(family Family, field string)
// CapabilityContradicted fires where an inferred capability is later
// contradicted by a probe.
CapabilityContradicted func(family Family, capability Capability)
// RateLimited fires on a throttle refusal.
RateLimited func(family Family)
// Retried fires per retried request.
Retried func(family Family, op string)
// PartialResult fires per partial result, by reason.
PartialResult func(family Family, reason PartialReason)
// RefreshOutcome fires per credential-refresh outcome, naming the terminal
// state it reached.
RefreshOutcome func(family Family, terminal CredState)
// HelperDecline fires per git-credential-helper decline, by reason, one of
// unowned_origin, expired, reconnect_required, spent, no_account and
// store_unreadable. A decline is the whole diagnosis a user gets when git
// exits without naming a cause, so it is counted as well as reported. It is
// the one counter here that carries no family, deliberately: its commonest
// decline is a URL no connection owns, where there is no family to name.
HelperDecline func(reason string)
// HelperErase fires per erase git sends the credential helper for an origin
// a connection owns, by that connection's family. Git sends one after any
// authentication failure, and the helper keeps the connection rather than
// let one failed push take it down, so this count is where those failures
// show; a token that is really revoked surfaces through the probe.
HelperErase func(family Family)
// EnvelopeError fires per document-envelope error, by upstream's own error
// type.
EnvelopeError func(family Family, errType string)
// SunsetSeen fires when an instance announces that a pinned API version is
// closing down. It is DORMANT at 1.0: the version this library pins has no
// closing date scheduled, so no instance announces one for it, and it fires
// the day a successor to that version ships and the pinned one enters its
// closing window. The older version that is already in such a window is
// one this library never sends.
SunsetSeen func(family Family)
// AddressRefusal fires when the address policy refuses a host, by family and
// by the refusal's kind. The library counts these itself rather than relying
// on the refusing library's own log line, which lands in a sink this one does
// not own, and a refusal is the event most worth correlating with the
// operation that failed, which is why the family is a parameter here rather
// than an addition after the tag, when adding one would break every
// implementer.
AddressRefusal func(family Family, kind string)
// RedirectHopCapExceeded fires when a redirect chain is refused for length.
// It is what separates a redirect loop from an operation that merely ran
// out of time.
RedirectHopCapExceeded func(family Family)
// RedirectPortRefused fires when a hop's port is outside the connection's
// allowlist.
RedirectPortRefused func(family Family)
// NonDurableCredentialWrite fires when a credential write returned no error
// but could not be proved durable, which this library treats as a failed
// write.
NonDurableCredentialWrite func(family Family)
// ReadDeferred fires when the governor defers a read rather than issuing one
// that would cross the mutation reserve, by family and operation. It is what
// separates a [PartialRateLimited] the library chose from one upstream
// imposed, since both carry that one partial reason. It is dormant on Gitea,
// the one product that sends no budget signal. The operation is spelled as
// [Error.Op] is, so its label set is closed with that field's.
ReadDeferred func(family Family, op string)
}
// Settings is the resolved option state: what [Resolve] produces and a family's
// constructor reads.
//
// A consumer cannot construct an [Option], so it cannot reach these fields
// except through the With* functions. A new field is additive.
type Settings struct { //nolint:govet // fieldalignment: the field order is this record's own reading order
// Logger receives the library's own lines. Nil means slog.Default.
Logger *slog.Logger
// Credential is the token source every request authenticates with. Nil
// means the connection is unauthenticated, which a family's constructor
// refuses rather than sending anonymous requests.
Credential CredentialSource
// WireTransport replaces the wire under the library's own client stack.
// Nil, which is the ordinary case, means the library builds its own.
WireTransport http.RoundTripper
// RequestObserver wraps the transport outside the retry layer, so what it
// installs sees one call per logical request. Nil means no hook.
RequestObserver func(next http.RoundTripper) http.RoundTripper
// AttemptObserver wraps inside the retry layer, so what it installs sees
// one call per attempt. Nil means no hook.
AttemptObserver func(next http.RoundTripper) http.RoundTripper
// Counters receives the event counts.
Counters Counters
// Budget is the resolved budget: [DefaultBudget]'s numbers with each knob
// the caller set in place of its default.
Budget Budget
// MutationReserve is the budget the governor holds back from reads, in the
// instance's own units.
MutationReserve int
// RotationCursor is where the rotation resumes, empty for a first run. It
// is the value [WithRotationCursor] took and the one
// [BudgetState.RotationCursor] hands back.
RotationCursor RotationCursor
// Mutations reports whether mutating operations are permitted.
Mutations bool
// PrivateAddresses reports whether this connection may reach a
// private-range or single-label host.
PrivateAddresses bool
// PlaintextHTTP reports whether this connection may speak plaintext HTTP.
PlaintextHTTP bool
// RefreshLead is how long before expiry a rotating credential is due for
// refresh, the value [WithRefreshLead] took.
RefreshLead time.Duration
// ReadCacheBytes bounds what the read cache holds, the value
// [WithReadCache] took; zero switches the cache off.
ReadCacheBytes int64
}
// Option configures a client. Every option takes a parameter and none signals by
// presence, so a caller composing options at runtime never has to rebuild an
// argument list; where two options set the same thing, the last one wins.
//
// The interface cannot be implemented outside this package, deliberately: an
// interface a consumer implements is the most frozen thing in a v1, and nothing
// here needs a consumer-supplied option.
type Option interface {
applyOption(*Settings)
}
// Resolve applies defaults and then every option in order, and is what a
// family's constructor calls on the options it was handed.
func Resolve(opts ...Option) Settings {
s := Settings{
Budget: DefaultBudget(),
MutationReserve: DefaultMutationReserve,
Mutations: true,
RefreshLead: DefaultRefreshLead,
ReadCacheBytes: DefaultReadCacheBytes,
}
for _, o := range opts {
if o != nil {
o.applyOption(&s)
}
}
return s
}
// WithMutations permits or refuses every mutating operation on this client.
// WithMutations(false) is enforced at the operation boundary with
// [CodeMutationsDisabled] on CreatePR, ClosePR, ReopenPR, MergePR, CreateIssue,
// CloseIssue, CreateRelease and RerunFailedChecks, which mutates no forge object
// but can carry deployment side effects. MergeStatus and ListRuns are reads.
//
// Default: true. An embedding that never sets it holds a client that can merge
// and close pull requests; read-only is the deliberate posture.
func WithMutations(allowed bool) Option {
return optionFunc(func(s *Settings) { s.Mutations = allowed })
}
// The budget knobs, one option each and the only door to a [Budget]: an
// option's absence is its default, which its Default: clause names, so a zero
// passed to one is the literal zero. A negative is refused by a family's
// constructor with [CodeBudgetInvalid] on every knob: a negative cap or interval
// is a bound of negative size, and a negative deadline fails every call like a
// slow instance. The constructor refuses because [Resolve] returns one value.
// [WithReadConcurrency] and [WithMutationConcurrency] refuse a zero too, because
// a client admitting no request could run no operation at all.
// WithListPages sets how many pages one list operation traverses before
// reporting [PartialPaginationCap]. Zero traverses none.
//
// Default: [DefaultListPages].
func WithListPages(n int) Option {
return optionFunc(func(s *Settings) { s.Budget.ListPages = n })
}
// WithStatusPages sets how many pages one folded commit status traverses. It is a
// bound of its own rather than the list bound, for the reason
// [Budget.StatusPages] states, and it is what bounds the complete fold on
// [PullRequests.ReadPR]. Zero traverses none.
//
// Default: [DefaultStatusPages].
func WithStatusPages(n int) Option {
return optionFunc(func(s *Settings) { s.Budget.StatusPages = n })
}
// WithStatusReadsPerInterval sets the folded-status read cap per rolling
// interval, on the family whose folded status costs one request per pull
// request. Zero issues none, which leaves every such row partial with
// [PartialBudget]. The rotation is fair within one list, not across lists: a
// caller sweeping repositories through [PullRequests.ListPRs] gives the first
// lists the folds, and [PullRequests.ListMyPRs] spends none of the cap.
//
// Default: [DefaultStatusReadsPerInterval].
func WithStatusReadsPerInterval(n int) Option {
return optionFunc(func(s *Settings) { s.Budget.StatusReadsPerInterval = n })
}
// WithRetries sets the per-request retry cap. Zero means no retries, which is a
// posture rather than an absence: a caller that wants one attempt asks for zero
// retries, and does not express it with a negative. A published price counts each
// request once; every attempt is sent, so a retried request adds up to n to the
// call's [BudgetState.LastCost].
//
// Default: [DefaultRetries].
func WithRetries(n int) Option {
return optionFunc(func(s *Settings) { s.Budget.Retries = n })
}
// WithReadConcurrency sets the concurrent-read limit per instance. Zero admits no
// read at all, so it is REFUSED by a family's constructor with
// [CodeBudgetInvalid] rather than installed: a client that can issue no read is a
// client with no working operation on it.
//
// Default: [DefaultReadConcurrency].
func WithReadConcurrency(n int) Option {
return optionFunc(func(s *Settings) { s.Budget.ReadConcurrency = n })
}
// WithMutationConcurrency sets the concurrent-mutation limit per instance. Zero
// admits no mutation at all, so it is REFUSED by a family's constructor with
// [CodeBudgetInvalid] rather than installed. A caller that wants a read-only client
// asks for one with [WithMutations](false), which refuses a mutation at the call
// with its own code and leaves every read working.
//
// Default: [DefaultMutationConcurrency].
func WithMutationConcurrency(n int) Option {
return optionFunc(func(s *Settings) { s.Budget.MutationConcurrency = n })
}
// WithOperationTimeout bounds one operation. It is applied as a context
// deadline, which is the only bound that can interrupt an attempt already in
// flight. Zero is a deadline that has already passed.
//
// Default: [DefaultOperationTimeout].
func WithOperationTimeout(d time.Duration) Option {
return optionFunc(func(s *Settings) { s.Budget.OperationTimeout = d })
}
// WithStatusTimePerInterval sets the wall clock one rolling interval of
// folded-status work is allowed. Zero allows none.
//
// Default: [DefaultStatusTimePerInterval].
func WithStatusTimePerInterval(d time.Duration) Option {
return optionFunc(func(s *Settings) { s.Budget.StatusTimePerInterval = d })
}
// WithMutationInterval sets the minimum gap between consecutive mutations on one
// instance. Zero leaves no gap, which is what a secondary rate limit answers a
// burst for.
//
// Default: [DefaultMutationInterval].
func WithMutationInterval(d time.Duration) Option {
return optionFunc(func(s *Settings) { s.Budget.MutationInterval = d })
}
// WithRotationCursor hands the library the [RotationCursor] a previous client
// reported through [BudgetState.RotationCursor], so this client resumes the
// rotation where that one stopped. It is checked as [ValidateCursor] checks a
// page cursor, never parsed, and a family's constructor refuses a malformed one
// with [CodeCursorInvalid]: something the consumer stored is corrupt.
//
// Default: the empty cursor, a first run. A family with nothing to rotate always
// reports it, so replaying the stored value needs no per-family branch.
func WithRotationCursor(c RotationCursor) Option {
return optionFunc(func(s *Settings) { s.RotationCursor = c })
}
// WithMutationReserve sets the budget the governor holds back from reads, in the
// instance's own units: a read is deferred, marked [PartialRateLimited] and counted
// by [Counters.ReadDeferred] while a rate-limit pool it draws on reports n or less,
// and no mutation is held back. A read is held against the pool its operation's last
// answer named, and on GitLab also against the authenticated API throttle; a pool
// whose whole limit is n or less is not held, and Gitea reports no figure to hold.
// Zero holds nothing back, and a family's constructor refuses a negative n with [CodeBudgetInvalid].
// Default: [DefaultMutationReserve].
func WithMutationReserve(n int) Option {
return optionFunc(func(s *Settings) { s.MutationReserve = n })
}
// WithReadCache bounds the bytes this connection's read cache holds, evicting the
// least recently used entry first. A held answer is served only where the instance
// answers the read's validator with a not-modified status. Zero holds nothing and
// sends every read unconditionally. A negative maxBytes is refused by a family's
// constructor with [CodeBudgetInvalid].
//
// Default: [DefaultReadCacheBytes].
func WithReadCache(maxBytes int64) Option {
return optionFunc(func(s *Settings) { s.ReadCacheBytes = maxBytes })
}
// WithCredentialSource sets the token source every request on this connection
// authenticates with. See [CredentialSource].
//
// Default: none, which a family's constructor refuses rather than sending
// anonymous requests: a client that silently reads only public data would look
// like a permission problem on the instance.
func WithCredentialSource(src CredentialSource) Option {
return optionFunc(func(s *Settings) { s.Credential = src })
}
// WithRefreshLead sets how long before its expiry a rotating credential is due
// for refresh. The creds package's source reads it and clamps it to half the
// lifetime the credential was issued with, so a short lifetime cannot make every
// fresh token due on arrival.
//
// Default: [DefaultRefreshLead]. Zero refreshes a token only once it has
// expired. A negative d is refused by that source's constructor with
// [CodeBudgetInvalid], because a lead of negative size hands over expired tokens.
func WithRefreshLead(d time.Duration) Option {
return optionFunc(func(s *Settings) { s.RefreshLead = d })
}
// WithWireTransport replaces the wire under this client's own HTTP stack, as a
// test does to answer without a network. It bypasses containment, which lives in
// the transport it replaces: address policy, port allowlist, trust anchor,
// redirect policy and proxy. Close leaves its pool to its author, and its reads are
// cached only where each answer reports the request it answered as its Request.
//
// Default: none, and the library builds its own transport. It never falls back
// to http.DefaultClient, which has no timeout and no redirect policy of ours.
func WithWireTransport(rt http.RoundTripper) Option {
return optionFunc(func(s *Settings) { s.WireTransport = rt })
}
// WithRequestObserver installs a hook outside the retry layer, called once per
// logical request whatever the attempt count. The hook takes the next round
// tripper and must pass the request on, its context derived from the one it got;
// a second one replaces the first. Both observer positions sit above
// [WithWireTransport]'s, so containment holds. A revalidated read reaches both as
// the instance's 304, and a read a hook changes bypasses the read cache.
//
// Default: none.
func WithRequestObserver(wrap func(next http.RoundTripper) http.RoundTripper) Option {
return optionFunc(func(s *Settings) { s.RequestObserver = wrap })
}
// WithAttemptObserver installs a hook inside the retry layer, called once per
// attempt, so a retried request shows as more than one call. Its shape, its
// containment and its effect on the read cache are [WithRequestObserver]'s: the
// hook must pass the request on, and a second WithAttemptObserver replaces the
// first.
//
// Default: none.
func WithAttemptObserver(wrap func(next http.RoundTripper) http.RoundTripper) Option {
return optionFunc(func(s *Settings) { s.AttemptObserver = wrap })
}
// WithLogger routes the library's own lines, the transport's address and port
// refusals included. The library retries, refreshes credentials and follows
// redirects, so it emits as part of its job rather than returning diagnostics and
// staying silent.
//
// Default: slog.Default.
func WithLogger(l *slog.Logger) Option {
return optionFunc(func(s *Settings) { s.Logger = l })
}
// WithCounters installs the counter seam. See [Counters].
//
// Default: no counters, so every event is logged and none is counted.
//
//nolint:gocritic // hugeParam: the counter set is the published signature's own parameter
func WithCounters(c Counters) Option {
return optionFunc(func(s *Settings) { s.Counters = c })
}
// WithPrivateAddresses permits this connection to reach a private-range address
// or a single-label host, the ordinary case for a self-hosted forge. It contains
// this library's own credentialed requests, not a process that can open a
// socket for itself, and the per-hop redirect revalidation runs regardless.
//
// Default: false. Reaching a private host is one deliberate statement per
// instance, so an agent driven by untrusted content cannot open one unasked.
func WithPrivateAddresses(allowed bool) Option {
return optionFunc(func(s *Settings) { s.PrivateAddresses = allowed })
}
// WithPlaintextHTTP permits this connection to speak http rather than https.
// The token and every request then travel in cleartext on that network, a
// consequence the consumer names to the user. A redirect from https down to
// http stays refused whatever this is set to.
//
// Default: false.
func WithPlaintextHTTP(allowed bool) Option {
return optionFunc(func(s *Settings) { s.PlaintextHTTP = allowed })
}
// ListSettings is the resolved per-call option state for a list operation.
type ListSettings struct { //nolint:govet // fieldalignment: each field was appended in turn, and api/forgeapi.txt records that order
// After is the continuation to resume from, empty for a first page.
After Cursor
// Owner is the owner scope [WithOwner] named, empty where it named none.
Owner string
// PageBound is the page size to ask for.
PageBound int
// State is the state filter, [ListStateOpen] where the caller named none.
State ListState
// StateSet reports that the caller named the filter, whatever it named:
// [WithState] sets it even for the default value. A family sees only this
// struct, since [ListOption]'s mutator is unexported, and without this field
// ResolveList() and ResolveList(WithState(ListStateOpen)) would resolve the
// same. The stateful lists read State and ignore this; every other list
// refuses whenever it is true.
StateSet bool
// OwnerSet reports that the caller named an owner scope, whatever it named,
// for the reason StateSet exists: an empty owner is refused, and without
// this field it resolves to the same value as no owner at all. The lists an
// owner scopes read Owner where it is true; every other list refuses
// whenever it is true.
OwnerSet bool
// Since is the creation bound [WithSince] named, in UTC at whole seconds with no
// monotonic reading, the zero time where it named none.
Since time.Time
// SinceSet reports that the caller named a creation bound, for the reason
// OwnerSet exists. The run listing reads Since where it is true; every other
// list refuses whenever it is true.
SinceSet bool
}
// ListOption configures one list call. Like [Option] it cannot be implemented
// outside this package, and last one wins.
type ListOption interface {
applyListOption(*ListSettings)
}
// ResolveList applies defaults and then every list option in order, and refuses
// rather than clamps, with a [*Error]: [CodePageBoundInvalid] for a page bound that
// is not positive, [CodeListStateInvalid] for [ListStateUnknown],
// [CodeListOwnerInvalid] for an owner outside the form [WithOwner] states,
// [CodeListSinceInvalid] for the zero time, and [CodeCursorInvalid] for a malformed
// [WithAfter] cursor. It is not told which list it resolves for, so it records a
// named filter, owner or bound in the matching Set field, and the family method
// refuses one its list cannot take, under that option's code.
func ResolveList(opts ...ListOption) (ListSettings, error) {
s := ListSettings{PageBound: DefaultPageBound, State: ListStateOpen}
for _, o := range opts {
if o != nil {
o.applyListOption(&s)
}
}
switch {
case s.PageBound <= 0:
return ListSettings{}, localError(CodePageBoundInvalid, "page bound is not positive")
case s.State == ListStateUnknown:
return ListSettings{}, localError(CodeListStateInvalid, "list state filter is the unknown member")
case s.OwnerSet && !ownerInForm(s.Owner):
return ListSettings{}, localError(CodeListOwnerInvalid, "owner is outside the shared form")
case s.SinceSet && s.Since.IsZero():
return ListSettings{}, localError(CodeListSinceInvalid, "since bound is the zero time")
}
if err := ValidateCursor(s.After); err != nil {
return ListSettings{}, err
}
return s, nil
}
// ValidateOwner refuses, without a request, an owner the family's lists would refuse
// under [WithOwner], with [CodeListOwnerInvalid], the code the list answers, so a
// stored owner can be refused when it is stored. The shared form is one or more
// segments of ASCII letters, digits, '-', '_' and '.', joined by '/', none empty, "."
// or "..", at most 255 bytes. Only GitLab takes a path, a group's full path, and
// [FamilyUnknown] takes the single-name rule, as [ValidateSelector] gives it the
// single-separator one.
func ValidateOwner(family Family, owner string) error {
if !ownerInForm(owner) {
return localError(CodeListOwnerInvalid, "owner is outside the shared form")
}
if family != FamilyGitLab && strings.Contains(owner, "/") {
return localError(CodeListOwnerInvalid, "an owner on this family is one user or organization name, never a path")
}
return nil
}
// maxOwnerBytes bounds an owner as the shared form states it, separators
// included.
const maxOwnerBytes = 255
// ownerInForm reports whether an owner is in the shared form: one or more
// segments of ASCII letters, digits, '-', '_' and '.', joined by '/', no segment
// empty, "." or "..", and at most maxOwnerBytes in all.
func ownerInForm(owner string) bool {
if owner == "" || len(owner) > maxOwnerBytes {
return false
}
for segment := range strings.SplitSeq(owner, "/") {
if segment == "" || segment == "." || segment == ".." {
return false
}
for _, b := range []byte(segment) {
if !ownerByte(b) {
return false
}
}
}
return true
}
// ownerByte is the byte class an owner segment is made of.
func ownerByte(b byte) bool {
return ownerBytes[b]
}
var ownerBytes = byteClass("-_.")
// WithPageBound asks for n items per page. A page-numbered continuation
// resumes only at the bound it was minted at, as [WithAfter] states, so such a
// walk wanting another bound starts again from the first page.
//
// Default: [DefaultPageBound]. A zero or negative n is refused by
// [ResolveList] with [CodePageBoundInvalid] rather than sent.
func WithPageBound(n int) ListOption {
return listOptionFunc(func(s *ListSettings) { s.PageBound = n })
}
// WithAfter resumes a list from the opaque cursor a previous [Page] returned.
// [ResolveList] refuses a malformed one with [CodeCursorInvalid], the check
// [ValidateCursor] makes without a request. A call differing from the minting
// one in family, API base, operation, repository, state filter, scope or since
// bound refuses it with that code before any request. A page-numbered one is also refused at
// another page bound; an upstream connection position resumes at any bound.
//
// Default: the first page.
func WithAfter(c Cursor) ListOption {
return listOptionFunc(func(s *ListSettings) { s.After = c })
}
// WithState filters a listing by state. Only [PullRequests.ListPRs] and
// [Issues.ListIssues] take it: every other list refuses it, even at
// [ListStateOpen], with [CodeListStateInvalid], and so does a listing of issues
// asked for [ListStateMerged]. [PullRequests.ListMyPRs] and [Issues.ListMyIssues]
// list open items by construction, and [Checks.ListRuns] has no state to filter.
//
// Default: [ListStateOpen]. [ListStateUnknown] is refused by [ResolveList] with
// [CodeListStateInvalid] rather than sent.
func WithState(s ListState) ListOption {
return listOptionFunc(func(ls *ListSettings) {
ls.State = s
ls.StateSet = true
})
}
// WithOwner scopes [PullRequests.ListMyPRs] and [Issues.ListMyIssues] to every OPEN
// item under one owner, whoever authored it, and [Repos.ListRepos] to the repositories
// it owns: a login, or on GitLab a group's full path with its subgroups. An owner the
// instance does not resolve answers [CodeOwnerUnresolved], or an empty page where the
// per-forge block says so. Other lists, and an owner outside the form [ValidateOwner]
// checks, are refused before any request with [CodeListOwnerInvalid].
//
// Default: the credential's own authorship, and every repository it reaches.
func WithOwner(owner string) ListOption {
return listOptionFunc(func(ls *ListSettings) {
ls.Owner = owner
ls.OwnerSet = true
})
}
// WithSince bounds [Checks.ListRuns] to the runs created at or after t, read at
// whole seconds: GitHub, Gitea and Forgejo stamp a run's creation at whole seconds,
// so a run created in t's own second is answered. Every other list refuses it before
// any request with [CodeListSinceInvalid], as [ResolveList] does the zero time. A
// continuation names the bound it was minted under, so a walk resumes only under it.
//
// Default: no bound, every run of the repository.
func WithSince(t time.Time) ListOption {
return listOptionFunc(func(ls *ListSettings) {
ls.Since = t.UTC().Truncate(time.Second)
ls.SinceSet = true
})
}