project.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: Apache-2.0
  4
  5package project
  6
  7import (
  8	"crypto/sha256"
  9	"database/sql"
 10	"errors"
 11	"fmt"
 12	"log"
 13	"sort"
 14	"strings"
 15	"sync"
 16	"time"
 17
 18	"git.sr.ht/~amolith/willow/db"
 19	"git.sr.ht/~amolith/willow/git"
 20	"git.sr.ht/~amolith/willow/rss"
 21	"github.com/unascribed/FlexVer/go/flexver"
 22)
 23
 24const (
 25	refreshInterval = 3600 * time.Second
 26)
 27
 28type Project struct {
 29	ID       string
 30	URL      string
 31	Name     string
 32	Forge    string
 33	Running  string
 34	Releases []Release
 35}
 36
 37type Release struct {
 38	ID        string
 39	ProjectID string
 40	URL       string
 41	Tag       string
 42	Content   string
 43	Date      time.Time
 44}
 45
 46// GetReleases returns a list of all releases for a project from the database.
 47func GetReleases(dbConn *sql.DB, mu *sync.Mutex, proj Project) (Project, error) {
 48	proj.ID = GenProjectID(proj.URL, proj.Name, proj.Forge)
 49
 50	ret, err := db.GetReleases(dbConn, proj.ID)
 51	if err != nil {
 52		return proj, fmt.Errorf("failed to get releases from database: %w", err)
 53	}
 54
 55	if len(ret) == 0 {
 56		return fetchReleases(dbConn, mu, proj)
 57	}
 58
 59	for _, row := range ret {
 60		proj.Releases = append(proj.Releases, Release{
 61			ID:        row["id"],
 62			ProjectID: proj.ID,
 63			Tag:       row["tag"],
 64			Content:   row["content"],
 65			URL:       row["release_url"],
 66			Date:      time.Time{},
 67		})
 68	}
 69
 70	proj.Releases = SortReleases(proj.Releases)
 71
 72	return proj, nil
 73}
 74
 75// fetchReleases fetches releases from a project's forge given its URI.
 76func fetchReleases(dbConn *sql.DB, mu *sync.Mutex, p Project) (Project, error) {
 77	var err error
 78
 79	switch p.Forge {
 80	case "github", "gitea", "forgejo":
 81		rssReleases, err := rss.GetReleases(p.URL)
 82		if err != nil {
 83			fmt.Println("Error getting RSS releases:", err)
 84			return p, fmt.Errorf("failed to get releases from RSS feed: %w", err)
 85		}
 86
 87		for _, release := range rssReleases {
 88			p.Releases = append(p.Releases, Release{
 89				ID:        GenReleaseID(p.URL, release.URL, release.Tag),
 90				ProjectID: p.ID,
 91				Tag:       release.Tag,
 92				Content:   release.Content,
 93				URL:       release.URL,
 94				Date:      release.Date,
 95			})
 96
 97			err = upsertReleases(dbConn, mu, p.ID, p.Releases)
 98			if err != nil {
 99				log.Printf("Error upserting release: %v", err)
100				return p, err
101			}
102		}
103	default:
104		gitReleases, err := git.GetReleases(p.URL, p.Forge)
105		if err != nil {
106			return p, fmt.Errorf("failed to get releases from git: %w", err)
107		}
108
109		for _, release := range gitReleases {
110			p.Releases = append(p.Releases, Release{
111				ID:        GenReleaseID(p.URL, release.URL, release.Tag),
112				ProjectID: p.ID,
113				Tag:       release.Tag,
114				Content:   release.Content,
115				URL:       release.URL,
116				Date:      release.Date,
117			})
118
119			err = upsertReleases(dbConn, mu, p.ID, p.Releases)
120			if err != nil {
121				log.Printf("Error upserting release: %v", err)
122				return p, err
123			}
124		}
125	}
126
127	p.Releases = SortReleases(p.Releases)
128
129	return p, err
130}
131
132func SortReleases(releases []Release) []Release {
133	sort.Slice(releases, func(i, j int) bool {
134		return !flexver.Less(releases[i].Tag, releases[j].Tag)
135	})
136
137	return releases
138}
139
140func SortProjects(projects []Project) []Project {
141	sort.Slice(projects, func(i, j int) bool {
142		return strings.ToLower(projects[i].Name) < strings.ToLower(projects[j].Name)
143	})
144
145	return projects
146}
147
148// upsertReleases updates or inserts a release in the database.
149func upsertReleases(dbConn *sql.DB, mu *sync.Mutex, projID string, releases []Release) error {
150	for _, release := range releases {
151		date := release.Date.Format("2006-01-02 15:04:05")
152
153		err := db.UpsertRelease(dbConn, mu, release.ID, projID, release.URL, release.Tag, release.Content, date)
154		if err != nil {
155			log.Printf("Error upserting release: %v", err)
156			return fmt.Errorf("failed to upsert release: %w", err)
157		}
158	}
159
160	return nil
161}
162
163// GenReleaseID generates a likely-unique ID from its project's URL, its release's URL, and its tag.
164func GenReleaseID(projectURL, releaseURL, tag string) string {
165	idByte := sha256.Sum256([]byte(projectURL + releaseURL + tag))
166	return fmt.Sprintf("%x", idByte)
167}
168
169// GenProjectID generates a likely-unique ID from a project's URI, name, and forge.
170func GenProjectID(url, name, forge string) string {
171	idByte := sha256.Sum256([]byte(url + name + forge))
172	return fmt.Sprintf("%x", idByte)
173}
174
175func Track(dbConn *sql.DB, mu *sync.Mutex, manualRefresh *chan struct{}, name, url, forge, release string) {
176	id := GenProjectID(url, name, forge)
177
178	err := db.UpsertProject(dbConn, mu, id, url, name, forge, release)
179	if err != nil {
180		fmt.Println("Error upserting project:", err)
181	}
182
183	*manualRefresh <- struct{}{}
184}
185
186func Untrack(dbConn *sql.DB, mu *sync.Mutex, id string) {
187	proj, err := db.GetProject(dbConn, id)
188	if err != nil {
189		fmt.Println("Error getting project:", err)
190	}
191
192	err = db.DeleteProject(dbConn, mu, proj["id"])
193	if err != nil {
194		fmt.Println("Error deleting project:", err)
195	}
196
197	// TODO: before removing, check whether other tracked projects use the same
198	// repo
199	err = git.RemoveRepo(proj["url"])
200	if err != nil {
201		log.Println(err)
202	}
203}
204
205func RefreshLoop(dbConn *sql.DB, mu *sync.Mutex, interval int, manualRefresh, req *chan struct{}, res *chan []Project) {
206	ticker := time.NewTicker(time.Second * time.Duration(interval))
207
208	fetch := func() []Project {
209		projectsList, err := GetProjects(dbConn)
210		if err != nil {
211			fmt.Println("Error getting projects:", err)
212		}
213
214		for i, p := range projectsList {
215			p, err := fetchReleases(dbConn, mu, p)
216			if err != nil {
217				fmt.Println(err)
218				continue
219			}
220
221			projectsList[i] = p
222		}
223
224		sort.Slice(projectsList, func(i, j int) bool {
225			return strings.ToLower(projectsList[i].Name) < strings.ToLower(projectsList[j].Name)
226		})
227
228		for i := range projectsList {
229			err = upsertReleases(dbConn, mu, projectsList[i].ID, projectsList[i].Releases)
230			if err != nil {
231				fmt.Println("Error upserting release:", err)
232				continue
233			}
234		}
235
236		return projectsList
237	}
238
239	projects := fetch()
240
241	for {
242		select {
243		case <-ticker.C:
244			projects = fetch()
245		case <-*manualRefresh:
246			ticker.Reset(refreshInterval)
247
248			projects = fetch()
249		case <-*req:
250			projectsCopy := make([]Project, len(projects))
251			copy(projectsCopy, projects)
252
253			*res <- projectsCopy
254		}
255	}
256}
257
258// GetProject returns a project from the database.
259func GetProject(dbConn *sql.DB, proj Project) (Project, error) {
260	projectDB, err := db.GetProject(dbConn, proj.ID)
261	if err != nil && errors.Is(err, sql.ErrNoRows) {
262		return proj, nil
263	} else if err != nil {
264		return proj, fmt.Errorf("failed to get project from database: %w", err)
265	}
266
267	p := Project{
268		ID:       proj.ID,
269		URL:      proj.URL,
270		Name:     proj.Name,
271		Forge:    proj.Forge,
272		Running:  projectDB["version"],
273		Releases: nil,
274	}
275
276	return p, nil
277}
278
279// GetProjectWithReleases returns a single project from the database along with its releases.
280func GetProjectWithReleases(dbConn *sql.DB, mu *sync.Mutex, proj Project) (Project, error) {
281	project, err := GetProject(dbConn, proj)
282	if err != nil {
283		return Project{}, err
284	}
285
286	return GetReleases(dbConn, mu, project)
287}
288
289// GetProjects returns a list of all projects from the database.
290func GetProjects(dbConn *sql.DB) ([]Project, error) {
291	projectsDB, err := db.GetProjects(dbConn)
292	if err != nil {
293		return nil, fmt.Errorf("failed to get projects from database: %w", err)
294	}
295
296	projects := make([]Project, len(projectsDB))
297	for i, p := range projectsDB {
298		projects[i] = Project{
299			ID:       p["id"],
300			URL:      p["url"],
301			Name:     p["name"],
302			Forge:    p["forge"],
303			Running:  p["version"],
304			Releases: nil,
305		}
306	}
307
308	return SortProjects(projects), nil
309}
310
311// GetProjectsWithReleases returns a list of all projects and all their releases
312// from the database.
313func GetProjectsWithReleases(dbConn *sql.DB, mu *sync.Mutex) ([]Project, error) {
314	projects, err := GetProjects(dbConn)
315	if err != nil {
316		return nil, err
317	}
318
319	for i := range projects {
320		projects[i], err = GetReleases(dbConn, mu, projects[i])
321		if err != nil {
322			return nil, err
323		}
324
325		projects[i].Releases = SortReleases(projects[i].Releases)
326	}
327
328	return SortProjects(projects), nil
329}