diff --git a/CLAUDE.md b/CLAUDE.md index df36c00..4f81859 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ - node:sqlite refuse `undefined` et les booléens : passer `null` / `0|1`. - Synchro (`server/src/sync/`) : toute nouveauté passe par `pushInbox` avec une `external_key` unique ; la première synchro d'une source est « baselinée » (marquée lue) via la table `settings`. - `pushInbox` remplit aussi `fresh` (entrées nouvelles et non lues) : c'est ce qui part sur ntfy en fin de `syncProject` (`sync/notify.js`). La CI ne génère une entrée qu'aux changements d'état (vert → rouge, rouge → vert). -- Forges (`server/src/sync/forges/`) : un adaptateur par forge (github, gitlab, gitea — Forgejo = gitea) avec la même interface (`fetchRepo`, `fetchCi`, `latestRelease`, `createIssue`, `listOwnerRepos`). Toujours passer par `forgeFor(db, project)` qui résout le token (comptes `config:forges` puis variables d'env). `whoami()` (compte du token) et `fetchMentions` (GitHub) remplissent `github_items.mine` (author, assignee, review, mention) pour les filtres « Me concernant » et la page « Mon GitHub » (`/api/my-github`) ; `fetchPrState` (GitHub) remplit `github_items.pr_state` pour ces PR, et les transitions sur les PR de l'utilisateur deviennent des entrées `pr_status`. Un bug venu du forum fermé par la synchro crée une entrée `forum_reply` (`items.forum_replied_at` une fois répondu). Sujets suivis : table `forum_watches` (`fetchTopic`, entrées `forum_followed`). CI GitHub : seuls les runs `push` / `merge_group` comptent, GitLab ignore les pipelines `schedule`. Checklist de release : `milestones.checklist`, modèle dans `config:release_checklist`. Noms historiques : la table `github_items` et la colonne `projects.github` contiennent les données de toutes les forges. +- Forges (`server/src/sync/forges/`) : un adaptateur par forge (github, gitlab, gitea — Forgejo = gitea) avec la même interface (`fetchRepo`, `fetchCi`, `latestRelease`, `createIssue`, `listOwnerRepos`). Toujours passer par `forgeFor(db, project)` qui résout le token (comptes `config:forges` puis variables d'env). `whoami()` (compte du token) et `fetchMentions` (GitHub) remplissent `github_items.mine` (author, assignee, review, mention) pour les filtres « Me concernant » et la page « Mon GitHub » (`/api/my-github`) ; `fetchPrState` (GitHub) remplit `github_items.pr_state` pour ces PR, et les transitions sur les PR de l'utilisateur deviennent des entrées `pr_status`. Un bug venu du forum fermé par la synchro crée une entrée `forum_reply` (`items.forum_replied_at` une fois répondu). Sujets suivis : table `forum_watches` (`fetchTopic`, entrées `forum_followed`). CI GitHub : seuls les runs `push` / `merge_group` comptent, GitLab ignore les pipelines `schedule`. Portée par projet : `projects.github_scope` (auto | all | mine) → `projects.mine_only` (recalculé par `refreshMineOnly` et à la synchro) ; toute requête qui compte ou liste les `github_items` passe par `VISIBLE_ITEM` (`server/src/visibility.js`). Checklist de release : `milestones.checklist`, modèle dans `config:release_checklist`. Noms historiques : la table `github_items` et la colonne `projects.github` contiennent les données de toutes les forges. - Source de version `repo` = releases du dépôt du projet (toute forge) ; `github` = autre dépôt GitHub. - Tâches planifiées (`server/src/jobs.js`, tick 60 s) : résumé hebdo ntfy (`insights.js`) et sauvegardes `VACUUM INTO` (`backup.js`, `BACKUP_DIR`). Le flux `/api/calendar.ics?token=` échappe à `APP_PASSWORD` (clé dans `config:calendar`). Statistiques : `fetchActivity` des forges, agrégées par `stats.js`. - Pièces jointes (`server/src/uploads.js`) : fichiers dans `UPLOADS_DIR` (défaut `DATA_PATH/uploads`), référencés uniquement depuis le Markdown (`/api/uploads/`), sans table ; les orphelins sont supprimés au moment de la sauvegarde. Côté front, `MarkdownInput` remplace `Input.TextArea` pour tout champ Markdown. Vue « Aujourd'hui » : colonne `items.focus_date` (`server/src/today.js`). diff --git a/README.en.md b/README.en.md index 4fbf716..b47172f 100644 --- a/README.en.md +++ b/README.en.md @@ -28,8 +28,8 @@ Self-hosted (Docker), single-user, all data in one SQLite file. | **Projects grouped by category** | Freelance, Personal, Organisation (e.g. Gladys), Other — editable categories with colours. Status (idea, active, maintenance, paused, archived), client, tags, links, pinning. | | **GitHub, GitLab, Gitea / Forgejo** | Each project points to a repository on github.com, gitlab.com or a self-hosted instance (GitLab, Gitea, Forgejo). One token per instance in **Settings → Forges and tokens**. | | **Bulk repository import** | Lists the repositories of a user, organisation or group (filter, forks, archived) and creates the projects in one go, with category, status and release tracking. Repositories already tracked are skipped. | -| **Repository tracking** | Open issues and PRs / MRs, stars, last activity. "For me" filters (opened by you, assigned, review requested, mentioned — based on the token's account), by label, author or title, remembered per project: handy on a very active repository like Gladys. **New** ones land in the inbox; a "Track" button adds them to your bugs & tasks. | -| **My GitHub** | One page gathering, across all your projects, the reviews requested from you, your PRs, what is assigned to you, your mentions and your issues. Each PR shows its state: approved, changes requested, conflict with the main branch, CI. On your PRs, an approval, a change request, a conflict or a red CI lands in the inbox and on ntfy. | +| **Repository tracking** | Open issues and PRs / MRs, stars, last activity. "For me" filters (opened by you, assigned, review requested, mentioned — based on the token's account), by label, author or title, remembered per project: handy on a very active repository like Gladys. Per-project "Issues and PRs followed" setting: Auto (default) follows everything on your repositories and only what concerns you on other people's (counters, dashboard, stale list, notifications). **New** ones land in the inbox; a "Track" button adds them to your bugs & tasks. | +| **My GitHub** | One page gathering, across all your projects, the reviews requested from you, your PRs, what is assigned to you, your mentions and your issues. Each PR shows its state: approved, changes requested, conflict with the main branch, CI. One click turns it into a task with an explicit title ("Resolve the conflict: #3028…"). On your PRs, an approval, a change request, a conflict or a red CI lands in the inbox and on ntfy. | | **Create an issue from a bug** | A bug reported on a forum becomes an issue on the repository (title, description, forum link, target version, label). Linked both ways: when the issue is closed, the bug is marked as done. | | **CI status** | GitHub Actions (latest run of each workflow), GitLab pipelines, Gitea/Forgejo commit statuses (Actions, Woodpecker, Drone) on the default branch: green/red badge (only runs triggered by the code count: scheduled jobs and manually started workflows are ignored), dashboard alert, notification when CI breaks and when it is green again. | | **Personal GitHub notifications** | Review requests, mentions, assignments, replies on your issues/PRs (e.g. to GladysAssistant/Gladys), attached to the matching tracked project. Requires a classic token with the `notifications` scope. | @@ -38,7 +38,7 @@ Self-hosted (Docker), single-user, all data in one SQLite file. | **Upstream version watch** | Latest release/tag of the repository (any forge), of another GitHub repository, of an npm package or a Docker Hub image, compared with the version *you* use/deploy → "update available" badge and notification. | | **Bugs reported on forums** | **Discourse** forums (e.g. the [Gladys forum](https://community.gladysassistant.com): category, tag or search) and **RSS/Atom feeds** (keyword filter). One click turns a topic into a tracked bug. "Discourse: my mentions" source: only the posts that tag you (`@username`), with its own ntfy notification. When a bug that came from the forum is fixed, a ready-to-post message is offered ("fixed in version…"): copied, and the topic opens. **Followed topics**: every new reply of a topic (👁 button in the activity, or link pasted in the Activity tab) lands in the inbox and on ntfy. | | **"Today" view** | Pick 3 to 5 bugs or tasks for the day (☀ in the lists, or from the view) and tick them off as you go; unfinished ones carry over to the next day. Alongside: overdue or due today items, GitHub review requests, suggestions (in progress, high priority), failing CI, time spent. | -| **Screenshots and attachments** | Paste (Ctrl+V) or drop a screenshot or a file into a bug description, a note or a spec: it is stored in `data/uploads/` and inserted as Markdown (images shown inline, other files linked). 25 MB maximum per file. | +| **Screenshots and attachments** | Paste (Ctrl+V) or drop a screenshot or a file into a bug description, a note or a spec: it is stored in `data/uploads/` and inserted as Markdown (images shown inline, other files linked). Images show as thumbnails below the field (click to enlarge) and “Preview” shows the rendered text. 25 MB maximum per file. | | **Manual bugs & tasks** | Bug / feature / task / question, priority, status, due date, target version, external link. List or Kanban board (drag and drop). | | **Specifications** | Markdown documents per project with a pre-filled template (context, goals, acceptance criteria…) and draft / in review / approved status. | | **Notes** | Markdown notes per project or global, "Quick note" button everywhere in the app. | diff --git a/README.md b/README.md index b9de8f1..e3d6545 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Auto-hébergé (Docker), mono-utilisateur, données dans un seul fichier SQLite. | **Projets classés par catégories** | Freelance, Perso, Organisation (Gladys…), Autre — catégories modifiables avec couleur. Statut (idée, actif, maintenance, pause, archivé), client, tags, liens, épinglage. | | **GitHub, GitLab, Gitea / Forgejo** | Chaque projet pointe vers un dépôt sur github.com, gitlab.com ou une instance auto-hébergée (GitLab, Gitea, Forgejo). Un token par instance dans **Paramètres → Forges et tokens**. | | **Import de dépôts en masse** | Liste les dépôts d'un utilisateur, d'une organisation ou d'un groupe (filtre, forks, archivés) et crée les projets d'un coup avec catégorie, statut et suivi des releases. Les dépôts déjà suivis sont ignorés. | -| **Suivi des dépôts** | Issues et PR / MR ouvertes, étoiles, dernière activité. Filtres « Me concernant » (ouvert par toi, assigné, review demandée, mentionné — d'après le compte du token), par label, auteur ou titre, mémorisés par projet : pratique sur un dépôt très actif comme Gladys. Les **nouvelles** arrivent dans la boîte de réception ; un bouton « Suivre » les ajoute aux bugs & tâches. | -| **Mon GitHub** | Une page qui réunit, sur tous tes projets, les reviews qu'on te demande, tes PR, ce qui t'est assigné, tes mentions et tes issues. Chaque PR affiche son état : approuvée, changements demandés, conflit avec la branche principale, CI. Sur tes PR, une approbation, une demande de changements, un conflit ou une CI rouge arrive dans la boîte de réception et sur ntfy. | +| **Suivi des dépôts** | Issues et PR / MR ouvertes, étoiles, dernière activité. Filtres « Me concernant » (ouvert par toi, assigné, review demandée, mentionné — d'après le compte du token), par label, auteur ou titre, mémorisés par projet : pratique sur un dépôt très actif comme Gladys. Réglage par projet « Issues et PR suivies » : en mode Auto (par défaut), tout est suivi sur tes dépôts, et seulement ce qui te concerne sur ceux des autres (compteurs, tableau de bord, « En attente », notifications). Les **nouvelles** arrivent dans la boîte de réception ; un bouton « Suivre » les ajoute aux bugs & tâches. | +| **Mon GitHub** | Une page qui réunit, sur tous tes projets, les reviews qu'on te demande, tes PR, ce qui t'est assigné, tes mentions et tes issues. Chaque PR affiche son état : approuvée, changements demandés, conflit avec la branche principale, CI. Un clic en fait une tâche au titre explicite (« Résoudre le conflit : #3028… »). Sur tes PR, une approbation, une demande de changements, un conflit ou une CI rouge arrive dans la boîte de réception et sur ntfy. | | **Créer une issue depuis un bug** | Un bug remonté par le forum devient une issue sur le dépôt (titre, description, lien du forum, version cible, label). Lien dans les deux sens : quand l'issue est fermée, le bug passe à « Terminé ». | | **État de la CI** | GitHub Actions (dernier run de chaque workflow), pipelines GitLab, statuts de commit Gitea/Forgejo (Actions, Woodpecker, Drone) sur la branche principale : pastille verte/rouge (seuls les runs déclenchés par le code comptent : les tâches planifiées et les workflows lancés à la main sont ignorés), alerte au tableau de bord, notification quand la CI casse puis repasse au vert. | | **Notifications GitHub perso** | Reviews demandées, mentions, assignations, réponses sur tes issues/PR (ex. vers GladysAssistant/Gladys), rattachées au projet suivi correspondant. Nécessite un token classique avec le scope `notifications`. | @@ -37,7 +37,7 @@ Auto-hébergé (Docker), mono-utilisateur, données dans un seul fichier SQLite. | **Veille des versions** | Dernière release/tag du dépôt (toute forge), d'un autre dépôt GitHub, d'un paquet npm ou d'une image Docker Hub, comparée à la version que *tu* utilises/déploies → badge « mise à jour disponible » et notification. | | **Bugs remontés par un forum** | Forums **Discourse** (ex. [forum Gladys](https://community.gladysassistant.com) : catégorie, tag ou recherche) et **flux RSS/Atom** (filtre par mots-clés). Un clic transforme un sujet en bug suivi. Source « Discourse : mes mentions » : uniquement les messages qui te taguent (`@pseudo`), avec notification ntfy dédiée. Quand un bug venu du forum est corrigé, un message prêt à poster est proposé (« corrigé dans la version… ») : copié, et le sujet s'ouvre. **Sujets suivis** : chaque nouvelle réponse d'un sujet (bouton 👁 dans l'activité, ou lien collé dans l'onglet Activité) arrive dans la boîte de réception et sur ntfy. | | **Vue « Aujourd'hui »** | Choisis 3 à 5 bugs ou tâches pour la journée (☀ dans les listes, ou depuis la vue) et coche-les au fil de l'eau ; ce qui n'est pas fini est reporté au lendemain. À côté : échéances dépassées ou du jour, reviews GitHub demandées, suggestions (en cours, prioritaires), CI en échec, temps passé. | -| **Captures et pièces jointes** | Colle (Ctrl+V) ou glisse une capture d'écran ou un fichier dans la description d'un bug, une note ou une spec : il est enregistré dans `data/uploads/` et inséré en Markdown (image affichée, autres fichiers en lien). 25 Mo maximum par fichier. | +| **Captures et pièces jointes** | Colle (Ctrl+V) ou glisse une capture d'écran ou un fichier dans la description d'un bug, une note ou une spec : il est enregistré dans `data/uploads/` et inséré en Markdown (image affichée, autres fichiers en lien). Les images apparaissent en miniatures sous le champ (clic pour agrandir) et « Prévisualiser » affiche le rendu. 25 Mo maximum par fichier. | | **Bugs & tâches manuels** | Bug / fonctionnalité / tâche / question, priorité, statut, échéance, version cible, lien externe. Vue liste ou tableau Kanban (glisser-déposer). | | **Spécifications** | Documents Markdown par projet avec modèle prérempli (contexte, objectifs, critères d'acceptation…) et statut brouillon / à relire / validée. | | **Notes** | Notes Markdown par projet ou globales, bouton « Note rapide » partout dans l'app. | diff --git a/server/src/db.js b/server/src/db.js index 6d2a2e9..2e09097 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -191,6 +191,12 @@ const MIGRATIONS = [ -- Release checklist of a version: [{ label, done }]. ALTER TABLE milestones ADD COLUMN checklist TEXT; `, + ` + -- Which issues / PRs of the repository are followed: auto | all | mine. + ALTER TABLE projects ADD COLUMN github_scope TEXT NOT NULL DEFAULT 'auto'; + -- Effective value (1 = only those that concern the user), kept up to date. + ALTER TABLE projects ADD COLUMN mine_only INTEGER NOT NULL DEFAULT 0; + `, ] export function openDb(file = join(DATA_PATH, 'projectory.db')) { @@ -223,7 +229,7 @@ export function decode(row) { try { out[col] = JSON.parse(out[col]) } catch { /* keep raw */ } } } - for (const col of ['pinned', 'draft', 'notify']) if (col in out) out[col] = !!out[col] + for (const col of ['pinned', 'draft', 'notify', 'mine_only']) if (col in out) out[col] = !!out[col] return out } diff --git a/server/src/insights.js b/server/src/insights.js index 37f38b8..2ed7a03 100644 --- a/server/src/insights.js +++ b/server/src/insights.js @@ -1,3 +1,4 @@ +import { VISIBLE_ITEM } from './visibility.js' import { getConfig } from './db.js' import { tr } from './i18n.js' import { compareVersions } from './sync/http.js' @@ -17,7 +18,7 @@ export function findStale(db, now = new Date()) { const base = `SELECT g.id, g.project_id, g.type, g.number, g.title, g.url, g.author, g.comments, g.created_at, g.updated_at, p.name AS project_name, p.forge FROM github_items g JOIN projects p ON p.id = g.project_id - WHERE p.status != 'archived'` + WHERE p.status != 'archived' AND ${VISIBLE_ITEM}` const issues = db.prepare(`${base} AND g.type = 'issue' AND g.comments = 0 AND g.created_at <= ?`).all(daysAgo(issue_days, now)) .map((r) => ({ ...r, reason: 'no_reply', age_days: ageDays(r.created_at, now) })) const prs = db.prepare(`${base} AND g.type = 'pr' AND g.draft = 0 AND g.updated_at <= ?`).all(daysAgo(pr_days, now)) diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js index c57b285..6cdec10 100644 --- a/server/src/routes/integrations.js +++ b/server/src/routes/integrations.js @@ -140,7 +140,7 @@ export default async function integrationRoutes(app, { db }) { project_id: g.project_id, kind: req.body?.kind || (g.type === 'pr' ? 'task' : 'bug'), priority: req.body?.priority || 'normal', - title: g.title, + title: String(req.body?.title || '').trim() || g.title, source: 'github', issue_number: g.type === 'issue' ? g.number : null, issue_url: g.url, diff --git a/server/src/routes/overview.js b/server/src/routes/overview.js index e7ae449..459e957 100644 --- a/server/src/routes/overview.js +++ b/server/src/routes/overview.js @@ -5,6 +5,7 @@ import { forgeContext } from '../sync/forges/index.js' import { tr } from '../i18n.js' import { findStale } from '../insights.js' import { todayView } from '../today.js' +import { VISIBLE_ITEM } from '../visibility.js' const REPO_SOURCES = "'github', 'gitlab', 'gitea'" const INBOX_SQL = `SELECT x.*, p.name AS project_name FROM inbox x LEFT JOIN projects p ON p.id = x.project_id` @@ -59,8 +60,8 @@ export default async function overviewRoutes(app, { db }) { stats: { projects: projects.length, active: projects.filter((p) => p.status === 'active').length, - open_issues: count("SELECT count(*) n FROM github_items WHERE type = 'issue'"), - open_prs: count("SELECT count(*) n FROM github_items WHERE type = 'pr'"), + open_issues: count(`SELECT count(*) n FROM github_items g JOIN projects p ON p.id = g.project_id WHERE g.type = 'issue' AND p.status != 'archived' AND ${VISIBLE_ITEM}`), + open_prs: count(`SELECT count(*) n FROM github_items g JOIN projects p ON p.id = g.project_id WHERE g.type = 'pr' AND p.status != 'archived' AND ${VISIBLE_ITEM}`), ci_failing: projects.filter((p) => p.ci === 'failure').length, stale: findStale(db).length, open_bugs: count("SELECT count(*) n FROM items WHERE kind = 'bug' AND status IN ('todo', 'in_progress')"), @@ -80,7 +81,7 @@ export default async function overviewRoutes(app, { db }) { stale: findStale(db).slice(0, 8), recent_prs: db.prepare( `SELECT g.*, p.name AS project_name FROM github_items g JOIN projects p ON p.id = g.project_id - WHERE g.type = 'pr' ORDER BY g.updated_at DESC LIMIT 8` + WHERE g.type = 'pr' AND ${VISIBLE_ITEM} ORDER BY g.updated_at DESC LIMIT 8` ).all().map(decode), sync: syncState, } diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js index 05ed2cd..64afc02 100644 --- a/server/src/routes/projects.js +++ b/server/src/routes/projects.js @@ -3,18 +3,22 @@ import { compareVersions } from '../sync/http.js' import { normalizeRepo } from '../sync/forges/index.js' import { syncProject } from '../sync/index.js' import { tr } from '../i18n.js' +import { VISIBLE_ITEM, refreshMineOnly } from '../visibility.js' const FIELDS = [ 'name', 'description', 'category_id', 'status', 'tags', 'client', 'repo', 'homepage', 'links', - 'deployed_version', 'version_sources', 'forum_sources', 'pinned', 'notify', 'forge', 'forge_url', + 'deployed_version', 'version_sources', 'forum_sources', 'pinned', 'notify', 'forge', 'forge_url', 'github_scope', ] +// Item of Bugs & tasks already created from this issue / PR, if any. +const TRACKED = '(SELECT i.id FROM items i WHERE i.project_id = g.project_id AND i.issue_url = g.url LIMIT 1) AS item_id' + const LIST_SQL = ` SELECT p.*, c.name AS category_name, c.color AS category_color, (SELECT count(*) FROM items i WHERE i.project_id = p.id AND i.status IN ('todo', 'in_progress')) AS open_items, (SELECT count(*) FROM items i WHERE i.project_id = p.id AND i.status IN ('todo', 'in_progress') AND i.kind = 'bug') AS open_bugs, - (SELECT count(*) FROM github_items g WHERE g.project_id = p.id AND g.type = 'issue') AS open_issues, - (SELECT count(*) FROM github_items g WHERE g.project_id = p.id AND g.type = 'pr') AS open_prs, + (SELECT count(*) FROM github_items g WHERE g.project_id = p.id AND g.type = 'issue' AND ${VISIBLE_ITEM}) AS open_issues, + (SELECT count(*) FROM github_items g WHERE g.project_id = p.id AND g.type = 'pr' AND ${VISIBLE_ITEM}) AS open_prs, (SELECT count(*) FROM inbox x WHERE x.project_id = p.id AND x.read_at IS NULL) AS unread FROM projects p LEFT JOIN categories c ON c.id = p.category_id ` @@ -64,12 +68,14 @@ export default async function projectRoutes(app, { db }) { app.post('/projects', async (req, reply) => { if (!req.body?.name?.trim()) return reply.code(400).send({ error: tr('Le nom est obligatoire') }) const id = insertRow(db, 'projects', clean(req.body), FIELDS) + refreshMineOnly(db, id) return reply.code(201).send(listProjects(db, { where: ['p.id = ?'], params: [id] })[0]) }) app.put('/projects/:id', async (req, reply) => { const id = Number(req.params.id) if (!updateRow(db, 'projects', id, clean(req.body || {}), FIELDS)) return reply.code(404).send({ error: tr('Projet introuvable') }) + refreshMineOnly(db, id) return listProjects(db, { where: ['p.id = ?'], params: [id] })[0] }) @@ -86,7 +92,7 @@ export default async function projectRoutes(app, { db }) { // Open issues / PRs that concern the user, across every project. app.get('/my-github', async () => db.prepare( - `SELECT g.*, p.name AS project_name, p.forge FROM github_items g JOIN projects p ON p.id = g.project_id + `SELECT g.*, p.name AS project_name, p.forge, ${TRACKED} FROM github_items g JOIN projects p ON p.id = g.project_id WHERE g.mine != '[]' AND p.status != 'archived' ORDER BY g.updated_at DESC` ).all().map(decode)) @@ -97,7 +103,7 @@ export default async function projectRoutes(app, { db }) { if (project_id) { where.push('g.project_id = ?'); params.push(Number(project_id)) } if (type) { where.push('g.type = ?'); params.push(type) } return db.prepare( - `SELECT g.*, p.name AS project_name FROM github_items g JOIN projects p ON p.id = g.project_id + `SELECT g.*, p.name AS project_name, ${TRACKED} FROM github_items g JOIN projects p ON p.id = g.project_id WHERE ${where.join(' AND ')} ORDER BY g.updated_at DESC` ).all(...params).map(decode) }) diff --git a/server/src/sync/index.js b/server/src/sync/index.js index 19b07ab..08b8403 100644 --- a/server/src/sync/index.js +++ b/server/src/sync/index.js @@ -1,6 +1,7 @@ import { decode, getConfig } from '../db.js' import { failedRun, forgeFor, normalizeRepo } from './forges/index.js' import { involvement } from './forges/common.js' +import { computeMineOnly } from '../visibility.js' import { fetchForum, fetchTopic, forumSourceKey, latestVersion } from './sources.js' import { notifyProject } from './notify.js' import { tr } from '../i18n.js' @@ -53,6 +54,9 @@ async function syncForge(db, project, fresh, errors) { const login = await forge.whoami?.().catch(() => null) const mentioned = login && forge.fetchMentions ? await forge.fetchMentions(repo, login).catch(() => new Set()) : new Set() const mineOf = new Map(items.map((it) => [it, involvement(it, login, mentioned)])) + // Someone else's busy repository: only the user's own issues / PRs count. + info.viewer = login || project.github?.viewer || null + const mineOnly = computeMineOnly({ ...project, github: info }) // Review / CI / conflict state of the PRs that concern the user (capped: one // request set per PR). Fetched before the transaction, never fatal. const prStates = new Map() @@ -100,7 +104,7 @@ async function syncForge(db, project, fresh, errors) { if (mineOf.get(it).includes('author')) { pushPrTransitions(db, project, baseKey, it, previousStates.get(it.number), prStates.get(it.number), { asRead, fresh }) } - if (!existing.has(`${it.type}#${it.number}`)) { + if (!existing.has(`${it.type}#${it.number}`) && (!mineOnly || mineOf.get(it).length)) { pushInbox(db, { project_id: project.id, source, @@ -124,7 +128,7 @@ async function syncForge(db, project, fresh, errors) { } } closeLinkedItems(db, project, items, baseKey, { asRead, fresh }) - db.prepare('UPDATE projects SET github = ? WHERE id = ?').run(JSON.stringify(info), project.id) + db.prepare('UPDATE projects SET github = ?, mine_only = ? WHERE id = ?').run(JSON.stringify(info), mineOnly, project.id) markBaselined(db, baseKey) db.exec('COMMIT') } catch (err) { diff --git a/server/src/visibility.js b/server/src/visibility.js new file mode 100644 index 0000000..edc9736 --- /dev/null +++ b/server/src/visibility.js @@ -0,0 +1,26 @@ +import { normalizeRepo } from './sync/forges/index.js' + +// SQL condition on github_items g joined to projects p: on a project limited to +// the user's own issues / PRs, only those that concern them are counted. +export const VISIBLE_ITEM = "(p.mine_only = 0 OR g.mine != '[]')" + +// "auto": everything on the user's own repositories, only their own items on +// someone else's (e.g. GladysAssistant/Gladys); "all" / "mine" force it. +export function computeMineOnly(project) { + const scope = project.github_scope || 'auto' + if (scope === 'all') return 0 + if (scope === 'mine') return 1 + const viewer = project.github?.viewer + const owner = normalizeRepo(project.repo || '').split('/')[0] + return viewer && owner && owner.toLowerCase() !== viewer.toLowerCase() ? 1 : 0 +} + +export function refreshMineOnly(db, projectId) { + const row = db.prepare('SELECT repo, github, github_scope FROM projects WHERE id = ?').get(projectId) + if (!row) return 0 + let github = null + try { github = JSON.parse(row.github || 'null') } catch { /* keep null */ } + const value = computeMineOnly({ ...row, github }) + db.prepare('UPDATE projects SET mine_only = ? WHERE id = ?').run(value, projectId) + return value +} diff --git a/server/test/forges.test.js b/server/test/forges.test.js index b4d3ca2..9d9abfe 100644 --- a/server/test/forges.test.js +++ b/server/test/forges.test.js @@ -182,6 +182,25 @@ test('GitHub: flags the issues and PRs that concern the token owner', async () = const mine = Object.fromEntries(items.map((it) => [it.number, it.mine])) assert.deepEqual(mine, { 1: ['author'], 2: ['assignee'], 3: ['review'], 4: [], 5: ['mention'], 6: [], 7: ['author'] }) assert.deepEqual(items.find((it) => it.number === 2).assignees, ['will']) + + // org/big belongs to someone else: by default only the user's items are counted. + let project = (await call('GET', `/api/projects?q=Big`)).body.find((x) => x.id === p.id) + assert.equal(project.mine_only, true) + assert.deepEqual([project.open_issues, project.open_prs], [3, 2]) + const dash = (await call('GET', '/api/dashboard')).body + assert.ok(!dash.recent_prs.some((r) => r.project_id === p.id && r.number === 4), 'other people\'s PRs stay out of the dashboard') + project = (await call('PUT', `/api/projects/${p.id}`, { github_scope: 'all' })).body + assert.deepEqual([project.mine_only, project.open_issues, project.open_prs], [false, 4, 3]) + project = (await call('PUT', `/api/projects/${p.id}`, { github_scope: 'auto' })).body + assert.equal(project.mine_only, true) + + // A PR in conflict becomes a task with a custom title; tracking it again returns the same item. + const pr7 = items.find((it) => it.number === 7) + const { body: task } = await call('POST', `/api/github-items/${pr7.id}/track`, { title: 'Résoudre le conflit : #7', priority: 'high' }) + assert.deepEqual([task.title, task.kind, task.priority], ['Résoudre le conflit : #7', 'task', 'high']) + assert.equal((await call('POST', `/api/github-items/${pr7.id}/track`, {})).body.id, task.id) + const mineRow = (await call('GET', '/api/my-github')).body.find((it) => it.id === pr7.id) + assert.equal(mineRow.item_id, task.id) } finally { delete process.env.GITHUB_TOKEN } diff --git a/web/src/components/GithubPanel.jsx b/web/src/components/GithubPanel.jsx index 6fd172f..e9321bd 100644 --- a/web/src/components/GithubPanel.jsx +++ b/web/src/components/GithubPanel.jsx @@ -19,8 +19,10 @@ const MINE = { } const DEFAULT_FILTERS = { scope: 'all', labels: [], author: null, q: '' } -function readFilters(key) { - try { return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(key) || '{}') } } catch { return DEFAULT_FILTERS } +// Projects limited to the user's items open on "For me" until another view is chosen. +function readFilters(key, mineOnly) { + const defaults = { ...DEFAULT_FILTERS, scope: mineOnly ? 'mine' : 'all' } + try { return { ...defaults, ...JSON.parse(localStorage.getItem(key) || '{}') } } catch { return defaults } } // Open issues & PRs / MRs mirrored by the last sync. @@ -29,7 +31,7 @@ export default function GithubPanel({ projectId, project }) { const [type, setType] = useState('issue') // Filters are remembered per project: on a busy repository they are the default view. const storageKey = `projectory:gh-filters:${projectId || 'all'}` - const [filters, setFilters] = useState(() => readFilters(storageKey)) + const [filters, setFilters] = useState(() => readFilters(storageKey, project?.mine_only)) useEffect(() => { try { localStorage.setItem(storageKey, JSON.stringify({ ...filters, q: '' })) } catch { /* storage unavailable */ } }, [storageKey, filters]) @@ -115,7 +117,9 @@ export default function GithubPanel({ projectId, project }) { {it.comments} : null, - + - {t('ou colle / glisse une capture d’écran ici')} + {!preview && {t('ou colle / glisse une capture d’écran ici')}} + + diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 14f05f4..3205c98 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -68,6 +68,7 @@ export default { "Aucune échéance dépassée ni pour aujourd’hui": "Nothing overdue or due today", "Aujourd’hui": "Today", "Auteur": "Author", + "Auto : tout sur mes dépôts, les miennes ailleurs": "Auto: everything on my repositories, mine elsewhere", "Basse": "Low", "Bienvenue !": "Welcome!", "Bonjour,": "Hello,", @@ -123,6 +124,8 @@ export default { "Copier et ouvrir le sujet": "Copy and open the topic", "Copier le Markdown": "Copy Markdown", "Copié": "Copied", + "Corriger la CI : {ref}": "Fix the CI: {ref}", + "Corriger les retours de review : {ref}": "Address review feedback: {ref}", "Couleur": "Colour", "Critique": "Critical", "Créer l'issue sur le dépôt": "Create the issue on the repository", @@ -132,6 +135,8 @@ export default { "Créer la release sur {forge} ({repo})": "Create the release on {forge} ({repo})", "Créer un nouveau lien ? L’ancien cessera de fonctionner.": "Create a new link? The old one will stop working.", "Créer un projet": "Create a project", + "Créer une tâche": "Create a task", + "Créer une tâche : « {title} »": "Create a task: “{title}”", "Créé sur la branche {branch} s'il n'existe pas encore ; le changelog sert de description.": "Created on the {branch} branch if it does not exist yet; the changelog is used as description.", "C’est corrigé dans la version {v} ✅": "It is fixed in version {v} ✅", "C’est corrigé ✅": "It is fixed ✅", @@ -176,6 +181,7 @@ export default { "Exporter en JSON": "Export as JSON", "FR": "FR", "Facultatif : sinon la première ligne du texte sert de titre.": "Optional: otherwise the first line of the text is used as the title.", + "Faire la review : {ref}": "Review: {ref}", "Fermer": "Close", "Fermer sans enregistrer": "Close without saving", "Fermer sans enregistrer ?": "Close without saving?", @@ -218,6 +224,7 @@ export default { "Issue sur le dépôt": "Issue on the repository", "Issues et PR qui dorment": "Dormant issues and PRs", "Issues et PR qui te concernent, sur tous tes projets": "Issues and PRs that concern you, across all your projects", + "Issues et {prs} suivies": "Issues and {prs} followed", "Issues fermées": "Closed issues", "Issues fermées sur la période, de l’ouverture à la fermeture": "Issues closed over the period, from opening to closing", "Issues ouvertes": "Open issues", @@ -339,6 +346,7 @@ export default { "Projets": "Projects", "Projets actifs": "Active projects", "Pré-version": "Pre-release", + "Prévisualiser": "Preview", "Prévue": "Planned", "Publier": "Publish", "Publier {v}": "Publish {v}", @@ -371,10 +379,12 @@ export default { "Rien ne dort 👌": "Nothing dormant 👌", "Rien ne te concerne ici. Ces filtres utilisent le compte du token de la forge (Paramètres → Forges et tokens).": "Nothing for you here. These filters use the account of the forge token (Settings → Forges and tokens).", "Rien pour l’instant. Ces listes utilisent le compte du token de la forge (Paramètres → Forges et tokens) et se remplissent à la synchro.": "Nothing yet. These lists use the account of the forge token (Settings → Forges and tokens) and fill up on sync.", + "Rien à afficher": "Nothing to show", "Rien à suivre pour l'instant": "Nothing to track yet", "Réglages": "Settings", "Répondre": "Reply", "Répondre sur le forum": "Reply on the forum", + "Résoudre le conflit : {ref}": "Resolve the conflict: {ref}", "Résultats": "Results", "Résumé envoyé — vérifie ton téléphone": "Summary sent — check your phone", "Résumé hebdomadaire (ntfy)": "Weekly summary (ntfy)", @@ -384,6 +394,7 @@ export default { "Sauvegardes automatiques": "Automatic backups", "Semaine du": "Week of", "Serveur": "Server", + "Seulement celles qui me concernent": "Only those that concern me", "Seuls les messages qui te taguent (@pseudo) arrivent dans la boîte de réception.": "Only posts that tag you (@username) reach the inbox.", "Signalées au tableau de bord, dans l’onglet du dépôt et dans le résumé hebdomadaire.": "Shown on the dashboard, in the repository tab and in the weekly summary.", "Site / démo": "Website / demo", @@ -412,6 +423,7 @@ export default { "Supprimer le token": "Remove the token", "Supprimé": "Deleted", "Sur": "Release on", + "Sur un dépôt très actif (ex. Gladys), « Seulement celles qui me concernent » garde les compteurs, le tableau de bord et les notifications pour ce qui te concerne.": "On a very active repository (e.g. Gladys), “Only those that concern me” keeps counters, dashboard and notifications to what concerns you.", "Surveiller les releases": "Watch releases", "Surveiller une version": "Watch a version", "Synchro terminée avec des erreurs": "Sync finished with errors", @@ -457,11 +469,14 @@ export default { "Tout est à jour": "Everything is up to date", "Tout marquer comme lu": "Mark all as read", "Tout voir": "See all", + "Toutes": "All", "Toutes les données sont dans": "All data lives in", "Toutes sources": "All sources", "Transformer en bug / tâche": "Turn into a bug / task", "Type": "Type", "Tâche": "Task", + "Tâche créée": "Task created", + "Tâche créée dans {project}": "Task created in {project}", "Télécharger": "Download", "URL du flux RSS / Atom": "RSS / Atom feed URL", "URL du forum Discourse": "Discourse forum URL", diff --git a/web/src/pages/MyGithub.jsx b/web/src/pages/MyGithub.jsx index 886099f..e79c02b 100644 --- a/web/src/pages/MyGithub.jsx +++ b/web/src/pages/MyGithub.jsx @@ -1,7 +1,8 @@ import { Link } from 'react-router-dom' -import { Card, Empty, Flex, List, Spin, Tag, Typography } from 'antd' +import { App, Button, Card, Empty, Flex, List, Spin, Tag, Tooltip, Typography } from 'antd' +import { PlusSquareOutlined } from '@ant-design/icons' import dayjs from 'dayjs' -import { useApi } from '../api/client.js' +import { api, notifyChange, useApi } from '../api/client.js' import { refLabel } from '../components/labels.js' import PrStatus from '../components/PrStatus.jsx' import { t } from '../i18n/index.js' @@ -14,9 +15,36 @@ const SECTIONS = [ { key: 'issues', title: 'Mes issues', match: (it) => it.type === 'issue' && it.mine.includes('author'), empty: 'Aucune issue ouverte' }, ] +// Title of the task created from an item: what needs doing comes first. +function taskFor(it) { + const ref = `${refLabel(it.forge, it.type, it.number)} ${it.title}` + const st = it.type === 'pr' ? it.pr_state || {} : {} + if (st.conflict) return { title: t('Résoudre le conflit : {ref}', { ref }), priority: 'high' } + if (st.review === 'changes_requested') return { title: t('Corriger les retours de review : {ref}', { ref }), priority: 'high' } + if (st.ci === 'failure') return { title: t('Corriger la CI : {ref}', { ref }), priority: 'high' } + if (it.mine.includes('review')) return { title: t('Faire la review : {ref}', { ref }), priority: 'normal' } + return { title: ref, priority: 'normal' } +} + function Row({ it }) { + const { message } = App.useApp() + const createTask = async () => { + try { + await api.post(`/github-items/${it.id}/track`, { kind: 'task', ...taskFor(it) }) + message.success(t('Tâche créée dans {project}', { project: it.project_name })) + notifyChange() + } catch (err) { + message.error(err.message) + } + } return ( - + {t('Tâche créée')} + :