forked from tangled.org/core
this repo has no description

implement issues and comments

+30
appview/db/db.go
···
name text not null,
knot text not null,
rkey text not null,
+
at_uri text not null unique,
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
unique(did, name, knot, rkey)
);
···
create table if not exists follows (
user_did text not null,
subject_did text not null,
+
at_uri text not null unique,
rkey text not null,
followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
primary key (user_did, subject_did),
check (user_did <> subject_did)
);
+
create table if not exists issues (
+
id integer primary key autoincrement,
+
owner_did text not null,
+
repo_at text not null,
+
issue_id integer not null unique,
+
title text not null,
+
body text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
unique(repo_at, issue_id),
+
foreign key (repo_at) references repos(at_uri) on delete cascade
+
);
+
create table if not exists comments (
+
id integer primary key autoincrement,
+
owner_did text not null,
+
issue_id integer not null,
+
repo_at text not null,
+
comment_id integer not null,
+
body text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
unique(issue_id, comment_id),
+
foreign key (issue_id) references issues(issue_id) on delete cascade
+
);
create table if not exists _jetstream (
id integer primary key autoincrement,
last_time_us integer not null
);
+
+
create table if not exists repo_issue_seqs (
+
repo_at text primary key,
+
next_issue_id integer not null default 1
+
);
+
`)
if err != nil {
return nil, err
+179
appview/db/issues.go
···
+
package db
+
+
import "time"
+
+
type Issue struct {
+
RepoAt string
+
OwnerDid string
+
IssueId int
+
Created *time.Time
+
Title string
+
Body string
+
Open bool
+
}
+
+
type Comment struct {
+
OwnerDid string
+
RepoAt string
+
Issue int
+
CommentId int
+
Body string
+
Created *time.Time
+
}
+
+
func (d *DB) NewIssue(issue *Issue) (int, error) {
+
tx, err := d.db.Begin()
+
if err != nil {
+
return 0, err
+
}
+
defer tx.Rollback()
+
+
_, err = tx.Exec(`
+
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
+
values (?, 1)
+
`, issue.RepoAt)
+
if err != nil {
+
return 0, err
+
}
+
+
var nextId int
+
err = tx.QueryRow(`
+
update repo_issue_seqs
+
set next_issue_id = next_issue_id + 1
+
where repo_at = ?
+
returning next_issue_id - 1
+
`, issue.RepoAt).Scan(&nextId)
+
if err != nil {
+
return 0, err
+
}
+
+
issue.IssueId = nextId
+
+
_, err = tx.Exec(`
+
insert into issues (repo_at, owner_did, issue_id, title, body)
+
values (?, ?, ?, ?, ?)
+
`, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body)
+
if err != nil {
+
return 0, err
+
}
+
+
if err := tx.Commit(); err != nil {
+
return 0, err
+
}
+
+
return nextId, nil
+
}
+
+
func (d *DB) GetIssues(repoAt string) ([]Issue, error) {
+
var issues []Issue
+
+
rows, err := d.db.Query(`select owner_did, issue_id, created, title, body, open from issues where repo_at = ?`, repoAt)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var issue Issue
+
var createdAt string
+
err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
if err != nil {
+
return nil, err
+
}
+
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, err
+
}
+
issue.Created = &createdTime
+
+
issues = append(issues, issue)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return issues, nil
+
}
+
+
func (d *DB) GetIssueWithComments(repoAt string, issueId int) (*Issue, []Comment, error) {
+
query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
+
row := d.db.QueryRow(query, repoAt, issueId)
+
+
var issue Issue
+
var createdAt string
+
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
if err != nil {
+
return nil, nil, err
+
}
+
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, nil, err
+
}
+
issue.Created = &createdTime
+
+
comments, err := d.GetComments(repoAt, issueId)
+
if err != nil {
+
return nil, nil, err
+
}
+
+
return &issue, comments, nil
+
}
+
+
func (d *DB) NewComment(comment *Comment) error {
+
query := `insert into comments (owner_did, repo_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?)`
+
_, err := d.db.Exec(
+
query,
+
comment.OwnerDid,
+
comment.RepoAt,
+
comment.Issue,
+
comment.CommentId,
+
comment.Body,
+
)
+
return err
+
}
+
+
func (d *DB) GetComments(repoAt string, issueId int) ([]Comment, error) {
+
var comments []Comment
+
+
rows, err := d.db.Query(`select owner_did, issue_id, comment_id, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var comment Comment
+
var createdAt string
+
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.Body, &createdAt)
+
if err != nil {
+
return nil, err
+
}
+
+
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, err
+
}
+
comment.Created = &createdAtTime
+
+
comments = append(comments, comment)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return comments, nil
+
}
+
+
func (d *DB) CloseIssue(repoAt string, issueId int) error {
+
_, err := d.db.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
+
return err
+
}
+
+
func (d *DB) ReopenIssue(repoAt string, issueId int) error {
+
_, err := d.db.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
+
return err
+
}
+4 -3
appview/db/repos.go
···
Knot string
Rkey string
Created time.Time
+
AtUri string
}
func (d *DB) GetAllRepos() ([]Repo, error) {
···
func (d *DB) GetRepo(did, name string) (*Repo, error) {
var repo Repo
-
row := d.db.QueryRow(`select did, name, knot, created from repos where did = ? and name = ?`, did, name)
+
row := d.db.QueryRow(`select did, name, knot, created, at_uri from repos where did = ? and name = ?`, did, name)
var createdAt string
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt); err != nil {
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
}
func (d *DB) AddRepo(repo *Repo) error {
-
_, err := d.db.Exec(`insert into repos (did, name, knot, rkey) values (?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey)
+
_, err := d.db.Exec(`insert into repos (did, name, knot, rkey, at_uri) values (?, ?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri)
return err
}
+47
appview/pages/pages.go
···
func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
params.Active = "overview"
+
if params.IsEmpty {
+
return p.executeRepo("repo/empty", w, params)
+
}
return p.executeRepo("repo/index", w, params)
}
···
func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
params.Active = "settings"
return p.executeRepo("repo/settings", w, params)
+
}
+
+
type RepoIssuesParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
Issues []db.Issue
+
}
+
+
func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
+
params.Active = "issues"
+
return p.executeRepo("repo/issues", w, params)
+
}
+
+
type RepoSingleIssueParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
Issue db.Issue
+
Comments []db.Comment
+
IssueOwnerHandle string
+
+
State string
+
}
+
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
+
params.Active = "issues"
+
if params.Issue.Open {
+
params.State = "open"
+
} else {
+
params.State = "closed"
+
}
+
return p.execute("repo/issue", w, params)
+
}
+
+
type RepoNewIssueParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
}
+
+
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
+
params.Active = "issues"
+
return p.executeRepo("repo/new-issue", w, params)
}
func (p *Pages) Static() http.Handler {
+4 -4
appview/pages/templates/repo/empty.html
···
-
{{ define "title" }}{{ .RepoInfo.OwnerWithAt }} / {{ .RepoInfo.Name }}{{ end }}
-
-
{{ define "content" }}
+
{{ define "repoContent" }}
<main>
-
<p>This is an empty Git repository. Push some commits here.</p>
+
<p class="text-center pt-5 text-gray-400">
+
This is an empty repository. Push some commits here.
+
</p>
</main>
{{ end }}
+168 -142
appview/pages/templates/repo/index.html
···
{{ define "repoContent" }}
-
<main>
-
{{- if .IsEmpty }}
-
this repo is empty
-
{{ else }}
-
<div class="flex gap-4">
-
<div id="file-tree" class="w-3/5">
-
{{ $containerstyle := "py-1" }}
-
{{ $linkstyle := "no-underline hover:underline" }}
+
<main>
+
<div class="flex gap-4">
+
<div id="file-tree" class="w-3/5">
+
{{ $containerstyle := "py-1" }}
+
{{ $linkstyle := "no-underline hover:underline" }}
-
<div class="flex justify-end">
-
<select
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + this.value"
-
class="p-1 border border-gray-500 bg-white"
-
>
-
<optgroup label="branches" class="bold text-sm">
-
{{ range .Branches }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{if eq .Reference.Name $.Ref}}selected{{end}}
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</optgroup>
-
<optgroup label="tags" class="bold text-sm">
-
{{ range .Tags }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{if eq .Reference.Name $.Ref}}selected{{end}}
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ else }}
-
<option class="py-1" disabled>no tags found</option>
-
{{ end }}
-
</optgroup>
-
</select>
-
</div>
+
+
<div class="flex justify-end">
+
<select
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + this.value"
+
class="p-1 border border-gray-500 bg-white"
+
>
+
<optgroup label="branches" class="bold text-sm">
+
{{ range .Branches }}
+
<option
+
value="{{ .Reference.Name }}"
+
class="py-1"
+
{{ if eq .Reference.Name $.Ref }}
+
selected
+
{{ end }}
+
>
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</optgroup>
+
<optgroup label="tags" class="bold text-sm">
+
{{ range .Tags }}
+
<option
+
value="{{ .Reference.Name }}"
+
class="py-1"
+
{{ if eq .Reference.Name $.Ref }}
+
selected
+
{{ end }}
+
>
+
{{ .Reference.Name }}
+
</option>
+
{{ else }}
+
<option class="py-1" disabled>
+
no tags found
+
</option>
+
{{ end }}
+
</optgroup>
+
</select>
+
</div>
-
{{ range .Files }}
-
{{ if not .IsFile }}
-
<div class="{{ $containerstyle }}">
-
<div class="flex justify-between items-center">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}/{{ .Name }}"
-
class="{{ $linkstyle }}"
-
>
-
<div class="flex items-center gap-2">
-
<i
-
class="w-3 h-3 fill-current"
-
data-lucide="folder"
-
></i
-
>{{ .Name }}
-
</div>
-
</a>
-
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.Author.When }}</time>
-
</div>
-
</div>
-
{{ end }}
-
{{ end }}
+
{{ range .Files }}
+
{{ if not .IsFile }}
+
<div class="{{ $containerstyle }}">
+
<div class="flex justify-between items-center">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}/{{ .Name }}"
+
class="{{ $linkstyle }}"
+
>
+
<div class="flex items-center gap-2">
+
<i
+
class="w-3 h-3 fill-current"
+
data-lucide="folder"
+
></i
+
>{{ .Name }}
+
</div>
+
</a>
-
{{ range .Files }}
-
{{ if .IsFile }}
-
<div class="{{ $containerstyle }}">
-
<div class="flex justify-between items-center">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref }}/{{ .Name }}"
-
class="{{ $linkstyle }}"
-
>
-
<div class="flex items-center gap-2">
-
<i
-
class="w-3 h-3"
-
data-lucide="file"
-
></i
-
>{{ .Name }}
-
</div>
-
</a>
+
<time class="text-xs text-gray-500"
+
>{{ timeFmt .LastCommit.Author.When }}</time
+
>
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.Author.When }}</time>
-
</div>
-
</div>
-
{{ end }}
-
{{ end }}
-
</div>
-
<div id="commit-log" class="flex-1">
-
{{ range .Commits }}
-
<div
-
class="relative
+
{{ range .Files }}
+
{{ if .IsFile }}
+
<div class="{{ $containerstyle }}">
+
<div class="flex justify-between items-center">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref }}/{{ .Name }}"
+
class="{{ $linkstyle }}"
+
>
+
<div class="flex items-center gap-2">
+
<i
+
class="w-3 h-3"
+
data-lucide="file"
+
></i
+
>{{ .Name }}
+
</div>
+
</a>
+
+
<time class="text-xs text-gray-500"
+
>{{ timeFmt .LastCommit.Author.When }}</time
+
>
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
</div>
+
<div id="commit-log" class="flex-1">
+
{{ range .Commits }}
+
<div
+
class="relative
px-4
py-4
border-l
···
before:left-[-2.2px]
before:top-1/2
before:-translate-y-1/2
-
">
+
"
+
>
+
<div id="commit-message">
+
{{ $messageParts := splitN .Message "\n\n" 2 }}
+
<div class="text-base cursor-pointer">
+
<div>
+
<div>
+
<a
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
+
class="inline no-underline hover:underline"
+
>{{ index $messageParts 0 }}</a
+
>
+
{{ if gt (len $messageParts) 1 }}
-
<div id="commit-message">
-
{{ $messageParts := splitN .Message "\n\n" 2 }}
-
<div class="text-base cursor-pointer">
-
<div>
-
<div>
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" class="inline no-underline hover:underline">{{ index $messageParts 0 }}</a>
-
{{ if gt (len $messageParts) 1 }}
+
<button
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded"
+
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
+
>
+
<i
+
class="w-3 h-3"
+
data-lucide="ellipsis"
+
></i>
+
</button>
+
{{ end }}
+
</div>
+
{{ if gt (len $messageParts) 1 }}
+
<p
+
class="hidden mt-1 text-sm cursor-text pb-2"
+
>
+
{{ nl2br (unwrapText (index $messageParts 1)) }}
+
</p>
+
{{ end }}
+
</div>
+
</div>
+
</div>
-
<button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded"
-
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">
-
<i class="w-3 h-3" data-lucide="ellipsis"></i>
-
</button>
+
<div class="text-xs text-gray-500">
+
<span class="font-mono">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
+
class="text-gray-500 no-underline hover:underline"
+
>{{ slice .Hash.String 0 8 }}</a
+
>
+
</span>
+
<span
+
class="mx-2 before:content-['·'] before:select-none"
+
></span>
+
<span>
+
<a
+
href="mailto:{{ .Author.Email }}"
+
class="text-gray-500 no-underline hover:underline"
+
>{{ .Author.Name }}</a
+
>
+
</span>
+
<div
+
class="inline-block px-1 select-none after:content-['·']"
+
></div>
+
<span>{{ timeFmt .Author.When }}</span>
+
</div>
+
</div>
{{ end }}
-
</div>
-
{{ if gt (len $messageParts) 1 }}
-
<p class="hidden mt-1 text-sm cursor-text pb-2">{{ nl2br (unwrapText (index $messageParts 1)) }}</p>
-
{{ end }}
</div>
-
</div>
</div>
-
-
<div class="text-xs text-gray-500">
-
<span class="font-mono">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
-
class="text-gray-500 no-underline hover:underline"
-
>{{ slice .Hash.String 0 8 }}</a
-
>
-
</span>
-
<span class="mx-2 before:content-['·'] before:select-none"></span>
-
<span>
-
<a
-
href="mailto:{{ .Author.Email }}"
-
class="text-gray-500 no-underline hover:underline"
-
>{{ .Author.Name }}</a
-
>
-
</span>
-
<div class="inline-block px-1 select-none after:content-['·']"></div>
-
<span>{{ timeFmt .Author.When }}</span>
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
{{- end -}}
-
-
</main>
+
</main>
{{ end }}
{{ define "repoAfter" }}
-
{{- if .Readme }}
-
<section class="mt-4 p-6 border border-black w-full mx-auto">
-
<article class="readme">
-
{{- .Readme -}}
-
</article>
-
</section>
-
{{- end -}}
+
{{- if .Readme }}
+
<section class="mt-4 p-6 border border-black w-full mx-auto">
+
<article class="readme">
+
{{- .Readme -}}
+
</article>
+
</section>
+
{{- end -}}
-
<section class="mt-4 p-6 border border-black w-full mx-auto">
-
<strong>clone</strong>
-
<pre> git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre>
-
</section>
+
+
<section class="mt-4 p-6 border border-black w-full mx-auto">
+
<strong>clone</strong>
+
<pre>
+
git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre
+
>
+
</section>
{{ end }}
+107
appview/pages/templates/repo/issue.html
···
+
{{ define "title" }}
+
{{ .Issue.Title }} &middot;
+
{{ .RepoInfo.FullName }}
+
{{ end }}
+
+
{{ define "repoContent" }}
+
<div class="flex items-center justify-between">
+
<h1>
+
{{ .Issue.Title }}
+
<span class="text-gray-400">#{{ .Issue.IssueId }}</span>
+
</h1>
+
+
<time class="text-sm">{{ .Issue.Created | timeFmt }}</time>
+
</div>
+
+
{{ $bgColor := "bg-gray-800" }}
+
{{ $icon := "ban" }}
+
{{ if eq .State "open" }}
+
{{ $bgColor = "bg-green-600" }}
+
{{ $icon = "circle-dot" }}
+
{{ end }}
+
+
+
<section class="m-2">
+
<div class="flex items-center gap-2">
+
<div
+
id="state"
+
class="inline-flex items-center px-3 py-1 {{ $bgColor }}"
+
>
+
<i
+
data-lucide="{{ $icon }}"
+
class="w-4 h-4 mr-1.5 text-white"
+
></i>
+
<span class="text-white">{{ .State }}</span>
+
</div>
+
<span class="text-gray-400 text-sm">
+
opened by
+
{{ didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
+
</span>
+
</div>
+
+
{{ if .Issue.Body }}
+
<article id="body" class="mt-8">
+
{{ .Issue.Body | escapeHtml }}
+
</article>
+
{{ end }}
+
</section>
+
+
<section id="comments" class="mt-8 space-y-4">
+
{{ range .Comments }}
+
<div
+
id="comment-{{ .CommentId }}"
+
class="border border-gray-200 p-4"
+
>
+
<div class="flex items-center gap-2 mb-2">
+
<span class="text-gray-400 text-sm">
+
{{ .OwnerDid }}
+
</span>
+
<span class="text-gray-500 text-sm">
+
{{ .Created | timeFmt }}
+
</span>
+
</div>
+
<div class="">
+
{{ nl2br .Body }}
+
</div>
+
</div>
+
{{ end }}
+
</section>
+
+
{{ if .LoggedInUser }}
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
class="mt-8"
+
>
+
<textarea
+
name="body"
+
class="w-full p-2 border border-gray-200"
+
placeholder="Add to the discussion..."
+
></textarea>
+
<button type="submit" class="btn mt-2">comment</button>
+
<div id="issue-comment"></div>
+
</form>
+
{{ end }}
+
+
{{ if eq .LoggedInUser.Did .Issue.OwnerDid }}
+
{{ $action := "close" }}
+
{{ $icon := "circle-x" }}
+
{{ $hoverColor := "red" }}
+
{{ if eq .State "closed" }}
+
{{ $action = "reopen" }}
+
{{ $icon = "circle-dot" }}
+
{{ $hoverColor = "green" }}
+
{{ end }}
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}"
+
class="mt-8"
+
>
+
<button type="submit" class="btn hover:bg-{{ $hoverColor }}-300">
+
<i
+
data-lucide="{{ $icon }}"
+
class="w-4 h-4 mr-2 text-{{ $hoverColor }}-400"
+
></i>
+
<span class="text-black">{{ $action }}</span>
+
</button>
+
</form>
+
{{ end }}
+
{{ end }}
+48
appview/pages/templates/repo/issues.html
···
+
{{ define "title" }}issues | {{ .RepoInfo.FullName }}{{ end }}
+
+
{{ define "repoContent" }}
+
<div class="flex justify-between items-center">
+
<h1 class="m-0">issues</h1>
+
<div class="error" id="issues"></div>
+
<a
+
href="/{{ .RepoInfo.FullName }}/issues/new"
+
class="btn flex items-center gap-2 no-underline"
+
>
+
<i data-lucide="square-plus" class="w-5 h-5"></i>
+
<span>new issue</span>
+
</a>
+
</div>
+
+
<section id="issues" class="mt-8 space-y-4">
+
{{ range .Issues }}
+
<div class="border border-gray-200 p-4">
+
<time class="float-right text-sm">
+
{{ .Created | timeFmt }}
+
</time>
+
<div class="flex items-center gap-2 py-2">
+
{{ if .Open }}
+
<i
+
data-lucide="circle-dot"
+
class="w-4 h-4 text-green-600"
+
></i>
+
{{ else }}
+
<i data-lucide="ban" class="w-4 h-4 text-red-600"></i>
+
{{ end }}
+
<a
+
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
+
class="no-underline hover:underline"
+
>
+
{{ .Title }}
+
</a>
+
</div>
+
<div class="text-sm flex gap-2 text-gray-400">
+
<span>#{{ .IssueId }}</span>
+
<span class="before:content-['·']">
+
opened by
+
{{ .OwnerDid }}
+
</span>
+
</div>
+
</div>
+
{{ end }}
+
</section>
+
{{ end }}
+30
appview/pages/templates/repo/new-issue.html
···
+
{{ define "title" }}new issue | {{ .RepoInfo.FullName }}{{ end }}
+
+
{{ define "repoContent" }}
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
+
class="mt-6 space-y-6"
+
hx-swap="none"
+
>
+
<div class="flex flex-col gap-4">
+
<div>
+
<label for="title">title</label>
+
<input type="text" name="title" id="title" class="w-full" />
+
</div>
+
<div>
+
<label for="body">body</label>
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
class="w-full resize-y"
+
placeholder="Describe your issue."
+
></textarea>
+
</div>
+
<div>
+
<button type="submit" class="btn">create</button>
+
</div>
+
</div>
+
<div id="issues" class="error"></div>
+
</form>
+
{{ end }}
+1
appview/state/middleware.go
···
}
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
+
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
+239
appview/state/repo.go
···
"fmt"
"io"
"log"
+
"math/rand/v2"
"net/http"
"path"
+
"strconv"
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"github.com/sotangled/tangled/appview/auth"
+
"github.com/sotangled/tangled/appview/db"
"github.com/sotangled/tangled/appview/pages"
"github.com/sotangled/tangled/types"
)
···
Knot string
OwnerId identity.Identity
RepoName string
+
RepoAt string
}
func (f *FullyResolvedRepo) OwnerDid() string {
···
return collaborators, nil
}
+
func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
if err != nil {
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
issue, comments, err := s.db.GetIssueWithComments(f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to get issue and comments", err)
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
return
+
}
+
+
issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
+
if err != nil {
+
log.Println("failed to resolve issue owner", err)
+
}
+
+
s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
+
LoggedInUser: user,
+
RepoInfo: pages.RepoInfo{
+
OwnerDid: f.OwnerDid(),
+
OwnerHandle: f.OwnerHandle(),
+
Name: f.RepoName,
+
SettingsAllowed: settingsAllowed(s, user, f),
+
},
+
Issue: *issue,
+
Comments: comments,
+
+
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
+
})
+
+
}
+
+
func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
if err != nil {
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
if user.Did == f.OwnerDid() {
+
err := s.db.CloseIssue(f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to close issue", err)
+
s.pages.Notice(w, "issues", "Failed to close issue. Try again later.")
+
return
+
}
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
return
+
} else {
+
log.Println("user is not the owner of the repo")
+
http.Error(w, "for biden", http.StatusUnauthorized)
+
return
+
}
+
}
+
+
func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
if err != nil {
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
if user.Did == f.OwnerDid() {
+
err := s.db.ReopenIssue(f.RepoAt, issueIdInt)
+
if err != nil {
+
log.Println("failed to reopen issue", err)
+
s.pages.Notice(w, "issues", "Failed to reopen issue. Try again later.")
+
return
+
}
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
return
+
} else {
+
log.Println("user is not the owner of the repo")
+
http.Error(w, "forbidden", http.StatusUnauthorized)
+
return
+
}
+
}
+
+
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issueId := chi.URLParam(r, "issue")
+
issueIdInt, err := strconv.Atoi(issueId)
+
if err != nil {
+
http.Error(w, "bad issue id", http.StatusBadRequest)
+
log.Println("failed to parse issue id", err)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodPost:
+
body := r.FormValue("body")
+
if body == "" {
+
s.pages.Notice(w, "issue", "Body is required")
+
return
+
}
+
+
commentId := rand.IntN(1000000)
+
fmt.Println(commentId)
+
fmt.Println("comment id", commentId)
+
+
err := s.db.NewComment(&db.Comment{
+
OwnerDid: user.Did,
+
RepoAt: f.RepoAt,
+
Issue: issueIdInt,
+
CommentId: commentId,
+
Body: body,
+
})
+
if err != nil {
+
log.Println("failed to create comment", err)
+
s.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
+
return
+
}
+
}
+
+
func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
issues, err := s.db.GetIssues(f.RepoAt)
+
if err != nil {
+
log.Println("failed to get issues", err)
+
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
+
return
+
}
+
+
s.pages.RepoIssues(w, pages.RepoIssuesParams{
+
LoggedInUser: s.auth.GetUser(r),
+
RepoInfo: pages.RepoInfo{
+
OwnerDid: f.OwnerDid(),
+
OwnerHandle: f.OwnerHandle(),
+
Name: f.RepoName,
+
SettingsAllowed: settingsAllowed(s, user, f),
+
},
+
Issues: issues,
+
})
+
return
+
}
+
+
func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
+
LoggedInUser: user,
+
RepoInfo: pages.RepoInfo{
+
Name: f.RepoName,
+
OwnerDid: f.OwnerDid(),
+
OwnerHandle: f.OwnerHandle(),
+
SettingsAllowed: settingsAllowed(s, user, f),
+
},
+
})
+
case http.MethodPost:
+
title := r.FormValue("title")
+
body := r.FormValue("body")
+
+
if title == "" || body == "" {
+
s.pages.Notice(w, "issue", "Title and body are required")
+
return
+
}
+
+
issueId, err := s.db.NewIssue(&db.Issue{
+
RepoAt: f.RepoAt,
+
Title: title,
+
Body: body,
+
OwnerDid: user.Did,
+
})
+
if err != nil {
+
log.Println("failed to create issue", err)
+
s.pages.Notice(w, "issue", "Failed to create issue.")
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
+
return
+
}
+
}
+
func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
repoName := chi.URLParam(r, "repo")
knot, ok := r.Context().Value("knot").(string)
···
return nil, fmt.Errorf("malformed middleware")
}
+
repoAt, ok := r.Context().Value("repoAt").(string)
+
if !ok {
+
log.Println("malformed middleware")
+
return nil, fmt.Errorf("malformed middleware")
+
}
+
return &FullyResolvedRepo{
Knot: knot,
OwnerId: id,
RepoName: repoName,
+
RepoAt: repoAt,
}, nil
}
+12 -1
appview/state/state.go
···
}
did := e.Did
-
fmt.Println("got event", e.Commit.Collection, e.Commit.RKey, e.Commit.Record)
raw := json.RawMessage(e.Commit.Record)
switch e.Commit.Collection {
···
}
log.Println("created repo record: ", atresp.Uri)
+
repo.AtUri = atresp.Uri
+
err = s.db.AddRepo(repo)
if err != nil {
log.Println(err)
···
r.Get("/branches", s.RepoBranches)
r.Get("/tags", s.RepoTags)
r.Get("/blob/{ref}/*", s.RepoBlob)
+
+
r.Route("/issues", func(r chi.Router) {
+
r.Get("/", s.RepoIssues)
+
r.Get("/{issue}", s.RepoSingleIssue)
+
r.Get("/new", s.NewIssue)
+
r.Post("/new", s.NewIssue)
+
r.Post("/{issue}/comment", s.IssueComment)
+
r.Post("/{issue}/close", s.CloseIssue)
+
r.Post("/{issue}/reopen", s.ReopenIssue)
+
})
// These routes get proxied to the knot
r.Get("/info/refs", s.InfoRefs)
+1
go.mod
···
github.com/russross/blackfriday/v2 v2.1.0
github.com/sethvargo/go-envconfig v1.1.0
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
+
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
)
+2
go.sum
···
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
+
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=