snapshot of c000b74a5b09c433f96a69fa102c5c8f6583dff0 Annotations about the code that implements koment.

internal/application/service.go

1 package application
2
3 import (
4 "errors"
5 "fmt"
6 "path"
7 "strings"
8 "time"
9
10 "github.com/koment-dev/koment/internal/anchor"
11 "github.com/koment-dev/koment/internal/provenance"
12 "github.com/koment-dev/koment/internal/repository"
13 "github.com/koment-dev/koment/internal/store"
14 )
15
16 // Service owns local repository reads and mutations.
17 type Service struct {
18 repository repository.Repository
19 store *store.Store
20 }
21
22 // AddInput is the complete intent needed to create an annotation.
23 type AddInput struct {
24 File string
25 Excerpt string
26 Kind store.Type
27 Title string
28 Body string
29 Author store.Author
30 Policy *store.Policy
31 }
32
33 // ReanchorInput moves an existing annotation without changing its identity.
34 type ReanchorInput struct {
35 ID string
36 File string
37 Excerpt string
38 }
39
40 // Mutation is the durable record and its repository-relative path.
41 type Mutation struct {
42 Record store.Annotation
43 Path string
44 Warnings []string
45 }
46
47 // NewService constructs the application service for one repository.
48 func NewService(entry repository.Repository) *Service {
49 return &Service{repository: entry, store: entry.Store()}
50 }
51
52 // Snapshot reads the repository through the shared snapshot contract.
53 func (s *Service) Snapshot() (*RepositorySnapshot, error) {
54 return BuildSnapshot(s.repository)
55 }
56
57 // Add creates one fully validated annotation record.
58 func (s *Service) Add(input AddInput) (Mutation, error) {
59 file, err := s.store.FromRoot(input.File)
60 if err != nil {
61 return Mutation{}, err
62 }
63 if err := input.Author.Validate(); err != nil {
64 return Mutation{}, err
65 }
66 if _, err := store.ParseType(string(input.Kind)); err != nil {
67 return Mutation{}, err
68 }
69 id, err := store.NewID(time.Now())
70 if err != nil {
71 return Mutation{}, err
72 }
73 record := store.Annotation{
74 APIVersion: store.APIVersion,
75 Kind: store.KindAnnotation,
76 Metadata: store.Metadata{ID: id, Created: store.Now()},
77 Spec: store.Spec{
78 Target: store.Target{File: file},
79 Type: input.Kind,
80 Title: strings.TrimSpace(input.Title),
81 Body: store.WrapProse(input.Body),
82 Author: input.Author,
83 Policy: input.Policy,
84 },
85 }
86 if err := s.anchor(&record, file, input.Excerpt); err != nil {
87 return Mutation{}, err
88 }
89 warnings := s.captureGit(&record)
90 if strings.TrimSpace(record.Spec.Title) == "" {
91 warnings = append(warnings, "no title provided; the first sentence of the body will be shown as the headline (ADR 0115)")
92 }
93 s.observe(&record)
94 if err := s.store.Save(&record); err != nil {
95 return Mutation{}, err
96 }
97 return Mutation{Record: record, Path: recordPath(id), Warnings: warnings}, nil
98 }
99
100 // Reanchor changes only an annotation's target.
101 func (s *Service) Reanchor(input ReanchorInput) (Mutation, error) {
102 record, err := s.store.FindByID(input.ID)
103 if err != nil {
104 return Mutation{}, err
105 }
106 file := record.Spec.Target.File
107 if input.File != "" {
108 if file, err = s.store.FromRoot(input.File); err != nil {
109 return Mutation{}, err
110 }
111 }
112 excerpt := input.Excerpt
113 if excerpt == "" && record.Spec.Anchor.Scope == store.ScopeExcerpt {
114 excerpt = record.Spec.Anchor.Excerpt
115 }
116 moved := *record
117 if err := s.anchor(&moved, file, excerpt); err != nil {
118 return Mutation{}, err
119 }
120 moved.Spec.Target.File = file
121 s.observe(&moved)
122 if err := s.store.Save(&moved); err != nil {
123 return Mutation{}, err
124 }
125 return Mutation{Record: moved, Path: recordPath(moved.Metadata.ID)}, nil
126 }
127
128 func (s *Service) anchor(record *store.Annotation, file, excerpt string) error {
129 content, err := s.store.ReadSource(file)
130 if err != nil {
131 return fmt.Errorf("reading %s: %w", file, err)
132 }
133 if excerpt == "" {
134 record.Spec.Anchor = store.Anchor{Scope: store.ScopeFile}
135 record.Status.LastSeenLine = 0
136 return nil
137 }
138 captured, line, err := anchor.Capture(content, excerpt)
139 if err != nil {
140 lines := anchor.ExcerptLines(content, excerpt)
141 switch len(lines) {
142 case 0:
143 return fmt.Errorf("excerpt not found in %s; it must match the file verbatim%s", file, nearMissHint(content, excerpt))
144 default:
145 return fmt.Errorf("excerpt matches %d places in %s (lines %v); extend it until it is unique", len(lines), file, lines)
146 }
147 }
148 record.Spec.Anchor = captured
149 record.Status.LastSeenLine = line
150 return nil
151 }
152
153 func (s *Service) observe(record *store.Annotation) {
154 commit, err := provenance.Head(s.repository.Root)
155 if err != nil {
156 commit = ""
157 }
158 record.Status.Observe(store.AnchorOK, commit, store.Now())
159 }
160
161 func (s *Service) captureGit(record *store.Annotation) []string {
162 file := record.Spec.Target.File
163 context, err := provenance.Capture(s.repository.Root, file, record.Status.LastSeenLine, record.Status.LastSeenLine)
164 if err == nil {
165 record.Spec.Git = context
166 if provenance.WorktreeIsDirty(s.repository.Root, file) {
167 return []string{fmt.Sprintf("%s has uncommitted changes, so commit %s does not describe what was annotated", file, context.Commit[:7])}
168 }
169 return nil
170 }
171 if errors.Is(err, provenance.ErrNoGit) {
172 return []string{fmt.Sprintf("no git context recorded for %s", file)}
173 }
174 return []string{fmt.Sprintf("git context failed for %s: %v", file, err)}
175 }
176
177 func recordPath(id string) string {
178 return path.Join(store.DirName, "annotations", id+".yaml")
179 }
180
181 func nearMissHint(content []byte, excerpt string) string {
182 wanted := collapseWhitespace(excerpt)
183 if wanted == "" || !strings.Contains(collapseWhitespace(string(content)), wanted) {
184 return ""
185 }
186 if strings.Contains(string(content), "\r\n") {
187 return ". The text is there once whitespace is ignored, and the file has CRLF line endings"
188 }
189 return ". The text is there once whitespace is ignored, so check indentation and trailing spaces"
190 }
191
192 func collapseWhitespace(text string) string {
193 return strings.Join(strings.Fields(text), " ")
194 }
195
196 // EditInput changes only the prose a person can improve later. Identity,
197 // authorship, creation time and anchor are not editable here.
198 type EditInput struct {
199 ID string
200 Title *string
201 Body *string
202 }
203
204 // Edit rewrites an annotation's headline or rationale in place.
205 func (s *Service) Edit(input EditInput) (Mutation, error) {
206 record, err := s.store.FindByID(input.ID)
207 if err != nil {
208 return Mutation{}, err
209 }
210 if input.Title == nil && input.Body == nil {
211 return Mutation{}, fmt.Errorf("edit needs --title, --body, or both")
212 }
213 edited := *record
214 if input.Title != nil {
215 edited.Spec.Title = strings.TrimSpace(*input.Title)
216 }
217 if input.Body != nil {
218 edited.Spec.Body = store.WrapProse(*input.Body)
219 }
220 if err := edited.Validate(); err != nil {
221 return Mutation{}, err
222 }
223 if err := s.store.Save(&edited); err != nil {
224 return Mutation{}, err
225 }
226 return Mutation{Record: edited, Path: recordPath(edited.Metadata.ID)}, nil
227 }
228
229 // Forget deletes one annotation record. Git holds who removed it and why.
230 func (s *Service) Forget(id string) (store.Annotation, error) {
231 record, err := s.store.FindByID(id)
232 if err != nil {
233 return store.Annotation{}, err
234 }
235 if err := s.store.Remove(id); err != nil {
236 return store.Annotation{}, err
237 }
238 return *record, nil
239 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close