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