CLI/TUI for drafting, repeating, and publishing daily standup updates as GitHub issues
github go cli golang management project tui daily
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: rebrand standups as daily updates

+1153 -681
+3 -3
.github/ISSUE_TEMPLATE/async-daily.yml .github/ISSUE_TEMPLATE/daily-update.yml
··· 1 - name: Async Daily 2 - about: Daily async standup update 3 - title: "[Async Daily] [YYYY/MM/DD]" 1 + name: Daily Update 2 + about: Daily standup update 3 + title: "[Daily Update] [YYYY/MM/DD]" 4 4 labels: [] 5 5 body: 6 6 - type: markdown
-48
.github/scripts/close-individual-issues.js
··· 1 - /** 2 - * Close individual async daily issues after the report is generated 3 - * 4 - * @param {object} github - GitHub API client 5 - * @param {object} context - GitHub Actions context 6 - * @param {Array} issues - Array of issues to close 7 - * @param {string} reportIssueUrl - URL to the generated report issue 8 - * @returns {Promise<void>} 9 - */ 10 - async function closeIndividualIssues(github, context, issues, reportIssueUrl) { 11 - console.log(`Closing ${issues.length} individual async daily issues...`); 12 - 13 - const closeComment = `✅ This async daily report has been processed and included in the [Daily Report](${reportIssueUrl}).\n\nClosing this individual issue.`; 14 - 15 - let successCount = 0; 16 - let errorCount = 0; 17 - 18 - for (const issue of issues) { 19 - try { 20 - // Add a comment with the link to the report 21 - await github.rest.issues.createComment({ 22 - owner: context.repo.owner, 23 - repo: context.repo.repo, 24 - issue_number: issue.number, 25 - body: closeComment 26 - }); 27 - 28 - // Close the issue 29 - await github.rest.issues.update({ 30 - owner: context.repo.owner, 31 - repo: context.repo.repo, 32 - issue_number: issue.number, 33 - state: 'closed', 34 - state_reason: 'completed' 35 - }); 36 - 37 - console.log(`✓ Closed issue #${issue.number} by @${issue.user.login}`); 38 - successCount++; 39 - } catch (error) { 40 - console.error(`✗ Failed to close issue #${issue.number}:`, error.message); 41 - errorCount++; 42 - } 43 - } 44 - 45 - console.log(`\nSummary: ${successCount} issues closed successfully, ${errorCount} errors`); 46 - } 47 - 48 - module.exports = { closeIndividualIssues };
-55
.github/scripts/collect-asyncdaily-issues.js
··· 1 - /** 2 - * Collects async daily issues for a given date and returns the data 3 - * 4 - * @param {object} github - GitHub API client 5 - * @param {object} context - GitHub Actions context 6 - * @param {string} reportDate - Date string in YYYY-MM-DD format (optional) 7 - * @param {string} label - Label to filter issues (default: 'async-daily') 8 - * @returns {Promise<object>} Object containing asyncDailyIssues array and date information 9 - */ 10 - async function collectAsyncDailyIssues(github, context, reportDate, label = 'async-daily') { 11 - // Determine the date for the report 12 - let date; 13 - if (reportDate) { 14 - date = new Date(reportDate); 15 - } else { 16 - date = new Date(); 17 - } 18 - 19 - // Format date as YYYY-MM-DD 20 - const dateStr = date.toISOString().split('T')[0]; 21 - 22 - // Format date as YYYY/MM/DD for issue title matching 23 - const [year, month, day] = dateStr.split('-'); 24 - const titleDateStr = `${year}/${month}/${day}`; 25 - 26 - console.log(`Collecting async daily issues for: ${dateStr} (${titleDateStr})`); 27 - console.log(`Using label: ${label}`); 28 - 29 - // Get all issues with the specified label 30 - const issues = await github.rest.issues.listForRepo({ 31 - owner: context.repo.owner, 32 - repo: context.repo.repo, 33 - labels: label, 34 - state: 'all', 35 - sort: 'created', 36 - direction: 'desc', 37 - per_page: 100 38 - }); 39 - 40 - // Filter issues by title pattern only (allows creating issues in advance) 41 - const asyncDailyIssues = issues.data.filter(issue => { 42 - const titleMatch = issue.title.includes(titleDateStr); 43 - return titleMatch; 44 - }); 45 - 46 - console.log(`Found ${asyncDailyIssues.length} async daily issues`); 47 - 48 - return { 49 - asyncDailyIssues, 50 - dateStr, 51 - titleDateStr 52 - }; 53 - } 54 - 55 - module.exports = { collectAsyncDailyIssues };
-27
.github/scripts/create-report-issue.js
··· 1 - /** 2 - * Create a GitHub issue with the daily report 3 - * 4 - * @param {object} github - GitHub API client 5 - * @param {object} context - GitHub Actions context 6 - * @param {string} titleDateStr - Date string in YYYY/MM/DD format 7 - * @param {string} markdownBody - Report content in markdown format 8 - * @param {string} reportLabel - Label for report issues (default: 'async-daily/report') 9 - * @returns {Promise<object>} Created issue data 10 - */ 11 - async function createReportIssue(github, context, titleDateStr, markdownBody, reportLabel = 'async-daily/report') { 12 - console.log('Creating GitHub issue with the report...'); 13 - 14 - const reportIssue = await github.rest.issues.create({ 15 - owner: context.repo.owner, 16 - repo: context.repo.repo, 17 - title: `[Daily Report] ${titleDateStr}`, 18 - body: markdownBody, 19 - labels: [reportLabel] 20 - }); 21 - 22 - console.log(`Report issue created: #${reportIssue.data.number} - ${reportIssue.data.html_url}`); 23 - 24 - return reportIssue.data; 25 - } 26 - 27 - module.exports = { createReportIssue };
+491
.github/scripts/daily_update_workflow.py
··· 1 + from __future__ import annotations 2 + 3 + import json 4 + import os 5 + import re 6 + from dataclasses import dataclass 7 + from datetime import date, datetime, timedelta, timezone 8 + from html import unescape 9 + from typing import Any 10 + from urllib import error, parse, request 11 + 12 + 13 + API_BASE = "https://api.github.com" 14 + 15 + 16 + @dataclass 17 + class GitHubClient: 18 + token: str 19 + repository: str 20 + 21 + def __post_init__(self) -> None: 22 + parts = self.repository.split("/", 1) 23 + if len(parts) != 2 or not parts[0] or not parts[1]: 24 + raise ValueError(f"invalid GITHUB_REPOSITORY value: {self.repository!r}") 25 + self.owner = parts[0] 26 + self.repo = parts[1] 27 + 28 + def request_json( 29 + self, 30 + method: str, 31 + api_path: str, 32 + *, 33 + params: dict[str, Any] | None = None, 34 + body: dict[str, Any] | None = None, 35 + ) -> Any: 36 + url = API_BASE + api_path 37 + if params: 38 + query = parse.urlencode(params, doseq=True) 39 + if query: 40 + url += f"?{query}" 41 + 42 + headers = { 43 + "Accept": "application/vnd.github+json", 44 + "Authorization": f"Bearer {self.token}", 45 + "User-Agent": "pad-workflows", 46 + "X-GitHub-Api-Version": "2022-11-28", 47 + } 48 + 49 + payload = None 50 + if body is not None: 51 + payload = json.dumps(body).encode("utf-8") 52 + headers["Content-Type"] = "application/json" 53 + 54 + req = request.Request(url, data=payload, headers=headers, method=method) 55 + try: 56 + with request.urlopen(req) as response: 57 + content = response.read() 58 + except error.HTTPError as exc: 59 + detail = exc.read().decode("utf-8", errors="replace").strip() 60 + raise RuntimeError( 61 + f"GitHub API {method} {api_path} failed with HTTP {exc.code}: {detail}" 62 + ) from exc 63 + 64 + if not content: 65 + return None 66 + 67 + return json.loads(content) 68 + 69 + def paginate_json( 70 + self, api_path: str, *, params: dict[str, Any] | None = None 71 + ) -> list[dict[str, Any]]: 72 + page = 1 73 + items: list[dict[str, Any]] = [] 74 + base_params = dict(params or {}) 75 + 76 + while True: 77 + page_params = {**base_params, "per_page": 100, "page": page} 78 + batch = self.request_json("GET", api_path, params=page_params) 79 + if not batch: 80 + break 81 + if not isinstance(batch, list): 82 + raise RuntimeError( 83 + f"expected list response from {api_path}, got {type(batch).__name__}" 84 + ) 85 + 86 + items.extend(batch) 87 + if len(batch) < 100: 88 + break 89 + page += 1 90 + 91 + return items 92 + 93 + 94 + def require_env(name: str) -> str: 95 + value = os.getenv(name, "").strip() 96 + if not value: 97 + raise RuntimeError(f"missing required environment variable {name}") 98 + return value 99 + 100 + 101 + def resolve_report_date(raw: str) -> tuple[str, str]: 102 + resolved = parse_date(raw) if raw else datetime.now(timezone.utc).date() 103 + return resolved.strftime("%Y-%m-%d"), resolved.strftime("%Y/%m/%d") 104 + 105 + 106 + def resolve_template_date(raw: str) -> str: 107 + resolved = ( 108 + parse_date(raw) 109 + if raw 110 + else datetime.now(timezone.utc).date() + timedelta(days=1) 111 + ) 112 + return resolved.strftime("%Y/%m/%d") 113 + 114 + 115 + def parse_date(raw: str) -> date: 116 + trimmed = raw.strip() 117 + for layout in ("%Y-%m-%d", "%Y/%m/%d"): 118 + try: 119 + return datetime.strptime(trimmed, layout).date() 120 + except ValueError: 121 + continue 122 + 123 + raise RuntimeError(f"invalid date {raw!r}; use YYYY-MM-DD or YYYY/MM/DD") 124 + 125 + 126 + def collect_daily_update_issues( 127 + client: GitHubClient, 128 + report_date: str, 129 + label: str, 130 + ) -> tuple[list[dict[str, Any]], str, str]: 131 + date_str, title_date_str = resolve_report_date(report_date) 132 + print( 133 + f"Collecting member issues for {date_str} ({title_date_str}) with label {label!r}" 134 + ) 135 + 136 + issues = client.paginate_json( 137 + f"/repos/{client.owner}/{client.repo}/issues", 138 + params={ 139 + "labels": label, 140 + "state": "all", 141 + "sort": "created", 142 + "direction": "desc", 143 + }, 144 + ) 145 + 146 + filtered = [ 147 + issue 148 + for issue in issues 149 + if "pull_request" not in issue and title_date_str in issue.get("title", "") 150 + ] 151 + print(f"Found {len(filtered)} member issue(s)") 152 + return filtered, date_str, title_date_str 153 + 154 + 155 + def enrich_user_info( 156 + client: GitHubClient, issues: list[dict[str, Any]] 157 + ) -> list[dict[str, Any]]: 158 + cache: dict[str, dict[str, Any]] = {} 159 + enriched: list[dict[str, Any]] = [] 160 + 161 + for issue in issues: 162 + login = issue.get("user", {}).get("login", "") 163 + profile = cache.get(login) 164 + if profile is None and login: 165 + try: 166 + profile = client.request_json( 167 + "GET", f"/users/{parse.quote(login, safe='')}" 168 + ) 169 + except RuntimeError as exc: 170 + print(f"Warning: failed to fetch user info for {login}: {exc}") 171 + profile = {} 172 + cache[login] = profile 173 + 174 + user = dict(issue.get("user") or {}) 175 + user["display_name"] = (profile or {}).get("name") or login 176 + user["avatar_url"] = (profile or {}).get("avatar_url") or user.get( 177 + "avatar_url", "" 178 + ) 179 + 180 + enriched_issue = dict(issue) 181 + enriched_issue["user"] = user 182 + enriched.append(enriched_issue) 183 + 184 + return enriched 185 + 186 + 187 + def create_or_reuse_report_issue( 188 + client: GitHubClient, 189 + title_date_str: str, 190 + markdown_body: str, 191 + report_label: str, 192 + ) -> dict[str, Any]: 193 + title = f"[Daily Report] {title_date_str}" 194 + report_issues = client.paginate_json( 195 + f"/repos/{client.owner}/{client.repo}/issues", 196 + params={"labels": report_label, "state": "all"}, 197 + ) 198 + 199 + matches = [ 200 + issue 201 + for issue in report_issues 202 + if "pull_request" not in issue and issue.get("title") == title 203 + ] 204 + if matches: 205 + matches.sort(key=lambda issue: issue.get("number", 0), reverse=True) 206 + report_issue = matches[0] 207 + print( 208 + f"Reusing report issue #{report_issue['number']}: {report_issue['html_url']}" 209 + ) 210 + return report_issue 211 + 212 + created = client.request_json( 213 + "POST", 214 + f"/repos/{client.owner}/{client.repo}/issues", 215 + body={ 216 + "title": title, 217 + "body": markdown_body, 218 + "labels": [report_label], 219 + }, 220 + ) 221 + print(f"Created report issue #{created['number']}: {created['html_url']}") 222 + return created 223 + 224 + 225 + def update_issue_body(client: GitHubClient, issue_number: int, body: str) -> None: 226 + client.request_json( 227 + "PATCH", 228 + f"/repos/{client.owner}/{client.repo}/issues/{issue_number}", 229 + body={"body": body}, 230 + ) 231 + 232 + 233 + def close_individual_issues( 234 + client: GitHubClient, 235 + issues: list[dict[str, Any]], 236 + report_issue_url: str, 237 + ) -> tuple[int, int]: 238 + comment = ( 239 + f"Processed and included in the [Daily Report]({report_issue_url}).\n\n" 240 + "Closing this individual issue." 241 + ) 242 + 243 + success_count = 0 244 + error_count = 0 245 + for issue in issues: 246 + number = issue.get("number") 247 + login = issue.get("user", {}).get("login", "unknown") 248 + try: 249 + if not issue_comment_exists(client, number, report_issue_url): 250 + client.request_json( 251 + "POST", 252 + f"/repos/{client.owner}/{client.repo}/issues/{number}/comments", 253 + body={"body": comment}, 254 + ) 255 + 256 + if issue.get("state", "").lower() != "closed": 257 + client.request_json( 258 + "PATCH", 259 + f"/repos/{client.owner}/{client.repo}/issues/{number}", 260 + body={"state": "closed", "state_reason": "completed"}, 261 + ) 262 + 263 + print(f"Processed member issue #{number} by @{login}") 264 + success_count += 1 265 + except RuntimeError as exc: 266 + print(f"Warning: failed to process issue #{number}: {exc}") 267 + error_count += 1 268 + 269 + return success_count, error_count 270 + 271 + 272 + def issue_comment_exists( 273 + client: GitHubClient, issue_number: int, report_issue_url: str 274 + ) -> bool: 275 + comments = client.paginate_json( 276 + f"/repos/{client.owner}/{client.repo}/issues/{issue_number}/comments" 277 + ) 278 + for comment in comments: 279 + body = comment.get("body", "") 280 + if report_issue_url in body or report_issue_url in unescape(body): 281 + return True 282 + return False 283 + 284 + 285 + def parse_issue_body(body: str) -> dict[str, Any]: 286 + sections = { 287 + "yesterday": "", 288 + "today": "", 289 + "blockers": "", 290 + "parking_lot": False, 291 + "parking_lot_details": "", 292 + "additional_comments": "", 293 + } 294 + if not body: 295 + return sections 296 + 297 + sections["yesterday"] = extract_section( 298 + body, 299 + [ 300 + r"### ✅ What did you do yesterday\?\s*([\s\S]*?)(?=###|##|$)", 301 + r"## ✅ What did you do yesterday\?\s*([\s\S]*?)(?=##|$)", 302 + ], 303 + ) 304 + sections["today"] = extract_section( 305 + body, 306 + [ 307 + r"### 🎯 What will you do today\?\s*([\s\S]*?)(?=###|##|$)", 308 + r"## 🎯 What will you do today\?\s*([\s\S]*?)(?=##|$)", 309 + ], 310 + ) 311 + sections["blockers"] = extract_section( 312 + body, 313 + [ 314 + r"### 🚧 Any blockers\?\s*([\s\S]*?)(?=###|##|$)", 315 + r"## 🚧 Any blockers\?\s*([\s\S]*?)(?=##|$)", 316 + ], 317 + clean_empty=True, 318 + ) 319 + sections["parking_lot_details"] = extract_section( 320 + body, 321 + [ 322 + r"### 📝 Parking Lot Details\s*([\s\S]*?)(?=###|##|$)", 323 + r"## 📝 Parking Lot Details\s*([\s\S]*?)(?=##|$)", 324 + ], 325 + clean_empty=True, 326 + ) 327 + sections["additional_comments"] = extract_section( 328 + body, 329 + [ 330 + r"### 💬 Additional Comments\s*([\s\S]*?)(?=###|##|$)", 331 + r"## 💬 Additional Comments\s*([\s\S]*?)(?=##|$)", 332 + ], 333 + clean_empty=True, 334 + ) 335 + sections["parking_lot"] = bool( 336 + re.search(r"- \[x\].*Parking Lot", body, flags=re.IGNORECASE) 337 + or re.search(r"- ✅ Yes, I need a Parking Lot", body, flags=re.IGNORECASE) 338 + ) 339 + return sections 340 + 341 + 342 + def extract_section( 343 + body: str, patterns: list[str], *, clean_empty: bool = False 344 + ) -> str: 345 + for pattern in patterns: 346 + match = re.search(pattern, body, flags=re.MULTILINE) 347 + if not match: 348 + continue 349 + value = match.group(1).strip() 350 + return clean_empty_response(value) if clean_empty else value 351 + return "" 352 + 353 + 354 + def clean_empty_response(text: str) -> str: 355 + trimmed = text.strip() 356 + if trimmed in ("", "_No response_", "_None._") or trimmed.lower() == "none": 357 + return "" 358 + return trimmed 359 + 360 + 361 + def generate_reports( 362 + daily_issues: list[dict[str, Any]], 363 + title_date_str: str, 364 + client: GitHubClient, 365 + ) -> tuple[str, list[dict[str, Any]]]: 366 + parking_lot_items: list[dict[str, Any]] = [] 367 + markdown_body = generate_markdown_report( 368 + daily_issues, title_date_str, client, parking_lot_items 369 + ) 370 + return markdown_body, parking_lot_items 371 + 372 + 373 + def generate_markdown_report( 374 + daily_issues: list[dict[str, Any]], 375 + title_date_str: str, 376 + client: GitHubClient, 377 + parking_lot_items: list[dict[str, Any]], 378 + ) -> str: 379 + markdown_body = f"# Daily Update Summary - {title_date_str}\n\n" 380 + markdown_body += ( 381 + f"**Team Updates:** {len(daily_issues)} member(s) reported today\n\n" 382 + ) 383 + markdown_body += "---\n\n" 384 + 385 + for issue in daily_issues: 386 + author = issue.get("user", {}).get("login", "unknown") 387 + display_name = issue.get("user", {}).get("display_name") or author 388 + avatar_url = issue.get("user", {}).get("avatar_url") or issue.get( 389 + "user", {} 390 + ).get("avatar_url", "") 391 + issue_number = issue.get("number") 392 + issue_url = issue.get("html_url") 393 + sections = parse_issue_body(issue.get("body", "")) 394 + 395 + parking_flag = " 🚨 **PARKING LOT**" if sections["parking_lot"] else "" 396 + markdown_body += ( 397 + f'## <img src="{avatar_url}" width="24" height="24" ' 398 + f'style="border-radius: 50%; vertical-align: middle;"> ' 399 + f"{display_name} (@{author}){parking_flag} | #{issue_number}\n\n" 400 + ) 401 + 402 + markdown_body += "### ✅ What did you do yesterday?\n\n" 403 + markdown_body += section_or_default(sections["yesterday"]) 404 + markdown_body += "\n\n### 🎯 What will you do today?\n\n" 405 + markdown_body += section_or_default(sections["today"]) 406 + markdown_body += "\n\n" 407 + 408 + if sections["blockers"]: 409 + markdown_body += f"### 🚧 Any blockers?\n\n{sections['blockers']}\n\n" 410 + if sections["parking_lot_details"]: 411 + markdown_body += ( 412 + f"### 📝 Parking Lot Details\n\n{sections['parking_lot_details']}\n\n" 413 + ) 414 + if sections["additional_comments"]: 415 + markdown_body += ( 416 + f"### 💬 Additional Comments\n\n{sections['additional_comments']}\n\n" 417 + ) 418 + 419 + markdown_body += "---\n\n" 420 + 421 + if sections["parking_lot"]: 422 + parking_lot_items.append( 423 + { 424 + "author": author, 425 + "display_name": display_name, 426 + "avatar_url": avatar_url, 427 + "issue_url": issue_url, 428 + "issue_number": issue_number, 429 + } 430 + ) 431 + 432 + if parking_lot_items: 433 + markdown_body += f"## 🚨 Parking Lot Items ({len(parking_lot_items)})\n\n" 434 + markdown_body += ( 435 + "The following team members requested follow-up or escalation:\n\n" 436 + ) 437 + for item in parking_lot_items: 438 + markdown_body += ( 439 + f'- <img src="{item["avatar_url"]}" width="20" height="20" ' 440 + f'style="border-radius: 50%; vertical-align: middle;"> ' 441 + f"**{item['display_name']} (@{item['author']})** - " 442 + f"[Issue #{item['issue_number']}]({item['issue_url']})\n" 443 + ) 444 + markdown_body += "\n" 445 + 446 + markdown_body += "---\n\n" 447 + markdown_body += "_Generated automatically from GitHub issues._\n" 448 + markdown_body += f"_Repository: {client.owner}/{client.repo}_\n" 449 + return markdown_body 450 + 451 + 452 + def section_or_default(value: str) -> str: 453 + return value if value else "_No information provided_" 454 + 455 + 456 + def write_summary( 457 + summary_path: str, 458 + report_issue: dict[str, Any], 459 + parking_lot_items: list[dict[str, Any]], 460 + issues: list[dict[str, Any]], 461 + *, 462 + date_str: str, 463 + message: str | None = None, 464 + ) -> None: 465 + if not summary_path: 466 + return 467 + 468 + lines: list[str] = [] 469 + if message: 470 + lines.append(f"## {date_str}") 471 + lines.append("") 472 + lines.append(message) 473 + else: 474 + lines.append(f"👉 [**Full Daily Report**]({report_issue['html_url']})") 475 + lines.append("") 476 + if parking_lot_items: 477 + lines.append(f"Parking lot requests: **{len(parking_lot_items)}**") 478 + lines.append("") 479 + lines.append("## Team Members") 480 + lines.append("") 481 + for issue in issues: 482 + display_name = issue.get("user", {}).get("display_name") or issue.get( 483 + "user", {} 484 + ).get("login", "unknown") 485 + login = issue.get("user", {}).get("login", "unknown") 486 + lines.append( 487 + f"- {display_name} (@{login}) - [#{issue['number']}]({issue['html_url']})" 488 + ) 489 + 490 + with open(summary_path, "a", encoding="utf-8") as handle: 491 + handle.write("\n".join(lines).rstrip() + "\n")
-44
.github/scripts/enrich-user-info.js
··· 1 - /** 2 - * Enrich issues with user display names from GitHub API 3 - * 4 - * @param {object} github - GitHub API client 5 - * @param {Array} issues - Array of GitHub issues 6 - * @returns {Promise<Array>} Array of issues with enriched user information 7 - */ 8 - async function enrichUserInfo(github, issues) { 9 - const enrichedIssues = []; 10 - 11 - for (const issue of issues) { 12 - try { 13 - // Get user details from GitHub API 14 - const { data: user } = await github.rest.users.getByUsername({ 15 - username: issue.user.login 16 - }); 17 - 18 - // Add display name to the issue 19 - enrichedIssues.push({ 20 - ...issue, 21 - user: { 22 - ...issue.user, 23 - displayName: user.name || issue.user.login, 24 - avatarUrl: user.avatar_url 25 - } 26 - }); 27 - } catch (error) { 28 - console.warn(`Failed to fetch user info for ${issue.user.login}:`, error.message); 29 - // Fallback: use login as display name 30 - enrichedIssues.push({ 31 - ...issue, 32 - user: { 33 - ...issue.user, 34 - displayName: issue.user.login, 35 - avatarUrl: issue.user.avatar_url 36 - } 37 - }); 38 - } 39 - } 40 - 41 - return enrichedIssues; 42 - } 43 - 44 - module.exports = { enrichUserInfo };
-143
.github/scripts/generate-reports.js
··· 1 - const fs = require('fs'); 2 - const path = require('path'); 3 - const { parseIssueBody } = require('./parse-issue'); 4 - 5 - /** 6 - * Convert basic markdown to HTML 7 - * 8 - * @param {string} text - Markdown text 9 - * @returns {string} HTML text 10 - */ 11 - function markdownToHtml(text) { 12 - if (!text) return text; 13 - 14 - // Convert lists with nested support 15 - const lines = text.split('\n'); 16 - let html = ''; 17 - let listStack = []; 18 - 19 - for (let i = 0; i < lines.length; i++) { 20 - const line = lines[i]; 21 - const trimmedLine = line.trim(); 22 - 23 - const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/); 24 - 25 - if (listMatch) { 26 - const indent = listMatch[1].length; 27 - const listContent = listMatch[2]; 28 - const level = Math.floor(indent / 2); 29 - 30 - while (listStack.length > level + 1) { 31 - html += '</ul>\n'; 32 - listStack.pop(); 33 - } 34 - 35 - if (listStack.length === level) { 36 - html += '<ul>\n'; 37 - listStack.push(level); 38 - } 39 - 40 - html += `<li>${escapeHtml(listContent)}</li>\n`; 41 - } else { 42 - while (listStack.length > 0) { 43 - html += '</ul>\n'; 44 - listStack.pop(); 45 - } 46 - 47 - if (trimmedLine) { 48 - html += escapeHtml(line) + '\n'; 49 - } else { 50 - html += '\n'; 51 - } 52 - } 53 - } 54 - 55 - while (listStack.length > 0) { 56 - html += '</ul>\n'; 57 - listStack.pop(); 58 - } 59 - 60 - return html; 61 - } 62 - 63 - function escapeHtml(text) { 64 - return text 65 - .replace(/&/g, '&amp;') 66 - .replace(/</g, '&lt;') 67 - .replace(/>/g, '&gt;') 68 - .replace(/"/g, '&quot;') 69 - .replace(/'/g, '&#039;'); 70 - } 71 - 72 - function generateReports(dailyIssues, titleDateStr, context, reportIssueUrl = null) { 73 - const parkingLotItems = []; 74 - const markdownBody = generateMarkdownReport(dailyIssues, titleDateStr, context, parkingLotItems); 75 - 76 - return { 77 - markdownBody, 78 - parkingLotItems 79 - }; 80 - } 81 - 82 - function generateMarkdownReport(dailyIssues, titleDateStr, context, parkingLotItems) { 83 - let markdownBody = `# 📅 Async Daily Summary - ${titleDateStr}\n\n`; 84 - markdownBody += `**Team Updates:** ${dailyIssues.length} member(s) reported today\n\n`; 85 - markdownBody += `---\n\n`; 86 - 87 - for (const issue of dailyIssues) { 88 - const author = issue.user.login; 89 - const displayName = issue.user.displayName || author; 90 - const avatarUrl = issue.user.avatarUrl || issue.user.avatar_url; 91 - const issueNumber = issue.number; 92 - const issueUrl = issue.html_url; 93 - const sections = parseIssueBody(issue.body); 94 - 95 - markdownBody += `## <img src="${avatarUrl}" width="24" height="24" style="border-radius: 50%; vertical-align: middle;"> ${displayName} (@${author}) ${sections.parkingLot ? '🚨 **PARKING LOT**' : ''} | #${issueNumber}\n\n`; 96 - 97 - markdownBody += `### ✅ What did you do yesterday?\n\n`; 98 - markdownBody += sections.yesterday ? sections.yesterday + '\n\n' : '_No information provided_\n\n'; 99 - 100 - markdownBody += `### 🎯 What will you do today?\n\n`; 101 - markdownBody += sections.today ? sections.today + '\n\n' : '_No information provided_\n\n'; 102 - 103 - if (sections.blockers) { 104 - markdownBody += `### 🚧 Any blockers?\n\n`; 105 - markdownBody += sections.blockers + '\n\n'; 106 - } 107 - 108 - if (sections.parkingLotDetails) { 109 - markdownBody += `### 📝 Parking Lot Details\n\n`; 110 - markdownBody += sections.parkingLotDetails + '\n\n'; 111 - } 112 - 113 - if (sections.additionalComments) { 114 - markdownBody += `### 💬 Additional Comments\n\n`; 115 - markdownBody += sections.additionalComments + '\n\n'; 116 - } 117 - 118 - markdownBody += `---\n\n`; 119 - 120 - if (sections.parkingLot) { 121 - parkingLotItems.push({ author, displayName, avatarUrl, issueUrl, issueNumber }); 122 - } 123 - } 124 - 125 - if (parkingLotItems.length > 0) { 126 - markdownBody += `## 🚨 Parking Lot Items (${parkingLotItems.length})\n\n`; 127 - markdownBody += `The following team members have requested a parking lot discussion or escalation:\n\n`; 128 - 129 - for (const item of parkingLotItems) { 130 - markdownBody += `- <img src="${item.avatarUrl}" width="20" height="20" style="border-radius: 50%; vertical-align: middle;"> **${item.displayName} (@${item.author})** - [Issue #${item.issueNumber}](${item.issueUrl})\n`; 131 - } 132 - 133 - markdownBody += `\n`; 134 - } 135 - 136 - markdownBody += `---\n\n`; 137 - markdownBody += `_This is an automated async daily report generated from GitHub issues._\n`; 138 - markdownBody += `_Repository: ${context.repo.owner}/${context.repo.repo}_\n`; 139 - 140 - return markdownBody; 141 - } 142 - 143 - module.exports = { generateReports };
-54
.github/scripts/parse-issue.js
··· 1 - /** 2 - * Parse issue body to extract async daily information 3 - * 4 - * @param {string} body - The issue body text 5 - * @returns {object} Object containing yesterday, today, blockers, parkingLotDetails, additionalComments sections and parkingLot flag 6 - */ 7 - function parseIssueBody(body) { 8 - const sections = { 9 - yesterday: '', 10 - today: '', 11 - blockers: '', 12 - parkingLot: false, 13 - parkingLotDetails: '', 14 - additionalComments: '' 15 - }; 16 - 17 - if (!body) return sections; 18 - 19 - // Helper function to clean empty responses 20 - const cleanEmptyResponse = (text) => { 21 - if (!text) return ''; 22 - const trimmed = text.trim(); 23 - if (trimmed === '_No response_' || trimmed.toLowerCase() === 'none' || trimmed === '_None._') { 24 - return ''; 25 - } 26 - return trimmed; 27 - }; 28 - 29 - // Handle both GitHub form format (###) and rendered markdown (##) 30 - const yesterdayMatch = body.match(/### ✅ What did you do yesterday\?\s*([\s\S]*?)(?=###|##|$)/) || 31 - body.match(/## ✅ What did you do yesterday\?\s*([\s\S]*?)(?=##|$)/); 32 - const todayMatch = body.match(/### 🎯 What will you do today\?\s*([\s\S]*?)(?=###|##|$)/) || 33 - body.match(/## 🎯 What will you do today\?\s*([\s\S]*?)(?=##|$)/); 34 - const blockersMatch = body.match(/### 🚧 Any blockers\?\s*([\s\S]*?)(?=###|##|$)/) || 35 - body.match(/## 🚧 Any blockers\?\s*([\s\S]*?)(?=##|$)/); 36 - const parkingLotMatch = body.match(/- \[x\].*Parking Lot/i) || 37 - body.match(/- ✅ Yes, I need a Parking Lot/i); 38 - const parkingLotDetailsMatch = body.match(/### 📝 Parking Lot Details\s*([\s\S]*?)(?=###|##|$)/) || 39 - body.match(/## 📝 Parking Lot Details\s*([\s\S]*?)(?=##|$)/); 40 - const additionalCommentsMatch = body.match(/### 💬 Additional Comments\s*([\s\S]*?)(?=###|##|$)/) || 41 - body.match(/## 💬 Additional Comments\s*([\s\S]*?)(?=##|$)/); 42 - 43 - if (yesterdayMatch) sections.yesterday = yesterdayMatch[1].trim(); 44 - if (todayMatch) sections.today = todayMatch[1].trim(); 45 - if (blockersMatch) sections.blockers = cleanEmptyResponse(blockersMatch[1]); 46 - if (parkingLotDetailsMatch) sections.parkingLotDetails = cleanEmptyResponse(parkingLotDetailsMatch[1]); 47 - if (additionalCommentsMatch) sections.additionalComments = cleanEmptyResponse(additionalCommentsMatch[1]); 48 - 49 - sections.parkingLot = !!parkingLotMatch; 50 - 51 - return sections; 52 - } 53 - 54 - module.exports = { parseIssueBody };
+64
.github/scripts/publish_team_digest.py
··· 1 + from __future__ import annotations 2 + 3 + import os 4 + import sys 5 + 6 + from daily_update_workflow import ( 7 + GitHubClient, 8 + close_individual_issues, 9 + collect_daily_update_issues, 10 + create_or_reuse_report_issue, 11 + enrich_user_info, 12 + generate_reports, 13 + require_env, 14 + update_issue_body, 15 + write_summary, 16 + ) 17 + 18 + 19 + def main() -> int: 20 + client = GitHubClient(require_env("GITHUB_TOKEN"), require_env("GITHUB_REPOSITORY")) 21 + report_date = os.getenv("REPORT_DATE", "") 22 + daily_label = ( 23 + os.getenv("DAILY_UPDATE_LABEL", "daily-update").strip() or "daily-update" 24 + ) 25 + report_label = ( 26 + os.getenv("DAILY_REPORT_LABEL", "daily-update/report").strip() 27 + or "daily-update/report" 28 + ) 29 + summary_path = os.getenv("GITHUB_STEP_SUMMARY", "") 30 + 31 + issues, date_str, title_date_str = collect_daily_update_issues( 32 + client, report_date, daily_label 33 + ) 34 + if not issues: 35 + message = f"No daily update issues found for {date_str}." 36 + print(message) 37 + write_summary(summary_path, {}, [], [], date_str=date_str, message=message) 38 + return 0 39 + 40 + issues = enrich_user_info(client, issues) 41 + issues.sort(key=lambda issue: issue.get("user", {}).get("login", "").lower()) 42 + 43 + report_issue = create_or_reuse_report_issue( 44 + client, title_date_str, "Generating report...", report_label 45 + ) 46 + markdown_body, parking_lot_items = generate_reports(issues, title_date_str, client) 47 + update_issue_body(client, report_issue["number"], markdown_body) 48 + _, error_count = close_individual_issues(client, issues, report_issue["html_url"]) 49 + write_summary( 50 + summary_path, report_issue, parking_lot_items, issues, date_str=date_str 51 + ) 52 + 53 + print(report_issue["html_url"]) 54 + if error_count: 55 + print(f"Completed with {error_count} member issue error(s)") 56 + return 0 57 + 58 + 59 + if __name__ == "__main__": 60 + try: 61 + raise SystemExit(main()) 62 + except Exception as exc: # pragma: no cover 63 + print(f"Error: {exc}", file=sys.stderr) 64 + raise SystemExit(1) from exc
+47
.github/scripts/refresh_issue_template.py
··· 1 + from __future__ import annotations 2 + 3 + import os 4 + import re 5 + import sys 6 + 7 + from daily_update_workflow import resolve_template_date 8 + 9 + 10 + def main() -> int: 11 + template_path = os.getenv( 12 + "ISSUE_TEMPLATE_PATH", ".github/ISSUE_TEMPLATE/daily-update.yml" 13 + ).strip() 14 + if not template_path: 15 + raise RuntimeError("ISSUE_TEMPLATE_PATH cannot be empty") 16 + 17 + if not os.path.exists(template_path): 18 + print(f"Skipping template refresh; {template_path} does not exist") 19 + return 0 20 + 21 + target_date = resolve_template_date(os.getenv("TEMPLATE_DATE", "")) 22 + with open(template_path, "r", encoding="utf-8") as handle: 23 + content = handle.read() 24 + 25 + updated, replacements = re.subn( 26 + r"^title:\s*.*$", 27 + f'title: "[Daily Update] [{target_date}]"', 28 + content, 29 + count=1, 30 + flags=re.MULTILINE, 31 + ) 32 + if replacements == 0: 33 + raise RuntimeError(f"could not find title line in {template_path}") 34 + 35 + with open(template_path, "w", encoding="utf-8") as handle: 36 + handle.write(updated) 37 + 38 + print(f"Updated {template_path} to {target_date}") 39 + return 0 40 + 41 + 42 + if __name__ == "__main__": 43 + try: 44 + raise SystemExit(main()) 45 + except Exception as exc: # pragma: no cover 46 + print(f"Error: {exc}", file=sys.stderr) 47 + raise SystemExit(1) from exc
-111
.github/workflows/asyncdaily-reporter.yml.example
··· 1 - name: Async Daily Reporter 2 - 3 - on: 4 - # Run every weekday at 10:00 AM UTC (adjust timezone as needed) 5 - schedule: 6 - - cron: '45 10 * * 1-5' 7 - 8 - # Allow manual execution with date parameter 9 - workflow_dispatch: 10 - inputs: 11 - report_date: 12 - description: 'Date for the report (YYYY-MM-DD). Leave empty for today.' 13 - required: false 14 - type: string 15 - 16 - jobs: 17 - generate-asyncdaily-report: 18 - runs-on: ubuntu-24.04 19 - steps: 20 - - name: Checkout repository 21 - uses: actions/checkout@v4 22 - 23 - - name: Collect async daily issues and send report 24 - id: generate_report 25 - uses: actions/github-script@v7 26 - with: 27 - github-token: ${{ secrets.GITHUB_TOKEN }} 28 - script: | 29 - const { collectAsyncDailyIssues } = require('./.github/scripts/collect-asyncdaily-issues.js'); 30 - const { enrichUserInfo } = require('./.github/scripts/enrich-user-info.js'); 31 - const { generateReports } = require('./.github/scripts/generate-reports.js'); 32 - const { createReportIssue } = require('./.github/scripts/create-report-issue.js'); 33 - const { closeIndividualIssues } = require('./.github/scripts/close-individual-issues.js'); 34 - 35 - // Collect async daily issues 36 - const reportDate = context.payload.inputs?.report_date; 37 - const { asyncDailyIssues, dateStr, titleDateStr } = await collectAsyncDailyIssues(github, context, reportDate, '${{ vars.ASYNC_DAILY_LABEL || ''async-daily'' }}'); 38 - 39 - if (asyncDailyIssues.length === 0) { 40 - console.log('No async daily issues found for this date. Skipping report.'); 41 - core.setOutput('has_issues', 'false'); 42 - return; 43 - } 44 - 45 - core.setOutput('has_issues', 'true'); 46 - 47 - // Enrich issues with user display names 48 - let enrichedIssues = await enrichUserInfo(github, asyncDailyIssues); 49 - 50 - // Sort issues alphabetically by user handle to ensure predictable order 51 - enrichedIssues = enrichedIssues.sort((a, b) => { 52 - return a.user.login.localeCompare(b.user.login); 53 - }); 54 - 55 - // Create report issue first to get its URL 56 - const reportIssue = await createReportIssue(github, context, titleDateStr, 'Generating report...', '${{ vars.ASYNC_DAILY_REPORT_LABEL || ''async-daily/report'' }}'); 57 - 58 - // Generate reports with the report issue URL 59 - const { markdownBody, parkingLotItems } = generateReports(enrichedIssues, titleDateStr, context, reportIssue.html_url); 60 - 61 - // Update the report issue with the actual markdown content 62 - await github.rest.issues.update({ 63 - owner: context.repo.owner, 64 - repo: context.repo.repo, 65 - issue_number: reportIssue.number, 66 - body: markdownBody 67 - }); 68 - 69 - console.log('Report issue updated with final content'); 70 - 71 - // Close individual issues after the report is generated 72 - await closeIndividualIssues(github, context, enrichedIssues, reportIssue.html_url); 73 - 74 - core.setOutput('report_issue_number', reportIssue.number); 75 - core.setOutput('report_issue_url', reportIssue.html_url); 76 - 77 - // Generate summary content 78 - let summaryContent = `👉 [**Full Daily Report**](${reportIssue.html_url}) 👈\n\n`; 79 - 80 - if (parkingLotItems.length > 0) { 81 - summaryContent += `🚨 **Parking lot requests:** ${parkingLotItems.length}\n\n`; 82 - } 83 - 84 - summaryContent += `---\n\n`; 85 - summaryContent += `## Team Members\n\n`; 86 - 87 - for (const issue of enrichedIssues) { 88 - const displayName = issue.user.displayName || issue.user.login; 89 - summaryContent += `- ${displayName} (@${issue.user.login}) - [#${issue.number}](${issue.html_url})\n`; 90 - } 91 - 92 - // Write summary to file 93 - const fs = require('fs'); 94 - const summaryPath = `${process.env.GITHUB_WORKSPACE}/asyncdaily-summary.md`; 95 - fs.writeFileSync(summaryPath, summaryContent); 96 - console.log(`Summary written to: ${summaryPath}`); 97 - 98 - - name: Publish summary 99 - if: steps.generate_report.outputs.has_issues == 'true' 100 - run: | 101 - cat asyncdaily-summary.md >> "$GITHUB_STEP_SUMMARY" 102 - 103 - - name: Set report today date 104 - if: steps.generate_report.outputs.has_issues == 'true' && github.event.inputs.report_date == '' 105 - id: set_date 106 - run: | 107 - if [ -z "${{ github.event.inputs.report_date }}" ]; then 108 - echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT 109 - else 110 - echo "date=${{ github.event.inputs.report_date }}" >> $GITHUB_OUTPUT 111 - fi
-61
.github/workflows/asyncdaily-updater.yml.example
··· 1 - name: Async Daily Template Updater 2 - 3 - on: 4 - # Run every day at 12:00 PM UTC 5 - schedule: 6 - - cron: '0 12 * * *' 7 - 8 - # Allow manual execution for testing 9 - workflow_dispatch: 10 - 11 - permissions: 12 - contents: write 13 - 14 - jobs: 15 - update-template-date: 16 - runs-on: ubuntu-24.04 17 - steps: 18 - - name: Checkout repository 19 - uses: actions/checkout@v4 20 - 21 - - name: Update template date 22 - run: | 23 - # Get tomorrow's date in YYYY/MM/DD format 24 - TOMORROW=$(date -d '+1 day' +"%Y/%m/%d") 25 - echo "TOMORROW=$TOMORROW" >> $GITHUB_ENV 26 - 27 - echo "Updating template date to: $TOMORROW" 28 - 29 - # Path to the template file 30 - TEMPLATE_FILE=".github/ISSUE_TEMPLATE/async-daily.yml" 31 - 32 - # Check if template exists 33 - if [ ! -f "$TEMPLATE_FILE" ]; then 34 - echo "Template file not found: $TEMPLATE_FILE" 35 - exit 0 36 - fi 37 - 38 - # Update the title line with tomorrow's date 39 - # Using sed to replace the date in the title line 40 - sed -i "s|title: '\[Async Daily\] \[.*\]'|title: '[Async Daily] [$TOMORROW]'|g" "$TEMPLATE_FILE" 41 - 42 - echo "Template updated successfully" 43 - cat "$TEMPLATE_FILE" | grep "title:" || echo "No title line found" 44 - 45 - - name: Commit and push changes 46 - run: | 47 - # Check if there are changes 48 - if git diff --quiet; then 49 - echo "No changes to commit" 50 - exit 0 51 - fi 52 - 53 - git config user.name "GitHub Action" 54 - git config user.email "actions@github.com" 55 - 56 - # Stage, commit and push 57 - git add .github/ISSUE_TEMPLATE/async-daily.yml 58 - git commit -m "chore: update async daily template date to $TOMORROW" 59 - git push origin ${{ github.ref_name }} 60 - 61 - echo "Changes pushed successfully"
+42
.github/workflows/publish-team-digest.yml.example
··· 1 + name: Publish Team Digest 2 + 3 + on: 4 + schedule: 5 + - cron: '45 10 * * 1-5' 6 + 7 + workflow_dispatch: 8 + inputs: 9 + report_date: 10 + description: 'Report date in YYYY-MM-DD. Leave empty to use today in UTC.' 11 + required: false 12 + type: string 13 + 14 + permissions: 15 + contents: read 16 + issues: write 17 + 18 + concurrency: 19 + group: team-digest-${{ github.ref }} 20 + cancel-in-progress: false 21 + 22 + jobs: 23 + publish-team-digest: 24 + runs-on: ubuntu-24.04 25 + steps: 26 + - name: Check out repository 27 + uses: actions/checkout@v4 28 + 29 + - name: Set up Python 30 + uses: actions/setup-python@v5 31 + with: 32 + python-version: '3.12' 33 + 34 + - name: Publish team digest 35 + env: 36 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 + GITHUB_REPOSITORY: ${{ github.repository }} 38 + REPORT_DATE: ${{ github.event.inputs.report_date || '' }} 39 + DAILY_UPDATE_LABEL: ${{ vars.DAILY_UPDATE_LABEL || 'daily-update' }} 40 + DAILY_REPORT_LABEL: ${{ vars.DAILY_REPORT_LABEL || 'daily-update/report' }} 41 + run: | 42 + python3 .github/scripts/publish_team_digest.py
+48
.github/workflows/refresh-daily-update-template.yml.example
··· 1 + name: Refresh Issue Template Date 2 + 3 + on: 4 + schedule: 5 + - cron: '0 12 * * *' 6 + 7 + workflow_dispatch: 8 + inputs: 9 + template_date: 10 + description: 'Template date in YYYY-MM-DD or YYYY/MM/DD. Leave empty to use tomorrow in UTC.' 11 + required: false 12 + type: string 13 + 14 + permissions: 15 + contents: write 16 + 17 + jobs: 18 + refresh-issue-template-date: 19 + runs-on: ubuntu-24.04 20 + steps: 21 + - name: Check out repository 22 + uses: actions/checkout@v4 23 + 24 + - name: Set up Python 25 + uses: actions/setup-python@v5 26 + with: 27 + python-version: '3.12' 28 + 29 + - name: Refresh issue template date 30 + env: 31 + ISSUE_TEMPLATE_PATH: .github/ISSUE_TEMPLATE/daily-update.yml 32 + TEMPLATE_DATE: ${{ github.event.inputs.template_date || '' }} 33 + run: | 34 + python3 .github/scripts/refresh_issue_template.py 35 + 36 + - name: Commit template update 37 + run: | 38 + if git diff --quiet; then 39 + echo "No template changes to commit" 40 + exit 0 41 + fi 42 + 43 + git config user.name "GitHub Action" 44 + git config user.email "actions@github.com" 45 + 46 + git add .github/ISSUE_TEMPLATE/daily-update.yml 47 + git commit -m "chore: refresh daily update template date" 48 + git push origin "${{ github.ref_name }}"
+1 -1
.github/workflows/release.yml
··· 49 49 if: steps.check_branch.outputs.on_main == 'false' 50 50 run: | 51 51 mkdir -p dist 52 - GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${{ github.ref_name }}-snapshot" -o dist/pad . 52 + GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.buildVersion=${{ github.ref_name }}-snapshot" -o dist/pad . 53 53 54 54 - name: Upload snapshot artifacts 55 55 if: steps.check_branch.outputs.on_main == 'false'
+3 -3
AGENTS.md
··· 1 - This repo contains `pad`, a Go CLI/TUI that helps teams draft, repeat, review, and publish async daily standup updates as GitHub issues with less manual copy/paste. 1 + This repo contains `pad`, a Go CLI/TUI that helps teams draft, repeat, review, and publish daily standup updates as GitHub issues with less manual copy/paste. 2 2 3 3 ## Stack 4 4 ··· 6 6 - CLI: `github.com/spf13/cobra` 7 7 - Simple terminal UI: `github.com/charmbracelet/bubbletea` 8 8 - GitHub integration for the first iterations: `gh` CLI, so the app can reuse the user's existing GitHub auth 9 - - Local storage: config only in the user's config directory; async daily history is read from GitHub 9 + - Local storage: config only in the user's config directory; daily update history is read from GitHub 10 10 - Database: none for now; only introduce SQLite if plain files become a real limitation 11 11 12 12 ## Release Versioning ··· 28 28 - 2026-04-16: Initial GitHub issue creation goes through `gh` instead of direct API calls so setup stays simple and users can rely on existing GitHub authentication. 29 29 - 2026-04-16: Go's `os.UserConfigDir()` does not honor `XDG_CONFIG_HOME` on macOS. Keep `pad` on explicit env-aware path helpers so tests and local overrides can isolate config/data cleanly. 30 30 - 2026-04-16: `gh` authentication depends on its normal config home. If tests or wrappers override `XDG_CONFIG_HOME`, remote commands like `pad list` and remote `pad show` will not see existing `gh auth login` state unless that config is also made available. 31 - - 2026-04-16: `pad repeat` now pre-fills from the latest GitHub async-daily issue by the authenticated user instead of reading local JSON entries. 31 + - 2026-04-16: `pad repeat` now pre-fills from the latest GitHub daily update issue by the authenticated user instead of reading local JSON entries.
+13 -13
README.md
··· 4 4 5 5 # pad 6 6 7 - `pad` is a small CLI with a simple terminal UI that helps teams draft and publish async daily standup updates without repeating the same manual steps every day. 7 + `pad` is a small CLI with a simple terminal UI that helps teams draft and publish daily standup updates without repeating the same manual steps every day. 8 8 9 9 Current first iteration focuses on the fastest useful workflow: 10 10 11 11 - open a split editor with live preview directly from `pad create` 12 - - prefill from your latest GitHub async daily issue with `pad repeat` 13 - - list your already-published async dailies directly from GitHub with `pad list` 12 + - prefill from your latest GitHub daily update issue with `pad repeat` 13 + - list your already-published daily updates directly from GitHub with `pad list` 14 14 - read the merged team report issue with `pad report` 15 15 - preview the rendered GitHub issue body with `pad show` or `pad create --dry-run` 16 16 - create the GitHub issue in your configured repository with `pad create` ··· 78 78 79 79 Copy the issue template from this repository: 80 80 81 - - [`.github/ISSUE_TEMPLATE/async-daily.yml`](.github/ISSUE_TEMPLATE/async-daily.yml) 81 + - [`.github/ISSUE_TEMPLATE/daily-update.yml`](.github/ISSUE_TEMPLATE/daily-update.yml) 82 82 83 - Place it in your repository at the same path. This template defines the structure that `pad` expects when parsing and creating issues. 83 + Place it in your repository at the same path. This template matches the structure that `pad` currently expects when parsing and creating issues. 84 84 85 85 ### 3. (Optional) Set Up Automated Workflows 86 86 87 87 This repository includes ready-to-use GitHub Actions workflows: 88 88 89 - **Async Daily Reporter** (`.github/workflows/asyncdaily-reporter.yml.example` → rename to `.yml`) 89 + **Publish Team Digest** (`.github/workflows/publish-team-digest.yml.example` → rename to `.yml`) 90 90 - Collects all daily issues from team members 91 91 - Generates a merged report with parking lot items highlighted 92 92 - Closes individual issues after including them in the report 93 93 - Runs weekdays at 10:45 AM UTC (customize the cron schedule as needed) 94 94 95 - **Async Daily Template Updater** (`.github/workflows/asyncdaily-updater.yml.example` → rename to `.yml`) 95 + **Refresh Daily Update Template** (`.github/workflows/refresh-daily-update-template.yml.example` → rename to `.yml`) 96 96 - Automatically updates the issue template date to tomorrow 97 97 - Runs daily at 12:00 PM UTC 98 98 99 99 To use these workflows: 100 100 101 101 1. Copy the workflow files from `.github/workflows/` to your repository (remove `.example` extension) 102 - 2. Copy the scripts from `.github/scripts/` to your repository 102 + 2. Copy the Python helper scripts from `.github/scripts/` to your repository 103 103 3. Set up repository variables (optional): 104 - - `ASYNC_DAILY_LABEL`: Label for individual daily issues (default: `async-daily`) 105 - - `ASYNC_DAILY_REPORT_LABEL`: Label for report issues (default: `async-daily/report`) 104 + - `DAILY_UPDATE_LABEL`: Label for individual updates (default: `daily-update`) 105 + - `DAILY_REPORT_LABEL`: Label for report issues (default: `daily-update/report`) 106 106 4. The reporter workflow uses `GITHUB_TOKEN` which is automatically available 107 107 108 108 The report issue title follows this format: `[Daily Report] YYYY/MM/DD` ··· 182 182 183 183 ## Main Usage 184 184 185 - Open the async daily editor for today. The left pane shows the template fields, the right pane shows a live preview, and `pad` asks for confirmation before publishing: 185 + Open the daily update editor for today. The left pane shows the template fields, the right pane shows a live preview, and `pad` asks for confirmation before publishing: 186 186 187 187 ```bash 188 188 ./pad create 189 189 ``` 190 190 191 - Repeat from your latest GitHub async daily issue into today's editor and create a new issue: 191 + Repeat from your latest GitHub daily update issue into today's editor and create a new issue: 192 192 193 193 ```bash 194 194 ./pad repeat ··· 220 220 ./pad create --dry-run 221 221 ``` 222 222 223 - List your async daily issues from GitHub: 223 + List your daily update issues from GitHub: 224 224 225 225 ```bash 226 226 ./pad list
+3 -3
cmd/create.go
··· 5 5 "errors" 6 6 "fmt" 7 7 8 - "github.com/prefapp/pad/internal/daily" 9 - "github.com/prefapp/pad/internal/tui" 10 8 "github.com/spf13/cobra" 9 + "github.com/vieitesss/pad/internal/daily" 10 + "github.com/vieitesss/pad/internal/tui" 11 11 ) 12 12 13 13 func newCreateCmd() *cobra.Command { ··· 16 16 17 17 cmd := &cobra.Command{ 18 18 Use: "create", 19 - Short: "Open the async daily editor and create the GitHub issue", 19 + Short: "Open the daily update editor and create the GitHub issue", 20 20 RunE: func(cmd *cobra.Command, _ []string) error { 21 21 env, err := loadEnv() 22 22 if err != nil {
+3 -3
cmd/init.go
··· 3 3 import ( 4 4 "fmt" 5 5 6 - "github.com/prefapp/pad/internal/appfs" 7 - "github.com/prefapp/pad/internal/config" 8 6 "github.com/spf13/cobra" 7 + "github.com/vieitesss/pad/internal/appfs" 8 + "github.com/vieitesss/pad/internal/config" 9 9 ) 10 10 11 11 func newInitCmd() *cobra.Command { ··· 40 40 }, 41 41 } 42 42 43 - cmd.Flags().StringVar(&repo, "repo", "", "GitHub repository for async daily issues (required)") 43 + cmd.Flags().StringVar(&repo, "repo", "", "GitHub repository for daily update issues (required)") 44 44 cmd.Flags().StringSliceVar(&labels, "labels", nil, "Labels to apply when creating issues (can be specified multiple times)") 45 45 46 46 return cmd
+3 -3
cmd/list.go
··· 13 13 14 14 cmd := &cobra.Command{ 15 15 Use: "list", 16 - Short: "List async daily issues created by you", 16 + Short: "List daily update issues created by you", 17 17 RunE: func(cmd *cobra.Command, _ []string) error { 18 18 env, err := loadEnv() 19 19 if err != nil { ··· 25 25 return err 26 26 } 27 27 28 - issues, err := env.gh.ListAsyncDailyIssues(ctx, env.cfg.GitHubRepo, env.cfg.Labels, limit) 28 + issues, err := env.gh.ListDailyUpdateIssues(ctx, env.cfg.GitHubRepo, env.cfg.Labels, limit) 29 29 if err != nil { 30 30 return err 31 31 } 32 32 33 33 if len(issues) == 0 { 34 - fmt.Fprintln(cmd.OutOrStdout(), "no remote async daily issues found for the authenticated user") 34 + fmt.Fprintln(cmd.OutOrStdout(), "no remote daily update issues found for the authenticated user") 35 35 return nil 36 36 } 37 37
+6 -6
cmd/repeat.go
··· 5 5 "errors" 6 6 "fmt" 7 7 8 - "github.com/prefapp/pad/internal/daily" 9 - "github.com/prefapp/pad/internal/ghcli" 10 - "github.com/prefapp/pad/internal/tui" 11 8 "github.com/spf13/cobra" 9 + "github.com/vieitesss/pad/internal/daily" 10 + "github.com/vieitesss/pad/internal/ghcli" 11 + "github.com/vieitesss/pad/internal/tui" 12 12 ) 13 13 14 14 func newRepeatCmd() *cobra.Command { ··· 17 17 18 18 cmd := &cobra.Command{ 19 19 Use: "repeat", 20 - Short: "Prefill from your latest GitHub async daily issue and create a new one", 20 + Short: "Prefill from your latest GitHub daily update issue and create a new one", 21 21 RunE: func(cmd *cobra.Command, _ []string) error { 22 22 env, err := loadEnv() 23 23 if err != nil { ··· 40 40 } 41 41 } 42 42 43 - latestIssue, err := env.gh.LatestAsyncDailyIssue(ctx, env.cfg.GitHubRepo, env.cfg.Labels) 43 + latestIssue, err := env.gh.LatestDailyUpdateIssue(ctx, env.cfg.GitHubRepo, env.cfg.Labels) 44 44 if err != nil { 45 45 if errors.Is(err, ghcli.ErrIssueNotFound) { 46 - return fmt.Errorf("no previous async daily issues found for the authenticated user") 46 + return fmt.Errorf("no previous daily update issues found for the authenticated user") 47 47 } 48 48 49 49 return err
+4 -4
cmd/report.go
··· 6 6 "fmt" 7 7 "text/tabwriter" 8 8 9 - "github.com/prefapp/pad/internal/ghcli" 10 9 "github.com/spf13/cobra" 10 + "github.com/vieitesss/pad/internal/ghcli" 11 11 ) 12 12 13 13 func newReportCmd() *cobra.Command { ··· 17 17 18 18 cmd := &cobra.Command{ 19 19 Use: "report", 20 - Short: "Show the merged async daily team report issue", 20 + Short: "Show the merged daily update team report issue", 21 21 RunE: func(cmd *cobra.Command, _ []string) error { 22 22 env, err := loadEnv() 23 23 if err != nil { ··· 36 36 } 37 37 38 38 if len(issues) == 0 { 39 - fmt.Fprintln(cmd.OutOrStdout(), "no async daily report issues found") 39 + fmt.Fprintln(cmd.OutOrStdout(), "no daily update report issues found") 40 40 return nil 41 41 } 42 42 ··· 57 57 issue, err := env.gh.FindReportIssueByDate(ctx, env.cfg.GitHubRepo, resolvedDate) 58 58 if err != nil { 59 59 if errors.Is(err, ghcli.ErrIssueNotFound) { 60 - return fmt.Errorf("no async daily report issue found for %s", resolvedDate) 60 + return fmt.Errorf("no daily update report issue found for %s", resolvedDate) 61 61 } 62 62 63 63 return err
+2 -2
cmd/root.go
··· 5 5 "os" 6 6 7 7 "github.com/charmbracelet/lipgloss" 8 - "github.com/prefapp/pad/internal/version" 9 8 "github.com/spf13/cobra" 9 + "github.com/vieitesss/pad/internal/version" 10 10 ) 11 11 12 12 func NewRootCmd(version string) *cobra.Command { 13 13 rootCmd := &cobra.Command{ 14 14 Use: "pad", 15 - Short: "Async daily standup helper", 15 + Short: "Daily update standup helper", 16 16 SilenceUsage: true, 17 17 SilenceErrors: true, 18 18 Version: version,
+4 -4
cmd/show.go
··· 5 5 "errors" 6 6 "fmt" 7 7 8 - "github.com/prefapp/pad/internal/ghcli" 9 8 "github.com/spf13/cobra" 9 + "github.com/vieitesss/pad/internal/ghcli" 10 10 ) 11 11 12 12 func newShowCmd() *cobra.Command { ··· 14 14 15 15 cmd := &cobra.Command{ 16 16 Use: "show", 17 - Short: "Print your async daily issue body from GitHub for a date", 17 + Short: "Print your daily update issue body from GitHub for a date", 18 18 RunE: func(cmd *cobra.Command, _ []string) error { 19 19 env, err := loadEnv() 20 20 if err != nil { ··· 31 31 return err 32 32 } 33 33 34 - issue, err := env.gh.FindAsyncDailyIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, resolvedDate) 34 + issue, err := env.gh.FindDailyUpdateIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, resolvedDate) 35 35 if err != nil { 36 36 if errors.Is(err, ghcli.ErrIssueNotFound) { 37 - return fmt.Errorf("no remote async daily issue found for %s", resolvedDate) 37 + return fmt.Errorf("no remote daily update issue found for %s", resolvedDate) 38 38 } 39 39 40 40 return err
+6 -6
cmd/support.go
··· 6 6 "fmt" 7 7 "time" 8 8 9 - "github.com/prefapp/pad/internal/appfs" 10 - "github.com/prefapp/pad/internal/config" 11 - "github.com/prefapp/pad/internal/daily" 12 - "github.com/prefapp/pad/internal/ghcli" 9 + "github.com/vieitesss/pad/internal/appfs" 10 + "github.com/vieitesss/pad/internal/config" 11 + "github.com/vieitesss/pad/internal/daily" 12 + "github.com/vieitesss/pad/internal/ghcli" 13 13 ) 14 14 15 15 type commandEnv struct { ··· 55 55 return err 56 56 } 57 57 58 - existingIssue, err := env.gh.FindAsyncDailyIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, date) 58 + existingIssue, err := env.gh.FindDailyUpdateIssueByDate(ctx, env.cfg.GitHubRepo, env.cfg.Labels, date) 59 59 if err == nil { 60 - return fmt.Errorf("async daily issue already exists for %s: %s", date, existingIssue.URL) 60 + return fmt.Errorf("daily update issue already exists for %s: %s", date, existingIssue.URL) 61 61 } 62 62 63 63 if errors.Is(err, ghcli.ErrIssueNotFound) {
+153 -30
cmd/upgrade.go
··· 2 2 3 3 import ( 4 4 "archive/tar" 5 + "archive/zip" 5 6 "compress/gzip" 6 7 "fmt" 7 8 "io" 8 9 "net/http" 9 10 "os" 11 + "os/exec" 12 + "path" 10 13 "path/filepath" 11 14 "runtime" 12 15 "strings" 13 16 14 17 "github.com/charmbracelet/lipgloss" 15 - "github.com/prefapp/pad/internal/version" 16 18 "github.com/spf13/cobra" 19 + "github.com/vieitesss/pad/internal/version" 17 20 ) 18 21 19 22 func newUpgradeCmd() *cobra.Command { ··· 44 47 } 45 48 46 49 green := lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")) 50 + if runtime.GOOS == "windows" { 51 + yellow := lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) 52 + fmt.Println(yellow.Render("Upgrade scheduled. Restart pad after this command exits to finish replacing the binary.")) 53 + return nil 54 + } 55 + 47 56 fmt.Println(green.Render("✓ Upgrade successful! Restart pad to use the new version.")) 48 57 49 58 return nil ··· 60 69 realPath = binPath 61 70 } 62 71 63 - goos := runtime.GOOS 64 - goarch := runtime.GOARCH 65 - 66 - if goos == "darwin" { 67 - goos = "Darwin" 68 - } 69 - if goarch == "amd64" { 70 - goarch = "x86_64" 72 + release, err := version.ReleaseByTag(tagName) 73 + if err != nil { 74 + return fmt.Errorf("load release metadata: %w", err) 71 75 } 72 76 73 - url := fmt.Sprintf( 74 - "https://github.com/prefapp/pad/releases/download/%s/pad_%s_%s.tar.gz", 75 - tagName, goos, goarch, 76 - ) 77 + asset, err := release.AssetForRuntime(runtime.GOOS, runtime.GOARCH) 78 + if err != nil { 79 + return err 80 + } 77 81 78 82 tmpDir, err := os.MkdirTemp("", "pad-upgrade-*") 79 83 if err != nil { ··· 81 85 } 82 86 defer os.RemoveAll(tmpDir) 83 87 84 - tarPath := filepath.Join(tmpDir, "pad.tar.gz") 85 - if err := downloadFile(url, tarPath); err != nil { 88 + archivePath := filepath.Join(tmpDir, asset.Name) 89 + if err := downloadFile(asset.BrowserDownloadURL, archivePath); err != nil { 86 90 return fmt.Errorf("download release: %w", err) 87 91 } 88 92 89 - newBinPath := filepath.Join(tmpDir, "pad") 90 - if err := extractBinary(tarPath, newBinPath); err != nil { 93 + archiveBinaryName := archivedBinaryName(runtime.GOOS) 94 + newBinPath := filepath.Join(tmpDir, archiveBinaryName) 95 + if err := extractBinary(archivePath, archiveBinaryName, newBinPath); err != nil { 91 96 return fmt.Errorf("extract binary: %w", err) 97 + } 98 + 99 + if runtime.GOOS == "windows" { 100 + return stageWindowsUpgrade(newBinPath, realPath) 92 101 } 93 102 94 103 backupPath := realPath + ".backup" ··· 110 119 return nil 111 120 } 112 121 122 + func archivedBinaryName(goos string) string { 123 + if goos == "windows" { 124 + return "pad.exe" 125 + } 126 + 127 + return "pad" 128 + } 129 + 130 + func stageWindowsUpgrade(src, realPath string) error { 131 + stagedPath := realPath + ".new" 132 + if err := copyFile(src, stagedPath); err != nil { 133 + return fmt.Errorf("stage new binary: %w", err) 134 + } 135 + 136 + cmd := exec.Command( 137 + "powershell.exe", 138 + "-NoProfile", 139 + "-NonInteractive", 140 + "-Command", 141 + windowsUpgradeScript(realPath, stagedPath, realPath+".backup"), 142 + ) 143 + if err := cmd.Start(); err != nil { 144 + _ = os.Remove(stagedPath) 145 + return fmt.Errorf("start windows installer: %w", err) 146 + } 147 + 148 + if cmd.Process != nil { 149 + _ = cmd.Process.Release() 150 + } 151 + 152 + return nil 153 + } 154 + 155 + func windowsUpgradeScript(realPath, stagedPath, backupPath string) string { 156 + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' 157 + $target = %s 158 + $staged = %s 159 + $backup = %s 160 + for ($i = 0; $i -lt 20; $i++) { 161 + try { 162 + if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force } 163 + Move-Item -LiteralPath $target -Destination $backup -Force 164 + Move-Item -LiteralPath $staged -Destination $target -Force 165 + Remove-Item -LiteralPath $backup -Force 166 + exit 0 167 + } catch { 168 + if ((Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $target)) { 169 + Move-Item -LiteralPath $backup -Destination $target -Force 170 + } 171 + Start-Sleep -Milliseconds 500 172 + } 173 + } 174 + throw 'timed out replacing pad.exe' 175 + `, powershellString(realPath), powershellString(stagedPath), powershellString(backupPath)) 176 + } 177 + 178 + func powershellString(value string) string { 179 + return "'" + strings.ReplaceAll(value, "'", "''") + "'" 180 + } 181 + 113 182 func downloadFile(url, dest string) error { 114 183 resp, err := http.Get(url) 115 184 if err != nil { ··· 131 200 return err 132 201 } 133 202 134 - func extractBinary(tarPath, dest string) error { 135 - file, err := os.Open(tarPath) 203 + func extractBinary(archivePath, binaryName, dest string) error { 204 + switch { 205 + case strings.HasSuffix(archivePath, ".tar.gz"): 206 + return extractTarGzBinary(archivePath, binaryName, dest) 207 + case strings.HasSuffix(archivePath, ".zip"): 208 + return extractZipBinary(archivePath, binaryName, dest) 209 + default: 210 + return fmt.Errorf("unsupported archive format for %s", archivePath) 211 + } 212 + } 213 + 214 + func extractTarGzBinary(archivePath, binaryName, dest string) error { 215 + file, err := os.Open(archivePath) 136 216 if err != nil { 137 217 return err 138 218 } ··· 155 235 return err 156 236 } 157 237 158 - if header.Typeflag == tar.TypeReg && strings.Contains(header.Name, "pad") { 159 - out, err := os.Create(dest) 160 - if err != nil { 161 - return err 162 - } 163 - defer out.Close() 238 + if header.Typeflag == tar.TypeReg && path.Base(header.Name) == binaryName { 239 + return writeReaderToFile(dest, tr, 0o755) 240 + } 241 + } 242 + 243 + return fmt.Errorf("binary %s not found in archive", binaryName) 244 + } 245 + 246 + func extractZipBinary(archivePath, binaryName, dest string) error { 247 + reader, err := zip.OpenReader(archivePath) 248 + if err != nil { 249 + return err 250 + } 251 + defer reader.Close() 252 + 253 + for _, file := range reader.File { 254 + if file.FileInfo().IsDir() || path.Base(file.Name) != binaryName { 255 + continue 256 + } 257 + 258 + rc, err := file.Open() 259 + if err != nil { 260 + return err 261 + } 262 + 263 + perm := file.Mode() 264 + if perm == 0 { 265 + perm = 0o755 266 + } 164 267 165 - if _, err := io.Copy(out, tr); err != nil { 166 - return err 167 - } 168 - return nil 268 + writeErr := writeReaderToFile(dest, rc, perm) 269 + closeErr := rc.Close() 270 + if writeErr != nil { 271 + return writeErr 272 + } 273 + if closeErr != nil { 274 + return closeErr 169 275 } 276 + 277 + return nil 170 278 } 171 279 172 - return fmt.Errorf("binary not found in archive") 280 + return fmt.Errorf("binary %s not found in archive", binaryName) 281 + } 282 + 283 + func writeReaderToFile(dest string, src io.Reader, perm os.FileMode) error { 284 + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) 285 + if err != nil { 286 + return err 287 + } 288 + 289 + _, copyErr := io.Copy(out, src) 290 + closeErr := out.Close() 291 + if copyErr != nil { 292 + return copyErr 293 + } 294 + 295 + return closeErr 173 296 } 174 297 175 298 func copyFile(src, dest string) error {
+103
cmd/upgrade_test.go
··· 1 + package cmd 2 + 3 + import ( 4 + "archive/tar" 5 + "archive/zip" 6 + "compress/gzip" 7 + "os" 8 + "path/filepath" 9 + "testing" 10 + ) 11 + 12 + func TestExtractBinaryFromTarGz(t *testing.T) { 13 + archivePath := filepath.Join(t.TempDir(), "pad.tar.gz") 14 + writeTarGzArchive(t, archivePath, map[string]string{ 15 + "README.md": "docs", 16 + "pad": "darwin-binary", 17 + }) 18 + 19 + dest := filepath.Join(t.TempDir(), "pad") 20 + if err := extractBinary(archivePath, "pad", dest); err != nil { 21 + t.Fatalf("extract binary: %v", err) 22 + } 23 + 24 + data, err := os.ReadFile(dest) 25 + if err != nil { 26 + t.Fatalf("read extracted binary: %v", err) 27 + } 28 + 29 + if string(data) != "darwin-binary" { 30 + t.Fatalf("unexpected extracted content %q", string(data)) 31 + } 32 + } 33 + 34 + func TestExtractBinaryFromZip(t *testing.T) { 35 + archivePath := filepath.Join(t.TempDir(), "pad.zip") 36 + writeZipArchive(t, archivePath, map[string]string{ 37 + "README.md": "docs", 38 + "pad.exe": "windows-binary", 39 + }) 40 + 41 + dest := filepath.Join(t.TempDir(), "pad.exe") 42 + if err := extractBinary(archivePath, "pad.exe", dest); err != nil { 43 + t.Fatalf("extract binary: %v", err) 44 + } 45 + 46 + data, err := os.ReadFile(dest) 47 + if err != nil { 48 + t.Fatalf("read extracted binary: %v", err) 49 + } 50 + 51 + if string(data) != "windows-binary" { 52 + t.Fatalf("unexpected extracted content %q", string(data)) 53 + } 54 + } 55 + 56 + func writeTarGzArchive(t *testing.T, archivePath string, files map[string]string) { 57 + t.Helper() 58 + 59 + archive, err := os.Create(archivePath) 60 + if err != nil { 61 + t.Fatalf("create tar.gz: %v", err) 62 + } 63 + defer archive.Close() 64 + 65 + gz := gzip.NewWriter(archive) 66 + defer gz.Close() 67 + 68 + tw := tar.NewWriter(gz) 69 + defer tw.Close() 70 + 71 + for name, content := range files { 72 + header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content))} 73 + if err := tw.WriteHeader(header); err != nil { 74 + t.Fatalf("write tar header: %v", err) 75 + } 76 + if _, err := tw.Write([]byte(content)); err != nil { 77 + t.Fatalf("write tar content: %v", err) 78 + } 79 + } 80 + } 81 + 82 + func writeZipArchive(t *testing.T, archivePath string, files map[string]string) { 83 + t.Helper() 84 + 85 + archive, err := os.Create(archivePath) 86 + if err != nil { 87 + t.Fatalf("create zip: %v", err) 88 + } 89 + defer archive.Close() 90 + 91 + zw := zip.NewWriter(archive) 92 + defer zw.Close() 93 + 94 + for name, content := range files { 95 + writer, err := zw.Create(name) 96 + if err != nil { 97 + t.Fatalf("create zip entry: %v", err) 98 + } 99 + if _, err := writer.Write([]byte(content)); err != nil { 100 + t.Fatalf("write zip content: %v", err) 101 + } 102 + } 103 + }
+1 -1
go.mod
··· 1 - module github.com/prefapp/pad 1 + module github.com/vieitesss/pad 2 2 3 3 go 1.25.0 4 4
+1 -1
internal/appfs/paths.go
··· 1 1 package appfs 2 2 3 3 import ( 4 - "github.com/prefapp/pad/internal/config" 4 + "github.com/vieitesss/pad/internal/config" 5 5 ) 6 6 7 7 type Paths struct {
+3 -3
internal/daily/entry.go
··· 8 8 9 9 const DateLayout = "2006-01-02" 10 10 11 - const issueTitlePrefix = "[Async Daily] [" 11 + const issueTitlePrefix = "[Daily Update] [" 12 12 const reportTitlePrefix = "[Daily Report] " 13 13 14 14 type IssueRef struct { ··· 128 128 129 129 func (e Entry) ValidateForCreate() error { 130 130 if strings.TrimSpace(e.Yesterday) == "" { 131 - return fmt.Errorf("yesterday section is required; fill it in with `pad create` or `pad repeat --edit`") 131 + return fmt.Errorf("yesterday section is required; fill it in with `pad create` or `pad repeat`") 132 132 } 133 133 134 134 if strings.TrimSpace(e.Today) == "" { 135 - return fmt.Errorf("today section is required; fill it in with `pad create` or `pad repeat --edit`") 135 + return fmt.Errorf("today section is required; fill it in with `pad create` or `pad repeat`") 136 136 } 137 137 138 138 return nil
+1 -1
internal/daily/entry_test.go
··· 134 134 } 135 135 136 136 func TestDateFromIssueTitle(t *testing.T) { 137 - date, ok := DateFromIssueTitle("[Async Daily] [2026/04/16]") 137 + date, ok := DateFromIssueTitle("[Daily Update] [2026/04/16]") 138 138 if !ok { 139 139 t.Fatalf("expected title to parse") 140 140 }
+25 -25
internal/ghcli/issues.go
··· 12 12 "strings" 13 13 "time" 14 14 15 - "github.com/prefapp/pad/internal/daily" 15 + "github.com/vieitesss/pad/internal/daily" 16 16 ) 17 17 18 18 var ErrIssueNotFound = errors.New("issue not found") ··· 23 23 run runner 24 24 } 25 25 26 - type AsyncDailyIssue struct { 26 + type DailyUpdateIssue struct { 27 27 Number int 28 28 Title string 29 29 Body string ··· 34 34 UpdatedAt time.Time 35 35 } 36 36 37 - type ReportIssue = AsyncDailyIssue 37 + type ReportIssue = DailyUpdateIssue 38 38 39 39 type issueListItem struct { 40 40 Number int `json:"number"` ··· 101 101 return daily.IssueRef{Number: issueNumber, URL: issueURL}, nil 102 102 } 103 103 104 - func (c *Client) ListAsyncDailyIssues(ctx context.Context, repo string, labels []string, limit int) ([]AsyncDailyIssue, error) { 104 + func (c *Client) ListDailyUpdateIssues(ctx context.Context, repo string, labels []string, limit int) ([]DailyUpdateIssue, error) { 105 105 return c.searchIssues(ctx, repo, "@me", labels, limit, "") 106 106 } 107 107 108 - func (c *Client) FindAsyncDailyIssueByDate(ctx context.Context, repo string, labels []string, date string) (AsyncDailyIssue, error) { 108 + func (c *Client) FindDailyUpdateIssueByDate(ctx context.Context, repo string, labels []string, date string) (DailyUpdateIssue, error) { 109 109 title, err := daily.TitleForDate(date) 110 110 if err != nil { 111 - return AsyncDailyIssue{}, err 111 + return DailyUpdateIssue{}, err 112 112 } 113 113 114 114 issues, err := c.searchIssues(ctx, repo, "@me", labels, 5, fmt.Sprintf("%q in:title", title)) 115 115 if err != nil { 116 - return AsyncDailyIssue{}, err 116 + return DailyUpdateIssue{}, err 117 117 } 118 118 119 119 if len(issues) == 0 { 120 120 issues, err = c.searchIssues(ctx, repo, "@me", labels, 10, fmt.Sprintf("created:%s", date)) 121 121 if err != nil { 122 - return AsyncDailyIssue{}, err 122 + return DailyUpdateIssue{}, err 123 123 } 124 124 } 125 125 ··· 131 131 return c.ViewIssue(ctx, repo, issue.Number) 132 132 } 133 133 134 - return AsyncDailyIssue{}, ErrIssueNotFound 134 + return DailyUpdateIssue{}, ErrIssueNotFound 135 135 } 136 136 137 - func (c *Client) LatestAsyncDailyIssue(ctx context.Context, repo string, labels []string) (AsyncDailyIssue, error) { 138 - issues, err := c.ListAsyncDailyIssues(ctx, repo, labels, 1) 137 + func (c *Client) LatestDailyUpdateIssue(ctx context.Context, repo string, labels []string) (DailyUpdateIssue, error) { 138 + issues, err := c.ListDailyUpdateIssues(ctx, repo, labels, 1) 139 139 if err != nil { 140 - return AsyncDailyIssue{}, err 140 + return DailyUpdateIssue{}, err 141 141 } 142 142 143 143 if len(issues) == 0 { 144 - return AsyncDailyIssue{}, ErrIssueNotFound 144 + return DailyUpdateIssue{}, ErrIssueNotFound 145 145 } 146 146 147 147 return c.ViewIssue(ctx, repo, issues[0].Number) 148 148 } 149 149 150 150 func (c *Client) ListReportIssues(ctx context.Context, repo string, limit int) ([]ReportIssue, error) { 151 - return c.searchIssues(ctx, repo, "", []string{"async-daily/report"}, limit, "") 151 + return c.searchIssues(ctx, repo, "", []string{"daily-update/report"}, limit, "") 152 152 } 153 153 154 154 func (c *Client) FindReportIssueByDate(ctx context.Context, repo string, date string) (ReportIssue, error) { ··· 157 157 return ReportIssue{}, err 158 158 } 159 159 160 - issues, err := c.searchIssues(ctx, repo, "", []string{"async-daily/report"}, 5, fmt.Sprintf("%q in:title", title)) 160 + issues, err := c.searchIssues(ctx, repo, "", []string{"daily-update/report"}, 5, fmt.Sprintf("%q in:title", title)) 161 161 if err != nil { 162 162 return ReportIssue{}, err 163 163 } 164 164 165 165 if len(issues) == 0 { 166 - issues, err = c.searchIssues(ctx, repo, "", []string{"async-daily/report"}, 10, fmt.Sprintf("created:%s", date)) 166 + issues, err = c.searchIssues(ctx, repo, "", []string{"daily-update/report"}, 10, fmt.Sprintf("created:%s", date)) 167 167 if err != nil { 168 168 return ReportIssue{}, err 169 169 } ··· 180 180 return ReportIssue{}, ErrIssueNotFound 181 181 } 182 182 183 - func (c *Client) searchIssues(ctx context.Context, repo, author string, labels []string, limit int, search string) ([]AsyncDailyIssue, error) { 183 + func (c *Client) searchIssues(ctx context.Context, repo, author string, labels []string, limit int, search string) ([]DailyUpdateIssue, error) { 184 184 if limit <= 0 { 185 185 limit = 100 186 186 } ··· 215 215 return nil, fmt.Errorf("decode GitHub issues: %w", err) 216 216 } 217 217 218 - issues := make([]AsyncDailyIssue, 0, len(raw)) 218 + issues := make([]DailyUpdateIssue, 0, len(raw)) 219 219 for _, item := range raw { 220 220 createdAt, err := time.Parse(time.RFC3339, item.CreatedAt) 221 221 if err != nil { ··· 227 227 return nil, fmt.Errorf("parse issue updatedAt %q: %w", item.UpdatedAt, err) 228 228 } 229 229 230 - issues = append(issues, AsyncDailyIssue{ 230 + issues = append(issues, DailyUpdateIssue{ 231 231 Number: item.Number, 232 232 Title: item.Title, 233 233 URL: item.URL, ··· 241 241 return issues, nil 242 242 } 243 243 244 - func (c *Client) ViewIssue(ctx context.Context, repo string, number int) (AsyncDailyIssue, error) { 244 + func (c *Client) ViewIssue(ctx context.Context, repo string, number int) (DailyUpdateIssue, error) { 245 245 output, err := c.run(ctx, "issue", "view", strconv.Itoa(number), "--repo", repo, "--json", "number,title,body,url,state,createdAt,updatedAt") 246 246 if err != nil { 247 - return AsyncDailyIssue{}, fmt.Errorf("view GitHub issue: %s", strings.TrimSpace(string(output))) 247 + return DailyUpdateIssue{}, fmt.Errorf("view GitHub issue: %s", strings.TrimSpace(string(output))) 248 248 } 249 249 250 250 var item issueViewItem 251 251 if err := json.Unmarshal(output, &item); err != nil { 252 - return AsyncDailyIssue{}, fmt.Errorf("decode GitHub issue: %w", err) 252 + return DailyUpdateIssue{}, fmt.Errorf("decode GitHub issue: %w", err) 253 253 } 254 254 255 255 createdAt, err := time.Parse(time.RFC3339, item.CreatedAt) 256 256 if err != nil { 257 - return AsyncDailyIssue{}, fmt.Errorf("parse issue createdAt %q: %w", item.CreatedAt, err) 257 + return DailyUpdateIssue{}, fmt.Errorf("parse issue createdAt %q: %w", item.CreatedAt, err) 258 258 } 259 259 260 260 updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt) 261 261 if err != nil { 262 - return AsyncDailyIssue{}, fmt.Errorf("parse issue updatedAt %q: %w", item.UpdatedAt, err) 262 + return DailyUpdateIssue{}, fmt.Errorf("parse issue updatedAt %q: %w", item.UpdatedAt, err) 263 263 } 264 264 265 - return AsyncDailyIssue{ 265 + return DailyUpdateIssue{ 266 266 Number: item.Number, 267 267 Title: item.Title, 268 268 Body: item.Body,
+12 -12
internal/ghcli/issues_test.go
··· 6 6 "testing" 7 7 ) 8 8 9 - func TestListAsyncDailyIssuesKeepsLabeledIssuesAndFallsBackToCreatedDate(t *testing.T) { 9 + func TestListDailyUpdateIssuesKeepsLabeledIssuesAndFallsBackToCreatedDate(t *testing.T) { 10 10 client := newForTests(func(_ context.Context, args ...string) ([]byte, error) { 11 11 joined := strings.Join(args, " ") 12 12 if !strings.Contains(joined, "issue list") { ··· 18 18 } 19 19 20 20 return []byte(`[ 21 - {"number":470,"title":"[Async Daily] [2026/04/16]","url":"https://example.com/470","state":"CLOSED","createdAt":"2026-04-16T08:54:39Z","updatedAt":"2026-04-16T11:08:57Z"}, 21 + {"number":470,"title":"[Daily Update] [2026/04/16]","url":"https://example.com/470","state":"CLOSED","createdAt":"2026-04-16T08:54:39Z","updatedAt":"2026-04-16T11:08:57Z"}, 22 22 {"number":9,"title":"Unrelated issue","url":"https://example.com/9","state":"OPEN","createdAt":"2026-04-16T08:54:39Z","updatedAt":"2026-04-16T11:08:57Z"} 23 23 ]`), nil 24 24 }) 25 25 26 - issues, err := client.ListAsyncDailyIssues(context.Background(), "prefapp/doc-asyncdaily", []string{"async-daily/member"}, 10) 26 + issues, err := client.ListDailyUpdateIssues(context.Background(), "prefapp/doc-daily-updates", []string{"daily-update/member"}, 10) 27 27 if err != nil { 28 - t.Fatalf("list async daily issues: %v", err) 28 + t.Fatalf("list daily update issues: %v", err) 29 29 } 30 30 31 31 if len(issues) != 2 { ··· 41 41 } 42 42 } 43 43 44 - func TestFindAsyncDailyIssueByDateLoadsIssueBody(t *testing.T) { 44 + func TestFindDailyUpdateIssueByDateLoadsIssueBody(t *testing.T) { 45 45 client := newForTests(func(_ context.Context, args ...string) ([]byte, error) { 46 46 joined := strings.Join(args, " ") 47 47 switch { 48 48 case strings.Contains(joined, "issue list"): 49 49 return []byte(`[ 50 - {"number":470,"title":"[Async Daily] [2026/04/16]","url":"https://example.com/470","state":"CLOSED","createdAt":"2026-04-16T08:54:39Z","updatedAt":"2026-04-16T11:08:57Z"} 50 + {"number":470,"title":"[Daily Update] [2026/04/16]","url":"https://example.com/470","state":"CLOSED","createdAt":"2026-04-16T08:54:39Z","updatedAt":"2026-04-16T11:08:57Z"} 51 51 ]`), nil 52 52 case strings.Contains(joined, "issue view 470"): 53 - return []byte(`{"number":470,"title":"[Async Daily] [2026/04/16]","body":"remote body","url":"https://example.com/470","state":"CLOSED","createdAt":"2026-04-16T08:54:39Z","updatedAt":"2026-04-16T11:08:57Z"}`), nil 53 + return []byte(`{"number":470,"title":"[Daily Update] [2026/04/16]","body":"remote body","url":"https://example.com/470","state":"CLOSED","createdAt":"2026-04-16T08:54:39Z","updatedAt":"2026-04-16T11:08:57Z"}`), nil 54 54 default: 55 55 t.Fatalf("unexpected gh command %q", joined) 56 56 return nil, nil 57 57 } 58 58 }) 59 59 60 - issue, err := client.FindAsyncDailyIssueByDate(context.Background(), "prefapp/doc-asyncdaily", []string{"async-daily/member"}, "2026-04-16") 60 + issue, err := client.FindDailyUpdateIssueByDate(context.Background(), "prefapp/doc-daily-updates", []string{"daily-update/member"}, "2026-04-16") 61 61 if err != nil { 62 - t.Fatalf("find async daily issue: %v", err) 62 + t.Fatalf("find daily update issue: %v", err) 63 63 } 64 64 65 65 if issue.Body != "remote body" { ··· 74 74 t.Fatalf("did not expect author filter, got %q", joined) 75 75 } 76 76 77 - if !strings.Contains(joined, "--label async-daily/report") { 77 + if !strings.Contains(joined, "--label daily-update/report") { 78 78 t.Fatalf("expected report label filter, got %q", joined) 79 79 } 80 80 ··· 83 83 ]`), nil 84 84 }) 85 85 86 - issues, err := client.ListReportIssues(context.Background(), "prefapp/doc-asyncdaily", 10) 86 + issues, err := client.ListReportIssues(context.Background(), "prefapp/doc-daily-updates", 10) 87 87 if err != nil { 88 88 t.Fatalf("list report issues: %v", err) 89 89 } ··· 116 116 } 117 117 }) 118 118 119 - issue, err := client.FindReportIssueByDate(context.Background(), "prefapp/doc-asyncdaily", "2026-04-16") 119 + issue, err := client.FindReportIssueByDate(context.Background(), "prefapp/doc-daily-updates", "2026-04-16") 120 120 if err != nil { 121 121 t.Fatalf("find report issue: %v", err) 122 122 }
+3 -3
internal/tui/editor.go
··· 9 9 "github.com/charmbracelet/bubbles/viewport" 10 10 tea "github.com/charmbracelet/bubbletea" 11 11 "github.com/charmbracelet/lipgloss" 12 - "github.com/prefapp/pad/internal/daily" 12 + "github.com/vieitesss/pad/internal/daily" 13 13 ) 14 14 15 15 var ErrCanceled = errors.New("edit canceled") ··· 326 326 current := m.currentField() 327 327 editingBlock := []string{ 328 328 paneTitleStyle.Render("Template"), 329 - mutedStyle.Render("Fill the async-daily template on the left. The right pane updates live."), 329 + mutedStyle.Render("Fill the daily update template on the left. The right pane updates live."), 330 330 "", 331 331 strings.Join(fieldLines, "\n"), 332 332 "", ··· 413 413 414 414 func (m model) actionTitle() string { 415 415 if m.mode == modeCreate { 416 - return "Create Async Daily" 416 + return "Create Daily Update" 417 417 } 418 418 419 419 return "Edit Draft"
+2 -2
internal/tui/editor_test.go
··· 4 4 "strings" 5 5 "testing" 6 6 7 - "github.com/prefapp/pad/internal/daily" 7 + "github.com/vieitesss/pad/internal/daily" 8 8 ) 9 9 10 10 func TestNextVisibleIndexWrapsForwardFromLastField(t *testing.T) { ··· 78 78 }) 79 79 80 80 checks := []string{ 81 - "[Async Daily] [2026/04/16]", 81 + "[Daily Update] [2026/04/16]", 82 82 "## ✅ What did you do yesterday?", 83 83 "## 🎯 What will you do today?", 84 84 }
+60 -7
internal/version/check.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 7 8 "strings" 8 9 "time" 9 10 10 - "github.com/prefapp/pad/internal/appfs" 11 + "github.com/vieitesss/pad/internal/appfs" 11 12 ) 12 13 13 14 const ( 14 - githubRepo = "prefapp/pad" 15 + githubRepo = "vieitesss/pad" 15 16 checkInterval = 24 * time.Hour 16 17 ) 17 18 19 + type ReleaseAsset struct { 20 + Name string `json:"name"` 21 + BrowserDownloadURL string `json:"browser_download_url"` 22 + } 23 + 18 24 type ReleaseInfo struct { 19 - TagName string `json:"tag_name"` 20 - PublishedAt time.Time `json:"published_at"` 21 - HTMLURL string `json:"html_url"` 25 + TagName string `json:"tag_name"` 26 + PublishedAt time.Time `json:"published_at"` 27 + HTMLURL string `json:"html_url"` 28 + Assets []ReleaseAsset `json:"assets"` 22 29 } 23 30 24 31 type VersionState struct { ··· 67 74 return nil, false 68 75 } 69 76 77 + func LatestRelease() (*ReleaseInfo, error) { 78 + return fetchRelease(fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)) 79 + } 80 + 81 + func ReleaseByTag(tag string) (*ReleaseInfo, error) { 82 + return fetchRelease(fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", githubRepo, url.PathEscape(tag))) 83 + } 84 + 85 + func (r ReleaseInfo) AssetForRuntime(goos, goarch string) (ReleaseAsset, error) { 86 + name, err := assetNameFor(r.TagName, goos, goarch) 87 + if err != nil { 88 + return ReleaseAsset{}, err 89 + } 90 + 91 + for _, asset := range r.Assets { 92 + if asset.Name == name { 93 + return asset, nil 94 + } 95 + } 96 + 97 + return ReleaseAsset{}, fmt.Errorf("release %s does not contain asset %s", r.TagName, name) 98 + } 99 + 70 100 func fetchLatestRelease() (*ReleaseInfo, error) { 71 - url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) 101 + return LatestRelease() 102 + } 103 + 104 + func fetchRelease(apiURL string) (*ReleaseInfo, error) { 105 + req, err := http.NewRequest(http.MethodGet, apiURL, nil) 106 + if err != nil { 107 + return nil, err 108 + } 109 + req.Header.Set("Accept", "application/vnd.github+json") 110 + req.Header.Set("User-Agent", "pad") 72 111 73 - resp, err := http.Get(url) 112 + resp, err := http.DefaultClient.Do(req) 74 113 if err != nil { 75 114 return nil, err 76 115 } ··· 86 125 } 87 126 88 127 return &release, nil 128 + } 129 + 130 + func assetNameFor(tagName, goos, goarch string) (string, error) { 131 + trimmedTag := strings.TrimSpace(strings.TrimPrefix(tagName, "v")) 132 + if trimmedTag == "" { 133 + return "", fmt.Errorf("release tag is empty") 134 + } 135 + 136 + ext := ".tar.gz" 137 + if goos == "windows" { 138 + ext = ".zip" 139 + } 140 + 141 + return fmt.Sprintf("pad_%s_%s_%s%s", trimmedTag, goos, goarch, ext), nil 89 142 } 90 143 91 144 func isNewer(latest, current string) bool {
+44
internal/version/check_test.go
··· 1 + package version 2 + 3 + import "testing" 4 + 5 + func TestAssetNameForUnixArchives(t *testing.T) { 6 + name, err := assetNameFor("v0.2.1", "darwin", "arm64") 7 + if err != nil { 8 + t.Fatalf("asset name: %v", err) 9 + } 10 + 11 + if name != "pad_0.2.1_darwin_arm64.tar.gz" { 12 + t.Fatalf("expected darwin asset name, got %q", name) 13 + } 14 + } 15 + 16 + func TestAssetNameForWindowsArchives(t *testing.T) { 17 + name, err := assetNameFor("v0.2.1", "windows", "amd64") 18 + if err != nil { 19 + t.Fatalf("asset name: %v", err) 20 + } 21 + 22 + if name != "pad_0.2.1_windows_amd64.zip" { 23 + t.Fatalf("expected windows asset name, got %q", name) 24 + } 25 + } 26 + 27 + func TestReleaseInfoAssetForRuntime(t *testing.T) { 28 + release := ReleaseInfo{ 29 + TagName: "v0.2.1", 30 + Assets: []ReleaseAsset{ 31 + {Name: "pad_0.2.1_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/linux"}, 32 + {Name: "pad_0.2.1_windows_amd64.zip", BrowserDownloadURL: "https://example.com/windows"}, 33 + }, 34 + } 35 + 36 + asset, err := release.AssetForRuntime("windows", "amd64") 37 + if err != nil { 38 + t.Fatalf("asset for runtime: %v", err) 39 + } 40 + 41 + if asset.BrowserDownloadURL != "https://example.com/windows" { 42 + t.Fatalf("expected windows asset, got %q", asset.BrowserDownloadURL) 43 + } 44 + }
+2 -2
main.go
··· 4 4 "fmt" 5 5 "os" 6 6 7 - "github.com/prefapp/pad/cmd" 8 - "github.com/prefapp/pad/internal/version" 7 + "github.com/vieitesss/pad/cmd" 8 + "github.com/vieitesss/pad/internal/version" 9 9 ) 10 10 11 11 var buildVersion = "dev"