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