@@ -19,6 +19,13 @@ const (
1919 FacetPRDetails = "pr_details"
2020 FacetPRReviews = "pr_reviews"
2121 FacetPRReviewComments = "pr_review_comments"
22+ FacetPRChecks = "pr_checks"
23+ FacetPRReviewThreads = "pr_review_threads"
24+ FacetPRMergeState = "pr_merge_state"
25+ FacetPRMergeQueue = "pr_merge_queue"
26+ FacetPRClosingIssues = "pr_closing_issues"
27+ FacetPRFiles = "pr_files"
28+ FacetIssueTimeline = "issue_timeline"
2229)
2330
2431var issueFacets = []string {FacetIssueComments }
@@ -166,6 +173,8 @@ func (s *Service) HydrateThread(ctx context.Context, repo cli.RepoRef, number in
166173 facetResult , err = f .hydratePullRequestReviews ()
167174 case FacetPRReviewComments :
168175 facetResult , err = f .hydratePullRequestReviewComments ()
176+ case FacetIssueTimeline :
177+ facetResult , err = f .hydrateIssueTimeline ()
169178 default :
170179 hydrateErr = fmt .Errorf ("unknown facet %q" , facet )
171180 return nil , hydrateErr
@@ -208,6 +217,9 @@ func selectFacets(kind string, requested []string) ([]string, error) {
208217 if len (requested ) == 0 {
209218 return allowed , nil
210219 }
220+ // Timeline history is intentionally opt-in because it can be much larger
221+ // than the default hydration set.
222+ allowed = append (append ([]string (nil ), allowed ... ), FacetIssueTimeline )
211223
212224 allowedSet := make (map [string ]struct {}, len (allowed ))
213225 for _ , f := range allowed {
@@ -229,6 +241,119 @@ func selectFacets(kind string, requested []string) ([]string, error) {
229241 return out , nil
230242}
231243
244+ func (f * facetRunner ) hydrateIssueTimeline () (HydratedFacet , error ) {
245+ reader , ok := f .reader .(github.IssueTimelineReader )
246+ if ! ok {
247+ return HydratedFacet {}, errors .New ("GitHub reader does not support issue timelines" )
248+ }
249+ opts := github.PageOptions {Page : 1 , PerPage : 100 }
250+ var total , pages int
251+ var complete bool
252+ var pageObservations []corpus.FacetObservationInput
253+ sourceUpdatedAt := f .thread .SourceUpdatedAt
254+ var events []github.IssueTimelineEvent
255+ for pages < f .maxPages {
256+ if err := f .ctx .Err (); err != nil {
257+ return HydratedFacet {}, err
258+ }
259+ res , err := reader .ListIssueTimeline (f .ctx , f .ref .Owner , f .ref .Repo , f .thread .Number , opts )
260+ if err != nil {
261+ return HydratedFacet {}, err
262+ }
263+ pages ++
264+ pageUpdatedAt := sourceUpdatedAt
265+ for _ , event := range res .Items {
266+ if event .CreatedAt .After (pageUpdatedAt ) {
267+ pageUpdatedAt = event .CreatedAt
268+ }
269+ }
270+ payload , err := json .Marshal (res .Items )
271+ if err != nil {
272+ return HydratedFacet {}, fmt .Errorf ("marshal issue timeline: %w" , err )
273+ }
274+ pageObservations = append (pageObservations , corpus.FacetObservationInput {SourceUpdatedAt : pageUpdatedAt , Payload : string (payload )})
275+ events = append (events , res .Items ... )
276+ total += len (res .Items )
277+ if pageUpdatedAt .After (sourceUpdatedAt ) {
278+ sourceUpdatedAt = pageUpdatedAt
279+ }
280+ if ! res .Page .HasNext {
281+ complete = true
282+ break
283+ }
284+ opts .Page = res .Page .NextPage
285+ }
286+ if ! complete {
287+ if err := f .c .AdvanceFacet (f .ctx , f .repoID , & f .threadID , FacetIssueTimeline , sourceUpdatedAt , false , f .runID ); err != nil {
288+ return HydratedFacet {}, err
289+ }
290+ return HydratedFacet {Facet : FacetIssueTimeline , Count : total , Pages : pages , Complete : false }, nil
291+ }
292+ if err := f .c .ApplyFacetObservationSet (f .ctx , f .repoID , & f .threadID , FacetIssueTimeline , sourceUpdatedAt , pageObservations , true , f .runID ); err != nil {
293+ return HydratedFacet {}, err
294+ }
295+ coverage , err := f .c .GetCoverage (f .ctx , f .repoID , & f .threadID , FacetIssueTimeline )
296+ if err != nil {
297+ return HydratedFacet {}, err
298+ }
299+ if coverage == nil || ! coverage .Complete || ! coverage .SourceUpdatedAt .Equal (sourceUpdatedAt .Truncate (time .Second )) {
300+ // A newer stored snapshot won the stale-write comparison. Do not attach
301+ // this older derivation to that snapshot's observation identities.
302+ return HydratedFacet {Facet : FacetIssueTimeline , Count : total , Pages : pages , Complete : true }, nil
303+ }
304+ if err := f .persistTimelineResolution (events , sourceUpdatedAt ); err != nil {
305+ return HydratedFacet {}, err
306+ }
307+ return HydratedFacet {Facet : FacetIssueTimeline , Count : total , Pages : pages , Complete : true }, nil
308+ }
309+
310+ func (f * facetRunner ) persistTimelineResolution (events []github.IssueTimelineEvent , sourceUpdatedAt time.Time ) error {
311+ kind , summary := "" , ""
312+ selectedCommit := ""
313+ if f .thread .StateReason == "not_planned" {
314+ kind , summary = "not_planned" , "GitHub records this issue as closed without planned work."
315+ }
316+ for _ , event := range events {
317+ if event .Event == "closed" && event .CommitID != "" {
318+ kind , summary = "fixed_by_commit" , "GitHub records an explicit closing commit: " + event .CommitID
319+ selectedCommit = event .CommitID
320+ }
321+ }
322+ if kind == "" {
323+ return nil
324+ }
325+ var refs []corpus.ObservationRef
326+ if selectedCommit == "" {
327+ observation , err := f .c .GetThreadObservationRevision (f .ctx , f .threadID , f .thread .SourceUpdatedAt , f .thread .ObservationSequence )
328+ if err != nil {
329+ return err
330+ }
331+ refs = []corpus.ObservationRef {{Kind : "thread" , ID : observation .ID }}
332+ } else {
333+ observations , _ , err := f .c .ListFacetObservationsBounded (f .ctx , f .repoID , & f .threadID , FacetIssueTimeline , 100 )
334+ if err != nil {
335+ return err
336+ }
337+ for _ , observation := range observations {
338+ var page []github.IssueTimelineEvent
339+ if err := json .Unmarshal ([]byte (observation .Payload ), & page ); err != nil {
340+ return fmt .Errorf ("decode issue timeline provenance: %w" , err )
341+ }
342+ for _ , event := range page {
343+ if event .Event == "closed" && event .CommitID == selectedCommit {
344+ refs = append (refs , corpus.ObservationRef {Kind : "facet" , ID : observation .ID })
345+ break
346+ }
347+ }
348+ }
349+ if len (refs ) == 0 {
350+ return errors .New ("closing commit timeline observation is unavailable" )
351+ }
352+ }
353+ _ , err := f .c .SaveResolutionRecord (f .ctx , corpus.ResolutionRecord {ThreadID : f .threadID , Kind : kind , Summary : summary , RuleVersion : "resolution.v1" , SourceUpdatedAt : sourceUpdatedAt , SourceObservationRefs : refs })
354+ return err
355+ }
356+
232357type facetRunner struct {
233358 ctx context.Context
234359 c * corpus.Corpus
@@ -289,7 +414,13 @@ func (f *facetRunner) hydrateIssueComments() (HydratedFacet, error) {
289414 if err := f .ctx .Err (); err != nil {
290415 return HydratedFacet {}, err
291416 }
292- if err := f .c .ApplyFacetObservationSet (f .ctx , f .repoID , & f .threadID , FacetIssueComments , sourceUpdatedAt , pageObservations , complete , f .runID ); err != nil {
417+ if ! complete {
418+ if err := f .c .AdvanceFacet (f .ctx , f .repoID , & f .threadID , FacetIssueComments , sourceUpdatedAt , false , f .runID ); err != nil {
419+ return HydratedFacet {}, err
420+ }
421+ return HydratedFacet {Facet : FacetIssueComments , Count : total , Pages : pages , Complete : false }, nil
422+ }
423+ if err := f .c .ApplyFacetObservationSet (f .ctx , f .repoID , & f .threadID , FacetIssueComments , sourceUpdatedAt , pageObservations , true , f .runID ); err != nil {
293424 return HydratedFacet {}, err
294425 }
295426
@@ -367,7 +498,13 @@ func (f *facetRunner) hydratePullRequestReviews() (HydratedFacet, error) {
367498 if err := f .ctx .Err (); err != nil {
368499 return HydratedFacet {}, err
369500 }
370- if err := f .c .ApplyFacetObservationSet (f .ctx , f .repoID , & f .threadID , FacetPRReviews , sourceUpdatedAt , pageObservations , complete , f .runID ); err != nil {
501+ if ! complete {
502+ if err := f .c .AdvanceFacet (f .ctx , f .repoID , & f .threadID , FacetPRReviews , sourceUpdatedAt , false , f .runID ); err != nil {
503+ return HydratedFacet {}, err
504+ }
505+ return HydratedFacet {Facet : FacetPRReviews , Count : total , Pages : pages , Complete : false }, nil
506+ }
507+ if err := f .c .ApplyFacetObservationSet (f .ctx , f .repoID , & f .threadID , FacetPRReviews , sourceUpdatedAt , pageObservations , true , f .runID ); err != nil {
371508 return HydratedFacet {}, err
372509 }
373510
@@ -422,7 +559,13 @@ func (f *facetRunner) hydratePullRequestReviewComments() (HydratedFacet, error)
422559 if err := f .ctx .Err (); err != nil {
423560 return HydratedFacet {}, err
424561 }
425- if err := f .c .ApplyFacetObservationSet (f .ctx , f .repoID , & f .threadID , FacetPRReviewComments , sourceUpdatedAt , pageObservations , complete , f .runID ); err != nil {
562+ if ! complete {
563+ if err := f .c .AdvanceFacet (f .ctx , f .repoID , & f .threadID , FacetPRReviewComments , sourceUpdatedAt , false , f .runID ); err != nil {
564+ return HydratedFacet {}, err
565+ }
566+ return HydratedFacet {Facet : FacetPRReviewComments , Count : total , Pages : pages , Complete : false }, nil
567+ }
568+ if err := f .c .ApplyFacetObservationSet (f .ctx , f .repoID , & f .threadID , FacetPRReviewComments , sourceUpdatedAt , pageObservations , true , f .runID ); err != nil {
426569 return HydratedFacet {}, err
427570 }
428571
0 commit comments