···24672467 }
2468246824692469 cw := cbg.NewCborWriter(w)
24702470- fieldCount := 4
24702470+ fieldCount := 5
24712471+24722472+ if t.Evidences == nil {
24732473+ fieldCount--
24742474+ }
2471247524722476 if t.Reason == nil {
24732477 fieldCount--
···25732577 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
25742578 return err
25752579 }
25802580+25812581+ // t.Evidences ([]string) (slice)
25822582+ if t.Evidences != nil {
25832583+25842584+ if len("evidences") > 1000000 {
25852585+ return xerrors.Errorf("Value in field \"evidences\" was too long")
25862586+ }
25872587+25882588+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("evidences"))); err != nil {
25892589+ return err
25902590+ }
25912591+ if _, err := cw.WriteString(string("evidences")); err != nil {
25922592+ return err
25932593+ }
25942594+25952595+ if len(t.Evidences) > 8192 {
25962596+ return xerrors.Errorf("Slice value in field t.Evidences was too long")
25972597+ }
25982598+25992599+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Evidences))); err != nil {
26002600+ return err
26012601+ }
26022602+ for _, v := range t.Evidences {
26032603+ if len(v) > 1000000 {
26042604+ return xerrors.Errorf("Value in field v was too long")
26052605+ }
26062606+26072607+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
26082608+ return err
26092609+ }
26102610+ if _, err := cw.WriteString(string(v)); err != nil {
26112611+ return err
26122612+ }
26132613+26142614+ }
26152615+ }
25762616 return nil
25772617}
25782618···26702710 }
2671271126722712 t.CreatedAt = string(sval)
27132713+ }
27142714+ // t.Evidences ([]string) (slice)
27152715+ case "evidences":
27162716+27172717+ maj, extra, err = cr.ReadHeader()
27182718+ if err != nil {
27192719+ return err
27202720+ }
27212721+27222722+ if extra > 8192 {
27232723+ return fmt.Errorf("t.Evidences: array too large (%d)", extra)
27242724+ }
27252725+27262726+ if maj != cbg.MajArray {
27272727+ return fmt.Errorf("expected cbor array")
27282728+ }
27292729+27302730+ if extra > 0 {
27312731+ t.Evidences = make([]string, extra)
27322732+ }
27332733+27342734+ for i := 0; i < int(extra); i++ {
27352735+ {
27362736+ var maj byte
27372737+ var extra uint64
27382738+ var err error
27392739+ _ = maj
27402740+ _ = extra
27412741+ _ = err
27422742+27432743+ {
27442744+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
27452745+ if err != nil {
27462746+ return err
27472747+ }
27482748+27492749+ t.Evidences[i] = string(sval)
27502750+ }
27512751+27522752+ }
26732753 }
2674275426752755 default:
+2
api/tangled/graphvouch.go
···1919type GraphVouch struct {
2020 LexiconTypeID string `json:"$type,const=sh.tangled.graph.vouch" cborgen:"$type,const=sh.tangled.graph.vouch"`
2121 CreatedAt string `json:"createdAt" cborgen:"createdAt"`
2222+ // evidences: Optional list of ATURIs serving as evidence for this vouch (ex. issues, PRs)
2323+ Evidences []string `json:"evidences,omitempty" cborgen:"evidences,omitempty"`
2224 // kind: Whether this user is being vouched for or denounced
2325 Kind string `json:"kind" cborgen:"kind"`
2426 // reason: The reason for this vouch/denouncement
+49-5
appview/db/db.go
···651651 foreign key (repo_at) references repos(at_uri) on delete cascade
652652 );
653653654654- create table if not exists migrations (
655655- id integer primary key autoincrement,
656656- name text unique
657657- );
658658-659654 create table if not exists punchcard_preferences (
660655 id integer primary key autoincrement,
661656 user_did text not null unique,
···669664 status text not null check (status in ('subscribed', 'dismissed')),
670665 email text,
671666 updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
667667+ );
668668+669669+ create table if not exists vouch_evidences (
670670+ id integer primary key autoincrement,
671671+ vouch_id integer not null,
672672+ at_uri text not null,
673673+ unique(vouch_id, at_uri),
674674+ foreign key (vouch_id) references vouches(id) on delete cascade
675675+ );
676676+677677+ create table if not exists vouch_skips (
678678+ did text not null,
679679+ subject_did text not null,
680680+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
681681+ primary key (did, subject_did),
682682+ check (did <> subject_did)
683683+ );
684684+685685+686686+ create table if not exists migrations (
687687+ id integer primary key autoincrement,
688688+ name text unique
672689 );
673690674691 -- indexes for better performance
···14741491 `)
14751492 return err
14761493 })
14941494+14951495+ conn.ExecContext(ctx, "pragma foreign_keys = off;")
14961496+ orm.RunMigration(conn, logger, "add-id-to-vouches", func(tx *sql.Tx) error {
14971497+ _, err := tx.Exec(`
14981498+ create table vouches_new (
14991499+ id integer primary key autoincrement,
15001500+ did text not null,
15011501+ subject_did text not null,
15021502+ cid text not null,
15031503+ kind text not null default 'vouch',
15041504+ reason text,
15051505+ created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
15061506+ unique(did, subject_did),
15071507+ check (did <> subject_did),
15081508+ check (kind in ('vouch', 'denounce'))
15091509+ );
15101510+15111511+ insert into vouches_new (did, subject_did, cid, kind, reason, created_at)
15121512+ select did, subject_did, cid, kind, reason, created_at
15131513+ from vouches;
15141514+15151515+ drop table vouches;
15161516+ alter table vouches_new rename to vouches;
15171517+ `)
15181518+ return err
15191519+ })
15201520+ conn.ExecContext(ctx, "pragma foreign_keys = on;")
1477152114781522 return &DB{
14791523 db,
+97-10
appview/db/vouch.go
···1515)
16161717func AddVouch(e Execer, vouch *models.Vouch) error {
1818- query := `insert or replace into vouches (did, subject_did, cid, kind, reason) values (?, ?, ?, ?, ?)`
1919- _, err := e.Exec(query, vouch.Did, vouch.SubjectDid, vouch.Cid.String(), vouch.Kind, vouch.Reason)
2020- return err
1818+ // insert if not exists
1919+ _, err := e.Exec(
2020+ `insert or ignore into vouches (did, subject_did, cid, kind, reason) values (?, ?, ?, ?, ?)`,
2121+ vouch.Did, vouch.SubjectDid, vouch.Cid.String(), vouch.Kind, vouch.Reason,
2222+ )
2323+ if err != nil {
2424+ return err
2525+ }
2626+2727+ // then update
2828+ _, err = e.Exec(
2929+ `update vouches set cid = ?, kind = ?, reason = ? where did = ? and subject_did = ?`,
3030+ vouch.Cid.String(), vouch.Kind, vouch.Reason, vouch.Did, vouch.SubjectDid,
3131+ )
3232+ if err != nil {
3333+ return err
3434+ }
3535+3636+ // replace evidences: delete all existing, then insert new ones.
3737+ _, err = e.Exec(
3838+ `delete from vouch_evidences where vouch_id = (select id from vouches where did = ? and subject_did = ?)`,
3939+ vouch.Did, vouch.SubjectDid,
4040+ )
4141+ if err != nil {
4242+ return err
4343+ }
4444+ for _, uri := range vouch.Evidences {
4545+ _, err = e.Exec(
4646+ `insert into vouch_evidences (vouch_id, at_uri)
4747+ values ((select id from vouches where did = ? and subject_did = ?), ?)`,
4848+ vouch.Did, vouch.SubjectDid, uri.String(),
4949+ )
5050+ if err != nil {
5151+ return err
5252+ }
5353+ }
5454+ return nil
2155}
22562357func GetVouch(e Execer, did, subjectDid string) (*models.Vouch, error) {
···101135 return vouches, nil
102136}
103137138138+func GetVouchEvidences(e Execer, did, subjectDid string) ([]syntax.ATURI, error) {
139139+ rows, err := e.Query(
140140+ `select at_uri from vouch_evidences
141141+ where vouch_id = (select id from vouches where did = ? and subject_did = ?)
142142+ order by id asc`,
143143+ did, subjectDid,
144144+ )
145145+ if err != nil {
146146+ return nil, err
147147+ }
148148+ defer rows.Close()
149149+150150+ var evidences []syntax.ATURI
151151+ for rows.Next() {
152152+ var uri string
153153+ if err := rows.Scan(&uri); err != nil {
154154+ log.Println("error scanning vouch evidence:", err)
155155+ continue
156156+ }
157157+ evidences = append(evidences, syntax.ATURI(uri))
158158+ }
159159+ return evidences, nil
160160+}
161161+104162func DeleteVouch(e Execer, did, subjectDid string) error {
105163 _, err := e.Exec(`delete from vouches where did = ? and subject_did = ?`, did, subjectDid)
106164 return err
···118176 }
119177120178 query := fmt.Sprintf(
121121- `select did, subject_did, cid, kind, reason, created_at
122122- from vouches
179179+ `select v.did, v.subject_did, v.cid, v.kind, v.reason, v.created_at,
180180+ group_concat(ve.at_uri, '|') as evidences
181181+ from vouches v
182182+ left join vouch_evidences ve on ve.vouch_id = v.id
123183 where (
124124- subject_did = ? and did in (select subject_did from vouches where did = ? and kind = 'vouch')
184184+ v.subject_did = ? and v.did in (select subject_did from vouches where did = ? and kind = 'vouch')
125185 ) or (
126126- did = ? and subject_did in (select subject_did from vouches where did = ? and kind = 'vouch')
186186+ v.did = ? and v.subject_did in (select subject_did from vouches where did = ? and kind = 'vouch')
127187 )
128128- order by created_at desc
188188+ group by v.did, v.subject_did
189189+ order by v.created_at desc
129190 %s`,
130191 pageClause)
131192···141202 var cidStr string
142203 var createdAt string
143204 var reason sql.NullString
205205+ var evidences sql.NullString
144206145145- if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt); err != nil {
207207+ if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt, &evidences); err != nil {
146208 log.Println("error scanning vouch:", err)
147209 continue
148210 }
···165227 v.Reason = &reason.String
166228 }
167229230230+ if evidences.Valid && evidences.String != "" {
231231+ for _, s := range strings.Split(evidences.String, "|") {
232232+ v.Evidences = append(v.Evidences, syntax.ATURI(s))
233233+ }
234234+ }
235235+168236 vouches = append(vouches, v)
169237 }
170238 return vouches, nil
···240308 return batch[subjectDid], nil
241309}
242310311311+func IsVouchSkipped(e Execer, did, subjectDid string) (bool, error) {
312312+ var exists bool
313313+ err := e.QueryRow(
314314+ `select exists(select 1 from vouch_skips where did = ? and subject_did = ?)`,
315315+ did, subjectDid,
316316+ ).Scan(&exists)
317317+ return exists, err
318318+}
319319+320320+func SkipVouchSuggestion(e Execer, did, subjectDid string) error {
321321+ _, err := e.Exec(
322322+ `insert or ignore into vouch_skips (did, subject_did) values (?, ?)`,
323323+ did, subjectDid,
324324+ )
325325+ return err
326326+}
327327+243328// priority:
244329// 1. collaborator invites sent
245330// 2. knot member invites sent
···322407 )
323408 where did not in (
324409 select subject_did from vouches where vouches.did = ?
410410+ union
411411+ select subject_did from vouch_skips where vouch_skips.did = ?
325412 )
326413 group by did
327414 order by min(priority) asc, max(created) desc
···337424 did, did, // issue_comments
338425 did, did, // follows
339426 did, did, // stars
340340- did, // vouches exclusion
427427+ did, did, // existing vouches + skips exclusion
341428 limit,
342429 }
343430
···11package state
2233import (
44+ "cmp"
45 "net/http"
66+ "slices"
57 "strings"
68 "time"
79···6870 return
6971 }
70727171- // sort repos to match search result order (by relevance)
7272- repoMap := make(map[int64]models.Repo, len(repos))
7373- for _, repo := range repos {
7474- repoMap[repo.Id] = repo
7373+ hitIdx := make(map[int64]int, len(res.Hits))
7474+ for i, id := range res.Hits {
7575+ hitIdx[id] = i
7576 }
7676- repos = make([]models.Repo, 0, len(res.Hits))
7777- for _, id := range res.Hits {
7878- if repo, ok := repoMap[id]; ok {
7979- repos = append(repos, repo)
8080- }
8181- }
7777+ slices.SortFunc(repos, func(a, b models.Repo) int {
7878+ return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id])
7979+ })
8280 }
8381 resultCount = int(res.Total)
8482···155153 })
156154 if err != nil {
157155 l.Error("failed to render page", "err", err)
156156+ }
157157+}
158158+159159+func (s *State) SearchQuick(w http.ResponseWriter, r *http.Request) {
160160+ s.searchQuick(w, r, false)
161161+}
162162+163163+func (s *State) SearchQuickMobile(w http.ResponseWriter, r *http.Request) {
164164+ s.searchQuick(w, r, true)
165165+}
166166+167167+func (s *State) searchQuick(w http.ResponseWriter, r *http.Request, mobile bool) {
168168+ rawQuery := r.URL.Query().Get("q")
169169+ if rawQuery == "" {
170170+ w.WriteHeader(http.StatusOK)
171171+ return
172172+ }
173173+174174+ const pageSize = 5
175175+176176+ query := searchquery.Parse(rawQuery)
177177+ tf := searchquery.ExtractTextFilters(query)
178178+179179+ searchOpts := models.RepoSearchOptions{
180180+ Keywords: tf.Keywords,
181181+ Phrases: tf.Phrases,
182182+ NegatedKeywords: tf.NegatedKeywords,
183183+ NegatedPhrases: tf.NegatedPhrases,
184184+ Page: pagination.Page{Limit: pageSize},
185185+ }
186186+187187+ var repos []models.Repo
188188+ var total int
189189+190190+ if searchOpts.HasSearchFilters() {
191191+ res, err := s.indexer.Repos.Search(r.Context(), searchOpts)
192192+ if err != nil {
193193+ s.logger.Error("failed quick search", "err", err)
194194+ http.Error(w, "search failed", http.StatusInternalServerError)
195195+ return
196196+ }
197197+ total = int(res.Total)
198198+ if len(res.Hits) > 0 {
199199+ repos, err = db.GetRepos(s.db, orm.FilterIn("id", res.Hits))
200200+ if err != nil {
201201+ s.logger.Error("failed to get repos for quick search", "err", err)
202202+ http.Error(w, "search failed", http.StatusInternalServerError)
203203+ return
204204+ }
205205+ hitIdx := make(map[int64]int, len(res.Hits))
206206+ for i, id := range res.Hits {
207207+ hitIdx[id] = i
208208+ }
209209+ slices.SortFunc(repos, func(a, b models.Repo) int {
210210+ return cmp.Compare(hitIdx[a.Id], hitIdx[b.Id])
211211+ })
212212+ }
213213+ }
214214+215215+ params := pages.SearchQuickParams{
216216+ Repos: repos,
217217+ Query: rawQuery,
218218+ Total: total,
219219+ }
220220+221221+ render := s.pages.SearchQuick
222222+ if mobile {
223223+ render = s.pages.SearchQuickMobile
224224+ }
225225+ if err := render(w, params); err != nil {
226226+ s.logger.Error("failed to render quick search", "err", err)
158227 }
159228}
160229
+2-3
appview/state/timeline.go
···11package state
2233import (
44- "fmt"
54 "net/http"
6576 "github.com/bluesky-social/indigo/atproto/syntax"
···3029 s.logger.Error("failed to get bluesky posts", "err", err)
3130 }
32313333- fmt.Println(s.pages.Home(w, pages.TimelineParams{
3232+ s.pages.Home(w, pages.TimelineParams{
3433 LoggedInUser: user,
3534 Timeline: timeline,
3635 BlueskyPosts: blueskyPosts,
3736 ShowNewsletter: s.showNewsletter(user),
3838- }))
3737+ })
3938}
4039func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
4140 if s.oauth.GetMultiAccountUser(r) != nil {
+66-1
appview/state/vouch.go
···1414 "tangled.org/core/log"
1515)
16161717+func (s *State) SkipVouchSuggestion(w http.ResponseWriter, r *http.Request) {
1818+ l := log.SubLogger(s.logger, "skipVouchSuggestion")
1919+ currentUser := s.oauth.GetMultiAccountUser(r)
2020+2121+ subject := r.FormValue("subject")
2222+ if subject == "" {
2323+ l.Warn("missing subject")
2424+ s.pages.Notice(w, "error", "Missing subject user.")
2525+ return
2626+ }
2727+2828+ subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject)
2929+ if err != nil {
3030+ l.Error("failed to resolve subject", "subject", subject, "err", err)
3131+ s.pages.Notice(w, "error", "Could not find that user.")
3232+ return
3333+ }
3434+3535+ if currentUser.Did == subjectIdent.DID.String() {
3636+ l.Warn("cannot skip yourself")
3737+ s.pages.Notice(w, "error", "You cannot skip yourself.")
3838+ return
3939+ }
4040+4141+ if err := db.SkipVouchSuggestion(s.db, currentUser.Did, subjectIdent.DID.String()); err != nil {
4242+ l.Error("failed to skip vouch suggestion", "err", err)
4343+ s.pages.Notice(w, "error", "Failed to skip suggestion.")
4444+ return
4545+ }
4646+4747+ s.pages.HxRefresh(w)
4848+}
4949+1750func (s *State) Vouch(w http.ResponseWriter, r *http.Request) {
1851 l := log.SubLogger(s.logger, "vouch")
1952 l = s.logger.With("handler", "Vouch")
···103136 reasonPtr = &reason
104137 }
105138139139+ var evidences []syntax.ATURI
140140+ for _, raw := range r.Form["evidences"] {
141141+ uri, err := syntax.ParseATURI(raw)
142142+ if err != nil {
143143+ l.Warn("invalid evidence AT-URI, skipping", "uri", raw, "err", err)
144144+ continue
145145+ }
146146+ evidences = append(evidences, uri)
147147+ }
148148+106149 var swapCid *string
107150 existingVouch, err := db.GetVouch(s.db, currentUser.Did, subjectDid)
108151 if err == nil {
···120163 Kind: string(kind),
121164 Reason: reasonPtr,
122165 CreatedAt: createdAt,
166166+ Evidences: func() []string {
167167+ ss := make([]string, len(evidences))
168168+ for i, e := range evidences {
169169+ ss[i] = e.String()
170170+ }
171171+ return ss
172172+ }(),
123173 }},
124174 })
125175 if err != nil {
···143193 Cid: newCid,
144194 Kind: kind,
145195 Reason: reasonPtr,
196196+ Evidences: evidences,
146197 }
147198148148- err = db.AddVouch(s.db, vouch)
199199+ tx, err := s.db.Begin()
200200+ if err != nil {
201201+ l.Error("failed to start transaction", "err", err)
202202+ s.pages.Notice(w, "error", "Failed to save vouch.")
203203+ return
204204+ }
205205+ defer tx.Rollback()
206206+207207+ err = db.AddVouch(tx, vouch)
149208 if err != nil {
150209 l.Error("failed to add vouch to db", "err", err)
210210+ s.pages.Notice(w, "error", "Failed to save vouch.")
211211+ return
212212+ }
213213+214214+ if err = tx.Commit(); err != nil {
215215+ l.Error("failed to commit vouch transaction", "err", err)
151216 s.pages.Notice(w, "error", "Failed to save vouch.")
152217 return
153218 }
+18-17
docs/DOCS.md
···170170171171You'll see something like:
172172173173-```
174174-origin git@github.com:username/my-project (fetch)
175175-origin git@github.com:username/my-project (push)
173173+```bash
174174+origin git@github.com:username/my-project.git (fetch)
175175+origin git@github.com:username/my-project.git (push)
176176```
177177178178Update the remote URL to point to tangled:
···189189190190You should now see:
191191192192-```
192192+```bash
193193origin git@tangled.org:user.tngl.sh/my-project (fetch)
194194origin git@tangled.org:user.tngl.sh/my-project (push)
195195```
···209209If you want to maintain your repository on multiple forges
210210simultaneously, for example, keeping your primary repository
211211on GitHub while mirroring to Tangled for backup or
212212-redundancy, you can do so by adding multiple remotes.
212212+redundancy, you can do so by adding [multiple remotes](https://git-scm.com/docs/git-push#_remotes).
213213214214You can configure your local repository to push to both
215215Tangled and, say, GitHub. You may already have the following
216216setup:
217217218218-```
218218+```bash
219219$ git remote -v
220220-origin git@github.com:username/my-project (fetch)
221221-origin git@github.com:username/my-project (push)
220220+origin git@github.com:username/my-project.git (fetch)
221221+origin git@github.com:username/my-project.git (push)
222222```
223223224224Now add Tangled as an additional push URL to the same
···229229```
230230231231You also need to re-add the original URL as a push
232232-destination (Git replaces the push URL when you use `--add`
233233-the first time):
232232+destination (Git will now use the original URL to fetch only):
234233235234```bash
236236-git remote set-url --add --push origin git@github.com:username/my-project
235235+git remote set-url --add --push origin git@github.com:username/my-project.git
237236```
238237239238Verify your configuration:
240239241241-```
240240+```bash
242241$ git remote -v
243243-origin git@github.com:username/repo (fetch)
244244-origin git@tangled.org:username/my-project (push)
245245-origin git@github.com:username/repo (push)
242242+origin git@github.com:username/my-project.git (fetch)
243243+origin git@tangled.org:user.tngl.sh/my-project (push)
244244+origin git@github.com:username/my-project.git (push)
246245```
247246248247Notice that there's one fetch URL (the primary remote) and
···267266you can maintain separate remotes:
268267269268```bash
270270-git remote add github git@github.com:username/my-project
271271-git remote add tangled git@tangled.org:username/my-project
269269+git remote add github git@github.com:username/my-project.git
270270+git remote add tangled git@tangled.org:user.tngl.sh/my-project
272271```
273272274273Then push to each explicitly:
···831830832831- `CI` - Always set to `true` to indicate a CI environment
833832- `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline
833833+- `TANGLED_PIPELINE_KIND` - One of `push`, `pull_request` or
834834+ `manual`
834835- `TANGLED_REPO_KNOT` - The repository's knot hostname
835836- `TANGLED_REPO_DID` - The DID of the repository owner
836837- `TANGLED_REPO_NAME` - The name of the repository