internal/application/snapshot.go
1
package application
3
import (
4
"errors"
5
"io/fs"
6
"sort"
7
"strings"
8
"time"
10
"github.com/janpuc/koment/internal/anchor"
11
"github.com/janpuc/koment/internal/provenance"
12
"github.com/janpuc/koment/internal/repository"
13
"github.com/janpuc/koment/internal/store"
14
)
16
// RepositorySnapshot is one internally consistent read of a repository.
17
type RepositorySnapshot struct {
18
Repository RepositoryIdentity
19
Commit string
20
Dirty bool
21
GeneratedAt time.Time
22
Files []FileSnapshot
23
}
25
// RepositoryIdentity is stable presentation metadata for a snapshot.
26
type RepositoryIdentity struct {
27
ID string
28
Name string
29
CloneURL string
30
DefaultBranch string
31
}
33
// FileSnapshot contains source and resolved annotations from the same read.
34
type FileSnapshot struct {
35
Path string
36
Content []byte
37
Exists bool
38
Annotations []AnnotationView
39
}
41
// AnnotationView is the common record and resolution presented by every reader.
42
type AnnotationView struct {
43
Record store.Annotation
44
Status anchor.Status
45
Line int
46
Occurrences int
47
Warning string
48
}
50
// SnapshotInput is a complete repository read from one source revision.
51
type SnapshotInput struct {
52
Repository RepositoryIdentity
53
Commit string
54
Dirty bool
55
GeneratedAt time.Time
56
Records []store.Annotation
57
Sources map[string][]byte
58
}
60
// BuildSnapshot reads every annotation and annotated source file once.
61
func BuildSnapshot(entry repository.Repository) (*RepositorySnapshot, error) {
62
annotations := entry.Store()
63
records, err := annotations.All()
64
if err != nil {
65
return nil, err
66
}
67
sources := make(map[string][]byte)
68
for _, record := range records {
69
if _, loaded := sources[record.File]; loaded {
70
continue
71
}
72
content, readErr := annotations.ReadSource(record.File)
73
switch {
74
case errors.Is(readErr, fs.ErrNotExist):
75
case readErr != nil:
76
return nil, readErr
77
default:
78
sources[record.File] = content
79
}
80
}
82
input := SnapshotInput{
83
Repository: RepositoryIdentity{
84
ID: entry.ID, Name: entry.Display(), CloneURL: entry.CloneURL,
85
DefaultBranch: entry.DefaultBranch,
86
},
87
GeneratedAt: time.Now().UTC(), Records: records, Sources: sources,
88
}
89
if commit, commitErr := provenance.HeadCommit(entry.Root); commitErr == nil {
90
input.Commit = commit
91
input.Dirty = provenance.TreeIsDirty(entry.Root)
92
} else if !errors.Is(commitErr, provenance.ErrNoGit) {
93
return nil, commitErr
94
}
95
return AssembleSnapshot(input)
96
}
98
// AssembleSnapshot resolves a complete source revision without performing I/O.
99
func AssembleSnapshot(input SnapshotInput) (*RepositorySnapshot, error) {
100
records := append([]store.Annotation(nil), input.Records...)
101
sort.Slice(records, func(left, right int) bool { return records[left].ID < records[right].ID })
102
grouped := make(map[string][]store.Annotation)
103
seen := make(map[string]struct{}, len(records))
104
for _, record := range records {
105
if err := record.Validate(); err != nil {
106
return nil, err
107
}
108
if _, duplicate := seen[record.ID]; duplicate {
109
return nil, errors.New("duplicate annotation id " + record.ID)
110
}
111
seen[record.ID] = struct{}{}
112
grouped[record.File] = append(grouped[record.File], record)
113
}
114
paths := make([]string, 0, len(grouped))
115
for path := range grouped {
116
paths = append(paths, path)
117
}
118
sort.Strings(paths)
119
generatedAt := input.GeneratedAt
120
if generatedAt.IsZero() {
121
generatedAt = time.Now().UTC()
122
}
123
snapshot := &RepositorySnapshot{
124
Repository: input.Repository, Commit: input.Commit, Dirty: input.Dirty,
125
GeneratedAt: generatedAt,
126
}
128
for _, path := range paths {
129
content, exists := input.Sources[path]
130
file := FileSnapshot{Path: path, Exists: exists}
131
if !exists {
132
for _, record := range grouped[path] {
133
file.Annotations = append(file.Annotations, describe(anchor.ResolveOrphaned(record)))
134
}
135
} else {
136
file.Content = append([]byte(nil), content...)
137
for _, record := range grouped[path] {
138
file.Annotations = append(file.Annotations, describe(anchor.Resolve(record, file.Content)))
139
}
140
}
141
snapshot.Files = append(snapshot.Files, file)
142
}
143
return snapshot, nil
144
}
146
func describe(resolution anchor.Resolution) AnnotationView {
147
return AnnotationView{
148
Record: resolution.Annotation, Status: resolution.Status, Line: resolution.Line,
149
Occurrences: resolution.Occurrences, Warning: WarningFor(resolution.Status),
150
}
151
}
153
// WarningFor is the single stale-record warning policy for every surface.
154
func WarningFor(status anchor.Status) string {
155
switch status {
156
case anchor.StatusAmbiguous:
157
return "STALE: the excerpt matches several places and its context does not identify one. Treat it as history until someone explicitly reanchors it."
158
case anchor.StatusDrifted:
159
return "STALE: the annotated code changed. Treat this as history until someone explicitly reanchors it."
160
case anchor.StatusOrphaned:
161
return "STALE: the annotated file no longer exists. Treat this as history only."
162
default:
163
return ""
164
}
165
}
167
// File returns one annotated file from the snapshot.
168
func (s *RepositorySnapshot) File(path string) (FileSnapshot, bool) {
169
for _, file := range s.Files {
170
if file.Path == path {
171
return file, true
172
}
173
}
174
return FileSnapshot{}, false
175
}
177
// Search returns annotations whose record fields contain the query.
178
func (s *RepositorySnapshot) Search(query string) []AnnotationView {
179
needle := strings.ToLower(strings.TrimSpace(query))
180
if needle == "" {
181
return nil
182
}
183
var matches []AnnotationView
184
for _, file := range s.Files {
185
for _, annotation := range file.Annotations {
186
record := annotation.Record
187
haystack := strings.ToLower(strings.Join([]string{
188
record.ID, record.File, string(record.Kind), record.Body,
189
record.Author.Name, record.Author.Email, record.Author.Account,
190
}, "\n"))
191
if strings.Contains(haystack, needle) {
192
matches = append(matches, annotation)
193
}
194
}
195
}
196
return matches
197
}
199
// Counts returns resolution counts for the repository.
200
func (s *RepositorySnapshot) Counts() map[anchor.Status]int {
201
counts := map[anchor.Status]int{}
202
for _, file := range s.Files {
203
for _, annotation := range file.Annotations {
204
counts[annotation.Status]++
205
}
206
}
207
return counts
208
}