| 
 | 1 | +name: follow-up  | 
 | 2 | + | 
 | 3 | +on:  | 
 | 4 | +  schedule:  | 
 | 5 | +    - cron: "23 3 * * *"  | 
 | 6 | +  workflow_dispatch:  | 
 | 7 | + | 
 | 8 | +permissions:  | 
 | 9 | +  contents: read  | 
 | 10 | +  issues: write  | 
 | 11 | + | 
 | 12 | +jobs:  | 
 | 13 | +  scan:  | 
 | 14 | +    runs-on: ubuntu-latest  | 
 | 15 | + | 
 | 16 | +    env:  | 
 | 17 | +      DAYS_WAIT: "7"  | 
 | 18 | +      FLAG_LABEL: "follow up"  | 
 | 19 | +      EXEMPT_LABELS: "release,stale,p0-critical,p1-high,p2-medium,p3-low"  | 
 | 20 | +      POST_COMMENT: "true"  | 
 | 21 | +      DRY_RUN: "true"  | 
 | 22 | + | 
 | 23 | +    steps:  | 
 | 24 | +      - uses: actions/github-script@v8  | 
 | 25 | +        with:  | 
 | 26 | +          script: |  | 
 | 27 | +            const DAYS_WAIT = parseInt(process.env.DAYS_WAIT || "7", 10);  | 
 | 28 | +            const FLAG_LABEL = (process.env.FLAG_LABEL || "follow up").trim();  | 
 | 29 | +            const EXEMPT_LABELS = (process.env.EXEMPT_LABELS || "")  | 
 | 30 | +              .split(",").map(s => s.trim().toLowerCase()).filter(Boolean);  | 
 | 31 | +            const POST_COMMENT = (process.env.POST_COMMENT || "true").toLowerCase() === "true";  | 
 | 32 | +            const DRY_RUN = (process.env.DRY_RUN || "false").toLowerCase() === "true";  | 
 | 33 | +
  | 
 | 34 | +            const OWNER_TYPES = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);  | 
 | 35 | +            const now = new Date();  | 
 | 36 | +
  | 
 | 37 | +            async function ensureLabel(number) {  | 
 | 38 | +              try {  | 
 | 39 | +                if (DRY_RUN) { core.info(`[DRY] Would add label '${FLAG_LABEL}' to #${number}`); return; }  | 
 | 40 | +                await github.rest.issues.addLabels({  | 
 | 41 | +                  owner: context.repo.owner,  | 
 | 42 | +                  repo: context.repo.repo,  | 
 | 43 | +                  issue_number: number,  | 
 | 44 | +                  labels: [FLAG_LABEL],  | 
 | 45 | +                });  | 
 | 46 | +              } catch (e) {  | 
 | 47 | +                if (e.status !== 422) throw e; // 422 = already has label  | 
 | 48 | +              }  | 
 | 49 | +            }  | 
 | 50 | +
  | 
 | 51 | +            async function removeLabelIfPresent(number) {  | 
 | 52 | +              try {  | 
 | 53 | +                if (DRY_RUN) { core.info(`[DRY] Would remove label '${FLAG_LABEL}' from #${number}`); return; }  | 
 | 54 | +                await github.rest.issues.removeLabel({  | 
 | 55 | +                  owner: context.repo.owner,  | 
 | 56 | +                  repo: context.repo.repo,  | 
 | 57 | +                  issue_number: number,  | 
 | 58 | +                  name: FLAG_LABEL,  | 
 | 59 | +                });  | 
 | 60 | +              } catch (e) {  | 
 | 61 | +                if (e.status !== 404) throw e; // 404 = label not present  | 
 | 62 | +              }  | 
 | 63 | +            }  | 
 | 64 | +
  | 
 | 65 | +            async function postNudgeComment(number, issueAuthor, lastMaintainerAt) {  | 
 | 66 | +              if (!POST_COMMENT) return;  | 
 | 67 | +              const days = DAYS_WAIT;  | 
 | 68 | +              const body =  | 
 | 69 | +                `Hi @${issueAuthor}! A maintainer responded on ${new Date(lastMaintainerAt).toISOString().slice(0,10)}.\n\n` +  | 
 | 70 | +                `If the answer solved your problem, please consider closing this issue. ` +  | 
 | 71 | +                `Otherwise, feel free to reply with more details so we can help.\n\n` +  | 
 | 72 | +                `_(Label: \`${FLAG_LABEL}\` — added after ${days} day${days === 1 ? '' : 's'} without a follow-up from the author.)_`;  | 
 | 73 | +
  | 
 | 74 | +              if (DRY_RUN) { core.info(`[DRY] Would comment on #${number}: ${body}`); return; }  | 
 | 75 | +              await github.rest.issues.createComment({  | 
 | 76 | +                owner: context.repo.owner,  | 
 | 77 | +                repo: context.repo.repo,  | 
 | 78 | +                issue_number: number,  | 
 | 79 | +                body,  | 
 | 80 | +              });  | 
 | 81 | +            }  | 
 | 82 | +
  | 
 | 83 | +            const issues = await github.paginate(  | 
 | 84 | +              github.rest.issues.listForRepo,  | 
 | 85 | +              {  | 
 | 86 | +                owner: context.repo.owner,  | 
 | 87 | +                repo: context.repo.repo,  | 
 | 88 | +                state: "open",  | 
 | 89 | +                per_page: 100,  | 
 | 90 | +              }  | 
 | 91 | +            );  | 
 | 92 | +
  | 
 | 93 | +            let flagged = 0, unflagged = 0, skipped = 0;  | 
 | 94 | +
  | 
 | 95 | +            for (const issue of issues) {  | 
 | 96 | +              if (issue.pull_request) { skipped++; continue; }  | 
 | 97 | +
  | 
 | 98 | +              const number = issue.number;  | 
 | 99 | +              const author = issue.user?.login;  | 
 | 100 | +              const authorAssoc = String(issue.author_association || "").toUpperCase();  | 
 | 101 | +              const issueLabels = (issue.labels || []).map(l => (typeof l === 'string' ? l : l.name).toLowerCase());  | 
 | 102 | +
  | 
 | 103 | +              // Skip issues created by a maintainer  | 
 | 104 | +              if (OWNER_TYPES.has(authorAssoc)) {  | 
 | 105 | +                if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {  | 
 | 106 | +                  await removeLabelIfPresent(number);  | 
 | 107 | +                  unflagged++;  | 
 | 108 | +                } else {  | 
 | 109 | +                  skipped++;  | 
 | 110 | +                }  | 
 | 111 | +                continue;  | 
 | 112 | +              }  | 
 | 113 | +
  | 
 | 114 | +              // Skip exempt labels  | 
 | 115 | +              if (issueLabels.some(l => EXEMPT_LABELS.includes(l))) { skipped++; continue; }  | 
 | 116 | +
  | 
 | 117 | +              // Fetch comments  | 
 | 118 | +              const comments = await github.paginate(  | 
 | 119 | +                github.rest.issues.listComments,  | 
 | 120 | +                {  | 
 | 121 | +                  owner: context.repo.owner,  | 
 | 122 | +                  repo: context.repo.repo,  | 
 | 123 | +                  issue_number: number,  | 
 | 124 | +                  per_page: 100,  | 
 | 125 | +                }  | 
 | 126 | +              );  | 
 | 127 | +
  | 
 | 128 | +              if (comments.length === 0) {  | 
 | 129 | +                if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {  | 
 | 130 | +                  await removeLabelIfPresent(number);  | 
 | 131 | +                  unflagged++;  | 
 | 132 | +                } else {  | 
 | 133 | +                  skipped++;  | 
 | 134 | +                }  | 
 | 135 | +                continue;  | 
 | 136 | +              }  | 
 | 137 | +
  | 
 | 138 | +              const lastComment = comments[comments.length - 1];  | 
 | 139 | +
  | 
 | 140 | +              // If last comment is by issue author, remove label  | 
 | 141 | +              if (lastComment.user?.login === author) {  | 
 | 142 | +                if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {  | 
 | 143 | +                  await removeLabelIfPresent(number);  | 
 | 144 | +                  unflagged++;  | 
 | 145 | +                } else {  | 
 | 146 | +                  skipped++;  | 
 | 147 | +                }  | 
 | 148 | +                continue;  | 
 | 149 | +              }  | 
 | 150 | +
  | 
 | 151 | +              // Find last maintainer comment  | 
 | 152 | +              const maintainerComments = comments.filter(c =>  | 
 | 153 | +                OWNER_TYPES.has(String(c.author_association).toUpperCase())  | 
 | 154 | +              );  | 
 | 155 | +              if (maintainerComments.length === 0) {  | 
 | 156 | +                if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {  | 
 | 157 | +                  await removeLabelIfPresent(number);  | 
 | 158 | +                  unflagged++;  | 
 | 159 | +                } else {  | 
 | 160 | +                  skipped++;  | 
 | 161 | +                }  | 
 | 162 | +                continue;  | 
 | 163 | +              }  | 
 | 164 | +
  | 
 | 165 | +              const lastMaintainer = maintainerComments[maintainerComments.length - 1];  | 
 | 166 | +              const lastMaintainerAt = new Date(lastMaintainer.created_at);  | 
 | 167 | +
  | 
 | 168 | +              // Did the author reply after that?  | 
 | 169 | +              const authorFollowUp = comments.some(c =>  | 
 | 170 | +                c.user?.login === author && new Date(c.created_at) > lastMaintainerAt  | 
 | 171 | +              );  | 
 | 172 | +
  | 
 | 173 | +              if (authorFollowUp) {  | 
 | 174 | +                if (issueLabels.includes(FLAG_LABEL.toLowerCase())) {  | 
 | 175 | +                  await removeLabelIfPresent(number);  | 
 | 176 | +                  unflagged++;  | 
 | 177 | +                } else {  | 
 | 178 | +                  skipped++;  | 
 | 179 | +                }  | 
 | 180 | +                continue;  | 
 | 181 | +              }  | 
 | 182 | +
  | 
 | 183 | +              // No author follow-up since maintainer reply  | 
 | 184 | +              const elapsedDays = Math.floor((now - lastMaintainerAt) / (1000 * 60 * 60 * 24));  | 
 | 185 | +              if (elapsedDays >= DAYS_WAIT) {  | 
 | 186 | +                await ensureLabel(number);  | 
 | 187 | +                await postNudgeComment(number, author, lastMaintainerAt);  | 
 | 188 | +                flagged++;  | 
 | 189 | +              } else {  | 
 | 190 | +                skipped++;  | 
 | 191 | +              }  | 
 | 192 | +            }  | 
 | 193 | +
  | 
 | 194 | +            core.info(`Done. Flagged: ${flagged}, Unflagged: ${unflagged}, Skipped: ${skipped}`);  | 
0 commit comments