|
1 | | -name: "Transfer Accepted Submission to Private Repo" |
| 1 | +name: "Transfer ALL Accepted to Private Repo" |
2 | 2 |
|
3 | 3 | on: |
4 | 4 | workflow_dispatch: |
5 | 5 | inputs: |
6 | | - issue_number: |
7 | | - description: "Issue number to transfer" |
| 6 | + private_owner: |
| 7 | + description: "Private repo owner" |
8 | 8 | required: true |
9 | | - type: number |
| 9 | + default: pytorch-fdn |
| 10 | + type: string |
| 11 | + private_name: |
| 12 | + description: "Private repo name" |
| 13 | + required: true |
| 14 | + default: ambassador-program-management |
| 15 | + type: string |
| 16 | + assignee: |
| 17 | + description: "Assignee in private repo" |
| 18 | + required: true |
| 19 | + default: reginankenchor |
| 20 | + type: string |
| 21 | + dry_run: |
| 22 | + description: "Preview without changing anything" |
| 23 | + required: true |
| 24 | + default: false |
| 25 | + type: boolean |
10 | 26 |
|
11 | 27 | permissions: |
12 | 28 | contents: read |
13 | 29 | issues: write |
14 | 30 |
|
| 31 | +concurrency: |
| 32 | + group: transfer-all-accepted |
| 33 | + cancel-in-progress: true |
| 34 | + |
15 | 35 | jobs: |
16 | | - transfer-submission: |
| 36 | + transfer: |
17 | 37 | runs-on: ubuntu-latest |
18 | 38 | steps: |
19 | | - - name: Checkout Repo |
| 39 | + - name: Checkout |
20 | 40 | uses: actions/checkout@v4 |
21 | 41 |
|
22 | | - - name: Transfer Issue to Private Repo |
| 42 | + - name: Transfer all Accepted issues |
23 | 43 | uses: actions/github-script@v7 |
24 | 44 | with: |
| 45 | + # IMPORTANT: this token must have write access to BOTH repos |
25 | 46 | github-token: ${{ secrets.PRIVATE_REPO_TOKEN }} |
26 | 47 | script: | |
27 | | - const issue_number = parseInt(core.getInput("issue_number")); |
| 48 | + const dryRun = core.getInput('dry_run') === 'true'; |
| 49 | + const privateOwner = core.getInput('private_owner'); |
| 50 | + const privateName = core.getInput('private_name'); |
| 51 | + const assignee = core.getInput('assignee'); |
| 52 | +
|
28 | 53 | const repoOwner = context.repo.owner; |
29 | 54 | const publicRepo = context.repo.repo; |
30 | | - const privateOwner = "pytorch-fdn"; |
31 | | - const privateName = "ambassador-program-management"; |
32 | | - const assignee = "reginankenchor"; |
33 | | -
|
34 | | - const issue = await github.rest.issues.get({ |
35 | | - owner: repoOwner, |
36 | | - repo: publicRepo, |
37 | | - issue_number |
38 | | - }); |
39 | | -
|
40 | | - const hasAcceptedLabel = (issue.data.labels || []).some( |
41 | | - l => String(l.name || "").toLowerCase().includes("accept") |
42 | | - ); |
43 | | -
|
44 | | - if (!hasAcceptedLabel) { |
45 | | - throw new Error(`Issue #${issue_number} does not have an 'Accepted' label. Aborting transfer.`); |
| 55 | +
|
| 56 | + const TRANSFER_MARKER = "<!-- transferred-to-private -->"; |
| 57 | +
|
| 58 | + async function listIssuesWithLabel(label) { |
| 59 | + const results = []; |
| 60 | + const per_page = 100; |
| 61 | + let page = 1; |
| 62 | + while (true) { |
| 63 | + const { data } = await github.rest.issues.listForRepo({ |
| 64 | + owner: repoOwner, |
| 65 | + repo: publicRepo, |
| 66 | + state: "all", |
| 67 | + labels: label, |
| 68 | + per_page, |
| 69 | + page, |
| 70 | + }); |
| 71 | + const issuesOnly = data.filter(i => !i.pull_request); |
| 72 | + results.push(...issuesOnly); |
| 73 | + if (data.length < per_page) break; |
| 74 | + page += 1; |
| 75 | + } |
| 76 | + return results; |
46 | 77 | } |
47 | 78 |
|
48 | | - const bodyContent = [ |
49 | | - "📝 Submission Transferred from Public Repository", |
50 | | - "", |
51 | | - "----------------------------------------", |
52 | | - issue.data.body || "", |
53 | | - "----------------------------------------", |
54 | | - `🔔 @${assignee} — this submission has been accepted and is now ready for program-level follow-up.` |
55 | | - ].join("\n\n"); |
56 | | -
|
57 | | - const newIssue = await github.rest.issues.create({ |
58 | | - owner: privateOwner, |
59 | | - repo: privateName, |
60 | | - title: issue.data.title, |
61 | | - body: bodyContent, |
62 | | - assignees: [assignee] |
63 | | - }); |
64 | | -
|
65 | | - // Invite the issue author to the private repo with read access |
66 | | - const applicant = issue.data.user?.login; |
67 | | - if (applicant) { |
68 | | - try { |
69 | | - await github.rest.repos.addCollaborator({ |
70 | | - owner: privateOwner, |
71 | | - repo: privateName, |
72 | | - username: applicant, |
73 | | - permission: "pull" // read-only access |
| 79 | + async function hasMarkerComment(owner, repo, issue_number) { |
| 80 | + const per_page = 100; |
| 81 | + let page = 1; |
| 82 | + while (true) { |
| 83 | + const { data } = await github.rest.issues.listComments({ |
| 84 | + owner, repo, issue_number, per_page, page |
74 | 85 | }); |
75 | | - } catch (e) { |
76 | | - // ignore invite errors (already invited, org policy, etc.) |
| 86 | + if (data.some(c => (c.body || "").includes(TRANSFER_MARKER))) return true; |
| 87 | + if (data.length < per_page) break; |
| 88 | + page += 1; |
77 | 89 | } |
| 90 | + return false; |
78 | 91 | } |
79 | 92 |
|
80 | | - const confirmation = [ |
81 | | - "✅ This submission has been **accepted** and transferred to the private program management repository.", |
82 | | - "", |
83 | | - `🔗 [View it here](${newIssue.data.html_url})`, |
84 | | - "", |
85 | | - "ℹ️ This issue remains open here for visibility, but ongoing tracking happens in the private repository." |
86 | | - ].join("\n\n"); |
87 | | -
|
88 | | - // --- Ensure the public issue is unlocked before commenting --- |
89 | | - try { |
90 | | - await github.rest.issues.unlock({ |
91 | | - owner: repoOwner, |
92 | | - repo: publicRepo, |
93 | | - issue_number |
94 | | - }); |
95 | | - } catch (e) { |
96 | | - // ignore if already unlocked |
| 93 | + function buildPrivateBody(publicIssue) { |
| 94 | + return [ |
| 95 | + "📝 Submission Transferred from Public Repository", |
| 96 | + "", |
| 97 | + "----------------------------------------", |
| 98 | + publicIssue.body || "", |
| 99 | + "----------------------------------------", |
| 100 | + `Source: ${publicIssue.html_url}`, |
| 101 | + `Original Author: @${publicIssue.user?.login || "unknown"}`, |
| 102 | + "", |
| 103 | + `🔔 @${assignee} — this submission has been accepted and is now ready for program-level follow-up.` |
| 104 | + ].join("\n\n"); |
| 105 | + } |
| 106 | +
|
| 107 | + const accepted = await listIssuesWithLabel("Accepted"); |
| 108 | + core.info(`Found ${accepted.length} issues labeled 'Accepted'.`); |
| 109 | +
|
| 110 | + let transferred = 0, skipped = 0, failed = 0; |
| 111 | +
|
| 112 | + for (const pub of accepted) { |
| 113 | + const number = pub.number; |
| 114 | +
|
| 115 | + // Skip if already transferred (marker present) |
| 116 | + const already = await hasMarkerComment(repoOwner, publicRepo, number); |
| 117 | + if (already) { |
| 118 | + core.info(`#${number}: already transferred (marker found). Skipping.`); |
| 119 | + skipped += 1; |
| 120 | + continue; |
| 121 | + } |
| 122 | +
|
| 123 | + // Create issue in private repo |
| 124 | + const body = buildPrivateBody(pub); |
| 125 | +
|
| 126 | + if (dryRun) { |
| 127 | + core.info(`[DRY-RUN] Would create private issue for #${number} and invite @${pub.user?.login}`); |
| 128 | + } else { |
| 129 | + let newIssue; |
| 130 | + try { |
| 131 | + newIssue = await github.rest.issues.create({ |
| 132 | + owner: privateOwner, |
| 133 | + repo: privateName, |
| 134 | + title: pub.title, |
| 135 | + body, |
| 136 | + assignees: [assignee] |
| 137 | + }); |
| 138 | + } catch (e) { |
| 139 | + core.warning(`#${number}: creating private issue failed: ${e.message}`); |
| 140 | + failed += 1; |
| 141 | + continue; |
| 142 | + } |
| 143 | +
|
| 144 | + // Invite the applicant (read access) |
| 145 | + const applicant = pub.user?.login; |
| 146 | + if (applicant) { |
| 147 | + try { |
| 148 | + await github.rest.repos.addCollaborator({ |
| 149 | + owner: privateOwner, |
| 150 | + repo: privateName, |
| 151 | + username: applicant, |
| 152 | + permission: "pull" |
| 153 | + }); |
| 154 | + } catch (e) { |
| 155 | + // ignore invite errors (already invited/org policy/etc.) |
| 156 | + core.info(`#${number}: invite @${applicant} ignored: ${e.message}`); |
| 157 | + } |
| 158 | + } |
| 159 | +
|
| 160 | + // Ensure public issue is unlocked before commenting; leave it unlocked |
| 161 | + try { |
| 162 | + await github.rest.issues.unlock({ |
| 163 | + owner: repoOwner, repo: publicRepo, issue_number: number |
| 164 | + }); |
| 165 | + } catch (e) { |
| 166 | + // ignore if already unlocked or not lockable |
| 167 | + } |
| 168 | +
|
| 169 | + const confirmation = [ |
| 170 | + "✅ This submission has been **accepted** and transferred to the private program management repository.", |
| 171 | + "", |
| 172 | + `🔗 **Private tracking issue:** ${newIssue.data.html_url}`, |
| 173 | + "", |
| 174 | + TRANSFER_MARKER |
| 175 | + ].join("\n"); |
| 176 | +
|
| 177 | + try { |
| 178 | + await github.rest.issues.createComment({ |
| 179 | + owner: repoOwner, repo: publicRepo, issue_number: number, body: confirmation |
| 180 | + }); |
| 181 | + } catch (e) { |
| 182 | + core.warning(`#${number}: public confirmation comment failed: ${e.message}`); |
| 183 | + } |
| 184 | +
|
| 185 | + transferred += 1; |
| 186 | + } |
97 | 187 | } |
98 | 188 |
|
99 | | - await github.rest.issues.createComment({ |
100 | | - owner: repoOwner, |
101 | | - repo: publicRepo, |
102 | | - issue_number, |
103 | | - body: confirmation |
104 | | - }); |
| 189 | + core.info(`Done. transferred=${transferred}, skipped=${skipped}, failed=${failed}, dry_run=${dryRun}`); |
0 commit comments